fix(links): make the X the only unlink and let a chip open its record

A link chip was one button with an X drawn inside it, so tapping the
chip to open the linked record removed the link. The label now opens the
record (a dose, a visit, a condition or a document) and a separate X,
labelled with what it removes, unlinks, with an undo in the toast. Each
chip also carries its record's date, so several doses of one vaccine no
longer read as identical chips. The fix sits in the shared picker, which
is every chip list with this shape.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:10:50 +02:00
1 parent 0390d124cb
commit 31f0d7c9fb
13 files changed
+267 -34

No files matched your search

+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "Alle auswählen",
"searchPlaceholder": "Suchen",
"searchNoMatch": "Kein Treffer",
"remove": "Entfernen",
"suggested": "Um dieses Datum",
"loadError": "Die Liste konnte nicht geladen werden."
"loadError": "Die Liste konnte nicht geladen werden.",
"removeNamed": "{name} entfernen",
"removed": "Verknüpfung mit {name} gelöst"
}
}
}
+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "Select all",
"searchPlaceholder": "Search",
"searchNoMatch": "No match",
"remove": "Remove",
"suggested": "Around this date",
"loadError": "The list could not be loaded."
"loadError": "The list could not be loaded.",
"removeNamed": "Remove {name}",
"removed": "Unlinked {name}"
}
}
}
+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "Seleccionar todo",
"searchPlaceholder": "Buscar",
"searchNoMatch": "Sin coincidencias",
"remove": "Quitar",
"suggested": "En torno a esta fecha",
"loadError": "No se pudo cargar la lista."
"loadError": "No se pudo cargar la lista.",
"removeNamed": "Quitar {name}",
"removed": "Se desvinculó {name}"
}
}
}
+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "Tout sélectionner",
"searchPlaceholder": "Rechercher",
"searchNoMatch": "Aucun résultat",
"remove": "Retirer",
"suggested": "Autour de cette date",
"loadError": "Impossible de charger la liste."
"loadError": "Impossible de charger la liste.",
"removeNamed": "Retirer {name}",
"removed": "{name} dissocié"
}
}
}
+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "Seleziona tutto",
"searchPlaceholder": "Cerca",
"searchNoMatch": "Nessun risultato",
"remove": "Rimuovi",
"suggested": "Intorno a questa data",
"loadError": "Impossibile caricare l’elenco."
"loadError": "Impossibile caricare l’elenco.",
"removeNamed": "Rimuovi {name}",
"removed": "Collegamento a {name} rimosso"
}
}
}
+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "전체 선택",
"searchPlaceholder": "검색",
"searchNoMatch": "결과 없음",
"remove": "제거",
"suggested": "이 날짜 전후",
"loadError": "목록을 불러오지 못했습니다."
"loadError": "목록을 불러오지 못했습니다.",
"removeNamed": "{name} 제거",
"removed": "{name} 연결 해제됨"
}
}
}
+3 -2
View File
@@ -10880,9 +10880,10 @@
"selectAll": "Zaznacz wszystko",
"searchPlaceholder": "Szukaj",
"searchNoMatch": "Brak wyników",
"remove": "Usuń",
"suggested": "Wokół tej daty",
"loadError": "Nie udało się wczytać listy."
"loadError": "Nie udało się wczytać listy.",
"removeNamed": "Usuń {name}",
"removed": "Odłączono: {name}"
}
}
}
@@ -165,4 +165,78 @@ describe("<DocumentRecordLinks>", () => {
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
expect(html).not.toContain('data-slot="document-visit-links-add"');
});
describe("chips open the record they point at (#1024)", () => {
it("a manager's dose chip carries the dose date and opens the dose", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: "tetanus",
vaccineName: null,
},
],
})}
canManage
seedFresh
onChange={() => undefined}
/>,
);
const open = html.match(
/<a[^>]*data-slot="document-vaccination-links-chip-open"[^>]*>/,
);
expect(open?.[0]).toContain('href="/vaccinations?dose=dose-1"');
// The date tells two doses of one vaccine apart.
expect(open?.[0]).toBeDefined();
expect(html).toMatch(/>Tetanus<\/span><span[^>]*>04\/02\/1991</);
// The label is not the unlink control; the X is.
expect(html).toContain(
'data-slot="document-vaccination-links-chip-remove"',
);
});
it("a reader's dose chip opens that dose, not the whole list", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: null,
vaccineName: "DTP",
},
],
})}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('href="/vaccinations?dose=dose-1"');
});
it("a reader's visit chip opens the visit", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
encounterLinks: [
{
encounterId: "enc-1",
kind: "CHECKUP",
occurredAt: "2026-03-01T09:00:00.000Z",
},
],
} as never)}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('href="/checkups?visit=enc-1"');
});
});
});
@@ -47,6 +47,7 @@ export function recordOptions(
label: string;
meta?: string | null;
occurredAt: string;
href?: string | null;
}>,
anchor: string | null,
labels: { suggested: string; date: (iso: string) => string },
@@ -59,6 +60,7 @@ export function recordOptions(
label: record.label,
meta: record.meta ?? null,
dateLabel: labels.date(record.occurredAt),
href: record.href ?? null,
};
if (isNearAnchor(record.occurredAt, anchor)) {
near.push({
@@ -73,6 +75,16 @@ export function recordOptions(
return [...near, ...rest];
}
/** Where a visit opens: its sheet on the checkups page. */
function visitHref(encounterId: string): string {
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
}
/** Where a dose opens: its sheet on the vaccinations page. */
function doseHref(vaccinationId: string): string {
return `/vaccinations?dose=${encodeURIComponent(vaccinationId)}`;
}
/** A dose named the way the vaccination page names it. */
function doseName(
t: Translate,
@@ -146,6 +158,7 @@ export function DocumentRecordLinks({
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
anchor,
labels,
@@ -155,6 +168,7 @@ export function DocumentRecordLinks({
id: dose.id,
label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName),
occurredAt: dose.occurredAt,
href: doseHref(dose.id),
})),
anchor,
labels,
@@ -194,7 +208,7 @@ export function DocumentRecordLinks({
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
href={visitHref(link.encounterId)}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
@@ -244,7 +258,7 @@ export function DocumentRecordLinks({
{(doc.vaccinationLinks ?? []).map((link) => (
<Link
key={link.vaccinationId}
href="/vaccinations"
href={doseHref(link.vaccinationId)}
data-slot="document-vaccination-link"
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
@@ -132,6 +132,7 @@ export function EncounterLinkPickers({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}),
);
@@ -7,6 +7,7 @@ import {
EntityLinkPicker,
filterOptions,
groupOptions,
restoreLink,
toggleAll,
type EntityLinkOption,
} from "../entity-link-picker";
@@ -166,3 +167,66 @@ describe("<EntityLinkPicker> inline summary", () => {
expect(html).not.toContain("Nothing to link");
});
});
describe("<EntityLinkPicker> chips (#1024)", () => {
const base = {
icon: FolderOpen,
title: "Vaccinations",
slot: "test-link",
searchPlaceholder: "Search",
emptyLabel: "Nothing to link",
onChange: () => undefined,
pending: false,
};
const DOSES: EntityLinkOption[] = [
{
id: "d1",
label: "COVID-19",
dateLabel: "12 Sep 2025",
href: "/vaccinations?dose=d1",
},
{ id: "d2", label: "COVID-19", dateLabel: "3 Jan 2024" },
];
it("the chip itself is not the remove control", () => {
// Clicking a chip used to unlink it: the whole chip was the button.
const html = render(
<EntityLinkPicker {...base} options={DOSES} selected={["d1", "d2"]} />,
);
expect(html).not.toMatch(/<button[^>]*data-slot="test-link-chip"/);
// One labelled remove button per chip, separate from the label.
const removes = html.match(
/<button[^>]*data-slot="test-link-chip-remove"[^>]*>/g,
);
expect(removes).toHaveLength(2);
expect(removes![0]).toContain('aria-label="Remove COVID-19, 12 Sep 2025"');
});
it("two doses of the same vaccine are told apart by their date", () => {
const html = render(
<EntityLinkPicker {...base} options={DOSES} selected={["d1", "d2"]} />,
);
expect(html).toContain("12 Sep 2025");
expect(html).toContain("3 Jan 2024");
});
it("a chip with a target opens the linked record", () => {
const html = render(
<EntityLinkPicker {...base} options={DOSES} selected={["d1", "d2"]} />,
);
const open = html.match(/<a[^>]*data-slot="test-link-chip-open"[^>]*>/);
expect(open?.[0]).toContain('href="/vaccinations?dose=d1"');
// No target, no link: the label is plain text, never a remove button.
expect(html.match(/data-slot="test-link-chip-open"/g)).toHaveLength(1);
});
});
describe("restoreLink", () => {
it("puts an unlinked id back", () => {
expect(restoreLink(["a"], "b")).toEqual(["a", "b"]);
});
it("never duplicates an id that is already back", () => {
expect(restoreLink(["a", "b"], "b")).toEqual(["a", "b"]);
});
});
+89 -18
View File
@@ -19,10 +19,20 @@
* empty, and the link cap stays authoritative on the server. This surface only
* ever raises the FETCH limit so grouping has something to group; it never
* raises what may be linked.
*
* **A chip is a record, not a remove button.** The whole chip used to be the
* unlink control with an X drawn inside it, so a tap meant to open the linked
* record removed the link instead. The label now opens the record when the
* option names a target (`href`) and is plain text when it does not; only the
* separate X, labelled with what it removes, unlinks, and every unlink offers
* an undo. The chip carries the option's date, so two doses of one vaccine
* read as two different records.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Check, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -45,6 +55,11 @@ export interface EntityLinkOption {
* Options with the same key collapse under one heading, in first-seen order.
*/
group?: { key: string; label: string } | null;
/**
* Where the linked record opens, or null/omitted when it has no page of its
* own. The chip's label becomes a link to it; the X stays the only unlink.
*/
href?: string | null;
}
/** Lowercased haystack for the client search over one option. */
@@ -116,6 +131,18 @@ export function toggleAll(selected: string[], ids: string[]): string[] {
return merged;
}
/** Put an unlinked id back (the undo), never twice. */
export function restoreLink(selected: string[], id: string): string[] {
return selected.includes(id) ? selected : [...selected, id];
}
/** The chip's full name: the label and, when it has one, the date. */
function chipName(option: EntityLinkOption): string {
return option.dateLabel
? `${option.label}, ${option.dateLabel}`
: option.label;
}
function toggleOne(selected: string[], id: string): string[] {
return selected.includes(id)
? selected.filter((entry) => entry !== id)
@@ -170,6 +197,23 @@ export function EntityLinkPicker({
[options, term],
);
// The undo runs after later renders; it must restore into the selection as
// it is THEN, not as it was when the X was pressed.
const selectedRef = useRef(selected);
useEffect(() => {
selectedRef.current = selected;
}, [selected]);
const unlink = (option: EntityLinkOption) => {
onChange(selected.filter((id) => id !== option.id));
toast(t("links.picker.removed", { name: chipName(option) }), {
action: {
label: t("common.undo"),
onClick: () => onChange(restoreLink(selectedRef.current, option.id)),
},
});
};
const selectedSet = new Set(selected);
const selectedChips = selected
.map((id) => optionById.get(id))
@@ -201,25 +245,52 @@ export function EntityLinkPicker({
<div className="space-y-2">
{selectedChips.length > 0 ? (
<ul className="flex flex-wrap gap-1.5" data-slot={`${slot}-chips`}>
{selectedChips.map((option) => (
<li key={option.id}>
<button
type="button"
data-slot={`${slot}-chip`}
onClick={() => onChange(toggleOne(selected, option.id))}
className="border-border bg-muted/40 hover:bg-muted focus-visible:ring-ring/50 flex min-h-8 items-center gap-1.5 rounded-full border px-3 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="text-foreground max-w-[12rem] truncate">
{selectedChips.map((option) => {
const name = (
<>
<span className="text-foreground max-w-48 truncate">
{option.label}
</span>
<X
className="text-muted-foreground size-3 shrink-0"
aria-hidden
/>
<span className="sr-only">{t("links.picker.remove")}</span>
</button>
</li>
))}
{option.dateLabel ? (
<span className="text-muted-foreground shrink-0">
{option.dateLabel}
</span>
) : null}
</>
);
return (
<li
key={option.id}
data-slot={`${slot}-chip`}
className="border-border bg-muted/40 flex min-h-8 items-center rounded-full border text-xs"
>
{option.href ? (
<Link
href={option.href}
data-slot={`${slot}-chip-open`}
className="hover:bg-muted focus-visible:ring-ring/50 flex min-h-8 min-w-0 items-center gap-1.5 rounded-full py-1 pr-1.5 pl-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
{name}
</Link>
) : (
<span className="flex min-w-0 items-center gap-1.5 py-1 pr-1.5 pl-3">
{name}
</span>
)}
<button
type="button"
data-slot={`${slot}-chip-remove`}
aria-label={t("links.picker.removeNamed", {
name: chipName(option),
})}
onClick={() => unlink(option)}
className="text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring/50 mr-0.5 flex size-7 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="size-3.5" aria-hidden />
</button>
</li>
);
})}
</ul>
) : null}
@@ -113,6 +113,8 @@ export function documentOptionsWithSuggestions(
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
dateLabel: date ? labels.date(date) : null,
// The vault opens a document's sheet from `?doc=`.
href: `/documents?doc=${encodeURIComponent(doc.id)}`,
};
if (isNearAnchor(own, anchorIso)) {
suggested.push({