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/contact-detail.tsx b/components/contacts/contact-detail.tsx index 1537c9814..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"; @@ -268,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 bea3db826..c506aab54 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react"; import { useCloseBesideControls } from "@/hooks/use-close-beside-controls"; -import { useTranslations } from "next-intl"; +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"; @@ -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; @@ -221,6 +286,7 @@ 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); @@ -283,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' : '', }; } @@ -318,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" : "", })); @@ -328,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". @@ -372,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 || "", @@ -390,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, })); @@ -400,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 || "", @@ -475,6 +533,32 @@ 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(); @@ -547,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 = []; @@ -597,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 => { @@ -651,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, @@ -678,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 @@ -1029,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")} /> - -
-
- ))} + ); + })}