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({