mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(admin): make api-tokens table responsive on mobile
The `/admin/api-tokens` table rendered six full-width columns at every breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded the card and forced the document itself to scroll horizontally — a clear violation of the v1.4.x mobile-viewport contract guarded by `e2e/mobile-viewport.spec.ts`. Hide three lower-priority columns at narrow widths and surface the hidden user inline so no information is lost: - User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile the username paints under the token name as a small secondary line. - Last-used column: `hidden md:table-cell` (resurfaces ≥768px). - Created column: `hidden md:table-cell`. - Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS px on each side at mobile widths. The `<div className="overflow-x-auto">` wrapper stays as a belt-and-suspenders guard for ultra-narrow viewports where even the trimmed three-column layout might overflow (very long permission lists or long token names). Without it the table would push the card itself wider than the viewport. Add `api-token-overview-responsive.test.tsx` to lock the contract: the user / last-used / created columns must keep their `hidden + (sm|md):table-cell` classes, the inline mobile fallback span (`sm:hidden`) must duplicate the username so hiding the column doesn't drop data, and the card root must keep the responsive `p-4 sm:p-6` padding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
73afae0314
commit
dddc18c1b1
2 files changed
+141
-10
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase A2 — `/admin/api-tokens` table responsive guard.
|
||||
*
|
||||
* The token table previously rendered six full-width columns at every
|
||||
* breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded
|
||||
* the card and forced the document itself to scroll horizontally. The
|
||||
* fix hides three lower-priority columns (user, last-used, created)
|
||||
* until `sm:`/`md:` and surfaces the user inline under the token name
|
||||
* on mobile so no data is lost.
|
||||
*
|
||||
* This test asserts the responsive classes are present in the rendered
|
||||
* markup so a future refactor can't silently drop them.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/api-tokens",
|
||||
}));
|
||||
|
||||
const sampleTokens = [
|
||||
{
|
||||
id: "tok1",
|
||||
name: "iOS app",
|
||||
permissions: ["*"],
|
||||
lastUsedAt: "2026-05-08T12:00:00Z",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-01T08:00:00Z",
|
||||
revoked: false,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
];
|
||||
|
||||
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: "ADMIN",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiTokenOverviewSection } from "../api-token-overview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ApiTokenOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiTokenOverviewSection — responsive", () => {
|
||||
it("wraps the table in an overflow-x-auto container", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("overflow-x-auto");
|
||||
});
|
||||
|
||||
it("hides the user column until sm:", () => {
|
||||
const html = render();
|
||||
// <th> for user column carries the hidden-then-table-cell pattern.
|
||||
expect(html).toMatch(/<th[^>]*hidden[^>]*sm:table-cell[^>]*>/);
|
||||
});
|
||||
|
||||
it("hides the last-used and created columns until md:", () => {
|
||||
const html = render();
|
||||
// Both columns share the same md: pattern; expect at least two
|
||||
// matches (one per column).
|
||||
const matches = html.match(/hidden[^"]*md:table-cell/g) ?? [];
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("surfaces the username inline under the token name on mobile", () => {
|
||||
const html = render();
|
||||
// The mobile-only fallback span carries `sm:hidden` and the
|
||||
// username text. Without it, hiding the user column would lose
|
||||
// information on phones.
|
||||
expect(html).toContain("sm:hidden");
|
||||
// Username appears twice: once in the hidden user column TD, once
|
||||
// in the inline mobile fallback span.
|
||||
const userOccurrences = (html.match(/marc/g) ?? []).length;
|
||||
expect(userOccurrences).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("uses a smaller card padding on mobile (p-4 sm:p-6)", () => {
|
||||
const html = render();
|
||||
// The card root carries the responsive padding to leave more room
|
||||
// for the table within a 360 CSS-px viewport.
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export function ApiTokenOverviewSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
@@ -57,11 +57,17 @@ export function ApiTokenOverviewSection() {
|
||||
{t("admin.noTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
// Mobile fits within the card by hiding lower-priority
|
||||
// columns (user, last-used, created) until `sm`. The
|
||||
// remaining `overflow-x-auto` is a belt-and-suspenders
|
||||
// guard for ultra-narrow viewports — the table still
|
||||
// scrolls within its container instead of forcing the
|
||||
// page itself to scroll horizontally.
|
||||
<div className="-mx-2 overflow-x-auto sm:mx-0">
|
||||
<table className="w-full min-w-0 text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
<th className="hidden px-3 py-2 text-left font-medium sm:table-cell">
|
||||
{t("admin.tokenUser")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
@@ -73,10 +79,10 @@ export function ApiTokenOverviewSection() {
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenStatus")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
<th className="hidden px-3 py-2 text-right font-medium md:table-cell">
|
||||
{t("admin.tokenLastUsed")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
<th className="hidden px-3 py-2 text-right font-medium md:table-cell">
|
||||
{t("admin.tokenCreated")}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -90,10 +96,20 @@ export function ApiTokenOverviewSection() {
|
||||
key={token.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
<td className="hidden px-3 py-2 font-medium sm:table-cell">
|
||||
{token.user.username}
|
||||
</td>
|
||||
<td className="px-3 py-2">{token.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{token.name}</span>
|
||||
{/* On mobile the user column is hidden, so
|
||||
surface the username inline under the
|
||||
token name instead of dropping it. */}
|
||||
<span className="text-muted-foreground text-xs sm:hidden">
|
||||
{token.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{token.permissions.map((p) => (
|
||||
@@ -122,12 +138,12 @@ export function ApiTokenOverviewSection() {
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
<td className="text-muted-foreground hidden px-3 py-2 text-right text-xs whitespace-nowrap md:table-cell">
|
||||
{token.lastUsedAt
|
||||
? formatDateTime(token.lastUsedAt)
|
||||
: t("admin.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
<td className="text-muted-foreground hidden px-3 py-2 text-right text-xs whitespace-nowrap md:table-cell">
|
||||
{formatDate(token.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in new issue
Block a user