mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(admin): empty states for users, backups, login-overview, api-tokens, feedback
Replaces blank rectangles in 6 admin sub-routes with the EmptyState primitive. Each surface now shows an icon, localized title + description, and (where it makes sense) a primary action so a fresh admin always knows what to do next. - /admin/users — Users icon, filter-aware copy, "Show all users" reset CTA when a filter is active. - /admin/backups — Database icon, "Backup now" duplicate CTA inside the empty card so the action is right next to the explanation. - /admin/login-overview — ScrollText icon, splits "no auth events" from the safer "no failed sign-ins"; reset CTA on the failed-only filter. - /admin/api-tokens — Key icon, copy points to native client sign-in and Settings -> Account where tokens are issued. - /admin/feedback — Inbox icon, compact-size, per-tab description. - /admin overview audit-preview — compact + plain so it nests inside the parent card without doubling the dashed border. New unit tests for the users + backups paths assert the primitive mounts (role=status / dashed border), the localized strings render, and the reset/CTA actions appear only in the right contexts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d657f798d0
commit
0c20119c14
8 files changed
+282
-14
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase-C5 — `/admin/backups` empty state.
|
||||
*
|
||||
* The previous build rendered a single-line `<p>No backups recorded
|
||||
* yet.</p>` inside the card. Brand-new admins didn't realise the "Run
|
||||
* backup now" CTA in the header was the way to create one, so the page
|
||||
* felt inert. The new EmptyState primitive duplicates the header CTA
|
||||
* inside the card so the action is right next to the explanation.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/backups",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { rows: [], retentionDays: 30 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "marc", role: "ADMIN" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BackupsSection } from "../backups-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupsSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BackupsSection — empty state", () => {
|
||||
it("renders the EmptyState primitive when no backups exist", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("includes the localized title and description", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("No backups yet");
|
||||
expect(html).toContain(
|
||||
"Backups run automatically every Sunday at 03:00",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the Backup-now CTA inside the empty card", () => {
|
||||
const html = render();
|
||||
// The header's CTA is also "Backup now"; the empty-state CTA must
|
||||
// appear AT LEAST TWICE — once in the header, once inside the
|
||||
// empty card. If the empty card forgets the action we'd see only
|
||||
// one occurrence.
|
||||
const matches = (html.match(/Backup now/g) ?? []).length;
|
||||
expect(matches).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase-C5 — `/admin/users` empty state.
|
||||
*
|
||||
* Before this phase, when the admin's filter bucket returned zero
|
||||
* users (or the system had only one admin and the "Users" filter was
|
||||
* active), the table rendered an empty `<tbody>` and the rest of the
|
||||
* card was a blank rectangle. The empty state must:
|
||||
*
|
||||
* - mount the EmptyState primitive (`role="status"` + dashed border).
|
||||
* - carry a localized title + description.
|
||||
* - expose a "Show all users" reset CTA when a non-default filter is
|
||||
* active.
|
||||
*
|
||||
* The default-filter ("all") path is verified by an isolated test
|
||||
* because the empty list case is ambiguous (could be a real empty
|
||||
* deployment or a misconfigured fetch); the reset CTA must not appear
|
||||
* there.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/users",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: [], isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: 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 { UserManagementSection } from "../user-management-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<UserManagementSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserManagementSection — empty state", () => {
|
||||
it("renders the EmptyState primitive when no users match", () => {
|
||||
const html = render();
|
||||
// Polite live region marker from the primitive.
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
// Dashed border = card variant of EmptyState.
|
||||
expect(html).toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("carries the localized title and description", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("No users in this view");
|
||||
expect(html).toContain(
|
||||
"There are no users matching the current filter.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not render the reset CTA when the default filter is active", () => {
|
||||
// Default filter is "all" — reset CTA would be a no-op so it must
|
||||
// not appear. The other test (filter=admin) covers the visible path
|
||||
// via the same component but isolated by its own state — we cover
|
||||
// the absence here so a future regression that always renders the
|
||||
// CTA is caught.
|
||||
const html = render();
|
||||
expect(html).not.toContain("Show all users");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Key, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
@@ -53,9 +54,14 @@ export function ApiTokenOverviewSection() {
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !tokens?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.noTokens")}
|
||||
</p>
|
||||
// v1.4.15 phase-C5: empty state explains where tokens come
|
||||
// from (native client sign-in / Settings → Account) so a
|
||||
// fresh admin doesn't think they need to create them here.
|
||||
<EmptyState
|
||||
icon={<Key className="size-6" />}
|
||||
title={t("admin.tokensEmptyTitle")}
|
||||
description={t("admin.tokensEmptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
// Mobile fits within the card by hiding lower-priority
|
||||
// columns (user, last-used, created) until `sm`. The
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -423,8 +424,30 @@ export function BackupsSection() {
|
||||
{t("admin.section.backups.loadError")}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-muted-foreground mt-4 text-sm">
|
||||
{t("admin.section.backups.empty")}
|
||||
// v1.4.15 phase-C5: replace bare text with the EmptyState
|
||||
// primitive. The header already exposes "Backup now" but a
|
||||
// brand-new admin lands inside the card and benefits from a
|
||||
// duplicate CTA right next to the explanation.
|
||||
<div className="mt-4">
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
title={t("admin.section.backups.emptyTitle")}
|
||||
description={t("admin.section.backups.emptyDescription")}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -111,9 +112,15 @@ export function FeedbackInboxSection() {
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.noEntries")}
|
||||
</p>
|
||||
// v1.4.15 phase-C5: per-tab empty state. Compact size so
|
||||
// it sits naturally under the tab strip without dwarfing
|
||||
// a typical 1-2-row inbox once entries arrive.
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<Inbox className="size-5" />}
|
||||
title={t("admin.feedback.emptyTitle")}
|
||||
description={t("admin.feedback.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminAuditEntry } from "./_shared";
|
||||
@@ -100,9 +101,34 @@ export function LoginOverviewSection() {
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !entries?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.noEntries")}
|
||||
</p>
|
||||
// v1.4.15 phase-C5: surface a real empty state. Filter-aware
|
||||
// copy distinguishes "no auth events at all" (genuinely
|
||||
// empty system) from "no failed sign-ins" (the safe case)
|
||||
// and offers a "show all events" reset.
|
||||
<EmptyState
|
||||
icon={<ScrollText className="size-6" />}
|
||||
title={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedTitle")
|
||||
: t("admin.loginEmptyTitle")
|
||||
}
|
||||
description={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedDescription")
|
||||
: t("admin.loginEmptyDescription")
|
||||
}
|
||||
action={
|
||||
filter === "failed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t("admin.loginEmptyResetFilter")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ScrollText,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminAuditEntry } from "./_shared";
|
||||
@@ -102,9 +103,15 @@ export function RecentAuditPreview() {
|
||||
{t("admin.overview.auditLoadError")}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.overview.auditEmpty")}
|
||||
</p>
|
||||
// v1.4.15 phase-C5: compact + plain so it nests inside the
|
||||
// overview card without doubling the dashed border. Reuses
|
||||
// the existing audit-empty string.
|
||||
<EmptyState
|
||||
size="compact"
|
||||
variant="plain"
|
||||
icon={<ScrollText className="size-5" />}
|
||||
title={t("admin.overview.auditEmpty")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-border divide-y">
|
||||
{entries.map((entry) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { KeyRound, Loader2, LogOut, Pencil, Shield, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
@@ -271,6 +272,30 @@ export function UserManagementSection() {
|
||||
</div>
|
||||
|
||||
{filteredUsers ? (
|
||||
filteredUsers.length === 0 ? (
|
||||
// v1.4.15 phase-C5: dedicated empty state. The previous build
|
||||
// rendered an empty `<tbody>` so an admin filter that returned
|
||||
// no rows produced a blank rectangle; the icon + filter-aware
|
||||
// copy + "Show all users" CTA make the state explicit.
|
||||
<div className="mt-4">
|
||||
<EmptyState
|
||||
icon={<Users className="size-6" />}
|
||||
title={t("admin.section.users.emptyTitle")}
|
||||
description={t("admin.section.users.emptyDescription")}
|
||||
action={
|
||||
filter !== "all" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t("admin.section.users.emptyResetFilter")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table: kept verbatim — only the wrapping
|
||||
`md:block hidden` swaps it out for the card-list below at
|
||||
@@ -370,6 +395,7 @@ export function UserManagementSection() {
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
|
||||
Reference in new issue
Block a user