From 31f0d7c9fbb864fe7e3837e86a59e3f314212b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:10:50 +0200 Subject: [PATCH] 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. --- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- .../__tests__/document-record-links.test.tsx | 74 ++++++++++++ .../documents/document-record-links.tsx | 18 ++- .../encounters/encounter-link-pickers.tsx | 1 + .../__tests__/entity-link-picker.test.tsx | 64 +++++++++++ src/components/links/entity-link-picker.tsx | 107 +++++++++++++++--- .../links/vault-document-options.ts | 2 + 13 files changed, 267 insertions(+), 34 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8557410f9..8d7b962a7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } } diff --git a/messages/en.json b/messages/en.json index 0a02c12f6..e4572f7bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}" } } } diff --git a/messages/es.json b/messages/es.json index 853bb3f69..8a47c0ba9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}" } } } diff --git a/messages/fr.json b/messages/fr.json index 7942c43e9..c462c9af1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é" } } } diff --git a/messages/it.json b/messages/it.json index bb9c4302e..9787b6424 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" } } } diff --git a/messages/ko.json b/messages/ko.json index e629f3528..795279769 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10880,9 +10880,10 @@ "selectAll": "전체 선택", "searchPlaceholder": "검색", "searchNoMatch": "결과 없음", - "remove": "제거", "suggested": "이 날짜 전후", - "loadError": "목록을 불러오지 못했습니다." + "loadError": "목록을 불러오지 못했습니다.", + "removeNamed": "{name} 제거", + "removed": "{name} 연결 해제됨" } } } diff --git a/messages/pl.json b/messages/pl.json index 536400683..ada2daf60 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}" } } } diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index d06b0653e..e83374fcf 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -165,4 +165,78 @@ describe("", () => { 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( + undefined} + />, + ); + const open = html.match( + /]*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>]*>04\/02\/1991 { + const html = render( + undefined} + />, + ); + expect(html).toContain('href="/vaccinations?dose=dose-1"'); + }); + + it("a reader's visit chip opens the visit", () => { + const html = render( + undefined} + />, + ); + expect(html).toContain('href="/checkups?visit=enc-1"'); + }); + }); }); diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index cbf9e0b0a..b91b7fb0d 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -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) => ( @@ -244,7 +258,7 @@ export function DocumentRecordLinks({ {(doc.vaccinationLinks ?? []).map((link) => ( diff --git a/src/components/encounters/encounter-link-pickers.tsx b/src/components/encounters/encounter-link-pickers.tsx index dcdbea60f..1dcf1e536 100644 --- a/src/components/encounters/encounter-link-pickers.tsx +++ b/src/components/encounters/encounter-link-pickers.tsx @@ -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)}`, }), ); diff --git a/src/components/links/__tests__/entity-link-picker.test.tsx b/src/components/links/__tests__/entity-link-picker.test.tsx index 35b384895..de5414564 100644 --- a/src/components/links/__tests__/entity-link-picker.test.tsx +++ b/src/components/links/__tests__/entity-link-picker.test.tsx @@ -7,6 +7,7 @@ import { EntityLinkPicker, filterOptions, groupOptions, + restoreLink, toggleAll, type EntityLinkOption, } from "../entity-link-picker"; @@ -166,3 +167,66 @@ describe(" inline summary", () => { expect(html).not.toContain("Nothing to link"); }); }); + +describe(" 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( + , + ); + expect(html).not.toMatch(/]*data-slot="test-link-chip"/); + // One labelled remove button per chip, separate from the label. + const removes = html.match( + /]*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( + , + ); + 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( + , + ); + const open = html.match(/]*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"]); + }); +}); diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 296863af3..8926b8d79 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -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({
{selectedChips.length > 0 ? (
    - {selectedChips.map((option) => ( -
  • - -
  • - ))} + {option.dateLabel ? ( + + {option.dateLabel} + + ) : null} + + ); + return ( +
  • + {option.href ? ( + + {name} + + ) : ( + + {name} + + )} + +
  • + ); + })}
) : null} diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 83422ae4a..1c2832320 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -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({