feat(housekeeping): personalize command deck
This commit is contained in:
1 parent
300ac0ef95
commit
5574e601bb
12 files changed
+1549
-1
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import { defaultHousekeepingPreferences } from "../preferences/schema";
|
||||
import {
|
||||
createHousekeepingRegistry,
|
||||
loadHousekeepingWidgets,
|
||||
} from "../registry";
|
||||
import { Favorites, movePreferenceId, togglePinnedRoute } from "./favorites";
|
||||
import { RecentWork } from "./recent-work";
|
||||
import { WidgetGrid } from "./widget-grid";
|
||||
import {
|
||||
preferencesAfterSave,
|
||||
toggleOptionalWidget,
|
||||
WidgetSettings,
|
||||
} from "./widget-settings";
|
||||
|
||||
const usersView = anyCapability(PERMS.USERS_VIEW);
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === PERMS.USERS_VIEW,
|
||||
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
const registry = createHousekeepingRegistry([
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "people",
|
||||
descriptionKey: "people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: usersView,
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
labelKey: "Users",
|
||||
href: "/ase/people/users",
|
||||
capability: usersView,
|
||||
},
|
||||
{
|
||||
id: "people.tickets",
|
||||
labelKey: "Tickets",
|
||||
href: "/ase/people/tickets",
|
||||
capability: usersView,
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [
|
||||
{
|
||||
id: "people.mandatory",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "mandatory",
|
||||
load: async () => ok({ count: 1 }, "mandatory"),
|
||||
},
|
||||
{
|
||||
id: "people.optional",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "optional",
|
||||
load: async () => ok({ count: 2 }, "optional"),
|
||||
},
|
||||
{
|
||||
id: "people.failing",
|
||||
owner: "people",
|
||||
capability: usersView,
|
||||
kind: "optional",
|
||||
load: async () => fail("TIMEOUT", "timeout", "failing"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const initialPreferences = {
|
||||
...defaultHousekeepingPreferences(),
|
||||
pinnedRouteIds: ["people.users"],
|
||||
shortcutOrder: ["people.users"],
|
||||
widgetOrder: ["stale.widget", "people.optional", "people.mandatory"],
|
||||
enabledOptionalWidgetIds: ["people.optional", "people.failing"],
|
||||
};
|
||||
|
||||
const save = vi.fn(async (value) => ok(value, "saved"));
|
||||
|
||||
describe("housekeeping personalization", () => {
|
||||
it("loads enabled widgets independently in reconciled order and reports partial failures", async () => {
|
||||
const result = await loadHousekeepingWidgets(
|
||||
registry,
|
||||
initialPreferences,
|
||||
context,
|
||||
);
|
||||
expect(result.widgets).toEqual([
|
||||
{ id: "people.optional", data: { count: 2 } },
|
||||
{ id: "people.mandatory", data: { count: 1 } },
|
||||
]);
|
||||
expect(result.errors).toEqual([
|
||||
{ widgetId: "people.failing", code: "TIMEOUT" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pins, unpins, and reorders only stable preference identifiers", () => {
|
||||
const pinned = togglePinnedRoute(initialPreferences, "people.tickets");
|
||||
expect(pinned.pinnedRouteIds).toEqual(["people.users", "people.tickets"]);
|
||||
expect(pinned.shortcutOrder).toEqual(["people.users", "people.tickets"]);
|
||||
expect(
|
||||
movePreferenceId(pinned.shortcutOrder, "people.tickets", "people.users"),
|
||||
).toEqual(["people.tickets", "people.users"]);
|
||||
expect(togglePinnedRoute(pinned, "people.users")).toMatchObject({
|
||||
pinnedRouteIds: ["people.tickets"],
|
||||
shortcutOrder: ["people.tickets"],
|
||||
});
|
||||
});
|
||||
|
||||
it("locks mandatory widgets, toggles optional widgets, and retains UI state after a failed save", () => {
|
||||
const enabled = toggleOptionalWidget(initialPreferences, "people.optional");
|
||||
expect(enabled.enabledOptionalWidgetIds).toEqual(["people.failing"]);
|
||||
const failure = fail(
|
||||
"INTERNAL",
|
||||
"errors.housekeeping.preferences.save",
|
||||
"save-failed",
|
||||
);
|
||||
expect(preferencesAfterSave(initialPreferences, enabled, failure)).toEqual({
|
||||
preferences: initialPreferences,
|
||||
error: "errors.housekeeping.preferences.save",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders favorites, recent links, locked settings, keyboard ordering, and widget errors accessibly", () => {
|
||||
const widgets = {
|
||||
widgets: [
|
||||
{ id: "people.optional", data: { count: 2 } },
|
||||
{ id: "people.mandatory", data: { count: 1 } },
|
||||
],
|
||||
errors: [{ widgetId: "people.failing", code: "TIMEOUT" as const }],
|
||||
};
|
||||
const routes = registry.domains[0]?.routes ?? [];
|
||||
const definitions = registry.domains[0]?.widgets ?? [];
|
||||
const html = renderToStaticMarkup(
|
||||
<>
|
||||
<Favorites
|
||||
preferences={initialPreferences}
|
||||
routes={routes}
|
||||
surface="preview"
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
error: "Save failed",
|
||||
}}
|
||||
/>
|
||||
<RecentWork
|
||||
items={[
|
||||
{
|
||||
routeId: "people.users",
|
||||
canonicalHref: "/ase/people/users",
|
||||
labelKey: "Users",
|
||||
occurredAt: "2026-08-30T10:00:00.000Z",
|
||||
source: "route-visit",
|
||||
},
|
||||
]}
|
||||
surface="preview"
|
||||
labels={{ title: "Recent work", empty: "Nothing recent" }}
|
||||
/>
|
||||
<WidgetSettings
|
||||
preferences={initialPreferences}
|
||||
widgets={definitions}
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Widget settings",
|
||||
mandatory: "Required",
|
||||
error: "Save failed",
|
||||
}}
|
||||
/>
|
||||
<WidgetGrid
|
||||
preferences={initialPreferences}
|
||||
result={widgets}
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Widgets",
|
||||
partial: "Some widgets failed",
|
||||
error: "Save failed",
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
expect(html).toContain("Required");
|
||||
expect(html).toContain('disabled=""');
|
||||
expect(html).toContain("Some widgets failed");
|
||||
expect(html).toContain("people.failing");
|
||||
expect(html).toContain("TIMEOUT");
|
||||
const gridSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/features/housekeeping/foundation/shell/widget-grid.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(gridSource).toContain("KeyboardSensor");
|
||||
expect(gridSource).toContain("sortableKeyboardCoordinates");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user