mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: keep a contact's address as stored when other fields change, and lay addresses out by country
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user