fix(admin): api-tokens table truncate-with-tooltip (no scrollbar 4th attempt)

Marc has now reported a residual scrollbar on `/admin/api-tokens`
four times running. Earlier fixes addressed the obvious overflow
culprits (column-hide in v1.4.15, mobile card-list in v1.4.16,
admin-shell mobile-strip `no-scrollbar` in v1.4.18), but the
section itself still leaked a few quirks:

- The mobile token-name span carried `truncate` and `break-all` on
  the same element. `truncate` sets `white-space: nowrap`, which
  beats `word-break: break-all` per CSS spec — `break-all` was dead
  code AND it was misleading. Remove it.
- The desktop table had no per-column upper bound. A long token
  name could expand the row past the card on 768-1024 px viewports.
  Lock the layout with `table-fixed` + `<colgroup>` widths.
- Permission badges had no `max-width` cap. A single long
  permission string (`measurements:write:audit:legacy:extended`)
  could push the cell past the card. Cap each badge with
  `max-w-full truncate` and a native `title` attribute.

Wrap every potentially-long string (token name, username, permission
badge) in a small `<TruncatedCell>` helper that pairs the visual
truncation with a radix tooltip. The tooltip is the discoverable
hover/long-press path so the full value is still readable; the
native `title` attribute is a graceful fallback for screen readers
and right-click → "view full name" on desktop.

Adds an e2e regression that walks every element inside the mobile
card-list and asserts none of them carry `scrollWidth > clientWidth`,
exercised against a worst-case payload (long token name + long
username + long permission string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:21:36 +02:00
1 parent 088832aa39
commit dd8212e202
3 files changed
+454 -168

No files matched your search

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 A7 — `/admin/api-tokens` 4th-attempt scrollbar fix.
*
* Marc reported a "minimaler Scrollbar" still painting at the
* bottom-right of the api-tokens page, even after the v1.4.18 phase
* A2 mobile-strip `no-scrollbar` fix. The remaining culprits are
* inside the section card itself: long token names, long usernames,
* and long permission strings can each push their parent past the
* viewport. Cumulatively this leaves the page in an "almost-fits"
* state where the visible scrollbar Marc sees is the page-level main
* scroll picking up a fraction of pixels of horizontal scroll spill
* from the children.
*
* The fix is aggressive: every cell that could carry a long string
* gets `truncate` (overflow-hidden + text-ellipsis + whitespace-nowrap)
* AND a tooltip on hover/long-press so the full value is still
* discoverable. This is the same pattern UserManagementSection uses
* — apply it consistently here.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/api-tokens",
}));
const sampleTokens = [
{
id: "tok1",
name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF",
permissions: ["measurements:write", "medications:read", "*"],
lastUsedAt: "2026-05-08T12:00:00Z",
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: {
id: "u1",
username: "marc-the-very-long-username-that-overflows",
},
},
];
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 — truncate + tooltip (4th attempt)", () => {
it("mobile token-name span does NOT carry the contradictory `break-all` modifier", () => {
// `truncate` already sets `white-space: nowrap`, which beats
// `word-break: break-all` per CSS spec — `break-all` was dead
// code AND it is misleading: a future reader expects the long
// name to break onto multiple lines, when in fact it stays on a
// single ellipsised line. Strip the conflicting class.
const html = render();
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
expect(mobileMatch).not.toBeNull();
expect(mobileMatch![1]).not.toMatch(/\btruncate\b[^"]*\bbreak-all\b/);
});
it("desktop token-name and user cells truncate inside max-width wrappers so a long string cannot push the row past the viewport", () => {
const html = render();
// Desktop table block is `hidden md:block`. Its rows render at
// server-side render unconditionally (the visibility is purely
// CSS), so we can grep for the cell contents in the SSR output.
const desktopMatch = html.match(
/<div[^>]*\bhidden\b[^>]*md:block[^>]*>([\s\S]*?)<\/table>/,
);
expect(desktopMatch).not.toBeNull();
const desktop = desktopMatch![1];
// Token name and username cells must each carry the truncate
// modifier with a bounded max-width so they cannot expand the
// row past the available column width on any viewport.
expect(desktop).toMatch(/\btruncate\b/);
expect(desktop).toMatch(/\bmax-w-/);
});
it("permission badges carry max-width + truncate so a single long permission string cannot blow up the cell", () => {
const html = render();
// Even with `flex-wrap`, a single permission like
// "measurements:write:audit:legacy:..." can be wider than the
// card on mobile. Badge primitive already has `overflow-hidden`,
// we add an upper bound on width so it ellipsises instead of
// pushing the parent.
expect(html).toMatch(/<span[^>]*max-w-(?:\[[^\]]+\]|full)[^>]*\btruncate\b/);
});
it("renders shadcn tooltip wrappers around truncated cells so the full value stays discoverable", () => {
const html = render();
// Tooltip primitive uses `data-slot="tooltip-content"` for the
// hover bubble. Verify at least one tooltip-content node was
// rendered next to a truncated cell.
expect(html).toContain('data-slot="tooltip-trigger"');
});
});