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 }
|
||||
: {}),
|
||||
|
||||
Reference in new issue
Block a user