diff --git a/messages/de.json b/messages/de.json
index fa6e0151e..334d94127 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Was dieser Link enthält",
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
"statusNeedsReselect": "Inhalt neu wählen",
- "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt."
+ "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.",
+ "reselectAction": "Ersatz anlegen"
},
"genderOther": "Divers",
"cycleTracking": {
diff --git a/messages/en.json b/messages/en.json
index 65b5cfbac..564f393b2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "What this link contains",
"scopeInsuranceExcluded": "Insurance details are never shared through a link.",
"statusNeedsReselect": "Pick contents again",
- "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain."
+ "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
+ "reselectAction": "Create a replacement"
},
"genderOther": "Other",
"cycleTracking": {
diff --git a/messages/es.json b/messages/es.json
index 7fe4c4e81..7f14c9594 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Qué contiene este enlace",
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
"statusNeedsReselect": "Elegir contenido de nuevo",
- "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido."
+ "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.",
+ "reselectAction": "Crear un reemplazo"
},
"genderOther": "Otro",
"cycleTracking": {
diff --git a/messages/fr.json b/messages/fr.json
index b2235e769..8aa90fa95 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Ce que contient ce lien",
"scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
"statusNeedsReselect": "Choisir à nouveau le contenu",
- "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu."
+ "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
+ "reselectAction": "Créer un remplacement"
},
"genderOther": "Autre",
"cycleTracking": {
diff --git a/messages/it.json b/messages/it.json
index 177128bdc..198f067a2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Cosa contiene questo link",
"scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
"statusNeedsReselect": "Scegli di nuovo il contenuto",
- "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere."
+ "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
+ "reselectAction": "Crea un sostituto"
},
"genderOther": "Altro",
"cycleTracking": {
diff --git a/messages/pl.json b/messages/pl.json
index 613298b0d..563f4fc1f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Co zawiera ten link",
"scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
"statusNeedsReselect": "Wybierz zawartość ponownie",
- "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość."
+ "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
+ "reselectAction": "Utwórz zamiennik"
},
"genderOther": "Inna",
"cycleTracking": {
diff --git a/src/components/settings/__tests__/sharing-reselect.test.tsx b/src/components/settings/__tests__/sharing-reselect.test.tsx
new file mode 100644
index 000000000..d4bd1da15
--- /dev/null
+++ b/src/components/settings/__tests__/sharing-reselect.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * The pick-again state on a link the selection upgrade closed.
+ *
+ * Migration 0273 revoked every live record share link minted before the
+ * selection model, because their frozen scope was "everything except mood and
+ * cycle" — a scope nobody chose. The owner has to see WHY the link stopped
+ * working and be able to replace it without retyping what they already
+ * decided, or the revocation reads as a fault.
+ *
+ * Mutation checks:
+ * - drop the `needsReselection` branch in `sharing-section.tsx` → "explains
+ * why the link closed" goes red.
+ * - drop `initialRangeDays` from `ShareLinkCreateForm`'s state seed →
+ * "carries the closed link's window into the replacement" goes red.
+ * - clamp nothing in `prefillFrom` → "clamps a lifetime past the cap" goes
+ * red.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { ShareLinkCreateForm } from "../share-link-create-form";
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQuery: () => ({ data: undefined, isLoading: false }),
+}));
+
+function render(node: React.ReactElement): string {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+function numberInputValue(html: string, id: string): string | null {
+ const input = html.match(new RegExp(`]*id="${id}"[^>]*>`))?.[0];
+ return input?.match(/value="([^"]*)"/)?.[1] ?? null;
+}
+
+describe("share-link create form — re-mint seeding", () => {
+ it("carries the closed link's window and lifetime into the replacement", () => {
+ const html = render(
+ ,
+ );
+ expect(numberInputValue(html, "share-range")).toBe("180");
+ expect(numberInputValue(html, "share-expiry")).toBe("45");
+ expect(html).toContain("Cardiology clinic");
+ });
+
+ it("falls back to the 30-day default when nothing is carried across", () => {
+ const html = render();
+ expect(numberInputValue(html, "share-range")).toBe("30");
+ expect(numberInputValue(html, "share-expiry")).toBe("30");
+ });
+
+ it("offers the scope picker, with the insurance leaf absent from it", () => {
+ const html = render();
+ expect(html).toContain('data-testid="report-scope-picker"');
+ expect(html).toContain('data-testid="report-group-row-identity"');
+ // The server refuses the leaf with a 422; a control that cannot be
+ // honoured has no business rendering, so the identity group is one leaf
+ // wide here and two wide in the export panel.
+ const row = html.match(
+ /data-testid="report-group-row-identity"[\s\S]*?<\/div>/,
+ )?.[0];
+ expect(row).toBeDefined();
+ expect(row).toContain(">1/1<");
+ // The fenced tier renders its leaves unconditionally; none is checked.
+ expect(html).toContain('data-testid="report-leaf-MOOD"');
+ expect(html).not.toContain('data-testid="report-leaf-INSURANCE"');
+ });
+
+ it("hides the scope picker entirely for a documents-only share", () => {
+ const html = render();
+ expect(html).not.toContain('data-testid="report-scope-picker"');
+ });
+});
diff --git a/src/components/settings/report-selection/report-scope-picker.tsx b/src/components/settings/report-selection/report-scope-picker.tsx
index c72622686..f6173c72e 100644
--- a/src/components/settings/report-selection/report-scope-picker.tsx
+++ b/src/components/settings/report-selection/report-scope-picker.tsx
@@ -74,9 +74,12 @@ export function ReportScopePicker({
onChange(toggleLeaf(selected, leaf));
};
- const handleGroup = (group: ReportGroupId) => {
+ const handleGroup = (
+ group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
+ ) => {
setRemembered(new Set(selected));
- onChange(toggleGroup(selected, group, remembered));
+ onChange(toggleGroup(selected, group, leaves, remembered));
};
return (
@@ -89,11 +92,11 @@ export function ReportScopePicker({
labelKey={group.labelKey}
leaves={group.leaves}
selected={selected}
- checkState={groupCheckState(group.id, selected)}
- count={groupCount(group.id, selected)}
+ checkState={groupCheckState(group.leaves, selected)}
+ count={groupCount(group.leaves, selected)}
open={openGroups.has(group.id)}
panelId={`${idPrefix}-${group.id}`}
- onToggleGroup={() => handleGroup(group.id)}
+ onToggleGroup={() => handleGroup(group.id, group.leaves)}
onToggleLeaf={handleLeaf}
onToggleOpen={() =>
setOpenGroups((prev) => {
diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx
index 69cdbab55..148648835 100644
--- a/src/components/settings/share-link-create-form.tsx
+++ b/src/components/settings/share-link-create-form.tsx
@@ -168,6 +168,8 @@ export function ShareLinkCreateForm({
documentOnly = false,
initialDocuments,
initialLabel,
+ initialRangeDays,
+ initialExpiryDays,
onCreated,
}: {
/**
@@ -181,6 +183,14 @@ export function ShareLinkCreateForm({
initialDocuments?: PickedDocument[];
/** Optional pre-filled label (e.g. the document title). */
initialLabel?: string;
+ /**
+ * Optional pre-filled window and lifetime, in days. The sharing list passes
+ * these when the owner re-mints a link the selection upgrade closed, so the
+ * only decision left in front of them is the one that link never carried:
+ * what it should contain.
+ */
+ initialRangeDays?: number;
+ initialExpiryDays?: number;
/** Fired after a link is minted, so an outer surface can react if needed. */
onCreated?: (created: ShareLinkCreated) => void;
}) {
@@ -188,8 +198,10 @@ export function ShareLinkCreateForm({
const queryClient = useQueryClient();
const [label, setLabel] = useState(initialLabel ?? "");
- const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
- const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
+ const [rangeDays, setRangeDays] = useState(initialRangeDays ?? DEFAULT_DAYS);
+ const [expiryDays, setExpiryDays] = useState(
+ initialExpiryDays ?? DEFAULT_DAYS,
+ );
const [selectedDocs, setSelectedDocs] = useState(
initialDocuments ?? [],
);
diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx
index 78c2669e2..76ab105e2 100644
--- a/src/components/settings/sharing-section.tsx
+++ b/src/components/settings/sharing-section.tsx
@@ -42,6 +42,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
+ MAX_DAYS,
ShareLinkCreateForm,
type ShareLinkSummary,
} from "@/components/settings/share-link-create-form";
@@ -61,12 +62,49 @@ export function SharingSection() {
);
}
+/**
+ * Carry a closed link's window and lifetime into a new create form.
+ *
+ * The window is measured from the frozen `rangeStart` to now (a rolling link
+ * has no end), the lifetime from when it was created to when it would have
+ * expired. Both are clamped into the form's own bounds so a very old link
+ * cannot seed an out-of-range value.
+ */
+function prefillFrom(link: ShareLinkSummary) {
+ const day = 86_400_000;
+ const rangeDays = Math.max(
+ 1,
+ Math.round((Date.now() - new Date(link.rangeStart).getTime()) / day),
+ );
+ const expiryDays = Math.min(
+ MAX_DAYS,
+ Math.max(
+ 1,
+ Math.round(
+ (new Date(link.expiresAt).getTime() -
+ new Date(link.createdAt).getTime()) /
+ day,
+ ),
+ ),
+ );
+ return { key: Date.now(), label: link.label, rangeDays, expiryDays };
+}
+
function ShareLinksCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [showRevoked, setShowRevoked] = useState(false);
+ // A re-mint carries the closed link's label, window and lifetime across, so
+ // the only thing left to decide is the scope it never had. Keyed so the form
+ // remounts and picks the values up as its initial state.
+ const [prefill, setPrefill] = useState<{
+ key: number;
+ label: string;
+ rangeDays: number;
+ expiryDays: number;
+ } | null>(null);
const { data: links } = useQuery({
queryKey: queryKeys.shareLinks(),
@@ -101,7 +139,12 @@ function ShareLinksCard() {
description={t("settings.sharing.createDescription")}
/>
-
+
@@ -248,12 +291,24 @@ function ShareLinksCard() {
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
-
- {t("settings.sharing.reselectReason")}
-
+
+ {t("settings.sharing.reselectReason")}
+
+
+
) : null}
diff --git a/src/lib/report-selection/__tests__/panel-state.test.ts b/src/lib/report-selection/__tests__/panel-state.test.ts
index 02456f10d..694d4c15d 100644
--- a/src/lib/report-selection/__tests__/panel-state.test.ts
+++ b/src/lib/report-selection/__tests__/panel-state.test.ts
@@ -30,24 +30,45 @@ const sensitive = REPORT_GROUPS.find((g) => g.id === "sensitive")!;
describe("scope picker state", () => {
it("derives none / mixed / all from the leaves alone", () => {
- expect(groupCheckState("vitals", new Set())).toBe("none");
- expect(groupCheckState("vitals", new Set([vitals.leaves[0]]))).toBe(
+ expect(groupCheckState(vitals.leaves, new Set())).toBe("none");
+ expect(groupCheckState(vitals.leaves, new Set([vitals.leaves[0]]))).toBe(
"mixed",
);
- expect(groupCheckState("vitals", new Set(vitals.leaves))).toBe("all");
+ expect(groupCheckState(vitals.leaves, new Set(vitals.leaves))).toBe("all");
});
- it("counts what is on against what exists", () => {
- const count = groupCount("vitals", new Set([vitals.leaves[0]]));
+ it("counts what is on against what the surface renders", () => {
+ const count = groupCount(vitals.leaves, new Set([vitals.leaves[0]]));
expect(count).toEqual({ on: 1, total: vitals.leaves.length });
});
+ it("counts against the RENDERED leaves, not the catalogue group", () => {
+ // The share-link form hides the insurance leaf, so the identity group is
+ // one leaf wide there. A count over the full group would tell the owner a
+ // control covers something they cannot see or reach.
+ const identity = REPORT_GROUPS.find((g) => g.id === "identity")!;
+ const rendered = identity.leaves.filter((l) => l !== "INSURANCE");
+ const selected = new Set(["PATIENT_IDENTITY"]);
+ expect(groupCount(rendered, selected)).toEqual({ on: 1, total: 1 });
+ expect(groupCheckState(rendered, selected)).toBe("all");
+ // And the whole group, for contrast.
+ expect(groupCount(identity.leaves, selected)).toEqual({ on: 1, total: 2 });
+ });
+
+ it("never adds a leaf the surface does not render", () => {
+ const identity = REPORT_GROUPS.find((g) => g.id === "identity")!;
+ const rendered = identity.leaves.filter((l) => l !== "INSURANCE");
+ const after = toggleGroup(new Set(), "identity", rendered);
+ expect(after.has("PATIENT_IDENTITY")).toBe(true);
+ expect(after.has("INSURANCE")).toBe(false);
+ });
+
it("turns a mixed group fully on, and a full group fully off", () => {
const partial = new Set([vitals.leaves[0]]);
- const on = toggleGroup(partial, "vitals");
- expect(groupCheckState("vitals", on)).toBe("all");
- const off = toggleGroup(on, "vitals");
- expect(groupCheckState("vitals", off)).toBe("none");
+ const on = toggleGroup(partial, "vitals", vitals.leaves);
+ expect(groupCheckState(vitals.leaves, on)).toBe("all");
+ const off = toggleGroup(on, "vitals", vitals.leaves);
+ expect(groupCheckState(vitals.leaves, off)).toBe("none");
});
it("restores the previous pattern when a group comes back on", () => {
@@ -55,14 +76,14 @@ describe("scope picker state", () => {
vitals.leaves[0],
vitals.leaves[1],
]);
- const off = toggleGroup(original, "vitals", original);
- const back = toggleGroup(off, "vitals", original);
+ const off = toggleGroup(original, "vitals", vitals.leaves, original);
+ const back = toggleGroup(off, "vitals", vitals.leaves, original);
expect([...back].sort()).toEqual([...original].sort());
});
it("lets no group control reach a fenced leaf", () => {
const before = new Set();
- const after = toggleGroup(before, "sensitive");
+ const after = toggleGroup(before, "sensitive", sensitive.leaves);
expect([...after]).toEqual([]);
for (const leaf of sensitive.leaves) {
expect(after.has(leaf)).toBe(false);
diff --git a/src/lib/report-selection/panel-state.ts b/src/lib/report-selection/panel-state.ts
index 2ef8a734e..afa11dbf9 100644
--- a/src/lib/report-selection/panel-state.ts
+++ b/src/lib/report-selection/panel-state.ts
@@ -24,11 +24,16 @@ export type PanelSelection = ReadonlySet;
/** A group checkbox is on, off, or partly on. `mixed` is never a click target. */
export type GroupCheckState = "all" | "none" | "mixed";
+/**
+ * `leaves` is the group's leaves AS THE SURFACE RENDERS THEM, which is not
+ * always the catalogue's: the share-link form hides the insurance leaf, and a
+ * count or a check state computed over the full group would then describe
+ * something the user cannot see or reach.
+ */
export function groupCheckState(
- group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
selected: PanelSelection,
): GroupCheckState {
- const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
if (leaves.length === 0) return "none";
const on = leaves.filter((leaf) => selected.has(leaf)).length;
if (on === 0) return "none";
@@ -36,12 +41,11 @@ export function groupCheckState(
return "mixed";
}
-/** How many of a group's leaves are chosen, and how many there are. */
+/** How many of the rendered leaves are chosen, and how many there are. */
export function groupCount(
- group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
selected: PanelSelection,
): { on: number; total: number } {
- const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
return {
on: leaves.filter((leaf) => selected.has(leaf)).length,
total: leaves.length,
@@ -75,12 +79,12 @@ export function toggleLeaf(
export function toggleGroup(
selected: PanelSelection,
group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
remembered?: PanelSelection,
): Set {
if (group === SENSITIVE_GROUP_ID) return new Set(selected);
- const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
const next = new Set(selected);
- if (groupCheckState(group, selected) === "all") {
+ if (groupCheckState(leaves, selected) === "all") {
for (const leaf of leaves) next.delete(leaf);
return next;
}