From 5365b1a4612e40ca69da0c0f2d8befd5468f0c33 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 13:59:28 +0100
Subject: [PATCH] fix: lay out addresses in the merge preview as the card does,
list every field the card keeps, and sort Merge with by name
The contacts-app merge test now expects the address on its own lines instead of one comma-separated line, which was the bug.
---
.../contact-merge-preview-ux-p2.test.tsx | 56 +++++++++++++++
.../contacts/__tests__/contacts-app.test.tsx | 3 +-
components/contacts/contact-merge-dialog.tsx | 68 +++++++++++--------
3 files changed, 96 insertions(+), 31 deletions(-)
create mode 100644 components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx
diff --git a/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx
new file mode 100644
index 000000000..61ac6009d
--- /dev/null
+++ b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx
@@ -0,0 +1,56 @@
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { ContactCard } from '@/lib/jmap/types';
+import { ContactMergeDialog } from '../contact-merge-dialog';
+
+// pim-p2-14 and pim-p2-15 (2026-11-04): the merge preview listed "Hauptstraße 5,
+// Berlin, 10115, Germany" where the card shows "Hauptstraße 5" / "10115 Berlin", and
+// left out the birthday the merge keeps. "Merge with…" listed every contact unsorted.
+
+afterEach(() => cleanup());
+
+const anna = {
+ id: 'anna', kind: 'individual', name: { full: 'Anna Schmidt' },
+ emails: { e: { address: 'anna@example.org' } },
+ addresses: {
+ a: {
+ countryCode: 'DE',
+ components: [
+ { kind: 'name', value: 'Hauptstraße' }, { kind: 'number', value: '5' },
+ { kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' },
+ ],
+ },
+ },
+ updated: '2026-11-01T10:00:00Z',
+} as unknown as ContactCard;
+
+const annaOld = {
+ id: 'anna-old', kind: 'individual', name: { full: 'Anna Schmidt' },
+ emails: { e: { address: 'anna.s@example.org' } },
+ anniversaries: { b: { kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } },
+ updated: '2025-01-01T10:00:00Z',
+} as unknown as ContactCard;
+
+const named = (id: string, full: string) => ({ id, kind: 'individual', name: { full } }) as unknown as ContactCard;
+
+describe('the merge preview', () => {
+ it('lays the address out as the card does and lists the birthday it keeps', () => {
+ render();
+ const preview = screen.getByTestId('merge-preview');
+ const address = within(preview).getByText('group_adr_any').nextElementSibling as HTMLElement;
+ expect(address.textContent).toBe('Hauptstraße 5\n10115 Berlin\nGermany');
+ const birthday = within(preview).getByText('detail.anniversary_birth').nextElementSibling as HTMLElement;
+ expect(birthday.textContent).toMatch(/1990/);
+ });
+});
+
+describe('Merge with…', () => {
+ it('lists the contacts to pick by name', () => {
+ const candidates = [named('3', 'Jürgen'), named('1', 'Alice'), named('5', 'Zoë'), named('2', 'anna'), named('4', 'Bob')];
+ render();
+ const rows = within(screen.getByTestId('merge-pick-list')).getAllByRole('button');
+ expect(rows.map((row) => row.textContent)).toEqual(['Alice', 'anna', 'Bob', 'Jürgen', 'Zoë']);
+ fireEvent.click(rows[0]);
+ expect(screen.getByTestId('merge-preview')).toBeInTheDocument();
+ });
+});
diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx
index 59c673620..ab4c422ed 100644
--- a/components/contacts/__tests__/contacts-app.test.tsx
+++ b/components/contacts/__tests__/contacts-app.test.tsx
@@ -322,7 +322,8 @@ describe('ContactsApp', () => {
const preview = await screen.findByTestId('merge-preview');
expect(within(preview).getByText('anna@example.net')).toBeInTheDocument();
expect(within(preview).getByText('anna@home.example.net')).toBeInTheDocument();
- expect(within(preview).getByText('Hauptstraße 1, Berlin')).toBeInTheDocument();
+ // One line per address line, as the card shows it (pim-p2-14); getByText joins the lines with a space.
+ expect(within(preview).getByText('Hauptstraße 1 Berlin')).toBeInTheDocument();
expect(within(preview).getByText('Club')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'merge' }));
diff --git a/components/contacts/contact-merge-dialog.tsx b/components/contacts/contact-merge-dialog.tsx
index bc48e5e99..e3cf539f9 100644
--- a/components/contacts/contact-merge-dialog.tsx
+++ b/components/contacts/contact-merge-dialog.tsx
@@ -1,39 +1,20 @@
"use client";
import { useMemo, useState } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { Search } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
+import { formatAddressLines } from "@/lib/address-format";
+import { formatContactDate } from "@/lib/contact-dates";
import { cn } from "@/lib/utils";
-import type { ContactAddress, ContactCard } from "@/lib/jmap/types";
+import type { ContactCard } from "@/lib/jmap/types";
import { memberKeys, planMerge, type MergePlan } from "@/lib/contacts/merge";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
-/** One line for an address, as its parts and separators say. */
-export function addressLine(address: ContactAddress): string {
- const components = address.components ?? [];
- if (components.length > 0) {
- let out = "";
- let lastWasValue = false;
- for (const c of components) {
- if (c.kind === "separator") {
- out += c.value;
- lastWasValue = false;
- continue;
- }
- if (!c.value) continue;
- if (lastWasValue) out += address.defaultSeparator ?? ", ";
- out += c.value.replace(/\s*\n\s*/g, ", ");
- lastWasValue = true;
- }
- return out;
- }
- const legacy = [address.street, [address.postcode, address.locality].filter(Boolean).join(" "), address.region, address.country]
- .filter(Boolean).join(", ");
- return legacy || address.full || address.fullAddress || "";
-}
+/** Lines of one value, shown with whitespace-pre-line. */
+const NEWLINE = String.fromCharCode(10);
interface ContactMergeDialogProps {
/** Two or more cards: the dialog opens on what the merge keeps. */
@@ -69,11 +50,14 @@ function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) {
export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = [], suggested = [], groups, onMerge, onClose }: ContactMergeDialogProps) {
const t = useTranslations("ux_pim_cimport");
const tc = useTranslations("contacts");
+ const tm = useTranslations("ux_p2_pim_cm");
+ const locale = useLocale();
const [cards, setCards] = useState(initialCards && initialCards.length > 1 ? initialCards : null);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
const plan = useMemo(() => (cards ? planMerge(cards) : null), [cards]);
+ const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", numeric: true }), [locale]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -81,8 +65,10 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
return candidates
.filter((c) => !suggestedIds.has(c.id))
.filter((c) => !q || `${getContactDisplayName(c)} ${Object.values(c.emails ?? {}).map((e) => e.address).join(" ")}`.toLowerCase().includes(q))
+ // By name, as the contact list sorts, in the UI language's order.
+ .sort((a, b) => collator.compare(getContactDisplayName(a) || getContactPrimaryEmail(a) || "", getContactDisplayName(b) || getContactPrimaryEmail(b) || ""))
.slice(0, 200);
- }, [candidates, suggested, query]);
+ }, [candidates, suggested, query, collator]);
if (!plan) {
const name = pickFor ? getContactDisplayName(pickFor) || getContactPrimaryEmail(pickFor) : "";
@@ -135,11 +121,12 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
.filter((g) => Object.entries(g.members ?? {}).some(([k, on]) => on && keys.has(k)))
.map((g) => getContactDisplayName(g))
.filter(Boolean);
- const rows: Array<{ label: string; values: string[] }> = [
+ const rows: Array<{ label: string; values: string[]; multiline?: boolean }> = [
{ label: t("group_name"), values: [getContactDisplayName(r)].filter(Boolean) },
{ label: t("group_email"), values: Object.values(r.emails ?? {}).map((e) => e.address) },
{ label: t("group_phone"), values: Object.values(r.phones ?? {}).map((p) => p.number) },
- { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map(addressLine).filter(Boolean) },
+ // Laid out as the card shows it ("Hauptstraße 5" / "10115 Berlin"), not the parts in stored order.
+ { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map((a) => formatAddressLines(a, locale).join(NEWLINE)).filter(Boolean), multiline: true },
{
label: t("group_org"),
values: [
@@ -147,8 +134,29 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
...Object.values(r.titles ?? {}).map((ti) => ti.name),
].filter(Boolean),
},
+ { label: t("t_nickname"), values: Object.values(r.nicknames ?? {}).map((n) => n.name).filter(Boolean) },
+ // Every date the kept card will have, each under its own name ("Birthday").
+ ...(["birth", "wedding", "death", "other"] as const).map((kind) => ({
+ label: tc(`detail.anniversary_${kind}`),
+ values: Object.values(r.anniversaries ?? {}).filter((a) => a.kind === kind && a.date).map((a) => formatContactDate(a.date, locale)),
+ })),
+ {
+ label: tc("detail.gender"),
+ values: [
+ r.speakToAs?.grammaticalGender ? tc(`detail.gender_${r.speakToAs.grammaticalGender}`, { defaultValue: r.speakToAs.grammaticalGender }) : "",
+ ...Object.values(r.speakToAs?.pronouns ?? {}).map((p) => p.pronouns),
+ ].filter(Boolean),
+ },
+ { label: tc("detail.language_label"), values: Object.values(r.preferredLanguages ?? {}).map((l) => l.language).filter(Boolean) },
+ { label: tc("detail.personal_info"), values: Object.values(r.personalInfo ?? {}).map((pi) => pi.value).filter(Boolean) },
{ label: t("t_web"), values: Object.values(r.onlineServices ?? {}).map((s) => s.uri || s.user || "").filter(Boolean) },
- { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean) },
+ { label: tm("merge_links"), values: [...Object.values(r.links ?? {}), ...Object.values(r.directories ?? {})].map((l) => l.uri).filter(Boolean) },
+ { label: tc("detail.calendar_uri"), values: Object.values(r.calendars ?? {}).map((c) => c.uri).filter(Boolean) },
+ { label: tc("detail.scheduling_uri"), values: Object.values(r.schedulingAddresses ?? {}).map((c) => c.uri).filter(Boolean) },
+ { label: tc("detail.related_contacts"), values: Object.keys(r.relatedTo ?? {}) },
+ { label: tc("detail.crypto_keys"), values: Object.keys(r.cryptoKeys ?? {}).length > 0 ? [tm("merge_kept")] : [] },
+ { label: tm("merge_photo"), values: Object.keys(r.media ?? {}).length > 0 ? [tm("merge_kept")] : [] },
+ { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean), multiline: true },
{ label: t("t_categories"), values: Object.keys(r.keywords ?? {}).filter((k) => r.keywords?.[k]) },
{ label: t("groups"), values: groupNames },
].filter((row) => row.values.length > 0);
@@ -188,7 +196,7 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
{row.label}
{row.values.map((value, i) => (
- {value}
+ {value}
))}