feat(housekeeping): compose operations workspace
This commit is contained in:
1 parent
5574e601bb
commit
85ad0452d3
22 files changed
+1240
-87
No files matched your search
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
HousekeepingRouteDefinition,
|
||||
HousekeepingSurface,
|
||||
HousekeepingWidgetDefinition,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPreferences } from "../../../foundation/preferences/schema";
|
||||
import type { HousekeepingWidgetLoadResult } from "../../../foundation/registry";
|
||||
import { Favorites } from "../../../foundation/shell/favorites";
|
||||
import { WidgetGrid } from "../../../foundation/shell/widget-grid";
|
||||
import {
|
||||
type HousekeepingPreferenceSaver,
|
||||
WidgetSettings,
|
||||
} from "../../../foundation/shell/widget-settings";
|
||||
|
||||
const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => {
|
||||
const { saveHousekeepingPreferences } = await import(
|
||||
"@/actions/housekeeping-preferences"
|
||||
);
|
||||
return saveHousekeepingPreferences(preferences);
|
||||
};
|
||||
|
||||
export function OperationsPersonalization({
|
||||
preferences,
|
||||
routes,
|
||||
widgets,
|
||||
widgetResult,
|
||||
surface,
|
||||
savePreferences = persistPreferences,
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly widgets: readonly HousekeepingWidgetDefinition[];
|
||||
readonly widgetResult: HousekeepingWidgetLoadResult;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Favorites
|
||||
preferences={preferences}
|
||||
routes={routes}
|
||||
surface={surface}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Preferences could not be saved",
|
||||
}}
|
||||
/>
|
||||
<WidgetSettings
|
||||
preferences={preferences}
|
||||
widgets={widgets}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Widget settings",
|
||||
mandatory: "Mandatory",
|
||||
error: "Preferences could not be saved",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WidgetGrid
|
||||
preferences={preferences}
|
||||
result={widgetResult}
|
||||
savePreferences={savePreferences}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Some widgets are unavailable",
|
||||
error: "Widget order could not be saved",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingRouteDefinition,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { defaultHousekeepingPreferences } from "../../../foundation/preferences/schema";
|
||||
import type { OperationsWorkspaceLoadResult } from "../queries";
|
||||
import { OperationsWorkspacePage } from "./workspace";
|
||||
|
||||
const capability = anyCapability(PERMS.USERS_VIEW);
|
||||
const routes: readonly HousekeepingRouteDefinition[] = [
|
||||
{
|
||||
id: "people.users",
|
||||
labelKey: "Users",
|
||||
href: "/ase/people/users",
|
||||
capability,
|
||||
},
|
||||
];
|
||||
const widgets: readonly HousekeepingWidgetDefinition[] = [
|
||||
{
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability,
|
||||
kind: "mandatory",
|
||||
load: async () => ok({}, "widget"),
|
||||
},
|
||||
];
|
||||
const preferences = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.users"],
|
||||
shortcutOrder: ["people.users"],
|
||||
widgetOrder: ["people.queue"],
|
||||
};
|
||||
const result: OperationsWorkspaceLoadResult = {
|
||||
model: {
|
||||
inbox: {
|
||||
items: [
|
||||
{
|
||||
sourceId: "people.queue",
|
||||
itemId: "ticket-1",
|
||||
deduplicationKey: "people.queue:ticket-1",
|
||||
domain: "people",
|
||||
capability,
|
||||
severity: "warning",
|
||||
priority: "high",
|
||||
ageMs: 1_000,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-30T10:00:00.000Z",
|
||||
titleKey: "Ticket",
|
||||
href: "/ase/people/users",
|
||||
freshness: "fresh",
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
errors: [{ sourceId: "system.alerts", code: "TIMEOUT" }],
|
||||
correlationId: "inbox",
|
||||
},
|
||||
recent: [
|
||||
{
|
||||
routeId: "people.users",
|
||||
canonicalHref: "/ase/people/users",
|
||||
labelKey: "Users",
|
||||
occurredAt: "2026-08-30T09:00:00.000Z",
|
||||
source: "route-visit",
|
||||
},
|
||||
],
|
||||
favorites: preferences,
|
||||
widgets: {
|
||||
widgets: [{ id: "people.queue", data: { count: 1 } }],
|
||||
errors: [{ widgetId: "system.health", code: "TIMEOUT" }],
|
||||
},
|
||||
},
|
||||
errors: [{ section: "recent", code: "DEPENDENCY_UNAVAILABLE" }],
|
||||
correlationId: "workspace",
|
||||
};
|
||||
|
||||
const savePreferences = vi.fn(async (value) => ok(value, "saved"));
|
||||
|
||||
describe("OperationsWorkspacePage", () => {
|
||||
it("renders capability-filtered sections, partial failures, and preview links", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OperationsWorkspacePage
|
||||
result={result}
|
||||
routes={routes}
|
||||
widgetDefinitions={widgets}
|
||||
surface="preview"
|
||||
savePreferences={savePreferences}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Operations workspace");
|
||||
expect(html).toContain("Operational inbox");
|
||||
expect(html).toContain("Recent work");
|
||||
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).toContain('href="/ase-next/people/users"');
|
||||
});
|
||||
|
||||
it("renders an explicit state when no operational domain is accessible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OperationsWorkspacePage
|
||||
result={result}
|
||||
routes={[]}
|
||||
widgetDefinitions={[]}
|
||||
surface="preview"
|
||||
savePreferences={savePreferences}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("No operational domains are available");
|
||||
expect(html).not.toContain("Operational inbox");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
HousekeepingRouteDefinition,
|
||||
HousekeepingSurface,
|
||||
HousekeepingWidgetDefinition,
|
||||
} from "../../../foundation/contracts";
|
||||
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
||||
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";
|
||||
|
||||
export function OperationsWorkspacePage({
|
||||
result,
|
||||
routes,
|
||||
widgetDefinitions,
|
||||
surface,
|
||||
savePreferences,
|
||||
}: {
|
||||
readonly result: OperationsWorkspaceLoadResult;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly widgetDefinitions: readonly HousekeepingWidgetDefinition[];
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
}) {
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="Operations workspace"
|
||||
description="Your capability-derived operational home."
|
||||
>
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account can open Housekeeping, but no operational domain is
|
||||
currently authorized.
|
||||
</p>
|
||||
</div>
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="Operations workspace"
|
||||
description="Inbox, recent work, favorites, and widgets for the domains you can operate."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{result.errors.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
<RecentWork
|
||||
items={result.model.recent}
|
||||
surface={surface}
|
||||
labels={{
|
||||
title: "Recent work",
|
||||
empty: "No recent Housekeeping work",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OperationsPersonalization
|
||||
preferences={result.model.favorites}
|
||||
routes={routes}
|
||||
widgets={widgetDefinitions}
|
||||
widgetResult={result.model.widgets}
|
||||
surface={surface}
|
||||
{...(savePreferences ? { savePreferences } : {})}
|
||||
/>
|
||||
</div>
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user