fix(settings): show a read failure on the share-link list, not an empty state

The share-link query had no error or loading handling, so a failed read
collapsed to an empty list and rendered the "no active share links"
copy — on a clinical sharing surface a load failure was indistinguishable
from "you have shared nothing". Gate the list on the query state:
QueryErrorCard with retry on failure, a skeleton while loading, the lists
only once the data is in. The added test was watched fail first against
the old fall-through before this fix made it pass.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 04:03:09 +02:00
1 parent 6e3965146c
commit 53a463f11b
2 files changed
+242 -168

No files matched your search

@@ -0,0 +1,54 @@
/**
* A read failure on the owner share-link surface must not read as "no active
* links". `useQuery` is forced into its error state; the card has to render the
* `query-error-card` slot with a retry affordance and must never fall through
* to the empty-state copy — that fall-through is the §6-forbidden case a
* clinical sharing surface cannot afford (a failed read is indistinguishable
* from "you have shared nothing").
*
* SSR-static render, matching the sibling sharing-section test.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isError: true,
isLoading: false,
refetch: vi.fn(),
}),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SharingSection } from "../sharing-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SharingSection />
</I18nProvider>,
);
}
describe("<SharingSection> — a read failure is honest, not empty", () => {
it("renders the query-error card with retry and never the empty state", () => {
const html = render();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
// The empty-state must not appear: a failed read is not "no active links".
expect(html).not.toContain('data-testid="share-active-empty"');
expect(html).not.toContain("No active share links");
});
});
+188 -168
View File
@@ -39,6 +39,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
@@ -106,7 +108,12 @@ function ShareLinksCard() {
expiryDays: number;
} | null>(null);
const { data: links } = useQuery({
const {
data: links,
isError,
isLoading,
refetch,
} = useQuery({
queryKey: queryKeys.shareLinks(),
queryFn: () =>
apiGet<{ shareLinks: ShareLinkSummary[] }>("/api/share-links").then(
@@ -147,180 +154,193 @@ function ShareLinksCard() {
initialExpiryDays={prefill?.expiryDays}
/>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<EmptyState
size="compact"
data-testid="share-active-empty"
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11 w-full sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
{isError ? (
// A read failure must not read as "no active links" on a clinical
// sharing surface — that is the §6 fall-through this card must avoid.
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<div className="space-y-2" data-testid="share-links-loading">
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
) : (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<EmptyState
size="compact"
data-testid="share-active-empty"
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<Badge variant="secondary" className="text-xs">
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11 w-full sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-xs">
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
It served a scope nobody chose, so it was closed; the
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
<div
className="space-y-1.5"
data-testid={`share-needs-reselect-${link.id}`}
>
{link.needsReselection ? (
<div
className="space-y-1.5"
data-testid={`share-needs-reselect-${link.id}`}
>
<p className="text-muted-foreground text-xs">
{t("settings.sharing.reselectReason")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid={`share-reselect-${link.id}`}
onClick={() => setPrefill(prefillFrom(link))}
>
{t("settings.sharing.reselectAction")}
</Button>
</div>
) : null}
<p className="text-muted-foreground text-xs">
{t("settings.sharing.reselectReason")}
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid={`share-reselect-${link.id}`}
onClick={() => setPrefill(prefillFrom(link))}
>
{t("settings.sharing.reselectAction")}
</Button>
</div>
) : null}
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
)}
</div>
</>
)}
</SettingsCard>
);