mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(settings): pair the API section tables with a mobile card-list rendering
CF-19 follow-up: the endpoint catalogue, active-token table, and revoked-token table on `/settings/api` each gain a `md:hidden` card-list partner so phones never have to horizontal-scroll the tabular layout. Desktop tables stay verbatim behind `hidden md:block`. The active-token cards expose a full-width revoke button that clears the 44pt tap-target floor.
This commit is contained in:
1 parent
79c84097ad
commit
c77d5252a6
2 files changed
+389
-52
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/settings/api` mobile card-list parity.
|
||||
*
|
||||
* The section ships three tables: endpoint catalogue, active tokens,
|
||||
* revoked tokens. Each table now renders twice:
|
||||
* - desktop `<table>` wrapped in `hidden md:block`
|
||||
* - mobile `<ul>` tagged `md:hidden`
|
||||
*
|
||||
* This file pins both surfaces against regressions so a future tweak
|
||||
* can't strip the mobile card list (and force a 393 CSS-px viewport
|
||||
* back into horizontal scroll).
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/api",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const sampleTokens = [
|
||||
{
|
||||
id: "tok-active",
|
||||
name: "iOS app",
|
||||
permissions: ["ingest:medication"],
|
||||
lastUsedAt: "2026-05-08T12:00:00Z",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-01T08:00:00Z",
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
id: "tok-revoked",
|
||||
name: "Old laptop",
|
||||
permissions: ["ingest:weight"],
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
createdAt: "2026-04-20T08:00:00Z",
|
||||
revoked: true,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: sampleTokens,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiSection } from "../api-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ApiSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiSection — responsive dual-rendering", () => {
|
||||
it("renders the endpoint table behind `hidden md:block`", () => {
|
||||
const html = render();
|
||||
// The endpoint catalogue is the first table; its desktop wrapper
|
||||
// must carry the `hidden md:block` pair so phones never see it.
|
||||
expect(html).toMatch(/<div[^>]*\bhidden\b[^>]*md:block[^>]*>[\s\S]*<table/);
|
||||
});
|
||||
|
||||
it("exposes a mobile card list for the endpoint catalogue", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-api-endpoints-mobile-list"');
|
||||
// The mobile <ul> carries `md:hidden` (class attribute precedes
|
||||
// `data-testid` in the rendered markup, so we check both anchors
|
||||
// independently).
|
||||
const endpointMatch = html.match(
|
||||
/<ul[^>]*data-testid="settings-api-endpoints-mobile-list"[^>]*>/,
|
||||
);
|
||||
expect(endpointMatch).not.toBeNull();
|
||||
expect(endpointMatch![0]).toContain("md:hidden");
|
||||
});
|
||||
|
||||
it("exposes a mobile card list for the active tokens table", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-api-tokens-mobile-list"');
|
||||
const tokensMatch = html.match(
|
||||
/<ul[^>]*data-testid="settings-api-tokens-mobile-list"[^>]*>/,
|
||||
);
|
||||
expect(tokensMatch).not.toBeNull();
|
||||
expect(tokensMatch![0]).toContain("md:hidden");
|
||||
// Every column from the desktop table is reachable as labelled
|
||||
// copy in the mobile card body.
|
||||
expect(html).toContain("iOS app");
|
||||
expect(html).toContain("ingest:medication");
|
||||
});
|
||||
|
||||
it("mobile card lists carry no overflow-x-auto wrapper", () => {
|
||||
const html = render();
|
||||
// Both mobile <ul> blocks must contain zero `overflow-x-auto`. That
|
||||
// was the original v1.4.27 CF-19 regression — the table wrapper
|
||||
// forced horizontal scroll on phones for content that easily fits
|
||||
// a card column.
|
||||
const endpointMatch = html.match(
|
||||
/<ul[^>]*settings-api-endpoints-mobile-list[^>]*>([\s\S]*?)<\/ul>/,
|
||||
);
|
||||
expect(endpointMatch).not.toBeNull();
|
||||
expect(endpointMatch![1]).not.toContain("overflow-x-auto");
|
||||
|
||||
const tokensMatch = html.match(
|
||||
/<ul[^>]*settings-api-tokens-mobile-list[^>]*>([\s\S]*?)<\/ul>/,
|
||||
);
|
||||
expect(tokensMatch).not.toBeNull();
|
||||
expect(tokensMatch![1]).not.toContain("overflow-x-auto");
|
||||
});
|
||||
|
||||
it("preserves the desktop tables verbatim", () => {
|
||||
const html = render();
|
||||
// The desktop tables must still mount — we did not delete them,
|
||||
// only paired them with the mobile fallback.
|
||||
expect(html).toContain("<table");
|
||||
// Active-token cell content shows up inside <td> elements as it did
|
||||
// before this change.
|
||||
expect(html).toMatch(/<td[^>]*>iOS app<\/td>/);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,21 @@ export function ApiSection() {
|
||||
function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.27 MB5 — dual-render: <md gets a stacked card list, md+
|
||||
// keeps the original table. Mobile cards wrap inside their box and
|
||||
// never trigger horizontal scroll, mirroring the pattern from
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue currently
|
||||
// ships a single row but stays array-driven so adding new rows
|
||||
// remains a one-line edit on both surfaces.
|
||||
const endpoints = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/ingest/medication",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -70,7 +85,8 @@ function ApiEndpointsCard() {
|
||||
{t("settings.apiEndpointsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
@@ -89,19 +105,52 @@ function ApiEndpointsCard() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-3 py-2 font-medium">POST</td>
|
||||
<td className="px-3 py-2 font-mono">/api/ingest/medication</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
Authorization: Bearer hlk_...
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
{`{ "medicationName": "...", "scheduledFor": "..." }`}
|
||||
</td>
|
||||
</tr>
|
||||
{endpoints.map((endpoint) => (
|
||||
<tr key={`${endpoint.method} ${endpoint.path}`}>
|
||||
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each endpoint stacks its columns vertically
|
||||
with a labelled meta row. No horizontal scroll. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="settings-api-endpoints-mobile-list"
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
<li
|
||||
key={`${endpoint.method} ${endpoint.path}`}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="font-mono break-all">{endpoint.path}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</p>
|
||||
<code className="font-mono break-all">{endpoint.auth}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</p>
|
||||
<code className="block font-mono break-all">
|
||||
{endpoint.example}
|
||||
</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +285,8 @@ function ApiTokensCard() {
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[860px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
@@ -342,6 +392,103 @@ function ApiTokensCard() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each row stacks its columns vertically
|
||||
with explicit labels so the data is readable inside a
|
||||
narrow viewport. The revoke action stays full-width to
|
||||
clear the 44pt tap-target floor. */}
|
||||
{activeTokens.length === 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm md:hidden"
|
||||
data-testid="settings-api-tokens-active-empty"
|
||||
>
|
||||
{t("settings.noActiveTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-mobile-list"
|
||||
>
|
||||
{activeTokens.map((tok) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={tok.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-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{revokedTokens.length > 0 && (
|
||||
@@ -359,47 +506,89 @@ function ApiTokensCard() {
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
<>
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — stacked meta layout, no
|
||||
horizontal scroll. Revoked tokens are read-only so
|
||||
no action footer is needed. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-revoked-mobile-list"
|
||||
>
|
||||
{revokedTokens.map((tok) => (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user