fix: keep a contact's address as stored when other fields change, and lay addresses out by country

This commit is contained in:
Linus Rath
2026-11-04 11:06:40 +01:00
parent f39f10d593
commit e25d3e1e56
6 changed files with 854 additions and 120 deletions
@@ -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(<ContactForm contact={card} onSave={onSave} onCancel={vi.fn()} />);
change();
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
return onSave.mock.calls[0][0] as Partial<ContactCard>;
}
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(<ContactForm contact={card} onSave={vi.fn()} onCancel={vi.fn()} />);
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(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
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<ContactCard>;
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']);
});
});
+6 -14
View File
@@ -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 (
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts, contextLabel) || t("detail.address_default_label")}>
<div className="text-sm space-y-0.5">
<div className="text-sm space-y-0.5" data-testid="contact-address">
{lines.map((line, idx) => (
<div key={idx}>{line}</div>
<div key={idx} data-address-line="">{line}</div>
))}
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
+237 -106
View File
@@ -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<E extends { key?: string }, V>(
rows: E[],
initialRows: E[],
original: Record<string, V> | undefined,
prefix: string,
build: (row: E, previous: V | undefined, before: E | undefined) => V,
): Record<string, V> {
const out: Record<string, V> = {};
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<string, boolean> | undefined, before: { context: string } | undefined): Record<string, boolean> | 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<V extends object>(original: Record<string, V> | undefined, fallbackKey: string, value: V | null): Record<string, V> | undefined {
const entries = Object.entries(original ?? {});
const [firstKey, first] = entries[0] ?? [fallbackKey, undefined];
const rest = entries.slice(1);
const out: Record<string, V> = {};
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<T extends object>(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<HTMLDivElement | null>(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<EmailEntry[]>(() => {
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<PhoneEntry[]>(() => {
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<AddressEntry[]>(() => {
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<OnlineServiceEntry[]>(() => {
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<AnniversaryEntry[]>(() => {
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<PersonalInfoEntry[]>(() => {
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<string, unknown> = {
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<string, { address: string; contexts?: Record<string, boolean> }> = {};
validEmails.forEach((entry, i) => {
const obj: { address: string; contexts?: Record<string, boolean> } = { address: entry.address.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
emailsMap[`e${i}`] = obj;
});
type Email = NonNullable<ContactCard["emails"]>[string];
type PhoneValue = NonNullable<ContactCard["phones"]>[string];
const emailsMap = entriesToSave<EmailEntry, Email>(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<string, { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> }> = {};
validPhones.forEach((entry, i) => {
const obj: { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> } = { 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<PhoneEntry, PhoneValue>(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<string, ContactCard["addresses"] extends Record<string, infer V> ? 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<string, unknown> = { 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<AddressEntry, ContactAddress>(
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<string, ContactOnlineService> = {};
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<OnlineServiceEntry, ContactOnlineService>(
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<string, ContactAnniversary> = {};
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
});
const anniversariesMap = entriesToSave<AnniversaryEntry, ContactAnniversary>(
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<string, ContactPersonalInfo> = {};
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<PersonalInfoEntry, ContactPersonalInfo>(
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<string, boolean> = {};
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
@@ -651,21 +746,18 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const data: Partial<ContactCard> = {
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<string, unknown>;
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 */}
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
<IconButton label={t("remove_entry")} onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
<X />
</IconButton>
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} aria-label={t("street")} />
<div className="grid grid-cols-2 gap-2">
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} aria-label={t("city")} />
<Input value={addr.region} onChange={(e) => { 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<AddressEntry, "key">) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
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) => (
<Input key={name} value={addr[name]} onChange={set(name)} placeholder={label} aria-label={label} className={className} />
);
const country = <Input key="country" value={addr.country} onChange={set("country")} placeholder={t("country")} aria-label={t("country")} />;
const context = (
<Select key="context" value={addr.context} onChange={set("context")} aria-label={t("context_label")}>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{tx("context_private")}</option>
</Select>
);
return (
<div key={addr.key ?? `new-${i}`} data-address-layout={style.town} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
<IconButton label={t("remove_entry")} onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
<X />
</IconButton>
{/* Street and house number in one field, in the order the country writes them. */}
<Input value={addr.street} onChange={set("street")} placeholder={t("street")} aria-label={t("street")} className="pe-10" />
{style.town === "postcode-city" ? (
<>
<div className="grid grid-cols-3 gap-2">
{field("postcode", t("postcode"))}
{field("locality", t("city"), "col-span-2")}
</div>
<div className="grid grid-cols-3 gap-2">
{field("region", t("region"))}
{country}
{context}
</div>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2">
{field("locality", t("city"))}
{field("region", t("region"))}
</div>
<div className="grid grid-cols-3 gap-2">
{field("postcode", t("postcode"))}
{country}
{context}
</div>
</>
)}
</div>
<div className="grid grid-cols-3 gap-2">
<Input value={addr.postcode} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} aria-label={t("postcode")} />
<Input value={addr.country} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} aria-label={t("country")} />
<Select
value={addr.context}
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
aria-label={t("context_label")}
>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{tx("context_private")}</option>
</Select>
</div>
</div>
))}
);
})}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_address")}
+154
View File
@@ -0,0 +1,154 @@
import { test, expect, type Page } from '@playwright/test';
import { contextOptions } from './helpers/ent';
import { JmapClient } from './helpers/jmap';
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
/**
* Usability fix pim-4: saving a contact never rewrites an address the user
* didn't touch, and an edited address is saved in its country's order.
* Against a VM 106 Stalwart (1.0 and 0.16, which converts cards through
* vCard), every UI step is checked on the server.
*
* Account: dso-ux-pim (password IT_DSO_PASSWORD). The spec makes its own
* card, named "UXFIX-contact …", and removes it again.
*/
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
const A = 'dso-ux-pim@example.org';
const CORE = 'urn:ietf:params:jmap:core';
const CONTACTS = 'urn:ietf:params:jmap:contacts';
const RUN = Date.now().toString(36);
const PREFIX = 'UXFIX-contact';
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
type Address = Record<string, unknown> & { components?: Array<{ kind: string; value: string }> };
type Card = Record<string, unknown> & {
id: string;
name?: { full?: string; components?: Array<{ kind: string; value: string }> };
nicknames?: Record<string, { name: string }>;
addresses?: Record<string, Address>;
};
async function defaultBook(c: JmapClient): Promise<string> {
const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]);
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
return (list.find((b) => b.isDefault) ?? list[0]).id;
}
async function cards(c: JmapClient): Promise<Card[]> {
const r = await c.request([
['ContactCard/query', { accountId: c.accountId, limit: 1000 }, 'q'],
['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
], [CORE, CONTACTS]);
const named = (card: Card) => card.name?.full ?? card.name?.components?.map((x) => x.value).join(' ') ?? '';
return (r.methodResponses[1][1].list as Card[]).filter((card) => named(card).startsWith(PREFIX));
}
async function getCard(c: JmapClient, id: string): Promise<Card> {
const r = await c.request([['ContactCard/get', { accountId: c.accountId, ids: [id] }, '0']], [CORE, CONTACTS]);
return r.methodResponses[0][1].list[0] as Card;
}
async function login(page: Page): Promise<void> {
await neutralizeDevOverlay(page);
await page.goto('/');
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
await page.fill('#username', A);
await page.fill('#password', PASSWORD);
await page.click('button[type="submit"]');
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
}
test.describe('Saving a contact keeps its address (pim-4)', () => {
test.afterAll(async () => {
const a = await JmapClient.connect(A, PASSWORD);
const ids = (await cards(a)).filter((card) => JSON.stringify(card.name).includes(RUN)).map((card) => card.id);
if (ids.length) await a.request([['ContactCard/set', { accountId: a.accountId, destroy: ids }, '0']], [CORE, CONTACTS]);
});
test('a nickname edit leaves a German address as stored, an address edit saves it in German order', async ({ browser }) => {
test.setTimeout(240_000);
const a = await JmapClient.connect(A, PASSWORD);
const surname = `Adresse ${RUN}`;
const created = await a.request([['ContactCard/set', {
accountId: a.accountId,
create: {
c: {
'@type': 'Card',
version: '1.0',
kind: 'individual',
addressBookIds: { [await defaultBook(a)]: true },
name: { '@type': 'Name', components: [{ kind: 'given', value: PREFIX }, { kind: 'surname', value: surname }], isOrdered: true },
phones: { p1: { '@type': 'Phone', number: '+49 30 123456', features: { voice: true }, contexts: { private: true } } },
addresses: {
home: {
'@type': 'Address',
contexts: { private: true },
countryCode: 'DE',
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' },
],
},
},
},
},
}, '0']], [CORE, CONTACTS]);
const id = created.methodResponses[0][1].created?.c?.id as string | undefined;
expect(id, JSON.stringify(created.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
// The address as this server keeps it (0.16 goes through vCard).
const stored = (await getCard(a, id!)).addresses!;
expect(Object.keys(stored)).toHaveLength(1);
const context = await browser.newContext(contextOptions());
const page = await context.newPage();
await login(page);
await page.goto('/contacts');
await page.getByPlaceholder('Search contacts...').fill(RUN);
await page.locator('[data-contact-id]', { hasText: surname }).first().click();
// The contact page lays the address out the German way, one line per part.
const addressLines = page.getByTestId('contact-address').first().locator('[data-address-line]');
await expect(addressLines).toHaveText(['Hauptstraße 1, 3. OG', '10000 Berlin', 'Germany']);
await expect(page.getByText('Home · Phone', { exact: true })).toBeVisible();
// 1. Only the nickname changes: the server's address stays exactly as it was.
await page.getByRole('button', { name: 'Edit contact' }).click();
await expect(page.getByLabel('Street')).toHaveValue('Hauptstraße 1');
await page.getByLabel('Nickname').fill('Adi');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Contact updated')).toBeVisible({ timeout: 30000 });
await expect.poll(async () => Object.values((await getCard(a, id!)).nicknames ?? {})[0]?.name, { timeout: 30000 }).toBe('Adi');
expect((await getCard(a, id!)).addresses).toEqual(stored);
// 2. The address is edited: saved in German order, apartment and context kept.
await page.getByRole('button', { name: 'Edit contact' }).click();
await page.getByLabel('Street').fill('Hauptstraße 12a');
await page.getByLabel('Postal code').fill('10115');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(async () => {
const address = Object.values((await getCard(a, id!)).addresses ?? {})[0];
return address?.components?.map((x) => `${x.kind}:${x.value}`);
}, { timeout: 30000 }).toEqual([
'name:Hauptstraße', 'separator: ', 'number:12a', 'apartment:3. OG', 'postcode:10115', 'separator: ', 'locality:Berlin', 'country:Germany',
]);
const edited = Object.values((await getCard(a, id!)).addresses!)[0];
expect(edited).toMatchObject({ countryCode: 'DE', contexts: { private: true }, isOrdered: true });
await expect(addressLines).toHaveText(['Hauptstraße 12a, 3. OG', '10115 Berlin', 'Germany']);
// A reload reads the card back from the server, not from the app's memory.
await page.reload();
await page.getByPlaceholder('Search contacts...').fill(RUN);
await page.locator('[data-contact-id]', { hasText: surname }).first().click();
await expect(addressLines).toHaveText(['Hauptstraße 12a, 3. OG', '10115 Berlin', 'Germany']);
await context.close();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
addressStyle,
addressToParts,
countryCodeFromName,
formatAddressLines,
partsToComponents,
splitStreet,
} from '../address-format';
import type { ContactAddress } from '@/lib/jmap/types';
const german: ContactAddress = {
components: [
{ kind: 'name', value: 'Hauptstraße' },
{ kind: 'number', value: '1' },
{ kind: 'postcode', value: '10000' },
{ kind: 'locality', value: 'Berlin' },
{ kind: 'country', value: 'Germany' },
],
};
describe('address-format', () => {
it('knows countries by code and by name in several languages', () => {
expect(countryCodeFromName('Germany')).toBe('DE');
expect(countryCodeFromName('Deutschland')).toBe('DE');
expect(countryCodeFromName('Schweiz')).toBe('CH');
expect(countryCodeFromName('de')).toBe('DE');
expect(countryCodeFromName('USA')).toBe('US');
expect(countryCodeFromName('United Kingdom')).toBe('GB');
expect(countryCodeFromName('Atlantis')).toBeNull();
});
it('puts the number after the street for Germany and before it for the US', () => {
expect(addressStyle('Germany').street).toBe('number-after');
expect(addressStyle('Österreich').street).toBe('number-after');
expect(addressStyle('Netherlands').street).toBe('number-after');
expect(addressStyle('United States').street).toBe('number-before');
expect(addressStyle('France')).toEqual({ street: 'number-before', town: 'postcode-city' });
expect(addressStyle('', 'CA').town).toBe('city-region-postcode');
// No country: the UI language's.
expect(addressStyle('', null, 'de').street).toBe('number-after');
expect(addressStyle('', null, 'en').street).toBe('number-before');
});
it('shows street and number in one field in the country\'s order', () => {
expect(addressToParts(german).street).toBe('Hauptstraße 1');
expect(addressToParts({ ...german, countryCode: 'US', components: [
{ kind: 'number', value: '1' }, { kind: 'name', value: 'Main Street' }, { kind: 'country', value: 'USA' },
] }).street).toBe('1 Main Street');
// A country this file doesn't know keeps the card's own order.
expect(addressToParts({ components: [
{ kind: 'name', value: 'Ulitsa' }, { kind: 'number', value: '7' }, { kind: 'country', value: 'Atlantis' },
] }).street).toBe('Ulitsa 7');
});
it('splits a typed street line where the country puts the number', () => {
expect(splitStreet('Hauptstraße 12a', 'number-after')).toEqual({ name: 'Hauptstraße', number: '12a' });
expect(splitStreet('Am Markt 3-5', 'number-after')).toEqual({ name: 'Am Markt', number: '3-5' });
expect(splitStreet('Am Markt', 'number-after')).toEqual({ name: 'Am Markt', number: '' });
expect(splitStreet('221B Baker Street', 'number-before')).toEqual({ name: 'Baker Street', number: '221B' });
expect(splitStreet('Baker Street 221', 'number-before')).toEqual({ name: 'Baker Street 221', number: '' });
});
it('builds components in the country\'s order, keeping parts the form doesn\'t show', () => {
const parts = { street: 'Hauptstraße 1', locality: 'Berlin', region: '', postcode: '10000', country: 'Germany' };
const components = partsToComponents(parts, [{ kind: 'apartment', value: '3. OG' }]);
expect(components.map(c => `${c.kind}:${c.value}`)).toEqual([
'name:Hauptstraße', 'separator: ', 'number:1', 'apartment:3. OG', 'postcode:10000', 'separator: ', 'locality:Berlin', 'country:Germany',
]);
const us = partsToComponents({ street: '1 Main St', locality: 'Springfield', region: 'IL', postcode: '62701', country: 'USA' });
expect(us.map(c => `${c.kind}:${c.value}`)).toEqual([
'number:1', 'separator: ', 'name:Main St', 'locality:Springfield', 'region:IL', 'separator: ', 'postcode:62701', 'country:USA',
]);
});
it('formats an address one line per part, for its country', () => {
expect(formatAddressLines(german)).toEqual(['Hauptstraße 1', '10000 Berlin', 'Germany']);
expect(formatAddressLines({ components: [
{ kind: 'number', value: '1' }, { kind: 'name', value: 'Main St' }, { kind: 'locality', value: 'Springfield' },
{ kind: 'region', value: 'IL' }, { kind: 'postcode', value: '62701' }, { kind: 'country', value: 'USA' },
] })).toEqual(['1 Main St', 'Springfield, IL 62701', 'USA']);
expect(formatAddressLines({ street: '10 Downing Street', locality: 'London', postcode: 'SW1A 2AA', country: 'United Kingdom' }))
.toEqual(['10 Downing Street', 'London', 'SW1A 2AA', 'United Kingdom']);
expect(formatAddressLines({ full: 'Somewhere 1\n12345 Town' })).toEqual(['Somewhere 1', '12345 Town']);
});
});
+262
View File
@@ -0,0 +1,262 @@
import type { AddressComponent, ContactAddress } from "@/lib/jmap/types";
/**
* Postal addresses laid out the way the address's country writes them.
*
* Two things differ between countries here: whether the house number comes
* after the street name ("Hauptstraße 1") or before it ("1 Main Street"), and
* whether the postcode leads the town line ("10115 Berlin", "75001 Paris"),
* follows it ("Springfield, IL 62701") or has a line of its own (UK).
*/
export type StreetOrder = "number-after" | "number-before";
export type TownLine = "postcode-city" | "city-region-postcode" | "city-then-postcode";
export interface AddressStyle {
street: StreetOrder;
town: TownLine;
}
const NUMBER_AFTER = [
"DE", "AT", "CH", "LI", "NL", "BE", "IT", "SM", "VA", "ES", "AD", "PT", "DK", "IS", "NO", "SE", "FI",
"PL", "CZ", "SK", "HU", "SI", "HR", "BA", "RS", "ME", "MK", "AL", "RO", "BG", "GR", "CY", "TR",
"EE", "LV", "LT", "RU", "UA", "BY", "MX", "AR", "BR", "CL", "UY", "PY", "BO", "PE", "VE", "EC",
];
const NUMBER_BEFORE = ["US", "CA", "GB", "IE", "FR", "MC", "LU", "AU", "NZ", "ZA", "IN", "SG", "PH", "MY", "IL", "HK"];
const CITY_REGION_POSTCODE = ["US", "CA", "AU"];
const CITY_THEN_POSTCODE = ["GB", "IE"];
const KNOWN_CODES = [...NUMBER_AFTER, ...NUMBER_BEFORE];
/** The style for an ISO 3166 code, or null for a country this file doesn't know. */
export function styleForCountryCode(code: string | undefined | null): AddressStyle | null {
const cc = code?.trim().toUpperCase();
if (!cc) return null;
const c = cc === "UK" ? "GB" : cc;
if (!KNOWN_CODES.includes(c)) return null;
return {
street: NUMBER_AFTER.includes(c) ? "number-after" : "number-before",
town: CITY_REGION_POSTCODE.includes(c) ? "city-region-postcode"
: CITY_THEN_POSTCODE.includes(c) ? "city-then-postcode"
: "postcode-city",
};
}
// Country names as people type them: each known code's name in these
// languages, plus a few short forms.
const NAME_LOCALES = ["en", "de", "fr", "es", "it", "nl", "pt", "pl", "sv", "da", "nb", "fi", "cs", "sk", "hu", "tr", "ru", "uk", "ro", "el", "hr", "sl", "et", "lv", "lt", "ca"];
const ALIASES: Record<string, string> = {
usa: "US", "u.s.a.": "US", "u.s.": "US", "united states of america": "US", america: "US",
uk: "GB", "u.k.": "GB", england: "GB", scotland: "GB", wales: "GB", "great britain": "GB", "northern ireland": "GB",
holland: "NL", brd: "DE", "bundesrepublik deutschland": "DE", "the netherlands": "NL",
"schweiz": "CH", "suisse": "CH", "svizzera": "CH", "österreich": "AT", "osterreich": "AT",
};
let nameIndex: Map<string, string> | null = null;
function normalise(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, " ");
}
function countryNameIndex(): Map<string, string> {
if (nameIndex) return nameIndex;
const index = new Map<string, string>();
for (const [alias, code] of Object.entries(ALIASES)) index.set(alias, code);
for (const locale of NAME_LOCALES) {
let names: Intl.DisplayNames;
try {
names = new Intl.DisplayNames([locale], { type: "region" });
} catch {
continue;
}
for (const code of KNOWN_CODES) {
const name = names.of(code);
if (name && name !== code) {
const key = normalise(name);
if (!index.has(key)) index.set(key, code);
}
}
}
nameIndex = index;
return index;
}
/** The ISO code for a typed country ("Germany", "Deutschland", "DE"), or null. */
export function countryCodeFromName(country: string | undefined | null): string | null {
const key = normalise(country ?? "");
if (!key) return null;
if (/^[a-z]{2}$/.test(key)) {
const code = key.toUpperCase() === "UK" ? "GB" : key.toUpperCase();
return KNOWN_CODES.includes(code) ? code : null;
}
return countryNameIndex().get(key) ?? null;
}
// The country a UI language most likely means, for addresses without one.
const LANGUAGE_COUNTRY: Record<string, string> = {
de: "DE", nl: "NL", it: "IT", es: "ES", ca: "ES", pt: "PT", da: "DK", nb: "NO", sv: "SE", fi: "FI",
pl: "PL", cs: "CZ", sk: "SK", hu: "HU", ro: "RO", tr: "TR", et: "EE", lv: "LV", lt: "LT", ru: "RU",
uk: "UA", el: "GR", fr: "FR", en: "US",
};
/**
* The style for an address: from its country code, else its country's name,
* else the UI language's country, else US style.
*/
export function addressStyle(country: string | undefined | null, countryCode?: string | null, locale?: string): AddressStyle {
return styleForCountryCode(countryCode)
?? styleForCountryCode(countryCodeFromName(country))
?? styleForCountryCode(LANGUAGE_COUNTRY[(locale ?? "").split(/[-_]/)[0].toLowerCase()])
?? { street: "number-before", town: "city-region-postcode" };
}
/** "Hauptstraße" + "1" as the country writes it. */
export function joinStreet(name: string, number: string, order: StreetOrder): string {
const parts = order === "number-after" ? [name, number] : [number, name];
return parts.map(p => p.trim()).filter(Boolean).join(" ");
}
const HOUSE_NUMBER = String.raw`\d+\s?[a-zA-Z]?(?:\s?[-–/]\s?\d+\s?[a-zA-Z]?)?`;
const NUMBER_LAST = new RegExp(String.raw`^(.*\S)\s+(${HOUSE_NUMBER})$`);
const NUMBER_FIRST = new RegExp(String.raw`^(${HOUSE_NUMBER})\s+(\S.*)$`);
/**
* Splits a typed street line into street name and house number where the
* number sits where the country puts it. Anything else stays whole as the
* name ("Am Markt", "Hauptstraße 1, Hinterhaus").
*/
export function splitStreet(line: string, order: StreetOrder): { name: string; number: string } {
const text = line.trim().replace(/\s+/g, " ");
if (order === "number-after") {
const m = NUMBER_LAST.exec(text);
if (m) return { name: m[1].replace(/,$/, ""), number: m[2] };
} else {
const m = NUMBER_FIRST.exec(text);
if (m) return { name: m[2], number: m[1].replace(/,$/, "") };
}
return { name: text, number: "" };
}
/** Kinds the contact form shows as fields of their own. */
const FORM_KINDS = new Set(["name", "number", "locality", "region", "postcode", "country", "separator"]);
/** Kinds that belong on the street line ("Hauptstraße 1, 3. OG"). */
const STREET_EXTRAS = new Set(["apartment", "room", "floor", "building", "block", "direction", "landmark"]);
function valuesOf(components: AddressComponent[], kind: string): string {
return components.filter(c => c.kind === kind).map(c => c.value?.trim()).filter(Boolean).join(" ");
}
/** The parts of an address as the contact form edits them. */
export interface AddressParts {
street: string;
locality: string;
region: string;
postcode: string;
country: string;
}
/**
* The form's fields for an address. Street name and number become one line in
* the order of the address's country; for a country not known here, in the
* order the components have.
*/
export function addressToParts(address: ContactAddress, locale?: string): AddressParts {
const components = address.components ?? [];
if (components.length === 0) {
return {
street: address.street ?? "",
locality: address.locality ?? "",
region: address.region ?? "",
postcode: address.postcode ?? "",
country: address.country ?? "",
};
}
const country = valuesOf(components, "country");
const name = valuesOf(components, "name");
const number = valuesOf(components, "number");
const known = styleForCountryCode(address.countryCode) ?? styleForCountryCode(countryCodeFromName(country));
let order: StreetOrder;
if (known) order = known.street;
else {
const nameAt = components.findIndex(c => c.kind === "name");
const numberAt = components.findIndex(c => c.kind === "number");
order = nameAt >= 0 && numberAt >= 0
? (numberAt > nameAt ? "number-after" : "number-before")
: addressStyle(country, null, locale).street;
}
return {
street: joinStreet(name, number, order),
locality: valuesOf(components, "locality"),
region: valuesOf(components, "region"),
postcode: valuesOf(components, "postcode"),
country,
};
}
const sep = (value: string): AddressComponent => ({ kind: "separator", value });
/**
* RFC 9553 components for edited form fields, in the country's order, with
* separators inside a line and `defaultSeparator` (", ") between lines.
* Components the form doesn't show (apartment, district, PO box...) are kept
* from `previous`.
*/
export function partsToComponents(parts: AddressParts, previous: AddressComponent[] = [], locale?: string, countryCode?: string | null): AddressComponent[] {
const style = addressStyle(parts.country, countryCode, locale);
const { name, number } = splitStreet(parts.street, style.street);
const extras = previous.filter(c => !FORM_KINDS.has(c.kind) && c.value?.trim());
const comp = (kind: string, value: string): AddressComponent[] => value.trim() ? [{ kind, value: value.trim() }] : [];
const line = (...groups: AddressComponent[][]): AddressComponent[] => {
const filled = groups.filter(g => g.length > 0);
return filled.flatMap((g, i) => (i === 0 ? g : [sep(" "), ...g]));
};
const streetLine = style.street === "number-after"
? line(comp("name", name), comp("number", number))
: line(comp("number", number), comp("name", name));
const streetExtras = extras.filter(c => STREET_EXTRAS.has(c.kind));
const otherExtras = extras.filter(c => !STREET_EXTRAS.has(c.kind));
const lines: AddressComponent[][] = [streetLine, ...streetExtras.map(c => [c]), ...otherExtras.map(c => [c])];
if (style.town === "postcode-city") {
lines.push(line(comp("postcode", parts.postcode), comp("locality", parts.locality)), comp("region", parts.region));
} else if (style.town === "city-region-postcode") {
lines.push(comp("locality", parts.locality), line(comp("region", parts.region), comp("postcode", parts.postcode)));
} else {
lines.push(comp("locality", parts.locality), comp("region", parts.region), comp("postcode", parts.postcode));
}
lines.push(comp("country", parts.country));
// Lines follow each other through defaultSeparator, so no separator between them.
return lines.filter(l => l.length > 0).flat();
}
/**
* An address as lines to show, laid out for its country: "Hauptstraße 1" /
* "10000 Berlin" / "Germany". Addresses without components show their `full`
* text or their flat fields.
*/
export function formatAddressLines(address: ContactAddress, locale?: string): string[] {
const components = (address.components ?? []).filter(c => c.kind !== "separator" && c.value?.trim());
if (components.length === 0) {
const full = address.full || address.fullAddress;
if (full) return full.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
}
const parts = addressToParts(address, locale);
const style = addressStyle(parts.country, address.countryCode, locale);
const lines: string[] = [];
const extras = components.filter(c => !FORM_KINDS.has(c.kind));
const streetExtras = extras.filter(c => STREET_EXTRAS.has(c.kind)).map(c => c.value.trim());
const street = [parts.street, ...streetExtras].filter(Boolean).join(", ");
if (street) lines.push(street);
for (const c of extras.filter(c => !STREET_EXTRAS.has(c.kind))) lines.push(c.value.trim());
const join = (...xs: string[]) => xs.map(x => x.trim()).filter(Boolean).join(" ");
if (style.town === "postcode-city") {
lines.push(join(parts.postcode, parts.locality), parts.region);
} else if (style.town === "city-region-postcode") {
const regionPostcode = join(parts.region, parts.postcode);
lines.push([parts.locality.trim(), regionPostcode].filter(Boolean).join(", "));
} else {
lines.push(parts.locality, parts.region, parts.postcode);
}
lines.push(parts.country);
return lines.map(l => l.trim()).filter(Boolean);
}