feat(housekeeping): finish accessible command deck

This commit is contained in:
Simo committed 2026-08-30 18:49:10 +02:00
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 }
: {}),