diff --git a/components/contacts/__tests__/contact-detail.test.tsx b/components/contacts/__tests__/contact-detail.test.tsx index a06d4e1c3..d41b1ac3f 100644 --- a/components/contacts/__tests__/contact-detail.test.tsx +++ b/components/contacts/__tests__/contact-detail.test.tsx @@ -68,7 +68,7 @@ describe('ContactDetail', () => { render(); // The test translator returns the key, so a translated label shows up as its key. expect(screen.getByText('form.context_work')).toBeInTheDocument(); - expect(screen.getByText('form.context_private · form.phone_cell, x-sat')).toBeInTheDocument(); + expect(screen.getByText('ux_pim_contact.context_private · form.phone_cell, x-sat')).toBeInTheDocument(); // Custom vCard types are shown as stored. expect(screen.getByText('form.context_work, x-hq')).toBeInTheDocument(); }); diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index ef207dbdc..8dfeb0600 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -579,3 +579,112 @@ describe('ContactForm', () => { expect(options).toContain('Team (userb@example.org)'); }); }); + +// pim-4: saving never rewrites what the user didn't touch. +describe('ContactForm keeps what was not edited', () => { + // As a card with a structured German address comes from ContactCard/get. + const home = { + '@type': 'Address', + contexts: { private: true }, + countryCode: 'DE', + full: 'Hauptstraße 1, 3. OG, 10000 Berlin', + isOrdered: true, + defaultSeparator: ', ', + components: [ + { kind: 'name', value: 'Hauptstraße' }, + { kind: 'separator', value: ' ' }, + { kind: 'number', value: '1' }, + { kind: 'apartment', value: '3. OG' }, + { kind: 'postcode', value: '10000' }, + { kind: 'locality', value: 'Berlin' }, + { kind: 'country', value: 'Germany' }, + ], + }; + const office = { + components: [ + { kind: 'number', value: '1' }, + { kind: 'name', value: 'Main Street' }, + { kind: 'locality', value: 'Springfield' }, + { kind: 'region', value: 'IL' }, + { kind: 'postcode', value: '62701' }, + { kind: 'country', value: 'USA' }, + ], + contexts: { work: true }, + }; + const card: ContactCard = { + ...existingContact, + nicknames: { nick: { name: 'Ali', contexts: { private: true } } }, + emails: { home: { address: 'alice@example.com', pref: 1, label: 'main' }, w: { address: 'alice@work.example', contexts: { work: true } } }, + addresses: { home, office } as ContactCard['addresses'], + }; + + async function save(change: () => void) { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + change(); + fireEvent.submit(screen.getByText('save').closest('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + return onSave.mock.calls[0][0] as Partial; + } + + it('sends only the nickname when only the nickname changed', async () => { + const data = await save(() => fireEvent.change(screen.getByDisplayValue('Ali'), { target: { value: 'Lissy' } })); + expect(Object.keys(data)).toEqual(['nicknames']); + // The nickname keeps its key and its contexts. + expect(data.nicknames).toEqual({ nick: { name: 'Lissy', contexts: { private: true } } }); + }); + + it("shows street and number in the address's own order, German fields as Street, Postcode + City", () => { + render(); + expect(screen.getByDisplayValue('Hauptstraße 1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('1 Main Street')).toBeInTheDocument(); + const berlin = screen.getByDisplayValue('Berlin'); + const postcode = screen.getByDisplayValue('10000'); + // Postcode comes before the city for Germany, after it for the US. + expect(postcode.compareDocumentPosition(berlin) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const springfield = screen.getByDisplayValue('Springfield'); + const zip = screen.getByDisplayValue('62701'); + expect(springfield.compareDocumentPosition(zip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('keeps an untouched address exactly as stored when another address is edited', async () => { + const data = await save(() => fireEvent.change(screen.getByDisplayValue('62701'), { target: { value: '62702' } })); + expect(data.addresses!.home).toBe(home); + expect(data.addresses!.office.components!.find(c => c.kind === 'postcode')!.value).toBe('62702'); + expect('emails' in data).toBe(false); + }); + + it('saves an edited German address in German order and keeps the parts the form does not show', async () => { + const data = await save(() => fireEvent.change(screen.getByDisplayValue('Hauptstraße 1'), { target: { value: 'Hauptstraße 12a' } })); + const saved = data.addresses!.home; + expect(saved.components!.map(c => `${c.kind}:${c.value}`)).toEqual([ + 'name:Hauptstraße', 'separator: ', 'number:12a', 'apartment:3. OG', 'postcode:10000', 'separator: ', 'locality:Berlin', 'country:Germany', + ]); + expect(saved).toMatchObject({ '@type': 'Address', countryCode: 'DE', contexts: { private: true }, isOrdered: true, defaultSeparator: ', ' }); + // The old one-line text would contradict the new parts. + expect(saved.full).toBeUndefined(); + expect(data.addresses!.office).toBe(office); + }); + + it('keeps an untouched email with its key, pref and label when another is edited', async () => { + const data = await save(() => fireEvent.change(screen.getByDisplayValue('alice@work.example'), { target: { value: 'alice@new.example' } })); + expect(data.emails!.home).toBe(card.emails!.home); + expect(data.emails!.w).toEqual({ address: 'alice@new.example', contexts: { work: true } }); + }); + + it('still sends every field of a new contact, its address in the order of the country typed', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } }); + fireEvent.click(screen.getByText('addresses')); + fireEvent.click(screen.getByText('add_address')); + fireEvent.change(screen.getByPlaceholderText('street'), { target: { value: 'Hauptstraße 5' } }); + fireEvent.change(screen.getByPlaceholderText('country'), { target: { value: 'Deutschland' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + const data = onSave.mock.calls[0][0] as Partial; + expect('emails' in data && 'notes' in data && 'name' in data).toBe(true); + expect(data.addresses!.a0).toMatchObject({ countryCode: 'DE' }); + expect(data.addresses!.a0.components!.map(c => `${c.kind}:${c.value}`)).toEqual(['name:Hauptstraße', 'separator: ', 'number:5', 'country:Deutschland']); + }); +}); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index 802c8f994..31f533ba1 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -215,7 +215,7 @@ describe('ContactsApp', () => { renderContacts(); fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]); fireEvent.click(within(drawer()).getByRole('button', { name: 'new' })); - fireEvent.click(screen.getByText('import.title')); + fireEvent.click(screen.getByText('import_menu')); const dialog = screen.getByRole('dialog', { name: 'import.title' }); fireEvent.keyDown(dialog, { key: 'Escape' }); expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull(); @@ -273,7 +273,7 @@ describe('ContactsApp', () => { try { renderContacts(); fireEvent.click(screen.getByRole('button', { name: 'more' })); - fireEvent.click(screen.getByText('import.title')); + fireEvent.click(screen.getByText('import_menu')); expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument(); } finally { window.matchMedia = matchMedia; @@ -287,7 +287,7 @@ describe('ContactsApp', () => { fireEvent.click(within(drawer()).getByText('Work')); expect(screen.getByText('empty_view_title')).toBeInTheDocument(); expect(screen.queryByText('empty_state_title')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: /import.title/ })); + fireEvent.click(screen.getByRole('button', { name: /import_menu/ })); expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument(); }); @@ -307,4 +307,16 @@ describe('ContactsApp', () => { fireEvent.click(screen.getByText('context_menu.duplicate')); await waitFor(() => expect(useToastStore.getState().toasts.at(-1)?.title).toBe('toast.error_create')); }); + + it("marks no member in the list on a group's page, even with a contact open before", () => { + const team: ContactCard = { id: 'g-team', addressBookIds: {}, kind: 'group', name: { full: 'Team' }, members: { 'c-alice': true, 'c-bob': true } }; + renderContacts([alice, bob, team]); + fireEvent.click(screen.getByText('Alice Smith')); + expect(document.querySelector('[data-contact-id="c-alice"]')).toHaveAttribute('aria-selected', 'true'); + fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]); + fireEvent.click(within(drawer()).getByText('Team')); + expect(screen.getByText('groups.members_label')).toBeInTheDocument(); + expect(document.querySelector('[data-contact-id="c-alice"]')).toHaveAttribute('aria-selected', 'false'); + expect(document.querySelectorAll('[data-contact-id][aria-selected="true"]')).toHaveLength(0); + }); }); diff --git a/components/contacts/__tests__/contacts-ux-pim.test.tsx b/components/contacts/__tests__/contacts-ux-pim.test.tsx new file mode 100644 index 000000000..496ca5a72 --- /dev/null +++ b/components/contacts/__tests__/contacts-ux-pim.test.tsx @@ -0,0 +1,185 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { AddressBook, ContactCard } from '@/lib/jmap/types'; + +// Keys with their namespace, so a label read from ux_pim_contact can be told +// from the old one in contacts.form. +vi.mock('next-intl', () => ({ + useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key), + useLocale: () => 'en', + useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }), +})); +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], activeAccountId: null }; + const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return { useAccountStore: hook }; +}); +vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null })); + +import { ContactForm } from '../contact-form'; +import { ContactDetail } from '../contact-detail'; +import { ContactsSidebar } from '../contacts-sidebar'; +import { ContactList } from '../contact-list'; +import { ContactGroupDetail } from '../contact-group-detail'; + +const anna: ContactCard = { + id: 'c1', + addressBookIds: {}, + name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true }, + emails: { e0: { address: 'anna@example.net', contexts: { private: true } } }, + phones: { p0: { number: '+49 30 1', features: { voice: true }, contexts: { private: true } } }, +}; +const ben: ContactCard = { + id: 'c2', + addressBookIds: {}, + name: { components: [{ kind: 'given', value: 'Ben' }, { kind: 'surname', value: 'Muster' }], isOrdered: true }, + emails: { e0: { address: 'ben@example.net' } }, +}; + +describe('pim-19: standard words read as people say them', () => { + it('offers "Phone" and "Home" in the editor, storing voice and private as before', () => { + render(); + const phoneType = screen.getByDisplayValue('ux_pim_contact.phone_voice') as HTMLSelectElement; + expect(phoneType.value).toBe('voice'); + expect(screen.getAllByDisplayValue('ux_pim_contact.context_private').map(s => (s as HTMLSelectElement).value)).toEqual(['private', 'private']); + expect(screen.queryByText('contacts.form.phone_voice')).toBeNull(); + expect(screen.queryByText('contacts.form.context_private')).toBeNull(); + }); + + it('labels a voice phone "Phone" and a private one "Home" on the contact page', () => { + render(); + expect(screen.getByText('ux_pim_contact.context_private · ux_pim_contact.phone_voice')).toBeInTheDocument(); + }); +}); + +describe('pim-21: a long name in the contact header', () => { + it('wraps to two lines and shows the whole name on hover', () => { + const long = { ...anna, name: { full: 'Maximiliane Alexandra Friederike von Hohenzollern-Sigmaringen-Wittelsbach' } }; + render(); + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading).toHaveAttribute('title', long.name.full); + expect(heading.className).toContain('line-clamp-2'); + expect(heading.className).not.toMatch(/\btruncate\b/); + }); +}); + +describe('pim-26: the contact editor keeps rare fields under "More fields"', () => { + it('collapses Gender, Calendar, Online services and Personal info when none has a value', () => { + render(); + expect(screen.getByText('ux_pim_contact.more_fields')).toBeInTheDocument(); + expect(screen.queryByText('contacts.form.gender_sex')).toBeNull(); + expect(screen.queryByText('contacts.form.calendar_uri')).toBeNull(); + expect(screen.queryByText('contacts.form.add_online_service')).toBeNull(); + expect(screen.queryByText('contacts.form.add_personal_info')).toBeNull(); + fireEvent.click(screen.getByText('ux_pim_contact.more_fields')); + expect(screen.getByText('contacts.form.gender_sex')).toBeInTheDocument(); + expect(screen.getByText('contacts.form.calendar_uri')).toBeInTheDocument(); + expect(screen.getByText('contacts.form.add_online_service')).toBeInTheDocument(); + expect(screen.getByText('contacts.form.add_personal_info')).toBeInTheDocument(); + }); + + it('opens "More fields" when one of them has a value', () => { + render(); + expect(screen.getByDisplayValue('https://cal.example/anna')).toBeInTheDocument(); + }); +}); + +function renderSidebar(addressBooks: AddressBook[]) { + return render( + , + ); +} + +describe('pim-22: the Contacts sidebar', () => { + it("leaves out the app's own Trusted Senders book, which Settings manages", () => { + const { unmount } = renderSidebar([ + { id: 'b1', name: 'Contacts', isDefault: true } as AddressBook, + { id: 'b2', name: 'Trusted Senders', isDefault: false } as AddressBook, + ]); + expect(screen.getByText('Contacts')).toBeInTheDocument(); + expect(screen.queryByText('Trusted Senders')).toBeNull(); + unmount(); + // Someone else's book with that name is theirs, not the app's list. + renderSidebar([ + { id: 'b1', name: 'Contacts', isDefault: true } as AddressBook, + { id: 'b3', name: 'Trusted Senders', isShared: true, accountId: 'acc-b', accountName: 'b@example.org' } as AddressBook, + ]); + expect(screen.getByText('Trusted Senders')).toBeInTheDocument(); + }); +}); + +describe('pim-26: the Contacts menus', () => { + it('ends "Import contacts" and "New address book" in "…", as they open a dialog', () => { + renderSidebar([{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook]); + fireEvent.click(screen.getByRole('button', { name: 'contacts.new' })); + const menu = screen.getByRole('menu'); + expect(within(menu).getByText('ux_pim_contact.import_menu')).toBeInTheDocument(); + expect(within(menu).getByText('ux_pim_contact.new_address_book_menu')).toBeInTheDocument(); + }); +}); + +const listProps = { + contacts: [anna, ben], + searchQuery: '', + onSearchChange: vi.fn(), + onSelectContact: vi.fn(), + onCreateNew: vi.fn(), + selectedContactIds: new Set(), + onToggleSelection: vi.fn(), + onSelectRangeContacts: vi.fn(), + onSelectAll: vi.fn(), + onClearSelection: vi.fn(), + onBulkDelete: vi.fn(), + onBulkAddToGroup: vi.fn(), + onBulkExport: vi.fn(), + onEditContact: vi.fn(), + onDeleteContact: vi.fn(), + onAddContactToGroup: vi.fn(), +}; + +describe('pim-17: right-clicking a contact marks whose menu is open', () => { + it('gives the right-clicked row the hover colour while its menu is open', () => { + render(); + const row = (id: string) => document.querySelector(`[data-contact-id="${id}"]`) as HTMLElement; + expect(row('c2')).not.toHaveAttribute('data-menu-open'); + fireEvent.contextMenu(row('c2')); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(row('c2')).toHaveAttribute('data-menu-open'); + expect(row('c2').className).toMatch(/(^|\s)bg-muted(\s|$)/); + // The open contact stays as it is. + expect(row('c1')).not.toHaveAttribute('data-menu-open'); + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + expect(row('c2')).not.toHaveAttribute('data-menu-open'); + }); +}); + +describe("pim-18: a group's page manages its members", () => { + it('shows "Remove from group" on every member row, not only on hover', () => { + render( + , + ); + const remove = screen.getAllByRole('button', { name: 'contacts.groups.remove_member' }); + expect(remove).toHaveLength(2); + for (const button of remove) expect(button.className).not.toMatch(/(^|\s)opacity-0(\s|$)/); + }); +}); diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 66a3969e2..1c35b2977 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -8,6 +8,7 @@ import { Avatar } from "@/components/ui/avatar"; import { MailtoLink } from "@/components/ui/mailto-link"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { formatAddressLines } from "@/lib/address-format"; import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store"; import { ContactActivity } from "./contact-activity"; @@ -40,14 +41,15 @@ interface ContactDetailProps { } // Contexts and phone features the contact form offers, shown with the same -// labels here. Anything else (custom vCard types) is shown as stored. +// labels here. Anything else (custom vCard types) is shown as stored. The +// standard's "private" reads "Home" and "voice" reads "Phone" (#pim-19). const CONTEXT_LABEL_KEYS: Record = { work: "form.context_work", - private: "form.context_private", + private: "ux_pim_contact.context_private", }; const PHONE_FEATURE_LABEL_KEYS: Record = { - voice: "form.phone_voice", + voice: "ux_pim_contact.phone_voice", cell: "form.phone_cell", mobile: "form.phone_cell", fax: "form.phone_fax", @@ -78,12 +80,14 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null { export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) { const t = useTranslations("contacts"); + const tRoot = useTranslations(); const tCommon = useTranslations("common"); const locale = useLocale(); // The ⋮ shows the contact's menu, the one right-click shows in the list (R6). const moreMenu = useContextMenu(); - const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key); - const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key); + const label = (labelKey: string) => (labelKey.startsWith("ux_pim_contact.") ? tRoot(labelKey) : t(labelKey)); + const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? label(CONTEXT_LABEL_KEYS[key]) : key); + const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? label(PHONE_FEATURE_LABEL_KEYS[key]) : key); const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0; @@ -148,7 +152,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
-

{name || "-"}

+ {/* A long name takes two lines, then ends in "…" with the whole name on hover. */} +

{name || "-"}

{hasNickname && (

“{nicknames.map(n => n.name).join(", ")}”

)} @@ -264,23 +269,14 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli })} {addresses.map((a, i) => { - const lines: string[] = []; - if (a.full || a.fullAddress) { - lines.push((a.full || a.fullAddress) as string); - } else if (a.components && a.components.length > 0) { - const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", "); - if (joined) lines.push(joined); - } else { - const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country] - .map(s => (typeof s === "string" ? s.trim() : "")) - .filter(Boolean) as string[]; - lines.push(...parts); - } + // One line per part, in the order of the address's country: + // "Hauptstraße 1" / "10000 Berlin" / "Germany". + const lines = formatAddressLines(a, locale); return ( -
+
{lines.map((line, idx) => ( -
{line}
+
{line}
))} {a.timeZone && (
{t("detail.timezone")}: {a.timeZone}
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index fac3205b0..c506aab54 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -2,8 +2,8 @@ import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react"; import { useCloseBesideControls } from "@/hooks/use-close-beside-controls"; -import { useTranslations } from "next-intl"; -import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons"; +import { useLocale, useTranslations } from "next-intl"; +import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2, MoreHorizontal } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; @@ -11,38 +11,47 @@ import { Avatar } from "@/components/ui/avatar"; import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store"; import type { ContactSaveOptions } from "@/lib/contact-media"; import { cn } from "@/lib/utils"; +import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, type AddressStyle } from "@/lib/address-format"; import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; +// Each row of a list field keeps the `key` of the card's entry it was read +// from, so Save can send an entry the user didn't touch back unchanged. interface EmailEntry { + key?: string; address: string; context: "work" | "private" | ""; } interface PhoneEntry { + key?: string; number: string; context: "work" | "private" | ""; feature: "voice" | "mobile" | "fax" | "pager" | "video" | "text" | ""; } interface OnlineServiceEntry { + key?: string; uri: string; service: string; label: string; } interface AnniversaryEntry { + key?: string; date: string; kind: "birth" | "death" | "wedding" | "other"; } interface PersonalInfoEntry { + key?: string; value: string; kind: "expertise" | "hobby" | "interest" | "other"; level: "high" | "medium" | "low" | ""; } interface AddressEntry { + key?: string; street: string; locality: string; region: string; @@ -125,6 +134,62 @@ function mediaForPhotoEdit( return { media, photoKey }; } +/** + * The map to save for a list field (emails, phones, addresses...). A row the + * user didn't touch goes back exactly as the card had it, under its key. An + * edited row is built on the card's entry (`previous`), so properties the + * form doesn't show stay; a new row gets a key no other entry uses. + */ +function entriesToSave( + rows: E[], + initialRows: E[], + original: Record | undefined, + prefix: string, + build: (row: E, previous: V | undefined, before: E | undefined) => V, +): Record { + const out: Record = {}; + const taken = new Set(rows.map(r => r.key).filter((k): k is string => !!k && !!original?.[k])); + let n = 0; + const freshKey = () => { + let key = `${prefix}${n++}`; + while (taken.has(key)) key = `${prefix}${n++}`; + taken.add(key); + return key; + }; + for (const row of rows) { + const previous = row.key ? original?.[row.key] : undefined; + const before = previous !== undefined ? initialRows.find(r => r.key === row.key) : undefined; + if (previous !== undefined && before && JSON.stringify(before) === JSON.stringify(row)) { + out[row.key!] = previous; + } else { + out[previous !== undefined ? row.key! : freshKey()] = build(row, previous, before); + } + } + return out; +} + +/** `contexts` for a row: the card's own when the select wasn't changed. */ +function contextsToSave(context: string, previous: Record | undefined, before: { context: string } | undefined): Record | undefined { + if (before && before.context === context) return previous; + return context ? { [context]: true } : undefined; +} + +/** A card's first entry of a map with one field changed, the others kept; undefined when none is left. */ +function firstEntryToSave(original: Record | undefined, fallbackKey: string, value: V | null): Record | undefined { + const entries = Object.entries(original ?? {}); + const [firstKey, first] = entries[0] ?? [fallbackKey, undefined]; + const rest = entries.slice(1); + const out: Record = {}; + if (value) out[firstKey] = { ...(first ?? {}), ...value } as V; + for (const [k, v] of rest) out[k] = v; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Drops keys whose value is undefined, so a built entry has no empty properties. */ +function compact(obj: T): T { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T; +} + function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: { icon: React.ComponentType<{ className?: string }>; title: string; @@ -220,6 +285,8 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) { const t = useTranslations("contacts.form"); + const tx = useTranslations("ux_pim_contact"); + const locale = useLocale(); const isEditing = !!contact; // The header's close goes to its start when the window's own close is at its end. const [header, setHeader] = useState(null); @@ -282,25 +349,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress return pd; } - // Extract flat address fields from RFC 9553 components format - function addressToFlat(a: ContactAddress): AddressEntry { - if (a.components && a.components.length > 0) { - const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' '); - return { - street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '', - locality: findComp('locality'), - region: findComp('region'), - postcode: findComp('postcode'), - country: findComp('country'), - context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', - }; - } + // The form's fields for an address: street name and house number in one + // field, in the order of the address's country ("Hauptstraße 1"). + function addressToFlat(key: string, a: ContactAddress): AddressEntry { return { - street: a.street || '', - locality: a.locality || '', - region: a.region || '', - postcode: a.postcode || '', - country: a.country || '', + key, + ...addressToParts(a, locale), context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', }; } @@ -317,7 +371,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [emails, setEmails] = useState(() => { if (contact?.emails) { - return Object.values(contact.emails).map(e => ({ + return Object.entries(contact.emails).map(([key, e]) => ({ + key, address: e.address, context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "", })); @@ -327,7 +382,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [phones, setPhones] = useState(() => { if (contact?.phones) { - return Object.values(contact.phones).map(p => ({ + return Object.entries(contact.phones).map(([key, p]) => ({ + key, number: p.number, context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "", // RFC 9553 calls it "mobile"; cards saved by older versions of this form say "cell". @@ -371,14 +427,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [addresses, setAddresses] = useState(() => { if (contact?.addresses) { - return Object.values(contact.addresses).map(a => addressToFlat(a)); + return Object.entries(contact.addresses).map(([key, a]) => addressToFlat(key, a)); } return []; }); const [onlineServices, setOnlineServices] = useState(() => { if (contact?.onlineServices) { - return Object.values(contact.onlineServices).map(s => ({ + return Object.entries(contact.onlineServices).map(([key, s]) => ({ + key, uri: s.uri, service: s.service || "", label: s.label || "", @@ -389,7 +446,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [anniversaries, setAnniversaries] = useState(() => { if (contact?.anniversaries) { - return Object.values(contact.anniversaries).map(a => ({ + return Object.entries(contact.anniversaries).map(([key, a]) => ({ + key, date: anniversaryDateToString(a.date), kind: a.kind, })); @@ -399,7 +457,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [personalInfoEntries, setPersonalInfoEntries] = useState(() => { if (contact?.personalInfo) { - return Object.values(contact.personalInfo).map(p => ({ + return Object.entries(contact.personalInfo).map(([key, p]) => ({ + key, value: p.value, kind: p.kind, level: p.level || "", @@ -474,9 +533,37 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId, ]); const [initialFields] = useState(fields); + + // What each card property is built from. Saving an existing card sends only + // the properties whose fields changed, so the rest stay exactly as stored, + // structured address parts included (#pim-4). + const personNamed = !!(givenName.trim() || surname.trim()); + const propertyFields: Record = { + name: [prefix, givenName, additionalName, surname, suffix, isOrg, isOrg || !personNamed ? organization : ""], + nicknames: nickname, + emails, + phones, + titles: [jobTitle, role], + organizations: [organization, department], + addresses, + onlineServices, + anniversaries, + personalInfo: personalInfoEntries, + keywords: [keywordsStr, categoryDraft], + notes: note, + speakToAs: [genderSex, genderIdentity], + calendarUri: [calendarUri, freeBusyUri], + freeBusyUri: [calendarUri, freeBusyUri], + schedulingUri, + addressBookIds: selectedBookId, + }; + const [initialPropertyFields] = useState(() => JSON.stringify(propertyFields)); + const [initialRows] = useState(() => ({ emails, phones, addresses, onlineServices, anniversaries, personalInfoEntries, department })); const dirty = fields !== initialFields || photoEdit.kind !== "unchanged"; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); const footerRef = useBottomControls(); + const [hasMoreFields] = useState(() => onlineServices.length > 0 || personalInfoEntries.length > 0 + || !!(genderSex || genderIdentity) || !!(calendarUri || schedulingUri || freeBusyUri)); const handlePhotoSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -544,27 +631,21 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress } } - const emailsMap: Record }> = {}; - validEmails.forEach((entry, i) => { - const obj: { address: string; contexts?: Record } = { address: entry.address.trim() }; - if (entry.context) { - obj.contexts = { [entry.context]: true }; - } - emailsMap[`e${i}`] = obj; - }); + type Email = NonNullable[string]; + type PhoneValue = NonNullable[string]; + const emailsMap = entriesToSave(validEmails, initialRows.emails, contact?.emails, "e", (row, previous, before) => compact({ + ...previous, + address: row.address.trim(), + contexts: contextsToSave(row.context, previous?.contexts, before), + })); const validPhones = phones.filter(p => p.number.trim()); - const phonesMap: Record; features?: Record }> = {}; - validPhones.forEach((entry, i) => { - const obj: { number: string; contexts?: Record; features?: Record } = { number: entry.number.trim() }; - if (entry.context) { - obj.contexts = { [entry.context]: true }; - } - if (entry.feature) { - obj.features = { [entry.feature]: true }; - } - phonesMap[`p${i}`] = obj; - }); + const phonesMap = entriesToSave(validPhones, initialRows.phones, contact?.phones, "p", (row, previous, before) => compact({ + ...previous, + number: row.number.trim(), + contexts: contextsToSave(row.context, previous?.contexts, before), + features: before && before.feature === row.feature ? previous?.features : row.feature ? { [row.feature]: true } : undefined, + })); // Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly. const nameComponents = []; @@ -594,41 +675,58 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" }; if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" }; - const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined; + const firstOrg = Object.values(contact?.organizations ?? {})[0]; + const orgUnits = firstOrg && department === initialRows.department + ? firstOrg.units + : department.trim() ? [{ name: department.trim() }] : undefined; - const addressesMap: Record ? V : never> = {}; - addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => { - const components: Array<{ kind: string; value: string }> = []; - if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() }); - if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() }); - if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() }); - if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() }); - if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() }); - const obj: Record = { components, isOrdered: true, defaultSeparator: ", " }; - if (a.context) obj.contexts = { [a.context]: true }; - // @ts-expect-error - dynamic build - addressesMap[`a${i}`] = obj; - }); + // An address the user didn't touch goes back as the card had it. An edited + // one is rebuilt in its country's order ("Hauptstraße" + "1", then + // postcode and city), keeping the parts the form doesn't show. + const addressesMap = entriesToSave( + addresses.filter(a => a.street.trim() || a.locality.trim() || a.region.trim() || a.postcode.trim() || a.country.trim()), + initialRows.addresses, contact?.addresses, "a", + (row, previous, before) => { + const { components: previousComponents, full: _full, fullAddress: _fullAddress, street: _s, locality: _l, region: _r, postcode: _p, country: _c, ...kept } = previous ?? {}; + void _full; void _fullAddress; void _s; void _l; void _r; void _p; void _c; + const countryCode = before && before.country === row.country + ? previous?.countryCode + : countryCodeFromName(row.country) ?? undefined; + return compact({ + ...kept, + components: partsToComponents(row, previousComponents ?? [], locale, countryCode), + isOrdered: true, + defaultSeparator: previous?.defaultSeparator ?? ", ", + countryCode, + contexts: contextsToSave(row.context, previous?.contexts, before), + }); + }, + ); - const onlineServicesMap: Record = {}; - onlineServices.filter(s => s.uri.trim()).forEach((s, i) => { - const obj: ContactOnlineService = { uri: s.uri.trim() }; - if (s.service.trim()) obj.service = s.service.trim(); - if (s.label.trim()) obj.label = s.label.trim(); - onlineServicesMap[`os${i}`] = obj; - }); + const onlineServicesMap = entriesToSave( + onlineServices.filter(s => s.uri.trim()), initialRows.onlineServices, contact?.onlineServices, "os", + (row, previous) => compact({ + ...previous, + uri: row.uri.trim(), + service: row.service.trim() || undefined, + label: row.label.trim() || undefined, + }), + ); - const anniversariesMap: Record = {}; - anniversaries.filter(a => a.date.trim()).forEach((a, i) => { - anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind }; - }); + const anniversariesMap = entriesToSave( + anniversaries.filter(a => a.date.trim()), initialRows.anniversaries, contact?.anniversaries, "an", + (row, previous) => ({ ...previous, date: stringToPartialDate(row.date.trim()), kind: row.kind }), + ); - const personalInfoMap: Record = {}; - personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => { - const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind }; - if (p.level) obj.level = p.level as "high" | "medium" | "low"; - personalInfoMap[`pi${i}`] = obj; - }); + const personalInfoMap = entriesToSave( + personalInfoEntries.filter(p => p.value.trim()), initialRows.personalInfoEntries, contact?.personalInfo, "pi", + (row, previous) => compact({ + ...previous, + value: row.value.trim(), + kind: row.kind, + level: (row.level || undefined) as ContactPersonalInfo["level"], + }), + ); const keywordsMap: Record = {}; keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => { @@ -648,21 +746,18 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const data: Partial = { name: nameValue, ...(kindValue ? { kind: kindValue } : {}), - nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, + // The form edits the first nickname, note and organization; others stay. + nicknames: firstEntryToSave(contact?.nicknames, "n0", nickname.trim() ? { name: nickname.trim() } : null), emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined, phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined, titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined, - organizations: orgName - ? { o0: { name: orgName, units: orgUnits } } - : undefined, + organizations: firstEntryToSave(contact?.organizations, "o0", orgName ? { name: orgName, units: orgUnits } : null), addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined, onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined, anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined, personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined, keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined, - notes: note.trim() - ? { n0: { note: note.trim() } } - : undefined, + notes: firstEntryToSave(contact?.notes, "n0", note.trim() ? { note: note.trim() } : null), speakToAs: (genderSex.trim() || genderIdentity.trim()) ? { grammaticalGender: genderSex.trim() || undefined, @@ -675,6 +770,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), }; + // An existing card gets only what changed: every property left out stays + // on the server exactly as it is. + if (contact) { + const before = JSON.parse(initialPropertyFields) as Record; + for (const [property, value] of Object.entries(propertyFields)) { + if (JSON.stringify(value) === JSON.stringify(before[property])) delete data[property as keyof ContactCard]; + } + } + setIsSaving(true); try { // `media` goes out only when the photo changed. An update without it @@ -916,7 +1020,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress > - + {emails.length > 1 && ( setEmails(emails.filter((_, j) => j !== i))} className="shrink-0"> @@ -966,7 +1070,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress aria-label={t("phone_type")} > - + @@ -984,7 +1088,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress > - + setPhones(phones.filter((_, j) => j !== i))} className="shrink-0"> @@ -1026,31 +1130,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress {/* Addresses */} 0}>
- {addresses.map((addr, i) => ( -
- setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1"> - - - { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} aria-label={t("street")} /> -
- { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} aria-label={t("city")} /> - { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} aria-label={t("region")} /> + {addresses.map((addr, i) => { + const set = (name: keyof Omit) => (e: React.ChangeEvent) => { + const n = [...addresses]; + n[i] = { ...n[i], [name]: e.target.value }; + setAddresses(n); + }; + const original = addr.key ? contact?.addresses?.[addr.key] : undefined; + const initialCountry = addr.key ? initialRows.addresses.find(a => a.key === addr.key)?.country : undefined; + const style: AddressStyle = addressStyle(addr.country, initialCountry === addr.country ? original?.countryCode : null, locale); + const field = (name: "locality" | "region" | "postcode", label: string, className?: string) => ( + + ); + const country = ; + const context = ( + + ); + return ( +
+ setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1"> + + + {/* Street and house number in one field, in the order the country writes them. */} + + {style.town === "postcode-city" ? ( + <> +
+ {field("postcode", t("postcode"))} + {field("locality", t("city"), "col-span-2")} +
+
+ {field("region", t("region"))} + {country} + {context} +
+ + ) : ( + <> +
+ {field("locality", t("city"))} + {field("region", t("region"))} +
+
+ {field("postcode", t("postcode"))} + {country} + {context} +
+ + )}
-
- { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} aria-label={t("postcode")} /> - { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} aria-label={t("country")} /> - -
-
- ))} + ); + })}
- {/* Online Services */} - 0}> -
- {onlineServices.map((svc, i) => ( -
- { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }} - placeholder={t("url_placeholder")} - aria-label={t("online_service_url")} - className="flex-1" - /> - { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }} - placeholder={t("service_placeholder")} - aria-label={t("service_placeholder")} - className="w-24" - /> - setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0"> - - -
- ))} - -
-
- {/* Anniversaries */} 0}>
@@ -1123,50 +1226,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
- {/* Personal Info */} - 0}> -
- {personalInfoEntries.map((pi, i) => ( -
- { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }} - placeholder={t("personal_info_placeholder")} - aria-label={t("personal_info")} - className="flex-1" - /> - - - setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0"> - - -
- ))} - -
-
- {/* Categories */} - {/* Gender */} - -
-
- - -
-
- - setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} /> -
-
-
- - {/* Calendar */} - -
-
- - setCalendarUri(e.target.value)} placeholder="https://..." /> -
-
- - setSchedulingUri(e.target.value)} placeholder="https://..." /> -
-
- - setFreeBusyUri(e.target.value)} placeholder="https://..." /> -
-
-
- {/* Notes */}