feat: build housekeeping command deck shell
This commit is contained in:
1 parent
cfeb0f19b8
commit
addc9c7b1a
9 files changed
+528
No files matched your search
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface HousekeepingPageShellProps {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryAction?: ReactNode;
|
||||
context?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HousekeepingPageShell({
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
context,
|
||||
children,
|
||||
}: HousekeepingPageShellProps) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{description}
|
||||
</p>
|
||||
{context ? <div>{context}</div> : null}
|
||||
</div>
|
||||
{primaryAction ? <div>{primaryAction}</div> : null}
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HousekeepingPageShell } from "./housekeeping-page-shell";
|
||||
import { HousekeepingPageState } from "./housekeeping-page-state";
|
||||
|
||||
describe("HousekeepingPageState", () => {
|
||||
it.each([
|
||||
["loading", "status"],
|
||||
["empty", "status"],
|
||||
["partial", "status"],
|
||||
["error", "alert"],
|
||||
] as const)("renders %s with the %s role", (state, role) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain(`role="${role}"`);
|
||||
expect(html).toContain(`>${state} title<`);
|
||||
expect(html).toContain(`>${state} description<`);
|
||||
});
|
||||
|
||||
it("announces loading politely and makes the partial warning visible", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state="loading"
|
||||
title="Loading title"
|
||||
description="Loading description"
|
||||
/>,
|
||||
);
|
||||
const partial = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
title="Partial title"
|
||||
description="Partial description"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(loading).toContain('aria-live="polite"');
|
||||
expect(partial).toContain('data-state-tone="warning"');
|
||||
expect(partial).toContain(">Partial title<");
|
||||
});
|
||||
|
||||
it("renders supplied retry content for an error without owning a callback", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state="error"
|
||||
title="Could not load"
|
||||
description="Try again later"
|
||||
retryAction={<a href="/admin-next/operations">Retry preview</a>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/admin-next/operations"');
|
||||
expect(html).toContain(">Retry preview<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HousekeepingPageShell", () => {
|
||||
it("renders supplied header, context, action, and body content", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingPageShell
|
||||
title="People"
|
||||
description="Review operator-facing people data"
|
||||
context={<span>Preview context</span>}
|
||||
primaryAction={<a href="/admin-next/people/new">Create preview</a>}
|
||||
>
|
||||
<p>Page body</p>
|
||||
</HousekeepingPageShell>,
|
||||
);
|
||||
|
||||
expect(html).toContain("<header");
|
||||
expect(html).toContain(">People<");
|
||||
expect(html).toContain(">Review operator-facing people data<");
|
||||
expect(html).toContain(">Preview context<");
|
||||
expect(html).toContain('href="/admin-next/people/new"');
|
||||
expect(html).toContain(">Create preview<");
|
||||
expect(html).toContain(">Page body</p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface HousekeepingPageStateProps {
|
||||
state: "loading" | "empty" | "partial" | "error";
|
||||
title: string;
|
||||
description: string;
|
||||
retryAction?: ReactNode;
|
||||
}
|
||||
|
||||
export function HousekeepingPageState({
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
retryAction,
|
||||
}: HousekeepingPageStateProps) {
|
||||
const isError = state === "error";
|
||||
const isPartial = state === "partial";
|
||||
|
||||
return (
|
||||
<section
|
||||
role={isError ? "alert" : "status"}
|
||||
aria-live={state === "loading" ? "polite" : undefined}
|
||||
className={`rounded-lg border p-4 ${
|
||||
isError
|
||||
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
|
||||
: isPartial
|
||||
? "border-[var(--admin-warning)] bg-[var(--admin-surface)]"
|
||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
|
||||
}`}
|
||||
>
|
||||
{isPartial ? (
|
||||
<p
|
||||
data-state-tone="warning"
|
||||
className="font-medium text-[var(--admin-warning)]"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
) : (
|
||||
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{description}
|
||||
</p>
|
||||
{retryAction ? <div className="mt-3">{retryAction}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
interface CommandTriggerProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function CommandTrigger({ label }: CommandTriggerProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
interface ContextNavProps {
|
||||
items: readonly { id: string; href: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="min-w-0">
|
||||
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="min-w-max">
|
||||
<Link
|
||||
href={item.href}
|
||||
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FileText, Gem, Hotel, Inbox, Settings, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
|
||||
interface DomainRailProps {
|
||||
domains: readonly HousekeepingNavigationDomain[];
|
||||
activeDomainId: HousekeepingNavigationDomain["id"];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
function DomainIcon({ iconId }: Pick<HousekeepingNavigationDomain, "iconId">) {
|
||||
const Icon = {
|
||||
inbox: Inbox,
|
||||
users: Users,
|
||||
"file-text": FileText,
|
||||
gem: Gem,
|
||||
hotel: Hotel,
|
||||
settings: Settings,
|
||||
}[iconId];
|
||||
|
||||
return <Icon aria-hidden="true" size={18} />;
|
||||
}
|
||||
|
||||
export function DomainRail({
|
||||
domains,
|
||||
activeDomainId,
|
||||
ariaLabel,
|
||||
}: DomainRailProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="w-full lg:w-20">
|
||||
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||
{domains.map((domain) => (
|
||||
<li key={domain.id} className="min-w-max lg:min-w-0">
|
||||
<Link
|
||||
href={domain.href}
|
||||
aria-current={domain.id === activeDomainId ? "page" : undefined}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
|
||||
>
|
||||
<span data-domain-icon={domain.iconId}>
|
||||
<DomainIcon iconId={domain.iconId} />
|
||||
</span>
|
||||
<span className="lg:sr-only">{domain.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import { HousekeepingShell } from "./housekeeping-shell";
|
||||
|
||||
const labels = {
|
||||
skipToContent: "Skip to housekeeping content",
|
||||
primaryNavigation: "Primary housekeeping navigation",
|
||||
contextualNavigation: "Operations navigation",
|
||||
command: "Commands are unavailable in preview",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
};
|
||||
|
||||
const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
{
|
||||
id: "operations",
|
||||
href: "/admin-next/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Manage operations",
|
||||
items: [
|
||||
{ id: "queue", href: "/admin-next/operations/queue", label: "Queue" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
href: "/admin-next/people",
|
||||
iconId: "users",
|
||||
label: "People",
|
||||
description: "Manage people",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
href: "/admin-next/content",
|
||||
iconId: "file-text",
|
||||
label: "Content",
|
||||
description: "Manage content",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
href: "/admin-next/economy",
|
||||
iconId: "gem",
|
||||
label: "Economy",
|
||||
description: "Manage economy",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
href: "/admin-next/hotel",
|
||||
iconId: "hotel",
|
||||
label: "Hotel",
|
||||
description: "Manage hotel",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
href: "/admin-next/system",
|
||||
iconId: "settings",
|
||||
label: "System",
|
||||
description: "Manage system",
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
function renderShell(
|
||||
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId={activeDomainId}
|
||||
domains={domains}
|
||||
labels={labels}
|
||||
>
|
||||
<p>Deck body</p>
|
||||
</HousekeepingShell>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("HousekeepingShell", () => {
|
||||
it("renders the accessible command deck landmarks and supplied shell content", () => {
|
||||
const html = renderShell();
|
||||
|
||||
expect(html).toContain('href="#housekeeping-content"');
|
||||
expect(html).toContain(">Skip to housekeeping content<");
|
||||
expect(html).toContain("<aside");
|
||||
expect(html).toContain("<header");
|
||||
expect(html).toContain('<nav aria-label="Primary housekeeping navigation"');
|
||||
expect(html).toContain('<nav aria-label="Operations navigation"');
|
||||
expect(html).toContain('<main id="housekeeping-content"');
|
||||
expect(html).toContain(">Deck body</p>");
|
||||
expect(html).toMatch(
|
||||
/<a(?=[^>]*href="\/admin-next\/operations")(?=[^>]*aria-current="page")/,
|
||||
);
|
||||
expect(html).toContain('href="/admin-next/operations/queue"');
|
||||
expect(html).toContain(">Queue<");
|
||||
expect(html).toContain(">Nora<");
|
||||
expect(html).toContain(">6<");
|
||||
expect(html).toContain(">Preview<");
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain(">Back to site<");
|
||||
expect(html).toMatch(
|
||||
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps every authorized domain icon and only marks the active domain", () => {
|
||||
const html = renderShell("people");
|
||||
|
||||
for (const iconId of [
|
||||
"inbox",
|
||||
"users",
|
||||
"file-text",
|
||||
"gem",
|
||||
"hotel",
|
||||
"settings",
|
||||
]) {
|
||||
expect(html).toContain(`data-domain-icon="${iconId}"`);
|
||||
}
|
||||
expect(html).toMatch(
|
||||
/<a(?=[^>]*href="\/admin-next\/people")(?=[^>]*aria-current="page")/,
|
||||
);
|
||||
expect(html).not.toContain(
|
||||
'href="/admin-next/operations" aria-current="page"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no contextual items when the active domain is absent", () => {
|
||||
const html = renderShell("system");
|
||||
|
||||
expect(html).not.toContain('href="/admin-next/operations/queue"');
|
||||
expect(html).not.toContain(">Queue<");
|
||||
});
|
||||
|
||||
it("keeps Task 10 modules within the server-rendered source boundary", () => {
|
||||
const taskFiles = [
|
||||
"src/features/housekeeping/foundation/shell/housekeeping-shell.tsx",
|
||||
"src/features/housekeeping/foundation/shell/domain-rail.tsx",
|
||||
"src/features/housekeeping/foundation/shell/context-nav.tsx",
|
||||
"src/features/housekeeping/foundation/shell/command-trigger.tsx",
|
||||
"src/features/housekeeping/foundation/shell/operator-summary.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-state.tsx",
|
||||
"src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx",
|
||||
];
|
||||
const prohibited = [
|
||||
new RegExp("use " + "client"),
|
||||
new RegExp("cmd" + "k", "i"),
|
||||
new RegExp("addEvent" + "Listener"),
|
||||
new RegExp(
|
||||
"from\\s+['\"][^'\"]*(?:@/lib/" +
|
||||
"db|drizzle|/" +
|
||||
"actions)[^'\"]*['\"]",
|
||||
),
|
||||
new RegExp("next" + "-intl"),
|
||||
/(?:bg|text|border|from|to)-(?:red|blue|green|yellow|orange|purple|pink|gray|slate|zinc|neutral)-\d{2,3}/,
|
||||
new RegExp("#" + "[0-9a-fA-F]{3,8}"),
|
||||
new RegExp("(?:r" + "gb|h" + "sl)\\("),
|
||||
];
|
||||
|
||||
for (const path of taskFiles) {
|
||||
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
for (const pattern of prohibited) {
|
||||
expect(source).not.toMatch(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { HousekeepingActor } from "../contracts";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import { CommandTrigger } from "./command-trigger";
|
||||
import { ContextNav } from "./context-nav";
|
||||
import { DomainRail } from "./domain-rail";
|
||||
import { OperatorSummary } from "./operator-summary";
|
||||
|
||||
interface HousekeepingShellProps {
|
||||
actor: HousekeepingActor;
|
||||
activeDomainId: HousekeepingDomainId;
|
||||
domains: readonly HousekeepingNavigationDomain[];
|
||||
labels: {
|
||||
skipToContent: string;
|
||||
primaryNavigation: string;
|
||||
contextualNavigation: string;
|
||||
command: string;
|
||||
preview: string;
|
||||
backToSite: string;
|
||||
};
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HousekeepingShell({
|
||||
actor,
|
||||
activeDomainId,
|
||||
domains,
|
||||
labels,
|
||||
children,
|
||||
}: HousekeepingShellProps) {
|
||||
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||
<a
|
||||
href="#housekeeping-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
|
||||
>
|
||||
{labels.skipToContent}
|
||||
</a>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||
{labels.preview}
|
||||
</span>
|
||||
<OperatorSummary actor={actor} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandTrigger label={labels.command} />
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{labels.backToSite}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
|
||||
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
|
||||
<DomainRail
|
||||
domains={domains}
|
||||
activeDomainId={activeDomainId}
|
||||
ariaLabel={labels.primaryNavigation}
|
||||
/>
|
||||
</aside>
|
||||
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
|
||||
<ContextNav
|
||||
items={activeDomain?.items ?? []}
|
||||
ariaLabel={labels.contextualNavigation}
|
||||
/>
|
||||
</div>
|
||||
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { HousekeepingActor } from "../contracts";
|
||||
|
||||
interface OperatorSummaryProps {
|
||||
actor: HousekeepingActor;
|
||||
}
|
||||
|
||||
export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||
return (
|
||||
<div className="min-w-0 text-sm text-[var(--admin-text)]">
|
||||
<div className="truncate font-medium">{actor.username}</div>
|
||||
<div
|
||||
data-operator-rank={actor.rank}
|
||||
className="text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{actor.rank}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user