Merge branch 'feat/do-ux-pim-contact' into feat/do-ux-pim-fixes

This commit is contained in:
Linus Rath
2026-11-04 11:09:24 +01:00
44 changed files with 1428 additions and 261 deletions
@@ -68,7 +68,7 @@ describe('ContactDetail', () => {
render(<ContactDetail contact={typed} onEdit={vi.fn()} onDelete={vi.fn()} />);
// The test translator returns the key, so a translated label shows up as its key.
expect(screen.getByText('form.context_work')).toBeInTheDocument();
expect(screen.getByText('form.context_private · form.phone_cell, x-sat')).toBeInTheDocument();
expect(screen.getByText('ux_pim_contact.context_private · form.phone_cell, x-sat')).toBeInTheDocument();
// Custom vCard types are shown as stored.
expect(screen.getByText('form.context_work, x-hq')).toBeInTheDocument();
});
@@ -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']);
});
});
@@ -215,7 +215,7 @@ describe('ContactsApp', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
fireEvent.click(screen.getByText('import.title'));
fireEvent.click(screen.getByText('import_menu'));
const dialog = screen.getByRole('dialog', { name: 'import.title' });
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull();
@@ -273,7 +273,7 @@ describe('ContactsApp', () => {
try {
renderContacts();
fireEvent.click(screen.getByRole('button', { name: 'more' }));
fireEvent.click(screen.getByText('import.title'));
fireEvent.click(screen.getByText('import_menu'));
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
} finally {
window.matchMedia = matchMedia;
@@ -287,7 +287,7 @@ describe('ContactsApp', () => {
fireEvent.click(within(drawer()).getByText('Work'));
expect(screen.getByText('empty_view_title')).toBeInTheDocument();
expect(screen.queryByText('empty_state_title')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /import.title/ }));
fireEvent.click(screen.getByRole('button', { name: /import_menu/ }));
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
});
@@ -307,4 +307,16 @@ describe('ContactsApp', () => {
fireEvent.click(screen.getByText('context_menu.duplicate'));
await waitFor(() => expect(useToastStore.getState().toasts.at(-1)?.title).toBe('toast.error_create'));
});
it("marks no member in the list on a group's page, even with a contact open before", () => {
const team: ContactCard = { id: 'g-team', addressBookIds: {}, kind: 'group', name: { full: 'Team' }, members: { 'c-alice': true, 'c-bob': true } };
renderContacts([alice, bob, team]);
fireEvent.click(screen.getByText('Alice Smith'));
expect(document.querySelector('[data-contact-id="c-alice"]')).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
fireEvent.click(within(drawer()).getByText('Team'));
expect(screen.getByText('groups.members_label')).toBeInTheDocument();
expect(document.querySelector('[data-contact-id="c-alice"]')).toHaveAttribute('aria-selected', 'false');
expect(document.querySelectorAll('[data-contact-id][aria-selected="true"]')).toHaveLength(0);
});
});
@@ -0,0 +1,185 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
// Keys with their namespace, so a label read from ux_pim_contact can be told
// from the old one in contacts.form.
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], activeAccountId: null };
const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return { useAccountStore: hook };
});
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
import { ContactForm } from '../contact-form';
import { ContactDetail } from '../contact-detail';
import { ContactsSidebar } from '../contacts-sidebar';
import { ContactList } from '../contact-list';
import { ContactGroupDetail } from '../contact-group-detail';
const anna: ContactCard = {
id: 'c1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true },
emails: { e0: { address: 'anna@example.net', contexts: { private: true } } },
phones: { p0: { number: '+49 30 1', features: { voice: true }, contexts: { private: true } } },
};
const ben: ContactCard = {
id: 'c2',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Ben' }, { kind: 'surname', value: 'Muster' }], isOrdered: true },
emails: { e0: { address: 'ben@example.net' } },
};
describe('pim-19: standard words read as people say them', () => {
it('offers "Phone" and "Home" in the editor, storing voice and private as before', () => {
render(<ContactForm contact={anna} onSave={vi.fn()} onCancel={vi.fn()} />);
const phoneType = screen.getByDisplayValue('ux_pim_contact.phone_voice') as HTMLSelectElement;
expect(phoneType.value).toBe('voice');
expect(screen.getAllByDisplayValue('ux_pim_contact.context_private').map(s => (s as HTMLSelectElement).value)).toEqual(['private', 'private']);
expect(screen.queryByText('contacts.form.phone_voice')).toBeNull();
expect(screen.queryByText('contacts.form.context_private')).toBeNull();
});
it('labels a voice phone "Phone" and a private one "Home" on the contact page', () => {
render(<ContactDetail contact={anna} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('ux_pim_contact.context_private · ux_pim_contact.phone_voice')).toBeInTheDocument();
});
});
describe('pim-21: a long name in the contact header', () => {
it('wraps to two lines and shows the whole name on hover', () => {
const long = { ...anna, name: { full: 'Maximiliane Alexandra Friederike von Hohenzollern-Sigmaringen-Wittelsbach' } };
render(<ContactDetail contact={long} onEdit={vi.fn()} onDelete={vi.fn()} />);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('title', long.name.full);
expect(heading.className).toContain('line-clamp-2');
expect(heading.className).not.toMatch(/\btruncate\b/);
});
});
describe('pim-26: the contact editor keeps rare fields under "More fields"', () => {
it('collapses Gender, Calendar, Online services and Personal info when none has a value', () => {
render(<ContactForm contact={anna} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText('ux_pim_contact.more_fields')).toBeInTheDocument();
expect(screen.queryByText('contacts.form.gender_sex')).toBeNull();
expect(screen.queryByText('contacts.form.calendar_uri')).toBeNull();
expect(screen.queryByText('contacts.form.add_online_service')).toBeNull();
expect(screen.queryByText('contacts.form.add_personal_info')).toBeNull();
fireEvent.click(screen.getByText('ux_pim_contact.more_fields'));
expect(screen.getByText('contacts.form.gender_sex')).toBeInTheDocument();
expect(screen.getByText('contacts.form.calendar_uri')).toBeInTheDocument();
expect(screen.getByText('contacts.form.add_online_service')).toBeInTheDocument();
expect(screen.getByText('contacts.form.add_personal_info')).toBeInTheDocument();
});
it('opens "More fields" when one of them has a value', () => {
render(<ContactForm contact={{ ...anna, calendarUri: 'https://cal.example/anna' }} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByDisplayValue('https://cal.example/anna')).toBeInTheDocument();
});
});
function renderSidebar(addressBooks: AddressBook[]) {
return render(
<ContactsSidebar
groups={[]}
individuals={[]}
addressBooks={addressBooks}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onCreateAddressBook={vi.fn()}
onImport={vi.fn()}
onEditGroup={vi.fn()}
onDeleteGroup={vi.fn()}
/>,
);
}
describe('pim-22: the Contacts sidebar', () => {
it("leaves out the app's own Trusted Senders book, which Settings manages", () => {
const { unmount } = renderSidebar([
{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook,
{ id: 'b2', name: 'Trusted Senders', isDefault: false } as AddressBook,
]);
expect(screen.getByText('Contacts')).toBeInTheDocument();
expect(screen.queryByText('Trusted Senders')).toBeNull();
unmount();
// Someone else's book with that name is theirs, not the app's list.
renderSidebar([
{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook,
{ id: 'b3', name: 'Trusted Senders', isShared: true, accountId: 'acc-b', accountName: 'b@example.org' } as AddressBook,
]);
expect(screen.getByText('Trusted Senders')).toBeInTheDocument();
});
});
describe('pim-26: the Contacts menus', () => {
it('ends "Import contacts" and "New address book" in "…", as they open a dialog', () => {
renderSidebar([{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook]);
fireEvent.click(screen.getByRole('button', { name: 'contacts.new' }));
const menu = screen.getByRole('menu');
expect(within(menu).getByText('ux_pim_contact.import_menu')).toBeInTheDocument();
expect(within(menu).getByText('ux_pim_contact.new_address_book_menu')).toBeInTheDocument();
});
});
const listProps = {
contacts: [anna, ben],
searchQuery: '',
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(),
onSelectAll: vi.fn(),
onClearSelection: vi.fn(),
onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(),
onEditContact: vi.fn(),
onDeleteContact: vi.fn(),
onAddContactToGroup: vi.fn(),
};
describe('pim-17: right-clicking a contact marks whose menu is open', () => {
it('gives the right-clicked row the hover colour while its menu is open', () => {
render(<ContactList {...listProps} selectedContactId="c1" />);
const row = (id: string) => document.querySelector(`[data-contact-id="${id}"]`) as HTMLElement;
expect(row('c2')).not.toHaveAttribute('data-menu-open');
fireEvent.contextMenu(row('c2'));
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(row('c2')).toHaveAttribute('data-menu-open');
expect(row('c2').className).toMatch(/(^|\s)bg-muted(\s|$)/);
// The open contact stays as it is.
expect(row('c1')).not.toHaveAttribute('data-menu-open');
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
expect(row('c2')).not.toHaveAttribute('data-menu-open');
});
});
describe("pim-18: a group's page manages its members", () => {
it('shows "Remove from group" on every member row, not only on hover', () => {
render(
<ContactGroupDetail
group={{ id: 'g1', addressBookIds: {}, kind: 'group', name: { full: 'Team' }, members: { c1: true, c2: true } }}
members={[anna, ben]}
onEdit={vi.fn()}
onDelete={vi.fn()}
onRemoveMember={vi.fn()}
onSelectMember={vi.fn()}
/>,
);
const remove = screen.getAllByRole('button', { name: 'contacts.groups.remove_member' });
expect(remove).toHaveLength(2);
for (const button of remove) expect(button.className).not.toMatch(/(^|\s)opacity-0(\s|$)/);
});
});
+16 -20
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";
@@ -40,14 +41,15 @@ interface ContactDetailProps {
}
// Contexts and phone features the contact form offers, shown with the same
// labels here. Anything else (custom vCard types) is shown as stored.
// labels here. Anything else (custom vCard types) is shown as stored. The
// standard's "private" reads "Home" and "voice" reads "Phone" (#pim-19).
const CONTEXT_LABEL_KEYS: Record<string, string> = {
work: "form.context_work",
private: "form.context_private",
private: "ux_pim_contact.context_private",
};
const PHONE_FEATURE_LABEL_KEYS: Record<string, string> = {
voice: "form.phone_voice",
voice: "ux_pim_contact.phone_voice",
cell: "form.phone_cell",
mobile: "form.phone_cell",
fax: "form.phone_fax",
@@ -78,12 +80,14 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tRoot = useTranslations();
const tCommon = useTranslations("common");
const locale = useLocale();
// The ⋮ shows the contact's menu, the one right-click shows in the list (R6).
const moreMenu = useContextMenu<ContactCard>();
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
const label = (labelKey: string) => (labelKey.startsWith("ux_pim_contact.") ? tRoot(labelKey) : t(labelKey));
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? label(CONTEXT_LABEL_KEYS[key]) : key);
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? label(PHONE_FEATURE_LABEL_KEYS[key]) : key);
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
@@ -148,7 +152,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{/* A long name takes two lines, then ends in "…" with the whole name on hover. */}
<h2 title={name || undefined} className={cn("font-semibold line-clamp-2 break-words", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
@@ -264,23 +269,14 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
})}
{addresses.map((a, i) => {
const lines: string[] = [];
if (a.full || a.fullAddress) {
lines.push((a.full || a.fullAddress) as string);
} else if (a.components && a.components.length > 0) {
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
if (joined) lines.push(joined);
} else {
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
.map(s => (typeof s === "string" ? s.trim() : ""))
.filter(Boolean) as string[];
lines.push(...parts);
}
// One line per part, in the order of the address's country:
// "Hauptstraße 1" / "10000 Berlin" / "Germany".
const lines = formatAddressLines(a, locale);
return (
<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>
+363 -224
View File
@@ -2,8 +2,8 @@
import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
import { useLocale, useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2, MoreHorizontal } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
@@ -11,38 +11,47 @@ import { Avatar } from "@/components/ui/avatar";
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
import type { ContactSaveOptions } from "@/lib/contact-media";
import { cn } from "@/lib/utils";
import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, type AddressStyle } from "@/lib/address-format";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
// Each row of a list field keeps the `key` of the card's entry it was read
// from, so Save can send an entry the user didn't touch back unchanged.
interface EmailEntry {
key?: string;
address: string;
context: "work" | "private" | "";
}
interface PhoneEntry {
key?: string;
number: string;
context: "work" | "private" | "";
feature: "voice" | "mobile" | "fax" | "pager" | "video" | "text" | "";
}
interface OnlineServiceEntry {
key?: string;
uri: string;
service: string;
label: string;
}
interface AnniversaryEntry {
key?: string;
date: string;
kind: "birth" | "death" | "wedding" | "other";
}
interface PersonalInfoEntry {
key?: string;
value: string;
kind: "expertise" | "hobby" | "interest" | "other";
level: "high" | "medium" | "low" | "";
}
interface AddressEntry {
key?: string;
street: string;
locality: string;
region: string;
@@ -125,6 +134,62 @@ function mediaForPhotoEdit(
return { media, photoKey };
}
/**
* The map to save for a list field (emails, phones, addresses...). A row the
* user didn't touch goes back exactly as the card had it, under its key. An
* edited row is built on the card's entry (`previous`), so properties the
* form doesn't show stay; a new row gets a key no other entry uses.
*/
function entriesToSave<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;
@@ -220,6 +285,8 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const t = useTranslations("contacts.form");
const tx = useTranslations("ux_pim_contact");
const locale = useLocale();
const isEditing = !!contact;
// The header's close goes to its start when the window's own close is at its end.
const [header, setHeader] = useState<HTMLDivElement | null>(null);
@@ -282,25 +349,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
return pd;
}
// Extract flat address fields from RFC 9553 components format
function addressToFlat(a: ContactAddress): AddressEntry {
if (a.components && a.components.length > 0) {
const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
return {
street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
locality: findComp('locality'),
region: findComp('region'),
postcode: findComp('postcode'),
country: findComp('country'),
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
// The form's fields for an address: street name and house number in one
// field, in the order of the address's country ("Hauptstraße 1").
function addressToFlat(key: string, a: ContactAddress): AddressEntry {
return {
street: a.street || '',
locality: a.locality || '',
region: a.region || '',
postcode: a.postcode || '',
country: a.country || '',
key,
...addressToParts(a, locale),
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
@@ -317,7 +371,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [emails, setEmails] = useState<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" : "",
}));
@@ -327,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".
@@ -371,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 || "",
@@ -389,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,
}));
@@ -399,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 || "",
@@ -474,9 +533,37 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
]);
const [initialFields] = useState(fields);
// What each card property is built from. Saving an existing card sends only
// the properties whose fields changed, so the rest stay exactly as stored,
// structured address parts included (#pim-4).
const personNamed = !!(givenName.trim() || surname.trim());
const propertyFields: Record<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();
const [hasMoreFields] = useState(() => onlineServices.length > 0 || personalInfoEntries.length > 0
|| !!(genderSex || genderIdentity) || !!(calendarUri || schedulingUri || freeBusyUri));
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -544,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 = [];
@@ -594,41 +675,58 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" };
const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined;
const firstOrg = Object.values(contact?.organizations ?? {})[0];
const orgUnits = firstOrg && department === initialRows.department
? firstOrg.units
: department.trim() ? [{ name: department.trim() }] : undefined;
const addressesMap: Record<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 => {
@@ -648,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,
@@ -675,6 +770,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
// An existing card gets only what changed: every property left out stays
// on the server exactly as it is.
if (contact) {
const before = JSON.parse(initialPropertyFields) as Record<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
@@ -916,7 +1020,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
<option value="private">{tx("context_private")}</option>
</Select>
{emails.length > 1 && (
<IconButton label={t("remove_entry")} onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="shrink-0">
@@ -966,7 +1070,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
aria-label={t("phone_type")}
>
<option value="">{t("phone_type")}</option>
<option value="voice">{t("phone_voice")}</option>
<option value="voice">{tx("phone_voice")}</option>
<option value="mobile">{t("phone_cell")}</option>
<option value="fax">{t("phone_fax")}</option>
<option value="pager">{t("phone_pager")}</option>
@@ -984,7 +1088,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
<option value="private">{tx("context_private")}</option>
</Select>
<IconButton label={t("remove_entry")} onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="shrink-0">
<X />
@@ -1026,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">{t("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")}
@@ -1058,37 +1192,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
</FormSection>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={svc.uri}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
placeholder={t("url_placeholder")}
aria-label={t("online_service_url")}
className="flex-1"
/>
<Input
value={svc.service}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
placeholder={t("service_placeholder")}
aria-label={t("service_placeholder")}
className="w-24"
/>
<IconButton label={t("remove_entry")} onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_online_service")}
</Button>
</div>
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
<div className="space-y-2">
@@ -1123,50 +1226,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={pi.value}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
placeholder={t("personal_info_placeholder")}
aria-label={t("personal_info")}
className="flex-1"
/>
<Select
value={pi.kind}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
aria-label={t("personal_info_kind")}
>
<option value="expertise">{t("personal_expertise")}</option>
<option value="hobby">{t("personal_hobby")}</option>
<option value="interest">{t("personal_interest")}</option>
<option value="other">{t("personal_other")}</option>
</Select>
<Select
value={pi.level}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
aria-label={t("level")}
>
<option value="">{t("level")}</option>
<option value="high">{t("level_high")}</option>
<option value="medium">{t("level_medium")}</option>
<option value="low">{t("level_low")}</option>
</Select>
<IconButton label={t("remove_entry")} onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_personal_info")}
</Button>
</div>
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox
@@ -1181,45 +1240,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
/>
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor={fieldId("gender_sex")} className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select id={fieldId("gender_sex")} value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value="">-</option>
<option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option>
<option value="none">{t("gender_none")}</option>
<option value="unknown">{t("gender_unknown")}</option>
</Select>
</div>
<div>
<label htmlFor={fieldId("gender_identity")} className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
<Input id={fieldId("gender_identity")} value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
</div>
</div>
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
<div className="space-y-3">
<div>
<label htmlFor={fieldId("calendar_uri")} className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
<Input id={fieldId("calendar_uri")} value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label htmlFor={fieldId("scheduling_uri")} className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
<Input id={fieldId("scheduling_uri")} value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label htmlFor={fieldId("freebusy_uri")} className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
<Input id={fieldId("freebusy_uri")} value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
</div>
</div>
</FormSection>
{/* Notes */}
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<textarea
@@ -1231,6 +1251,125 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
/>
</FormSection>
{/* Fields most cards don't use, collapsed unless one has a value. */}
<FormSection icon={MoreHorizontal} title={tx("more_fields")} collapsible defaultOpen={hasMoreFields}>
<div className="divide-y divide-border/60 -my-5">
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")}>
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={svc.uri}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
placeholder={t("url_placeholder")}
aria-label={t("online_service_url")}
className="flex-1"
/>
<Input
value={svc.service}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
placeholder={t("service_placeholder")}
aria-label={t("service_placeholder")}
className="w-24"
/>
<IconButton label={t("remove_entry")} onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_online_service")}
</Button>
</div>
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")}>
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={pi.value}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
placeholder={t("personal_info_placeholder")}
aria-label={t("personal_info")}
className="flex-1"
/>
<Select
value={pi.kind}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
aria-label={t("personal_info_kind")}
>
<option value="expertise">{t("personal_expertise")}</option>
<option value="hobby">{t("personal_hobby")}</option>
<option value="interest">{t("personal_interest")}</option>
<option value="other">{t("personal_other")}</option>
</Select>
<Select
value={pi.level}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
aria-label={t("level")}
>
<option value="">{t("level")}</option>
<option value="high">{t("level_high")}</option>
<option value="medium">{t("level_medium")}</option>
<option value="low">{t("level_low")}</option>
</Select>
<IconButton label={t("remove_entry")} onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_personal_info")}
</Button>
</div>
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor={fieldId("gender_sex")} className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select id={fieldId("gender_sex")} value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value="">-</option>
<option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option>
<option value="none">{t("gender_none")}</option>
<option value="unknown">{t("gender_unknown")}</option>
</Select>
</div>
<div>
<label htmlFor={fieldId("gender_identity")} className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
<Input id={fieldId("gender_identity")} value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
</div>
</div>
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")}>
<div className="space-y-3">
<div>
<label htmlFor={fieldId("calendar_uri")} className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
<Input id={fieldId("calendar_uri")} value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label htmlFor={fieldId("scheduling_uri")} className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
<Input id={fieldId("scheduling_uri")} value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label htmlFor={fieldId("freebusy_uri")} className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
<Input id={fieldId("freebusy_uri")} value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
</div>
</div>
</FormSection>
</div>
</FormSection>
</div>
</div>
</div>
+1 -4
View File
@@ -135,12 +135,9 @@ export function ContactGroupDetail({
)}
</div>
</button>
{/* Always shown: this list is where members are managed (#pim-18). */}
<IconButton
label={t("groups.remove_member", { name: mName })}
className={cn(
"transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
)}
onClick={() => onRemoveMember(member.id)}
>
<UserMinus className="text-muted-foreground" />
+5 -2
View File
@@ -25,9 +25,11 @@ interface ContactListItemProps {
onFocus?: () => void;
/** Long-press on touch: the row's menu, as right-click does (R7). */
onLongPress?: (row: HTMLElement) => void;
/** The row's menu is open: it keeps the hover colour, so it's clear whose menu it is. */
isMenuTarget?: boolean;
}
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress }: ContactListItemProps) {
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress, isMenuTarget = false }: ContactListItemProps) {
const rowRef = useRef<HTMLDivElement>(null);
const longPress = useLongPress(() => {
if (rowRef.current) onLongPress?.(rowRef.current);
@@ -72,6 +74,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
aria-selected={isSelected || isChecked}
tabIndex={isFocused ? 0 : -1}
data-contact-id={contact.id}
data-menu-open={isMenuTarget || undefined}
onFocus={onFocus}
draggable
onDragStart={handleDragStart}
@@ -81,7 +84,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-inset",
isSelected
? "bg-selection shadow-sm"
: "bg-background hover:bg-muted hover:shadow-sm",
: isMenuTarget ? "bg-muted shadow-sm" : "bg-background hover:bg-muted hover:shadow-sm",
isChecked && !isSelected && "ring-2 ring-primary/20 bg-selection/60",
)}
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
+3 -1
View File
@@ -176,6 +176,7 @@ export function ContactList({
printTitle,
}: ContactListProps) {
const t = useTranslations("contacts");
const tx = useTranslations("ux_pim_contact");
const tCommon = useTranslations("common");
const locale = useLocale();
const density = useSettingsStore((state) => state.density);
@@ -697,7 +698,7 @@ export function ContactList({
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 me-1.5" />
{t("import.title")}
{tx("import_menu")}
</Button>
)}
</>
@@ -714,6 +715,7 @@ export function ContactList({
isChecked={selectedContactIds.has(contact.id)}
hasSelection={hasSelection}
isFocused={contact.id === cursorId}
isMenuTarget={contextMenu.isOpen && contextMenu.data?.id === contact.id}
onFocus={() => setFocusedId(contact.id)}
density={density}
selectedContactIds={selectedContactIds}
+4 -2
View File
@@ -101,6 +101,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
const printOff = useStrictestDevices().noPrint;
const tSidebar = useTranslations("sidebar");
const tUxContact = useTranslations("ux_pim_contact");
const tCommon = useTranslations("common");
const tShell = useTranslations("shell");
useDocumentTitle(tSidebar("contacts"));
@@ -1408,7 +1409,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<ContactList
contacts={displayedContacts}
isContactReadOnly={isContactReadOnly}
selectedContactId={selectedContactId}
// A group's page opens no member: none is marked in the list (#pim-18).
selectedContactId={view === "group-detail" || view === "group-edit" ? null : selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={guard(handleSelectContact)}
@@ -1435,7 +1437,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// On the phone these are otherwise only in the drawer's "+".
<>
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { close(); guard(handleCreateGroup)(); }} />
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { close(); setShowImportDialog(true); }} />
<ContextMenuItem icon={Upload} label={tUxContact("import_menu")} onClick={() => { close(); setShowImportDialog(true); }} />
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { close(); void requestPrint({ kind: "contact-list" }); }} />}
</>
)}
+14 -4
View File
@@ -10,7 +10,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { IconButton } from "@/components/ui/icon-button";
import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -90,10 +90,18 @@ function saveCollapsed(state: Record<string, boolean>) {
} catch { /* ignore */ }
}
/**
* The app's own "Trusted Senders" book: the senders whose images load, kept
* and managed in Settings, not a list of people. The sidebar leaves it out.
*/
export function isTrustedSendersBook(book: AddressBook): boolean {
return !book.isShared && book.name === TRUSTED_SENDERS_BOOK_NAME;
}
export function ContactsSidebar({
groups,
individuals,
addressBooks,
addressBooks: allAddressBooks,
activeCategory,
onSelectCategory,
onCreateGroup,
@@ -117,7 +125,9 @@ export function ContactsSidebar({
multiAccountMode,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const tx = useTranslations("ux_pim_contact");
const router = useRouter();
const addressBooks = useMemo(() => allAddressBooks.filter(b => !isTrustedSendersBook(b)), [allAddressBooks]);
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, openContextMenuFrom: openGroupMenuFrom, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, openContextMenuFrom: openBookMenuFrom, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook);
@@ -289,12 +299,12 @@ export function ContactsSidebar({
<ContextMenuItem icon={UserPlus} label={t("create_new")} onClick={() => { newMenu.closeContextMenu(); onCreateContact(); }} />
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { newMenu.closeContextMenu(); onCreateGroup(); }} />
{onCreateAddressBook && (
<ContextMenuItem icon={BookPlus} label={t("address_books.create")} onClick={() => { newMenu.closeContextMenu(); onCreateAddressBook(); }} />
<ContextMenuItem icon={BookPlus} label={tx("new_address_book_menu")} onClick={() => { newMenu.closeContextMenu(); onCreateAddressBook(); }} />
)}
{onImport && (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { newMenu.closeContextMenu(); onImport(); }} />
<ContextMenuItem icon={Upload} label={tx("import_menu")} onClick={() => { newMenu.closeContextMenu(); onImport(); }} />
</>
)}
</ContextMenu>
+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);
}
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "فتح قائمة جهة الاتصال"
}
},
"ux_pim_contact": {
"context_private": "المنزل",
"phone_voice": "هاتف",
"more_fields": "حقول إضافية",
"import_menu": "استيراد جهات الاتصال…",
"new_address_book_menu": "دفتر عناوين جديد…"
},
"uxs_wording": {
"more_options": "خيارات إضافية",
"use_in_other_apps": "الاستخدام في تطبيقات أخرى",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Obre el menú del contacte"
}
},
"ux_pim_contact": {
"context_private": "Casa",
"phone_voice": "Telèfon",
"more_fields": "Més camps",
"import_menu": "Importa contactes…",
"new_address_book_menu": "Llibreta d'adreces nova…"
},
"uxs_wording": {
"more_options": "Més opcions",
"use_in_other_apps": "Fes-ho servir en altres aplicacions",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Otevřít kategorie",
"row_actions": "Akce pro {name}"
},
"ux_pim_contact": {
"context_private": "Domácí",
"phone_voice": "Telefon",
"more_fields": "Další pole",
"import_menu": "Importovat kontakty…",
"new_address_book_menu": "Nový adresář…"
},
"uxs_wording": {
"more_options": "Další možnosti",
"use_in_other_apps": "Použít v jiných aplikacích",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Åbn kategorier",
"row_actions": "Handlinger for {name}"
},
"ux_pim_contact": {
"context_private": "Hjem",
"phone_voice": "Telefon",
"more_fields": "Flere felter",
"import_menu": "Importér kontakter…",
"new_address_book_menu": "Ny adressebog…"
},
"uxs_wording": {
"more_options": "Flere indstillinger",
"use_in_other_apps": "Brug i andre apps",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Kategorien öffnen",
"row_actions": "Aktionen für {name}"
},
"ux_pim_contact": {
"context_private": "Privat",
"phone_voice": "Telefon",
"more_fields": "Weitere Felder",
"import_menu": "Kontakte importieren…",
"new_address_book_menu": "Neues Adressbuch…"
},
"uxs_wording": {
"more_options": "Weitere Optionen",
"use_in_other_apps": "In anderen Apps nutzen",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Open the contact's menu"
}
},
"ux_pim_contact": {
"context_private": "Home",
"phone_voice": "Phone",
"more_fields": "More fields",
"import_menu": "Import contacts…",
"new_address_book_menu": "New address book…"
},
"uxs_wording": {
"more_options": "More options",
"use_in_other_apps": "Use in other apps",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Abrir categorías",
"row_actions": "Acciones para {name}"
},
"ux_pim_contact": {
"context_private": "Casa",
"phone_voice": "Teléfono",
"more_fields": "Más campos",
"import_menu": "Importar contactos…",
"new_address_book_menu": "Nueva libreta de direcciones…"
},
"uxs_wording": {
"more_options": "Más opciones",
"use_in_other_apps": "Usar en otras aplicaciones",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Ava kontakti menüü"
}
},
"ux_pim_contact": {
"context_private": "Kodu",
"phone_voice": "Telefon",
"more_fields": "Rohkem välju",
"import_menu": "Impordi kontaktid…",
"new_address_book_menu": "Uus aadressiraamat…"
},
"uxs_wording": {
"more_options": "Rohkem valikuid",
"use_in_other_apps": "Kasuta teistes rakendustes",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "باز کردن منوی مخاطب"
}
},
"ux_pim_contact": {
"context_private": "منزل",
"phone_voice": "تلفن",
"more_fields": "فیلدهای بیشتر",
"import_menu": "وارد کردن مخاطبین…",
"new_address_book_menu": "دفترچه آدرس جدید…"
},
"uxs_wording": {
"more_options": "گزینه‌های بیشتر",
"use_in_other_apps": "استفاده در برنامه‌های دیگر",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Ouvrir les catégories",
"row_actions": "Actions pour {name}"
},
"ux_pim_contact": {
"context_private": "Domicile",
"phone_voice": "Téléphone",
"more_fields": "Plus de champs",
"import_menu": "Importer des contacts…",
"new_address_book_menu": "Nouveau carnet d'adresses…"
},
"uxs_wording": {
"more_options": "Plus d'options",
"use_in_other_apps": "Utiliser dans d'autres applications",
+7
View File
@@ -4376,6 +4376,13 @@
"menu": "פתח את התפריט של איש הקשר"
}
},
"ux_pim_contact": {
"context_private": "בית",
"phone_voice": "טלפון",
"more_fields": "שדות נוספים",
"import_menu": "ייבוא אנשי קשר…",
"new_address_book_menu": "ספר כתובות חדש…"
},
"uxs_wording": {
"more_options": "אפשרויות נוספות",
"use_in_other_apps": "שימוש באפליקציות אחרות",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Névjegy menüjének megnyitása"
}
},
"ux_pim_contact": {
"context_private": "Otthoni",
"phone_voice": "Telefon",
"more_fields": "További mezők",
"import_menu": "Névjegyek importálása…",
"new_address_book_menu": "Új címjegyzék…"
},
"uxs_wording": {
"more_options": "További beállítások",
"use_in_other_apps": "Használat más alkalmazásokban",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Apri categorie",
"row_actions": "Azioni per {name}"
},
"ux_pim_contact": {
"context_private": "Casa",
"phone_voice": "Telefono",
"more_fields": "Altri campi",
"import_menu": "Importa contatti…",
"new_address_book_menu": "Nuova rubrica…"
},
"uxs_wording": {
"more_options": "Altre opzioni",
"use_in_other_apps": "Usa in altre app",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "カテゴリを開く",
"row_actions": "{name} の操作"
},
"ux_pim_contact": {
"context_private": "自宅",
"phone_voice": "電話",
"more_fields": "その他の項目",
"import_menu": "連絡先をインポート…",
"new_address_book_menu": "新しいアドレス帳…"
},
"uxs_wording": {
"more_options": "その他のオプション",
"use_in_other_apps": "ほかのアプリで使う",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "카테고리 열기",
"row_actions": "{name} 작업"
},
"ux_pim_contact": {
"context_private": "집",
"phone_voice": "전화",
"more_fields": "추가 항목",
"import_menu": "연락처 가져오기…",
"new_address_book_menu": "새 주소록…"
},
"uxs_wording": {
"more_options": "옵션 더 보기",
"use_in_other_apps": "다른 앱에서 사용",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Atvērt kategorijas",
"row_actions": "Darbības: {name}"
},
"ux_pim_contact": {
"context_private": "Mājas",
"phone_voice": "Tālrunis",
"more_fields": "Vairāk lauku",
"import_menu": "Kontaktu imports…",
"new_address_book_menu": "Jauna adrešu grāmata…"
},
"uxs_wording": {
"more_options": "Vairāk iespēju",
"use_in_other_apps": "Lietot citās lietotnēs",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Харилцагчийн цэсийг нээх"
}
},
"ux_pim_contact": {
"context_private": "Гэр",
"phone_voice": "Утас",
"more_fields": "Нэмэлт талбарууд",
"import_menu": "Харилцагчдыг импортлох…",
"new_address_book_menu": "Шинэ хаягийн дэвтэр…"
},
"uxs_wording": {
"more_options": "Бусад сонголт",
"use_in_other_apps": "Бусад апп-д ашиглах",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Åpne kontaktens meny"
}
},
"ux_pim_contact": {
"context_private": "Hjem",
"phone_voice": "Telefon",
"more_fields": "Flere felt",
"import_menu": "Importer kontakter…",
"new_address_book_menu": "Ny adressebok…"
},
"uxs_wording": {
"more_options": "Flere valg",
"use_in_other_apps": "Bruk i andre apper",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Categorieën openen",
"row_actions": "Acties voor {name}"
},
"ux_pim_contact": {
"context_private": "Thuis",
"phone_voice": "Telefoon",
"more_fields": "Meer velden",
"import_menu": "Contacten importeren…",
"new_address_book_menu": "Nieuw adresboek…"
},
"uxs_wording": {
"more_options": "Meer opties",
"use_in_other_apps": "Gebruiken in andere apps",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Otwórz kategorie",
"row_actions": "Działania dla {name}"
},
"ux_pim_contact": {
"context_private": "Dom",
"phone_voice": "Telefon",
"more_fields": "Więcej pól",
"import_menu": "Importuj kontakty…",
"new_address_book_menu": "Nowa książka adresowa…"
},
"uxs_wording": {
"more_options": "Więcej opcji",
"use_in_other_apps": "Użyj w innych aplikacjach",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Abrir categorias",
"row_actions": "Ações para {name}"
},
"ux_pim_contact": {
"context_private": "Casa",
"phone_voice": "Telefone",
"more_fields": "Mais campos",
"import_menu": "Importar contatos…",
"new_address_book_menu": "Novo livro de endereços…"
},
"uxs_wording": {
"more_options": "Mais opções",
"use_in_other_apps": "Usar noutras aplicações",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Deschideți meniul contactului"
}
},
"ux_pim_contact": {
"context_private": "Acasă",
"phone_voice": "Telefon",
"more_fields": "Mai multe câmpuri",
"import_menu": "Importați contactele…",
"new_address_book_menu": "Agendă nouă…"
},
"uxs_wording": {
"more_options": "Mai multe opțiuni",
"use_in_other_apps": "Folosește în alte aplicații",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Открыть категории",
"row_actions": "Действия для {name}"
},
"ux_pim_contact": {
"context_private": "Домашний",
"phone_voice": "Телефон",
"more_fields": "Другие поля",
"import_menu": "Импорт контактов…",
"new_address_book_menu": "Новая адресная книга…"
},
"uxs_wording": {
"more_options": "Другие параметры",
"use_in_other_apps": "Использовать в других приложениях",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "Otvoriť ponuku kontaktu"
}
},
"ux_pim_contact": {
"context_private": "Domov",
"phone_voice": "Telefón",
"more_fields": "Ďalšie polia",
"import_menu": "Importovať kontakty…",
"new_address_book_menu": "Nový adresár…"
},
"uxs_wording": {
"more_options": "Ďalšie možnosti",
"use_in_other_apps": "Použiť v iných aplikáciách",
+7
View File
@@ -3826,6 +3826,13 @@
"menu": "เปิดเมนูของผู้ติดต่อ"
}
},
"ux_pim_contact": {
"context_private": "บ้าน",
"phone_voice": "โทรศัพท์",
"more_fields": "ช่องเพิ่มเติม",
"import_menu": "นำเข้าผู้ติดต่อ…",
"new_address_book_menu": "สมุดที่อยู่ใหม่…"
},
"uxs_wording": {
"more_options": "ตัวเลือกเพิ่มเติม",
"use_in_other_apps": "ใช้ในแอปอื่น",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Kategorileri aç",
"row_actions": "{name} için işlemler"
},
"ux_pim_contact": {
"context_private": "Ev",
"phone_voice": "Telefon",
"more_fields": "Diğer alanlar",
"import_menu": "Kişileri İçe Aktar…",
"new_address_book_menu": "Yeni adres defteri…"
},
"uxs_wording": {
"more_options": "Diğer seçenekler",
"use_in_other_apps": "Başka uygulamalarda kullan",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "Відкрити категорії",
"row_actions": "Дії для {name}"
},
"ux_pim_contact": {
"context_private": "Домашній",
"phone_voice": "Телефон",
"more_fields": "Інші поля",
"import_menu": "Імпортувати контакти…",
"new_address_book_menu": "Нова адресна книга…"
},
"uxs_wording": {
"more_options": "Інші параметри",
"use_in_other_apps": "Використовувати в інших застосунках",
+7
View File
@@ -4495,6 +4495,13 @@
"menu": "Mở menu của liên hệ"
}
},
"ux_pim_contact": {
"context_private": "Nhà riêng",
"phone_voice": "Điện thoại",
"more_fields": "Thêm trường",
"import_menu": "Nhập danh bạ…",
"new_address_book_menu": "Sổ địa chỉ mới…"
},
"uxs_wording": {
"more_options": "Tùy chọn khác",
"use_in_other_apps": "Dùng trong ứng dụng khác",
+7
View File
@@ -4483,6 +4483,13 @@
"menu": "開啟聯絡人選單"
}
},
"ux_pim_contact": {
"context_private": "住家",
"phone_voice": "電話",
"more_fields": "更多欄位",
"import_menu": "匯入聯絡人…",
"new_address_book_menu": "新增通訊錄…"
},
"uxs_wording": {
"more_options": "更多選項",
"use_in_other_apps": "在其他應用程式中使用",
+7
View File
@@ -4483,6 +4483,13 @@
"open_categories": "打开分类",
"row_actions": "{name} 的操作"
},
"ux_pim_contact": {
"context_private": "住宅",
"phone_voice": "电话",
"more_fields": "更多字段",
"import_menu": "导入联系人…",
"new_address_book_menu": "新建通讯录…"
},
"uxs_wording": {
"more_options": "更多选项",
"use_in_other_apps": "在其他应用中使用",