From 53a463f11bbafaaa0b548ea678f97b331b88bdf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:03:09 +0200 Subject: [PATCH] fix(settings): show a read failure on the share-link list, not an empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sharing-section-error-state.test.tsx | 54 +++ src/components/settings/sharing-section.tsx | 356 +++++++++--------- 2 files changed, 242 insertions(+), 168 deletions(-) create mode 100644 src/components/settings/__tests__/sharing-section-error-state.test.tsx diff --git a/src/components/settings/__tests__/sharing-section-error-state.test.tsx b/src/components/settings/__tests__/sharing-section-error-state.test.tsx new file mode 100644 index 000000000..a1e110d50 --- /dev/null +++ b/src/components/settings/__tests__/sharing-section-error-state.test.tsx @@ -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( + + + , + ); +} + +describe(" — 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"); + }); +}); diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 6bbc8ba2a..88cb483ca 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -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} /> -
-

- {t("settings.sharing.activeTitle")} -

- {activeLinks.length === 0 ? ( - - ) : ( -
    - {activeLinks.map((link) => ( -
  • -
    -

    - {link.label} -

    -
    - - {t("settings.sharing.statusActive")} - - {link.protected && ( - - - {t("settings.sharing.protected")} - - )} - {link.documentCount > 0 && ( - - - {link.documentCount} - - )} -
    -
    -

    - - {t("settings.sharing.created")}: - {" "} - {formatDate(link.createdAt)} -

    -

    - - {t("settings.sharing.expires")}: - {" "} - {formatDateTime(link.expiresAt)} -

    -

    - - {t("settings.sharing.accessCount")}: - {" "} - {link.accessCount} - {link.lastAccessAt - ? ` · ${formatDateTime(link.lastAccessAt)}` - : ""} -

    - - - - - - - - {t("settings.sharing.revoke")} - - - {t("settings.sharing.revokeDescription")} - - - - - {t("common.cancel")} - - revokeMutation.mutate(link.id)} - > - {t("settings.sharing.revoke")} - - - - -
  • - ))} -
- )} -
- - {inactiveLinks.length > 0 && ( -
- - {showRevoked && ( -
    - {inactiveLinks.map((link) => ( -
  • -
    -

    - {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. + refetch()} /> + ) : isLoading ? ( +

    + + +
    + ) : ( + <> +
    +

    + {t("settings.sharing.activeTitle")} +

    + {activeLinks.length === 0 ? ( + + ) : ( +
      + {activeLinks.map((link) => ( +
    • +
      +

      + {link.label} +

      +
      + + {t("settings.sharing.statusActive")} + + {link.protected && ( + + + {t("settings.sharing.protected")} + + )} + {link.documentCount > 0 && ( + + + {link.documentCount} + + )} +
      +
      +

      + + {t("settings.sharing.created")}: + {" "} + {formatDate(link.createdAt)}

      - - {link.needsReselection - ? t("settings.sharing.statusNeedsReselect") - : link.revokedAt - ? t("settings.sharing.statusRevoked") - : t("settings.sharing.statusExpired")} - -
    - {/* A link whose frozen scope predates the selection model. +

    + + {t("settings.sharing.expires")}: + {" "} + {formatDateTime(link.expiresAt)} +

    +

    + + {t("settings.sharing.accessCount")}: + {" "} + {link.accessCount} + {link.lastAccessAt + ? ` · ${formatDateTime(link.lastAccessAt)}` + : ""} +

    + + + + + + + + {t("settings.sharing.revoke")} + + + {t("settings.sharing.revokeDescription")} + + + + + {t("common.cancel")} + + revokeMutation.mutate(link.id)} + > + {t("settings.sharing.revoke")} + + + + +
  • + ))} +
+ )} +
+ + {inactiveLinks.length > 0 && ( +
+ + {showRevoked && ( +
    + {inactiveLinks.map((link) => ( +
  • +
    +

    + {link.label} +

    + + {link.needsReselection + ? t("settings.sharing.statusNeedsReselect") + : link.revokedAt + ? t("settings.sharing.statusRevoked") + : t("settings.sharing.statusExpired")} + +
    + {/* 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 ? ( -
    + {link.needsReselection ? ( +
    +

    + {t("settings.sharing.reselectReason")} +

    + +
    + ) : null}

    - {t("settings.sharing.reselectReason")} + + {t("settings.sharing.accessCount")}: + {" "} + {link.accessCount}

    - -
    - ) : null} -

    - - {t("settings.sharing.accessCount")}: - {" "} - {link.accessCount} -

    -
  • - ))} -
+ + ))} + + )} +
)} - + )} );