feat(housekeeping): finish accessible command deck
This commit is contained in:
1 parent
85ad0452d3
commit
a113e48880
55 files changed
+17064
-509
No files matched your search
@@ -14,6 +14,27 @@ import {
|
||||
WidgetSettings,
|
||||
} from "../../../foundation/shell/widget-settings";
|
||||
|
||||
export interface OperationsPersonalizationLabels {
|
||||
readonly favorites: {
|
||||
readonly title: string;
|
||||
readonly pin: string;
|
||||
readonly unpin: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
};
|
||||
readonly widgetSettings: {
|
||||
readonly title: string;
|
||||
readonly mandatory: string;
|
||||
readonly error: string;
|
||||
};
|
||||
readonly widgets: {
|
||||
readonly title: string;
|
||||
readonly partial: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
};
|
||||
}
|
||||
|
||||
const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => {
|
||||
const { saveHousekeepingPreferences } = await import(
|
||||
"@/actions/housekeeping-preferences"
|
||||
@@ -27,6 +48,9 @@ export function OperationsPersonalization({
|
||||
widgets,
|
||||
widgetResult,
|
||||
surface,
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
savePreferences = persistPreferences,
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
@@ -34,6 +58,9 @@ export function OperationsPersonalization({
|
||||
readonly widgets: readonly HousekeepingWidgetDefinition[];
|
||||
readonly widgetResult: HousekeepingWidgetLoadResult;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly labels: OperationsPersonalizationLabels;
|
||||
readonly routeTitles: Readonly<Record<string, string>>;
|
||||
readonly widgetTitles: Readonly<Record<string, string>>;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
}) {
|
||||
return (
|
||||
@@ -44,22 +71,15 @@ export function OperationsPersonalization({
|
||||
routes={routes}
|
||||
surface={surface}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Preferences could not be saved",
|
||||
}}
|
||||
labels={labels.favorites}
|
||||
titles={routeTitles}
|
||||
/>
|
||||
<WidgetSettings
|
||||
preferences={preferences}
|
||||
widgets={widgets}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Widget settings",
|
||||
mandatory: "Mandatory",
|
||||
error: "Preferences could not be saved",
|
||||
}}
|
||||
labels={labels.widgetSettings}
|
||||
titles={widgetTitles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,11 +87,8 @@ export function OperationsPersonalization({
|
||||
preferences={preferences}
|
||||
result={widgetResult}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Some widgets are unavailable",
|
||||
error: "Widget order could not be saved",
|
||||
}}
|
||||
labels={labels.widgets}
|
||||
titles={widgetTitles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -79,6 +79,45 @@ const result: OperationsWorkspaceLoadResult = {
|
||||
};
|
||||
|
||||
const savePreferences = vi.fn(async (value) => ok(value, "saved"));
|
||||
const labels = {
|
||||
title: "Operations workspace",
|
||||
description: "Capability-derived operational home",
|
||||
noDomainsTitle: "No operational domains are available",
|
||||
noDomainsDescription: "No authorized domain",
|
||||
partial: "Some workspace sections are unavailable",
|
||||
inbox: {
|
||||
title: "Operational inbox",
|
||||
domainFilter: "Filter by domain",
|
||||
stateFilter: "Filter by state",
|
||||
priorityFilter: "Filter by priority",
|
||||
all: "All",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Some sources are unavailable",
|
||||
open: "Open",
|
||||
},
|
||||
recent: { title: "Recent work", empty: "Nothing recent" },
|
||||
favorites: {
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Preferences could not be saved",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
},
|
||||
widgetSettings: {
|
||||
title: "Widget settings",
|
||||
mandatory: "Mandatory",
|
||||
error: "Preferences could not be saved",
|
||||
},
|
||||
widgets: {
|
||||
title: "Widgets",
|
||||
partial: "Some widgets are unavailable",
|
||||
error: "Widget order could not be saved",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
},
|
||||
} as const;
|
||||
const routeTitles = { Users: "Users" };
|
||||
const widgetTitles = { "people.queue": "People queue" };
|
||||
|
||||
describe("OperationsWorkspacePage", () => {
|
||||
it("renders capability-filtered sections, partial failures, and preview links", () => {
|
||||
@@ -88,6 +127,9 @@ describe("OperationsWorkspacePage", () => {
|
||||
routes={routes}
|
||||
widgetDefinitions={widgets}
|
||||
surface="preview"
|
||||
labels={labels}
|
||||
routeTitles={routeTitles}
|
||||
widgetTitles={widgetTitles}
|
||||
savePreferences={savePreferences}
|
||||
/>,
|
||||
);
|
||||
@@ -97,8 +139,8 @@ describe("OperationsWorkspacePage", () => {
|
||||
expect(html).toContain("Favorites");
|
||||
expect(html).toContain("Widget settings");
|
||||
expect(html).toContain("Some workspace sections are unavailable");
|
||||
expect(html).toContain("system.alerts");
|
||||
expect(html).toContain("system.health");
|
||||
expect(html).not.toContain("system.alerts");
|
||||
expect(html).not.toContain("system.health");
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
});
|
||||
|
||||
@@ -109,6 +151,9 @@ describe("OperationsWorkspacePage", () => {
|
||||
routes={[]}
|
||||
widgetDefinitions={[]}
|
||||
surface="preview"
|
||||
labels={labels}
|
||||
routeTitles={{}}
|
||||
widgetTitles={{}}
|
||||
savePreferences={savePreferences}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -8,34 +8,66 @@ import { OperationalInbox } from "../../../foundation/shell/operational-inbox";
|
||||
import { RecentWork } from "../../../foundation/shell/recent-work";
|
||||
import type { HousekeepingPreferenceSaver } from "../../../foundation/shell/widget-settings";
|
||||
import type { OperationsWorkspaceLoadResult } from "../queries";
|
||||
import { OperationsPersonalization } from "./personalization";
|
||||
import {
|
||||
OperationsPersonalization,
|
||||
type OperationsPersonalizationLabels,
|
||||
} from "./personalization";
|
||||
|
||||
export interface OperationsWorkspaceLabels
|
||||
extends OperationsPersonalizationLabels {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly noDomainsTitle: string;
|
||||
readonly noDomainsDescription: string;
|
||||
readonly partial: string;
|
||||
readonly inbox: {
|
||||
readonly title: string;
|
||||
readonly domainFilter: string;
|
||||
readonly stateFilter: string;
|
||||
readonly priorityFilter: string;
|
||||
readonly all: string;
|
||||
readonly loading: string;
|
||||
readonly empty: string;
|
||||
readonly partial: string;
|
||||
readonly open: string;
|
||||
};
|
||||
readonly recent: {
|
||||
readonly title: string;
|
||||
readonly empty: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function OperationsWorkspacePage({
|
||||
result,
|
||||
routes,
|
||||
widgetDefinitions,
|
||||
surface,
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
inboxTitles = {},
|
||||
savePreferences,
|
||||
}: {
|
||||
readonly result: OperationsWorkspaceLoadResult;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly widgetDefinitions: readonly HousekeepingWidgetDefinition[];
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly labels: OperationsWorkspaceLabels;
|
||||
readonly routeTitles: Readonly<Record<string, string>>;
|
||||
readonly widgetTitles: Readonly<Record<string, string>>;
|
||||
readonly inboxTitles?: Readonly<Record<string, string>>;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
}) {
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="Operations workspace"
|
||||
description="Your capability-derived operational home."
|
||||
title={labels.title}
|
||||
description={labels.description}
|
||||
>
|
||||
<div className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-semibold">
|
||||
No operational domains are available
|
||||
</h2>
|
||||
<h2 className="font-semibold">{labels.noDomainsTitle}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account can open Housekeeping, but no operational domain is
|
||||
currently authorized.
|
||||
{labels.noDomainsDescription}
|
||||
</p>
|
||||
</div>
|
||||
</HousekeepingPageShell>
|
||||
@@ -44,25 +76,17 @@ export function OperationsWorkspacePage({
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="Operations workspace"
|
||||
description="Inbox, recent work, favorites, and widgets for the domains you can operate."
|
||||
title={labels.title}
|
||||
description={labels.description}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{result.errors.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
|
||||
>
|
||||
<p className="text-[var(--admin-warning)]">
|
||||
Some workspace sections are unavailable
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||
{result.errors.map((error) => (
|
||||
<li key={error.section}>
|
||||
{error.section}: {error.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -70,25 +94,14 @@ export function OperationsWorkspacePage({
|
||||
<OperationalInbox
|
||||
response={result.model.inbox}
|
||||
surface={surface}
|
||||
labels={{
|
||||
title: "Operational inbox",
|
||||
domainFilter: "Filter by domain",
|
||||
stateFilter: "Filter by state",
|
||||
priorityFilter: "Filter by priority",
|
||||
all: "All",
|
||||
loading: "Loading operational inbox",
|
||||
empty: "No operational work is waiting",
|
||||
partial: "Some inbox sources are unavailable",
|
||||
open: "Open",
|
||||
}}
|
||||
labels={labels.inbox}
|
||||
titles={inboxTitles}
|
||||
/>
|
||||
<RecentWork
|
||||
items={result.model.recent}
|
||||
surface={surface}
|
||||
labels={{
|
||||
title: "Recent work",
|
||||
empty: "No recent Housekeeping work",
|
||||
}}
|
||||
labels={labels.recent}
|
||||
titles={routeTitles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +111,9 @@ export function OperationsWorkspacePage({
|
||||
widgets={widgetDefinitions}
|
||||
widgetResult={result.model.widgets}
|
||||
surface={surface}
|
||||
labels={labels}
|
||||
routeTitles={routeTitles}
|
||||
widgetTitles={widgetTitles}
|
||||
{...(savePreferences ? { savePreferences } : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,20 @@ import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import { createHousekeepingRegistry } from "../../foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { OperationsWorkspacePage } from "./pages/workspace";
|
||||
import {
|
||||
type OperationsWorkspaceLabels,
|
||||
OperationsWorkspacePage,
|
||||
} from "./pages/workspace";
|
||||
import { operationsWorkspaceQuery } from "./queries";
|
||||
|
||||
export async function renderOperationsWorkspacePage(
|
||||
input: Parameters<HousekeepingRouteHandler["render"]>[0],
|
||||
) {
|
||||
const translate = input.translate ?? ((key: string) => key);
|
||||
const message = (key: string, fallback: string) => {
|
||||
const value = translate(key);
|
||||
return value === key ? fallback : value;
|
||||
};
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
const accessibleDomains = registry.domains.filter(
|
||||
(domain) =>
|
||||
@@ -27,11 +35,115 @@ export async function renderOperationsWorkspacePage(
|
||||
satisfiesCapability(input.context, widget.capability),
|
||||
),
|
||||
);
|
||||
const labels: OperationsWorkspaceLabels = {
|
||||
title: message("workspace.title", "Operations workspace"),
|
||||
description: message(
|
||||
"workspace.description",
|
||||
"Inbox, recent work, favorites, and widgets for the domains you can operate.",
|
||||
),
|
||||
noDomainsTitle: message(
|
||||
"workspace.noDomainsTitle",
|
||||
"No operational domains are available",
|
||||
),
|
||||
noDomainsDescription: message(
|
||||
"workspace.noDomainsDescription",
|
||||
"No operational domain is authorized for your account.",
|
||||
),
|
||||
partial: message(
|
||||
"workspace.partial",
|
||||
"Some workspace sections are unavailable",
|
||||
),
|
||||
inbox: {
|
||||
title: message("workspace.inbox.title", "Operational inbox"),
|
||||
domainFilter: message("workspace.inbox.domainFilter", "Filter by domain"),
|
||||
stateFilter: message("workspace.inbox.stateFilter", "Filter by state"),
|
||||
priorityFilter: message(
|
||||
"workspace.inbox.priorityFilter",
|
||||
"Filter by priority",
|
||||
),
|
||||
all: message("workspace.inbox.all", "All"),
|
||||
loading: message("workspace.inbox.loading", "Loading operational inbox"),
|
||||
empty: message("workspace.inbox.empty", "No operational work is waiting"),
|
||||
partial: message(
|
||||
"workspace.inbox.partial",
|
||||
"Some inbox sources are unavailable",
|
||||
),
|
||||
open: message("workspace.inbox.open", "Open"),
|
||||
},
|
||||
recent: {
|
||||
title: message("workspace.recent.title", "Recent work"),
|
||||
empty: message("workspace.recent.empty", "No recent Housekeeping work"),
|
||||
},
|
||||
favorites: {
|
||||
title: message("workspace.favorites.title", "Favorites"),
|
||||
pin: message("workspace.favorites.pin", "Pin"),
|
||||
unpin: message("workspace.favorites.unpin", "Unpin"),
|
||||
error: message(
|
||||
"workspace.favorites.error",
|
||||
"Preferences could not be saved",
|
||||
),
|
||||
reorderInstructions: message(
|
||||
"workspace.favorites.reorderInstructions",
|
||||
"Press Space, then use the arrow keys to reorder.",
|
||||
),
|
||||
},
|
||||
widgetSettings: {
|
||||
title: message("workspace.widgetSettings.title", "Widget settings"),
|
||||
mandatory: message("workspace.widgetSettings.mandatory", "Mandatory"),
|
||||
error: message(
|
||||
"workspace.widgetSettings.error",
|
||||
"Preferences could not be saved",
|
||||
),
|
||||
},
|
||||
widgets: {
|
||||
title: message("workspace.widgets.title", "Widgets"),
|
||||
partial: message(
|
||||
"workspace.widgets.partial",
|
||||
"Some widgets are unavailable",
|
||||
),
|
||||
error: message(
|
||||
"workspace.widgets.error",
|
||||
"Widget order could not be saved",
|
||||
),
|
||||
reorderInstructions: message(
|
||||
"workspace.widgets.reorderInstructions",
|
||||
"Press Space, then use the arrow keys to reorder.",
|
||||
),
|
||||
},
|
||||
};
|
||||
const routeTitles = Object.fromEntries(
|
||||
routes.map((route) => [
|
||||
route.labelKey,
|
||||
message(route.labelKey.slice("pages.housekeeping.".length), route.id),
|
||||
]),
|
||||
);
|
||||
const widgetTitles = Object.fromEntries(
|
||||
widgetDefinitions.map((widget) => [
|
||||
widget.id,
|
||||
message(`widgets.${widget.id}`, widget.id),
|
||||
]),
|
||||
);
|
||||
const workspaceResult = await operationsWorkspaceQuery(input.context);
|
||||
const inboxTitles = Object.fromEntries(
|
||||
workspaceResult.model.inbox.items.map((item) => [
|
||||
item.titleKey,
|
||||
item.titleKey.startsWith("pages.housekeeping.")
|
||||
? message(
|
||||
item.titleKey.slice("pages.housekeeping.".length),
|
||||
item.domain,
|
||||
)
|
||||
: item.titleKey,
|
||||
]),
|
||||
);
|
||||
return createElement(OperationsWorkspacePage, {
|
||||
result: await operationsWorkspaceQuery(input.context),
|
||||
result: workspaceResult,
|
||||
routes,
|
||||
widgetDefinitions,
|
||||
surface: input.surface ?? "preview",
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
inboxTitles,
|
||||
...(input.savePreferences
|
||||
? { savePreferences: input.savePreferences }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { anyCapability, ok } from "./contracts";
|
||||
import { HousekeepingPageShell } from "./page/housekeeping-page-shell";
|
||||
import { HousekeepingPageState } from "./page/housekeeping-page-state";
|
||||
import type { HousekeepingPreferences } from "./preferences/schema";
|
||||
import { Favorites } from "./shell/favorites";
|
||||
import { HousekeepingShell } from "./shell/housekeeping-shell";
|
||||
import { OperationalInbox } from "./shell/operational-inbox";
|
||||
import { WidgetGrid } from "./shell/widget-grid";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const capability = anyCapability("admin.users.view");
|
||||
const preferences: HousekeepingPreferences = {
|
||||
schemaVersion: 1,
|
||||
pinnedRouteIds: ["people.users.list"],
|
||||
pinnedCommandIds: [],
|
||||
shortcutOrder: ["people.users.list"],
|
||||
enabledOptionalWidgetIds: [],
|
||||
widgetOrder: ["people.queue"],
|
||||
};
|
||||
const savePreferences = vi.fn(async (value: HousekeepingPreferences) =>
|
||||
ok(value, "saved"),
|
||||
);
|
||||
|
||||
const shellLabels = {
|
||||
skipToContent: "Skip to content",
|
||||
primaryNavigation: "Primary navigation",
|
||||
contextualNavigation: "Context navigation",
|
||||
command: "Search",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Partial",
|
||||
close: "Close",
|
||||
},
|
||||
};
|
||||
|
||||
describe("housekeeping accessibility contract", () => {
|
||||
it("renders the shell and page with labelled landmarks and a coherent heading hierarchy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId="operations"
|
||||
domains={[
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Daily operations",
|
||||
items: [],
|
||||
},
|
||||
]}
|
||||
labels={shellLabels}
|
||||
surface="canonical"
|
||||
>
|
||||
<HousekeepingPageShell title="Operations" description="Daily work">
|
||||
<h2>Inbox</h2>
|
||||
</HousekeepingPageShell>
|
||||
</HousekeepingShell>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-housekeeping-root="true"');
|
||||
expect(html).toContain("<header");
|
||||
expect(html).toContain('<nav aria-label="Primary navigation"');
|
||||
expect(html).toContain('<nav aria-label="Context navigation"');
|
||||
expect(html).toContain('<main id="housekeeping-content"');
|
||||
expect(html).toContain('aria-labelledby="housekeeping-page-title"');
|
||||
expect(html).toContain('<h1 id="housekeeping-page-title"');
|
||||
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
|
||||
expect(html).toContain(">Rank 6</span>");
|
||||
expect(html).toContain("focus-visible:not-sr-only");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", "status", "polite"],
|
||||
["empty", "status", "polite"],
|
||||
["partial", "status", "polite"],
|
||||
["error", "alert", "assertive"],
|
||||
["forbidden", "alert", "assertive"],
|
||||
["ready", "status", "polite"],
|
||||
] as const)(
|
||||
"gives the %s page state explicit semantic announcements",
|
||||
(state, role, live) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial"
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
) : (
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
);
|
||||
const html = renderToStaticMarkup(pageState);
|
||||
expect(html).toContain(`data-page-state="${state}"`);
|
||||
expect(html).toContain(`role="${role}"`);
|
||||
expect(html).toContain(`aria-live="${live}"`);
|
||||
},
|
||||
);
|
||||
|
||||
it("provides keyboard reorder instructions for favorites and widgets", () => {
|
||||
const favorites = renderToStaticMarkup(
|
||||
<Favorites
|
||||
preferences={preferences}
|
||||
routes={[
|
||||
{
|
||||
id: "people.users.list",
|
||||
labelKey: "pages.housekeeping.routes.people.users.list",
|
||||
href: "/ase/people/users",
|
||||
capability,
|
||||
},
|
||||
]}
|
||||
surface="canonical"
|
||||
savePreferences={savePreferences}
|
||||
titles={{
|
||||
"pages.housekeeping.routes.people.users.list": "Users",
|
||||
}}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Could not save",
|
||||
reorderInstructions:
|
||||
"Press Space, then use the arrow keys to reorder.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const widgets = renderToStaticMarkup(
|
||||
<WidgetGrid
|
||||
preferences={preferences}
|
||||
result={{
|
||||
widgets: [{ id: "people.queue", data: { count: 1 } }],
|
||||
errors: [],
|
||||
}}
|
||||
savePreferences={savePreferences}
|
||||
titles={{ "people.queue": "People queue" }}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Partial widgets",
|
||||
error: "Could not save",
|
||||
reorderInstructions:
|
||||
"Press Space, then use the arrow keys to reorder.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(favorites).toContain(
|
||||
'id="housekeeping-favorites-reorder-instructions"',
|
||||
);
|
||||
expect(favorites).toContain(
|
||||
'aria-describedby="housekeeping-favorites-reorder-instructions"',
|
||||
);
|
||||
expect(favorites).toContain(">Users<");
|
||||
expect(favorites).not.toContain(
|
||||
">pages.housekeeping.routes.people.users.list<",
|
||||
);
|
||||
expect(widgets).toContain('id="housekeeping-widgets-reorder-instructions"');
|
||||
expect(widgets).toContain(
|
||||
'aria-describedby="housekeeping-widgets-reorder-instructions"',
|
||||
);
|
||||
expect(widgets).toContain(">People queue<");
|
||||
});
|
||||
|
||||
it("announces partial provider failures without exposing internal identifiers", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OperationalInbox
|
||||
response={{
|
||||
items: [],
|
||||
errors: [{ sourceId: "internal.provider", code: "TIMEOUT" }],
|
||||
correlationId: "private-correlation",
|
||||
}}
|
||||
labels={{
|
||||
title: "Inbox",
|
||||
domainFilter: "Domain",
|
||||
stateFilter: "State",
|
||||
priorityFilter: "Priority",
|
||||
all: "All",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Some data is unavailable",
|
||||
open: "Open",
|
||||
}}
|
||||
surface="canonical"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('aria-live="assertive"');
|
||||
expect(html).not.toContain("internal.provider");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
expect(html).not.toContain("private-correlation");
|
||||
});
|
||||
});
|
||||
@@ -1,207 +1,256 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ar from "@/messages/ar.json";
|
||||
import bg from "@/messages/bg.json";
|
||||
import cs from "@/messages/cs.json";
|
||||
import da from "@/messages/da.json";
|
||||
import de from "@/messages/de.json";
|
||||
import el from "@/messages/el.json";
|
||||
import en from "@/messages/en.json";
|
||||
import es from "@/messages/es.json";
|
||||
import fi from "@/messages/fi.json";
|
||||
import fr from "@/messages/fr.json";
|
||||
import hr from "@/messages/hr.json";
|
||||
import hu from "@/messages/hu.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
import ja from "@/messages/ja.json";
|
||||
import nl from "@/messages/nl.json";
|
||||
import no from "@/messages/no.json";
|
||||
import pl from "@/messages/pl.json";
|
||||
import pt from "@/messages/pt.json";
|
||||
import ro from "@/messages/ro.json";
|
||||
import ru from "@/messages/ru.json";
|
||||
import sk from "@/messages/sk.json";
|
||||
import sr from "@/messages/sr.json";
|
||||
import sv from "@/messages/sv.json";
|
||||
import tr from "@/messages/tr.json";
|
||||
import uk from "@/messages/uk.json";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
|
||||
const locales = {
|
||||
ar,
|
||||
bg,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hr,
|
||||
hu,
|
||||
it: itMessages,
|
||||
ja,
|
||||
nl,
|
||||
no,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
tr,
|
||||
uk,
|
||||
} as const;
|
||||
|
||||
const commandFiles = [
|
||||
"domains/content/commands/content-commands.ts",
|
||||
"domains/economy/commands/economy-commands.ts",
|
||||
"domains/hotel/commands/hotel-commands.ts",
|
||||
"domains/hotel/commands/studio-commands.ts",
|
||||
"domains/people/commands/user-commands.ts",
|
||||
"domains/people/commands/community-commands.ts",
|
||||
"domains/people/commands/support-commands.ts",
|
||||
"domains/people/commands/moderation-commands.ts",
|
||||
"domains/system/commands/system-commands.ts",
|
||||
] as const;
|
||||
|
||||
const commandIds = [
|
||||
...new Set(
|
||||
commandFiles.flatMap((path) => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "src/features/housekeeping", path),
|
||||
"utf8",
|
||||
);
|
||||
return [
|
||||
...source.matchAll(
|
||||
/"((?:content|economy|hotel|people|system)(?:\.[a-z0-9-]+)+)"/gu,
|
||||
),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((id): id is string => id !== undefined);
|
||||
}),
|
||||
),
|
||||
].sort();
|
||||
|
||||
const staticKeys = [
|
||||
"preview.badge",
|
||||
"preview.commandDisabled",
|
||||
"preview.backToSite",
|
||||
"navigation.skipToContent",
|
||||
"navigation.primary",
|
||||
"navigation.contextual",
|
||||
"navigation.operatorRank",
|
||||
"commandDeck.placeholder",
|
||||
"commandDeck.navigation",
|
||||
"commandDeck.commands",
|
||||
"commandDeck.entities",
|
||||
"commandDeck.loading",
|
||||
"commandDeck.empty",
|
||||
"commandDeck.partial",
|
||||
"commandDeck.close",
|
||||
"workspace.title",
|
||||
"workspace.description",
|
||||
"workspace.noDomainsTitle",
|
||||
"workspace.noDomainsDescription",
|
||||
"workspace.partial",
|
||||
"workspace.inbox.title",
|
||||
"workspace.inbox.domainFilter",
|
||||
"workspace.inbox.stateFilter",
|
||||
"workspace.inbox.priorityFilter",
|
||||
"workspace.inbox.all",
|
||||
"workspace.inbox.loading",
|
||||
"workspace.inbox.empty",
|
||||
"workspace.inbox.partial",
|
||||
"workspace.inbox.open",
|
||||
"workspace.recent.title",
|
||||
"workspace.recent.empty",
|
||||
"workspace.favorites.title",
|
||||
"workspace.favorites.pin",
|
||||
"workspace.favorites.unpin",
|
||||
"workspace.favorites.error",
|
||||
"workspace.favorites.reorderInstructions",
|
||||
"workspace.widgetSettings.title",
|
||||
"workspace.widgetSettings.mandatory",
|
||||
"workspace.widgetSettings.error",
|
||||
"workspace.widgets.title",
|
||||
"workspace.widgets.partial",
|
||||
"workspace.widgets.error",
|
||||
"workspace.widgets.reorderInstructions",
|
||||
] as const;
|
||||
|
||||
const stateKeys = [
|
||||
"loading",
|
||||
"empty",
|
||||
"partial",
|
||||
"error",
|
||||
"forbidden",
|
||||
"ready",
|
||||
].flatMap((state) => [`states.${state}.title`, `states.${state}.description`]);
|
||||
|
||||
const manifestKeys = HOUSEKEEPING_MANIFESTS.flatMap((manifest) => [
|
||||
manifest.labelKey.slice("pages.housekeeping.".length),
|
||||
manifest.descriptionKey.slice("pages.housekeeping.".length),
|
||||
...manifest.routes.map((route) =>
|
||||
route.labelKey.slice("pages.housekeeping.".length),
|
||||
),
|
||||
...manifest.widgets.map((widget) => `widgets.${widget.id}`),
|
||||
]);
|
||||
|
||||
const requiredKeys = [
|
||||
"pages.housekeeping.preview.badge",
|
||||
"pages.housekeeping.preview.commandDisabled",
|
||||
"pages.housekeeping.preview.backToSite",
|
||||
"pages.housekeeping.navigation.skipToContent",
|
||||
"pages.housekeeping.navigation.primary",
|
||||
"pages.housekeeping.navigation.contextual",
|
||||
"pages.housekeeping.commandDeck.placeholder",
|
||||
"pages.housekeeping.commandDeck.navigation",
|
||||
"pages.housekeeping.commandDeck.commands",
|
||||
"pages.housekeeping.commandDeck.entities",
|
||||
"pages.housekeeping.commandDeck.loading",
|
||||
"pages.housekeeping.commandDeck.empty",
|
||||
"pages.housekeeping.commandDeck.partial",
|
||||
"pages.housekeeping.commandDeck.close",
|
||||
"pages.housekeeping.states.loading.title",
|
||||
"pages.housekeeping.states.loading.description",
|
||||
"pages.housekeeping.states.empty.title",
|
||||
"pages.housekeeping.states.empty.description",
|
||||
"pages.housekeeping.states.partial.label",
|
||||
"pages.housekeeping.states.partial.title",
|
||||
"pages.housekeeping.states.partial.description",
|
||||
"pages.housekeeping.states.error.title",
|
||||
"pages.housekeeping.states.error.description",
|
||||
...staticKeys,
|
||||
...stateKeys,
|
||||
"states.partial.label",
|
||||
...manifestKeys,
|
||||
...commandIds.map((id) => `commands.${id}`),
|
||||
];
|
||||
|
||||
const systemRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.system.access.permissions",
|
||||
"pages.housekeeping.routes.system.access.permission-detail",
|
||||
"pages.housekeeping.routes.system.configuration.settings",
|
||||
"pages.housekeeping.routes.system.configuration.emulator",
|
||||
"pages.housekeeping.routes.system.observability.analytics",
|
||||
"pages.housekeeping.routes.system.observability.analytics-activity",
|
||||
"pages.housekeeping.routes.system.observability.analytics-economy",
|
||||
"pages.housekeeping.routes.system.observability.devops",
|
||||
"pages.housekeeping.routes.system.observability.devops-errors",
|
||||
"pages.housekeeping.routes.system.observability.logs-staff",
|
||||
"pages.housekeeping.routes.system.observability.logs-audit",
|
||||
"pages.housekeeping.routes.system.observability.logs-chat",
|
||||
"pages.housekeeping.routes.system.observability.logs-commands",
|
||||
"pages.housekeeping.routes.system.observability.logs-trades",
|
||||
"pages.housekeeping.routes.system.operations.alerts",
|
||||
"pages.housekeeping.routes.system.operations.command-center",
|
||||
"pages.housekeeping.routes.system.operations.maintenance",
|
||||
] as const;
|
||||
|
||||
const economyRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.economy.catalog.overview",
|
||||
"pages.housekeeping.routes.economy.catalog.detail",
|
||||
"pages.housekeeping.routes.economy.catalog.builder-club-detail",
|
||||
"pages.housekeeping.routes.economy.catalog.maintenance",
|
||||
"pages.housekeeping.routes.economy.items.overview",
|
||||
"pages.housekeeping.routes.economy.items.detail",
|
||||
"pages.housekeeping.routes.economy.commerce.shop",
|
||||
"pages.housekeeping.routes.economy.commerce.shop-create",
|
||||
"pages.housekeeping.routes.economy.commerce.shop-detail",
|
||||
"pages.housekeeping.routes.economy.commerce.marketplace",
|
||||
"pages.housekeeping.routes.economy.commerce.vouchers",
|
||||
"pages.housekeeping.routes.economy.commerce.subscriptions",
|
||||
"pages.housekeeping.routes.economy.history.transactions",
|
||||
"pages.housekeeping.routes.economy.value.rare-values",
|
||||
"pages.housekeeping.routes.economy.rewards.badges",
|
||||
"pages.housekeeping.routes.economy.rewards.achievements",
|
||||
"pages.housekeeping.routes.economy.rewards.sounds",
|
||||
"pages.housekeeping.routes.economy.rewards.calendar",
|
||||
"pages.housekeeping.routes.economy.rewards.calendar-detail",
|
||||
] as const;
|
||||
|
||||
const hotelRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.hotel.rooms",
|
||||
"pages.housekeeping.routes.hotel.room-detail",
|
||||
"pages.housekeeping.routes.hotel.room-furni",
|
||||
"pages.housekeeping.routes.hotel.navigation",
|
||||
"pages.housekeeping.routes.hotel.radio.overview",
|
||||
"pages.housekeeping.routes.hotel.radio.settings",
|
||||
"pages.housekeeping.routes.hotel.radio.monitoring",
|
||||
"pages.housekeeping.routes.hotel.radio.moderation",
|
||||
"pages.housekeeping.routes.hotel.radio.autodj",
|
||||
"pages.housekeeping.routes.hotel.radio.history",
|
||||
"pages.housekeeping.routes.hotel.radio.points",
|
||||
"pages.housekeeping.routes.hotel.radio.ranks",
|
||||
"pages.housekeeping.routes.hotel.radio.api-keys",
|
||||
"pages.housekeeping.routes.hotel.radio.banners",
|
||||
"pages.housekeeping.routes.hotel.radio.embed",
|
||||
] as const;
|
||||
|
||||
const expectedDomainMessages = [
|
||||
{
|
||||
id: "operations",
|
||||
labelKey: "pages.housekeeping.domains.operations.title",
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
labelKey: "pages.housekeeping.domains.content.title",
|
||||
descriptionKey: "pages.housekeeping.domains.content.description",
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
labelKey: "pages.housekeeping.domains.economy.title",
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
labelKey: "pages.housekeeping.domains.hotel.title",
|
||||
descriptionKey: "pages.housekeeping.domains.hotel.description",
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
labelKey: "pages.housekeeping.domains.system.title",
|
||||
descriptionKey: "pages.housekeeping.domains.system.description",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function resolveMessage(messages: unknown, key: string): unknown {
|
||||
return key.split(".").reduce<unknown>((value, segment) => {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
|
||||
return (value as Record<string, unknown>)[segment];
|
||||
}, messages);
|
||||
}
|
||||
|
||||
describe("housekeeping localization contract", () => {
|
||||
it.each([
|
||||
["English", en],
|
||||
["Italian", itMessages],
|
||||
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
|
||||
const housekeeping = resolveMessage(messages, "pages.housekeeping");
|
||||
|
||||
expect(housekeeping).toEqual(expect.any(Object));
|
||||
expect(Object.keys(housekeeping as object)).toEqual([
|
||||
"preview",
|
||||
"navigation",
|
||||
"commandDeck",
|
||||
"domains",
|
||||
"routes",
|
||||
"states",
|
||||
it("covers exactly the 25 supported locale files", () => {
|
||||
expect(Object.keys(locales)).toEqual([
|
||||
"ar",
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"fi",
|
||||
"fr",
|
||||
"hr",
|
||||
"hu",
|
||||
"it",
|
||||
"ja",
|
||||
"nl",
|
||||
"no",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sr",
|
||||
"sv",
|
||||
"tr",
|
||||
"uk",
|
||||
]);
|
||||
expect(commandIds.length).toBeGreaterThan(100);
|
||||
expect(commandIds.every((id) => !id.endsWith("."))).toBe(true);
|
||||
});
|
||||
|
||||
for (const key of requiredKeys) {
|
||||
expect(resolveMessage(messages, key), key).toEqual(expect.any(String));
|
||||
}
|
||||
it.each(Object.entries(locales))(
|
||||
"provides every manifest, route, command, widget, and state key in %s",
|
||||
(locale, messages) => {
|
||||
for (const key of requiredKeys) {
|
||||
const fullKey = `pages.housekeeping.${key}`;
|
||||
const value = resolveMessage(messages, fullKey);
|
||||
expect(value, `${locale}: ${fullKey}`).toEqual(expect.any(String));
|
||||
expect((value as string).trim(), `${locale}: ${fullKey}`).not.toBe("");
|
||||
expect(value, `${locale}: ${fullKey}`).not.toBe(fullKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const key of systemRouteMessageKeys) {
|
||||
const message = resolveMessage(messages, key);
|
||||
expect(message, key).toEqual(expect.any(String));
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const key of economyRouteMessageKeys) {
|
||||
const message = resolveMessage(messages, key);
|
||||
expect(message, key).toEqual(expect.any(String));
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const key of hotelRouteMessageKeys) {
|
||||
const message = resolveMessage(messages, key);
|
||||
expect(message, key).toEqual(expect.any(String));
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const [index, expected] of expectedDomainMessages.entries()) {
|
||||
const manifest = HOUSEKEEPING_MANIFESTS[index];
|
||||
|
||||
expect(manifest).toMatchObject(expected);
|
||||
expect(
|
||||
resolveMessage(messages, expected.labelKey),
|
||||
expected.labelKey,
|
||||
).toEqual(expect.any(String));
|
||||
expect(
|
||||
resolveMessage(messages, expected.descriptionKey),
|
||||
expected.descriptionKey,
|
||||
).toEqual(expect.any(String));
|
||||
it("does not use English shell copy as the fallback for another locale", () => {
|
||||
const operatorKeys = [
|
||||
"preview.backToSite",
|
||||
"commandDeck.navigation",
|
||||
"commandDeck.commands",
|
||||
"commandDeck.entities",
|
||||
"commandDeck.close",
|
||||
"workspace.favorites.pin",
|
||||
"workspace.favorites.unpin",
|
||||
"workspace.widgetSettings.mandatory",
|
||||
] as const;
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
if (locale === "en") continue;
|
||||
expect(messages.pages.housekeeping.preview.badge).not.toBe(
|
||||
en.pages.housekeeping.preview.badge,
|
||||
);
|
||||
expect(messages.pages.housekeeping.states.loading.description).not.toBe(
|
||||
en.pages.housekeeping.states.loading.description,
|
||||
);
|
||||
for (const key of operatorKeys) {
|
||||
expect(
|
||||
resolveMessage(messages.pages.housekeeping, key),
|
||||
`${locale}: pages.housekeeping.${key}`,
|
||||
).not.toBe(resolveMessage(en.pages.housekeeping, key));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses idiomatic Italian copy for the housekeeping shell", () => {
|
||||
expect(resolveMessage(itMessages, "pages.housekeeping.preview.badge")).toBe(
|
||||
it("keeps idiomatic Italian shell copy", () => {
|
||||
expect(itMessages.pages.housekeeping.preview.badge).toBe(
|
||||
"Anteprima della struttura",
|
||||
);
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.navigation.primary"),
|
||||
).toBe("Aree housekeeping");
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.commandDeck.placeholder"),
|
||||
).toBe("Cerca navigazione, comandi ed entità");
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.states.loading.title"),
|
||||
).toBe("Caricamento in corso");
|
||||
expect(
|
||||
resolveMessage(
|
||||
itMessages,
|
||||
"pages.housekeeping.states.loading.description",
|
||||
),
|
||||
).toBe("Stiamo preparando gli strumenti di housekeeping disponibili.");
|
||||
expect(itMessages.pages.housekeeping.navigation.primary).toBe(
|
||||
"Aree housekeeping",
|
||||
);
|
||||
expect(itMessages.pages.housekeeping.commandDeck.placeholder).toBe(
|
||||
"Cerca navigazione, comandi ed entità",
|
||||
);
|
||||
expect(itMessages.pages.housekeeping.states.loading.description).toBe(
|
||||
"Stiamo preparando gli strumenti di housekeeping disponibili.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,16 @@ export function HousekeepingPageShell({
|
||||
children,
|
||||
}: HousekeepingPageShellProps) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<section
|
||||
aria-labelledby="housekeeping-page-title"
|
||||
className="min-w-0 max-w-full 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)]">
|
||||
<h1
|
||||
id="housekeeping-page-title"
|
||||
className="text-xl font-semibold text-[var(--admin-text)]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
@@ -29,7 +35,7 @@ export function HousekeepingPageShell({
|
||||
</div>
|
||||
{primaryAction ? <div>{primaryAction}</div> : null}
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<div className="min-w-0 max-w-full">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ describe("HousekeepingPageState", () => {
|
||||
["empty", "status"],
|
||||
["partial", "status"],
|
||||
["error", "alert"],
|
||||
["forbidden", "alert"],
|
||||
["ready", "status"],
|
||||
] as const)("renders %s with the %s role", (state, role) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
@@ -34,6 +36,35 @@ describe("HousekeepingPageState", () => {
|
||||
expect(html).toContain(`>${state} description<`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", "polite"],
|
||||
["empty", "polite"],
|
||||
["partial", "polite"],
|
||||
["error", "assertive"],
|
||||
["forbidden", "assertive"],
|
||||
["ready", "polite"],
|
||||
] as const)("announces %s changes with %s priority", (state, live) => {
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial"
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
) : (
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
);
|
||||
const html = renderToStaticMarkup(pageState);
|
||||
|
||||
expect(html).toContain(`data-page-state="${state}"`);
|
||||
expect(html).toContain(`aria-live="${live}"`);
|
||||
});
|
||||
|
||||
it("announces loading politely", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
|
||||
@@ -6,10 +6,21 @@ interface HousekeepingPageStateBaseProps {
|
||||
retryAction?: ReactNode;
|
||||
}
|
||||
|
||||
export type HousekeepingPageState =
|
||||
| "loading"
|
||||
| "empty"
|
||||
| "partial"
|
||||
| "error"
|
||||
| "forbidden"
|
||||
| "ready";
|
||||
|
||||
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
|
||||
(
|
||||
| { state: "partial"; partialLabel: string }
|
||||
| { state: "loading" | "empty" | "error"; partialLabel?: never }
|
||||
| {
|
||||
state: Exclude<HousekeepingPageState, "partial">;
|
||||
partialLabel?: never;
|
||||
}
|
||||
);
|
||||
|
||||
export function HousekeepingPageState({
|
||||
@@ -19,13 +30,14 @@ export function HousekeepingPageState({
|
||||
description,
|
||||
retryAction,
|
||||
}: HousekeepingPageStateProps) {
|
||||
const isError = state === "error";
|
||||
const isError = state === "error" || state === "forbidden";
|
||||
const isPartial = state === "partial";
|
||||
|
||||
return (
|
||||
<section
|
||||
role={isError ? "alert" : "status"}
|
||||
aria-live={state === "loading" ? "polite" : undefined}
|
||||
aria-live={isError ? "assertive" : "polite"}
|
||||
data-page-state={state}
|
||||
className={`rounded-lg border p-4 ${
|
||||
isError
|
||||
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
|
||||
|
||||
@@ -15,6 +15,7 @@ const routeMocks = vi.hoisted(() => {
|
||||
"navigation.skipToContent": "HK::skip-to-content",
|
||||
"navigation.primary": "HK::primary-navigation",
|
||||
"navigation.contextual": "HK::contextual-navigation",
|
||||
"navigation.operatorRank": "HK::operator-rank",
|
||||
"commandDeck.placeholder": "HK::command-placeholder",
|
||||
"commandDeck.navigation": "HK::command-navigation",
|
||||
"commandDeck.commands": "HK::command-commands",
|
||||
@@ -802,6 +803,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.users",
|
||||
domain: "people",
|
||||
@@ -809,7 +811,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
canonicalHref: "/ase/people/users",
|
||||
},
|
||||
});
|
||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps nested preview segments to canonical dynamic parameters", async () => {
|
||||
@@ -828,6 +830,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.user-detail",
|
||||
domain: "people",
|
||||
@@ -853,6 +856,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.literal-tool",
|
||||
domain: "people",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CommandDeck } from "./shell/command-deck";
|
||||
import { HousekeepingShell } from "./shell/housekeeping-shell";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
skipToContent: "Skip",
|
||||
primaryNavigation: "Primary",
|
||||
contextualNavigation: "Context",
|
||||
command: "Search",
|
||||
preview: "Preview",
|
||||
backToSite: "Back",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search",
|
||||
navigation: "Navigation",
|
||||
commands: "Commands",
|
||||
entities: "Entities",
|
||||
loading: "Loading",
|
||||
empty: "Empty",
|
||||
partial: "Partial",
|
||||
close: "Close",
|
||||
},
|
||||
};
|
||||
|
||||
describe("housekeeping responsive contract", () => {
|
||||
it("stacks both navigation levels below lg without removing capabilities", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId="operations"
|
||||
domains={[
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Operations",
|
||||
items: [{ id: "workspace", href: "/ase", label: "Workspace" }],
|
||||
},
|
||||
]}
|
||||
labels={labels}
|
||||
surface="canonical"
|
||||
>
|
||||
<div>Body</div>
|
||||
</HousekeepingShell>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-responsive-mode="stacked-below-lg"');
|
||||
expect(html).toContain('data-navigation-collapse="domain"');
|
||||
expect(html).toContain('data-navigation-collapse="context"');
|
||||
expect(html).toContain('href="/ase/operations"');
|
||||
expect(html).toContain('href="/ase"');
|
||||
expect(html).toContain("overflow-x-auto");
|
||||
expect(html).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("keeps the command deck bounded at a 320px viewport", () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/features/housekeeping/foundation/shell/command-deck.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const trigger = renderToStaticMarkup(
|
||||
<CommandDeck
|
||||
labels={{ ...labels.commandDeck, trigger: "Search" }}
|
||||
surface="canonical"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(trigger).toContain("min-w-0");
|
||||
expect(source).toContain("max-h-[calc(100dvh-2rem)]");
|
||||
expect(source).toContain("overflow-hidden");
|
||||
expect(source).toContain("p-2 sm:p-4");
|
||||
});
|
||||
|
||||
it("provides non-overflow and reduced-motion safeguards for housekeeping content", () => {
|
||||
const css = readFileSync(
|
||||
resolve(process.cwd(), "src/app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(css).toContain("[data-housekeeping-root]");
|
||||
expect(css).toContain("#housekeeping-content table");
|
||||
expect(css).toContain("overflow-x: auto");
|
||||
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
|
||||
expect(css).toContain("transition-duration: 0.01ms");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,13 @@ function matches(tokens: readonly string[], ...values: readonly string[]) {
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
function commandTitle(commandId: string): string {
|
||||
function commandTitle(
|
||||
commandId: string,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
const key = `pages.housekeeping.commands.${commandId}`;
|
||||
const translated = translate(key);
|
||||
if (translated !== key) return translated;
|
||||
return commandId
|
||||
.split(".")
|
||||
.slice(1)
|
||||
@@ -120,7 +126,7 @@ export function searchNavigationAndCommands({
|
||||
(candidate) => candidate.id === command.owner,
|
||||
);
|
||||
if (!domain || !satisfiesCapability(context, domain.capability)) continue;
|
||||
const title = commandTitle(command.id);
|
||||
const title = commandTitle(command.id, translate);
|
||||
if (!matches(tokens, command.id, command.owner, title)) continue;
|
||||
commandsFound.push({
|
||||
id: `command:${command.id}`,
|
||||
|
||||
@@ -276,7 +276,7 @@ export function CommandDeck({
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)]"
|
||||
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<span>{labels.trigger}</span>
|
||||
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
|
||||
@@ -286,14 +286,14 @@ export function CommandDeck({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.trigger}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-4 pt-[10vh]"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-2 sm:p-4 pt-4 sm:pt-[10vh]"
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
|
||||
<div className="max-h-[calc(100dvh-2rem)] min-w-0 w-full max-w-2xl overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
|
||||
<div className="flex justify-end border-b border-[var(--admin-border)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)]"
|
||||
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.close}
|
||||
</button>
|
||||
@@ -316,10 +316,17 @@ export function CommandDeck({
|
||||
placeholder={labels.placeholder}
|
||||
aria-label={labels.placeholder}
|
||||
/>
|
||||
<CommandList aria-busy={loading}>
|
||||
<CommandList
|
||||
aria-busy={loading}
|
||||
className="min-w-0 overflow-y-auto"
|
||||
>
|
||||
{loading ? <p role="status">{labels.loading}</p> : null}
|
||||
{response.errors.length > 0 ? (
|
||||
<p role="status" className="px-3 py-2 text-sm">
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-3 py-2 text-sm"
|
||||
>
|
||||
{labels.partial}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -7,13 +7,17 @@ interface ContextNavProps {
|
||||
|
||||
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="min-w-0">
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
data-housekeeping-landmark="context-navigation"
|
||||
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)]"
|
||||
className="block min-h-11 rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
|
||||
@@ -27,14 +27,18 @@ export function DomainRail({
|
||||
ariaLabel,
|
||||
}: DomainRailProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className="w-full lg:w-20">
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
data-housekeeping-landmark="primary-navigation"
|
||||
className="min-w-0 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"
|
||||
className="flex min-h-11 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)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)] lg:justify-center"
|
||||
>
|
||||
<span data-domain-icon={domain.iconId}>
|
||||
<DomainIcon iconId={domain.iconId} />
|
||||
|
||||
@@ -72,14 +72,18 @@ function orderedPinnedRouteIds(preferences: HousekeepingPreferences): string[] {
|
||||
|
||||
function SortableFavorite({
|
||||
route,
|
||||
title,
|
||||
surface,
|
||||
unpin,
|
||||
unpinLabel,
|
||||
reorderInstructionsId,
|
||||
}: {
|
||||
readonly route: HousekeepingRouteDefinition;
|
||||
readonly title: string;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly unpin: () => void;
|
||||
readonly unpinLabel: string;
|
||||
readonly reorderInstructionsId: string;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
@@ -89,27 +93,28 @@ function SortableFavorite({
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] p-3"
|
||||
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] p-3 motion-reduce:transition-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={route.labelKey}
|
||||
className="cursor-grab text-[var(--admin-text-muted)]"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={title}
|
||||
aria-describedby={reorderInstructionsId}
|
||||
className="cursor-grab rounded text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
<Link
|
||||
className="min-w-0 flex-1"
|
||||
className="min-w-0 flex-1 break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
href={toHousekeepingHref(route.href, surface)}
|
||||
>
|
||||
{route.labelKey}
|
||||
{title}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={unpin}
|
||||
className="rounded-md px-2 py-1 text-sm"
|
||||
className="rounded-md px-2 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{unpinLabel}
|
||||
</button>
|
||||
@@ -122,6 +127,7 @@ export interface FavoritesLabels {
|
||||
readonly pin: string;
|
||||
readonly unpin: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
}
|
||||
|
||||
export function Favorites({
|
||||
@@ -130,12 +136,14 @@ export function Favorites({
|
||||
surface,
|
||||
savePreferences,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly savePreferences: HousekeepingPreferenceSaver;
|
||||
readonly labels: FavoritesLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const [preferences, setPreferences] = useState(initialPreferences);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -190,6 +198,9 @@ export function Favorites({
|
||||
<h2 id="housekeeping-favorites-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
<p id="housekeeping-favorites-reorder-instructions" className="sr-only">
|
||||
{labels.reorderInstructions}
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="text-[var(--admin-danger)]">
|
||||
{labels.error}
|
||||
@@ -207,8 +218,10 @@ export function Favorites({
|
||||
<SortableFavorite
|
||||
key={id}
|
||||
route={route}
|
||||
title={titles[route.labelKey] ?? route.id}
|
||||
surface={surface}
|
||||
unpinLabel={labels.unpin}
|
||||
reorderInstructionsId="housekeeping-favorites-reorder-instructions"
|
||||
unpin={() => void commit(togglePinnedRoute(preferences, id))}
|
||||
/>
|
||||
) : null;
|
||||
@@ -220,16 +233,18 @@ export function Favorites({
|
||||
{unpinned.map((route) => (
|
||||
<li
|
||||
key={route.id}
|
||||
className="flex items-center justify-between gap-3 p-2"
|
||||
className="flex min-w-0 items-center justify-between gap-3 p-2"
|
||||
>
|
||||
<span>{route.labelKey}</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{titles[route.labelKey] ?? route.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void commit(togglePinnedRoute(preferences, route.id))
|
||||
}
|
||||
className="rounded-md px-2 py-1 text-sm"
|
||||
className="rounded-md px-2 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.pin}
|
||||
</button>
|
||||
|
||||
@@ -17,6 +17,7 @@ const labels = {
|
||||
command: "Search housekeeping",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
placeholder: "Search navigation, commands, and entities",
|
||||
navigation: "Navigation",
|
||||
|
||||
@@ -8,6 +8,13 @@ import { ContextNav } from "./context-nav";
|
||||
import { DomainRail } from "./domain-rail";
|
||||
import { OperatorSummary } from "./operator-summary";
|
||||
|
||||
export const HOUSEKEEPING_LANDMARKS = [
|
||||
"banner",
|
||||
"primary-navigation",
|
||||
"context-navigation",
|
||||
"main",
|
||||
] as const;
|
||||
|
||||
interface HousekeepingShellProps {
|
||||
actor: HousekeepingActor;
|
||||
activeDomainId: HousekeepingDomainId;
|
||||
@@ -19,6 +26,7 @@ interface HousekeepingShellProps {
|
||||
command: string;
|
||||
preview: string;
|
||||
backToSite: string;
|
||||
operatorRank: string;
|
||||
commandDeck: {
|
||||
placeholder: string;
|
||||
navigation: string;
|
||||
@@ -45,21 +53,24 @@ export function HousekeepingShell({
|
||||
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||
<div
|
||||
data-housekeeping-root="true"
|
||||
className="min-h-screen min-w-0 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)]"
|
||||
className="sr-only rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)] focus-visible:not-sr-only focus-visible:absolute focus-visible:left-4 focus-visible:top-4 focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{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">
|
||||
<header className="flex min-w-0 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 min-w-0 items-center gap-3">
|
||||
{surface === "preview" ? (
|
||||
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||
{labels.preview}
|
||||
</span>
|
||||
) : null}
|
||||
<OperatorSummary actor={actor} />
|
||||
<OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandTrigger
|
||||
@@ -69,27 +80,40 @@ export function HousekeepingShell({
|
||||
/>
|
||||
<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)]"
|
||||
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{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">
|
||||
<div
|
||||
data-responsive-mode="stacked-below-lg"
|
||||
className="flex min-h-[calc(100vh-4rem)] min-w-0 flex-col lg:flex-row"
|
||||
>
|
||||
<aside
|
||||
data-navigation-collapse="domain"
|
||||
className="min-w-0 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">
|
||||
<div
|
||||
data-navigation-collapse="context"
|
||||
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:shrink-0 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">
|
||||
<main
|
||||
id="housekeeping-content"
|
||||
tabIndex={-1}
|
||||
className="housekeeping-content min-w-0 max-w-full flex-1 p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--admin-accent)] lg:p-6"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,9 @@ describe("OperationalInbox", () => {
|
||||
expect(html).toContain('aria-label="Filter by state"');
|
||||
expect(html).toContain('aria-label="Filter by priority"');
|
||||
expect(html).toContain("Some sources are unavailable");
|
||||
expect(html).toContain("content.publication");
|
||||
expect(html).not.toContain("content.publication");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
expect(html).not.toContain("Dismiss");
|
||||
expect(html).not.toContain("Assign");
|
||||
|
||||
@@ -90,7 +90,10 @@ export function OperationalInbox({
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="operational-inbox-title" className="space-y-4">
|
||||
<section
|
||||
aria-labelledby="operational-inbox-title"
|
||||
className="min-w-0 space-y-4"
|
||||
>
|
||||
<h2 id="operational-inbox-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
@@ -102,7 +105,7 @@ export function OperationalInbox({
|
||||
aria-label={labels.domainFilter}
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{domains.map((value) => (
|
||||
@@ -115,7 +118,7 @@ export function OperationalInbox({
|
||||
aria-label={labels.stateFilter}
|
||||
value={state}
|
||||
onChange={(event) => setState(event.target.value)}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{states.map((value) => (
|
||||
@@ -128,7 +131,7 @@ export function OperationalInbox({
|
||||
aria-label={labels.priorityFilter}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{priorities.map((value) => (
|
||||
@@ -141,17 +144,11 @@ export function OperationalInbox({
|
||||
|
||||
{response.errors.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
|
||||
>
|
||||
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||
{response.errors.map((error) => (
|
||||
<li key={error.sourceId}>
|
||||
{error.sourceId}: {error.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -160,10 +157,10 @@ export function OperationalInbox({
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={`${item.sourceId}:${item.itemId}`}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="min-w-0 break-words">
|
||||
<p className="font-medium">
|
||||
{titles[item.titleKey] ?? item.titleKey}
|
||||
</p>
|
||||
@@ -176,7 +173,7 @@ export function OperationalInbox({
|
||||
</div>
|
||||
<Link
|
||||
href={projectOperationalInboxHref(item.href, surface)}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.open}
|
||||
</Link>
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { HousekeepingActor } from "../contracts";
|
||||
|
||||
interface OperatorSummaryProps {
|
||||
actor: HousekeepingActor;
|
||||
rankLabel: string;
|
||||
}
|
||||
|
||||
export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||
export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
|
||||
return (
|
||||
<div className="min-w-0 text-sm text-[var(--admin-text)]">
|
||||
<div className="truncate font-medium">{actor.username}</div>
|
||||
@@ -12,7 +13,8 @@ export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||
data-operator-rank={actor.rank}
|
||||
className="text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{actor.rank}
|
||||
<span className="sr-only">{rankLabel}</span>
|
||||
<span aria-hidden="true">{actor.rank}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("housekeeping personalization", () => {
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Save failed",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
}}
|
||||
/>
|
||||
<RecentWork
|
||||
@@ -190,6 +191,7 @@ describe("housekeeping personalization", () => {
|
||||
title: "Widgets",
|
||||
partial: "Some widgets failed",
|
||||
error: "Save failed",
|
||||
reorderInstructions: "Press Space, then use arrow keys.",
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
@@ -198,8 +200,7 @@ describe("housekeeping personalization", () => {
|
||||
expect(html).toContain("Required");
|
||||
expect(html).toContain('disabled=""');
|
||||
expect(html).toContain("Some widgets failed");
|
||||
expect(html).toContain("people.failing");
|
||||
expect(html).toContain("TIMEOUT");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
const gridSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
|
||||
@@ -20,7 +20,10 @@ export function RecentWork({
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby="housekeeping-recent-title" className="space-y-3">
|
||||
<section
|
||||
aria-labelledby="housekeeping-recent-title"
|
||||
className="min-w-0 space-y-3"
|
||||
>
|
||||
<h2 id="housekeeping-recent-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
@@ -29,10 +32,13 @@ export function RecentWork({
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.routeId}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||
>
|
||||
<Link href={toHousekeepingHref(item.canonicalHref, surface)}>
|
||||
{titles[item.labelKey] ?? item.labelKey}
|
||||
<Link
|
||||
href={toHousekeepingHref(item.canonicalHref, surface)}
|
||||
className="break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{titles[item.labelKey] ?? item.routeId}
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">
|
||||
<span>{item.source}</span>
|
||||
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
|
||||
function SortableWidget({
|
||||
id,
|
||||
title,
|
||||
data,
|
||||
reorderInstructionsId,
|
||||
}: {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly data: unknown;
|
||||
readonly reorderInstructionsId: string;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
@@ -37,21 +41,22 @@ function SortableWidget({
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4 motion-reduce:transition-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={id}
|
||||
className="cursor-grab text-[var(--admin-text-muted)]"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={title}
|
||||
aria-describedby={reorderInstructionsId}
|
||||
className="cursor-grab rounded text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
<h3 className="font-medium">{id}</h3>
|
||||
<h3 className="min-w-0 break-words font-medium">{title}</h3>
|
||||
</div>
|
||||
<pre className="mt-2 overflow-auto text-xs">
|
||||
<pre className="mt-2 max-w-full overflow-auto text-xs">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</li>
|
||||
@@ -62,6 +67,7 @@ export interface WidgetGridLabels {
|
||||
readonly title: string;
|
||||
readonly partial: string;
|
||||
readonly error: string;
|
||||
readonly reorderInstructions: string;
|
||||
}
|
||||
|
||||
export function WidgetGrid({
|
||||
@@ -69,11 +75,13 @@ export function WidgetGrid({
|
||||
result,
|
||||
savePreferences,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly result: HousekeepingWidgetLoadResult;
|
||||
readonly savePreferences: HousekeepingPreferenceSaver;
|
||||
readonly labels: WidgetGridLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const [preferences, setPreferences] = useState(initialPreferences);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -122,6 +130,9 @@ export function WidgetGrid({
|
||||
<h2 id="housekeeping-widget-grid-title" className="text-lg font-semibold">
|
||||
{labels.title}
|
||||
</h2>
|
||||
<p id="housekeeping-widgets-reorder-instructions" className="sr-only">
|
||||
{labels.reorderInstructions}
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="text-[var(--admin-danger)]">
|
||||
{labels.error}
|
||||
@@ -129,17 +140,11 @@ export function WidgetGrid({
|
||||
) : null}
|
||||
{result.errors.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
|
||||
>
|
||||
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||
{result.errors.map((item) => (
|
||||
<li key={item.widgetId}>
|
||||
{item.widgetId}: {item.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<DndContext
|
||||
@@ -149,7 +154,13 @@ export function WidgetGrid({
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{ids.map((id) => (
|
||||
<SortableWidget key={id} id={id} data={dataById.get(id)} />
|
||||
<SortableWidget
|
||||
key={id}
|
||||
id={id}
|
||||
title={titles[id] ?? id}
|
||||
data={dataById.get(id)}
|
||||
reorderInstructionsId="housekeeping-widgets-reorder-instructions"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
|
||||
@@ -92,7 +92,11 @@ export function WidgetSettings({
|
||||
{labels.title}
|
||||
</h2>
|
||||
{error ? (
|
||||
<p role="alert" className="text-[var(--admin-danger)]">
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="text-[var(--admin-danger)]"
|
||||
>
|
||||
{error === labels.error ? error : labels.error}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -105,16 +109,19 @@ export function WidgetSettings({
|
||||
return (
|
||||
<li
|
||||
key={widget.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={mandatory || savingId !== null}
|
||||
onChange={() => void toggle(widget.id)}
|
||||
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
/>
|
||||
<span>{titles[widget.id] ?? widget.id}</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{titles[widget.id] ?? widget.id}
|
||||
</span>
|
||||
</label>
|
||||
{mandatory ? (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface HousekeepingPageInput {
|
||||
>;
|
||||
readonly surface?: HousekeepingSurface;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
readonly translate?: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HousekeepingRouteHandler {
|
||||
|
||||
Reference in new issue
Block a user