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
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
|
||||
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
|
||||
import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes";
|
||||
import { OPERATIONS_ROUTES } from "../domains/operations/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
@@ -27,6 +28,33 @@ const SERVER_CAPABILITY_CONTEXT =
|
||||
const PERMISSIONS_ADAPTER = "src/lib/permissions";
|
||||
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
|
||||
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/operations/manifest.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/operations/inbox",
|
||||
"src/features/housekeeping/domains/operations/routes",
|
||||
"src/features/housekeeping/domains/operations/search",
|
||||
"src/features/housekeeping/domains/operations/widgets",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/operations/pages/personalization.tsx",
|
||||
new Set(["src/actions/housekeeping-preferences"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/operations/pages/workspace.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/operations/pages/personalization",
|
||||
"src/features/housekeeping/domains/operations/queries",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/operations/route-handlers.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/operations/pages/workspace",
|
||||
"src/features/housekeeping/domains/operations/queries",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/foundation/shell/command-deck.tsx",
|
||||
new Set(["src/actions/housekeeping-search"]),
|
||||
@@ -923,6 +951,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/domains/content/route-handlers",
|
||||
"src/features/housekeeping/domains/economy/route-handlers",
|
||||
"src/features/housekeeping/domains/hotel/route-handlers",
|
||||
"src/features/housekeeping/domains/operations/route-handlers",
|
||||
"src/features/housekeeping/domains/people/route-handlers",
|
||||
"src/features/housekeeping/domains/system/route-handlers",
|
||||
]),
|
||||
@@ -1523,7 +1552,7 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the real six-domain registry with People, Content, Economy, Hotel, and System routes enabled", () => {
|
||||
it("creates the real six-domain registry with every domain route enabled", () => {
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
||||
@@ -1536,14 +1565,9 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
]);
|
||||
expect(
|
||||
registry.domains
|
||||
.filter(
|
||||
(domain) =>
|
||||
!["people", "content", "economy", "hotel", "system"].includes(
|
||||
domain.id,
|
||||
),
|
||||
)
|
||||
.every((domain) => domain.routes.length === 0),
|
||||
).toBe(true);
|
||||
.find((domain) => domain.id === "operations")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(OPERATIONS_ROUTES.map((route) => route.id));
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "people")
|
||||
|
||||
@@ -148,6 +148,7 @@ const routeMocks = vi.hoisted(() => {
|
||||
"routes.hotel.studio.repair-icons": "HK::hotel-studio-repair-icons",
|
||||
"routes.hotel.studio.sync": "HK::hotel-studio-sync",
|
||||
"routes.hotel.studio.upload": "HK::hotel-studio-upload",
|
||||
"routes.operations.workspace": "HK::operations-workspace",
|
||||
"states.empty.title": "Localized empty title",
|
||||
"states.empty.description": "Localized empty description",
|
||||
};
|
||||
@@ -276,6 +277,10 @@ vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("@/features/housekeeping/route-handlers", () => ({
|
||||
HOUSEKEEPING_ROUTE_HANDLERS: [
|
||||
{
|
||||
routeId: "operations.workspace",
|
||||
render: routeMocks.renderHousekeepingRoute,
|
||||
},
|
||||
{ routeId: "people.users", render: routeMocks.renderHousekeepingRoute },
|
||||
{
|
||||
routeId: "people.user-detail",
|
||||
@@ -617,24 +622,30 @@ describe("/ase-next preview gate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("/ase-next first visible domain", () => {
|
||||
describe("/ase-next operational home", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("redirects an administrator to the first permitted registered route", async () => {
|
||||
it("renders the Operations workspace directly for an administrator", async () => {
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
||||
);
|
||||
|
||||
await expect(AdminNextPage()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/ase-next/people/users",
|
||||
const html = await renderRoute(AdminNextPage());
|
||||
expect(html).toContain("Rendered operations.workspace");
|
||||
expect(html).toContain("HK::operations-workspace");
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
match: expect.objectContaining({ routeId: "operations.workspace" }),
|
||||
surface: "preview",
|
||||
}),
|
||||
);
|
||||
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
|
||||
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("redirects a moderator with only an approved mod view capability to People", async () => {
|
||||
@@ -790,6 +801,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
match: {
|
||||
routeId: "people.users",
|
||||
domain: "people",
|
||||
@@ -815,6 +827,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
match: {
|
||||
routeId: "people.user-detail",
|
||||
domain: "people",
|
||||
@@ -839,6 +852,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
match: {
|
||||
routeId: "people.literal-tool",
|
||||
domain: "people",
|
||||
|
||||
@@ -12,6 +12,10 @@ import { HOTEL_INBOX_SOURCES } from "../domains/hotel/inbox";
|
||||
import { HOTEL_ROUTES } from "../domains/hotel/routes";
|
||||
import { HOTEL_SEARCH_PROVIDERS } from "../domains/hotel/search";
|
||||
import { HOTEL_WIDGETS } from "../domains/hotel/widgets";
|
||||
import { OPERATIONS_INBOX_SOURCES } from "../domains/operations/inbox";
|
||||
import { OPERATIONS_ROUTES } from "../domains/operations/routes";
|
||||
import { OPERATIONS_SEARCH_PROVIDERS } from "../domains/operations/search";
|
||||
import { OPERATIONS_WIDGETS } from "../domains/operations/widgets";
|
||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
@@ -380,56 +384,64 @@ describe("housekeeping registry", () => {
|
||||
descriptionKey: expected.descriptionKey,
|
||||
});
|
||||
expect(actual.routes).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "content"
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_ROUTES
|
||||
: expected.id === "people"
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "content"
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_SEARCH_PROVIDERS
|
||||
: expected.id === "content"
|
||||
? CONTENT_SEARCH_PROVIDERS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_SEARCH_PROVIDERS
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_SEARCH_PROVIDERS
|
||||
: expected.id === "system"
|
||||
? SYSTEM_SEARCH_PROVIDERS
|
||||
: [],
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_SEARCH_PROVIDERS
|
||||
: expected.id === "people"
|
||||
? PEOPLE_SEARCH_PROVIDERS
|
||||
: expected.id === "content"
|
||||
? CONTENT_SEARCH_PROVIDERS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_SEARCH_PROVIDERS
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_SEARCH_PROVIDERS
|
||||
: expected.id === "system"
|
||||
? SYSTEM_SEARCH_PROVIDERS
|
||||
: [],
|
||||
);
|
||||
expect(actual.inboxSources).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_INBOX_SOURCES
|
||||
: expected.id === "content"
|
||||
? CONTENT_INBOX_SOURCES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_INBOX_SOURCES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_INBOX_SOURCES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_INBOX_SOURCES
|
||||
: [],
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_INBOX_SOURCES
|
||||
: expected.id === "people"
|
||||
? PEOPLE_INBOX_SOURCES
|
||||
: expected.id === "content"
|
||||
? CONTENT_INBOX_SOURCES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_INBOX_SOURCES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_INBOX_SOURCES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_INBOX_SOURCES
|
||||
: [],
|
||||
);
|
||||
expect(actual.widgets).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_WIDGETS
|
||||
: expected.id === "content"
|
||||
? CONTENT_WIDGETS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_WIDGETS
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_WIDGETS
|
||||
: expected.id === "system"
|
||||
? SYSTEM_WIDGETS
|
||||
: [],
|
||||
expected.id === "operations"
|
||||
? OPERATIONS_WIDGETS
|
||||
: expected.id === "people"
|
||||
? PEOPLE_WIDGETS
|
||||
: expected.id === "content"
|
||||
? CONTENT_WIDGETS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_WIDGETS
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_WIDGETS
|
||||
: expected.id === "system"
|
||||
? SYSTEM_WIDGETS
|
||||
: [],
|
||||
);
|
||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ const {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({ cache: wrapCache }));
|
||||
vi.mock("react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react")>()),
|
||||
cache: wrapCache,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ notFound, redirect }));
|
||||
vi.mock("next-intl/server", () => ({ getTranslations }));
|
||||
vi.mock("@/lib/auth", () => ({ auth }));
|
||||
@@ -74,17 +77,17 @@ function adminContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeRequestConsumers(expectedPageError = "NEXT_NOT_FOUND") {
|
||||
async function invokeRequestConsumers() {
|
||||
const shell = await AdminNextDomainLayout({
|
||||
children: null,
|
||||
params: Promise.resolve({ domain: "operations" }),
|
||||
});
|
||||
await expect(AdminNextPage()).rejects.toThrow(expectedPageError);
|
||||
const page = await AdminNextPage();
|
||||
const commandContext = await requireHousekeepingCapability(
|
||||
anyCapability("admin.dashboard"),
|
||||
);
|
||||
|
||||
return { commandContext, shell };
|
||||
return { commandContext, page, shell };
|
||||
}
|
||||
|
||||
describe("getHousekeepingCapabilityContext", () => {
|
||||
@@ -102,11 +105,10 @@ describe("getHousekeepingCapabilityContext", () => {
|
||||
});
|
||||
|
||||
it("isolates real shell, page, and command preflight consumers between logical requests", async () => {
|
||||
const first = await invokeRequestConsumers(
|
||||
"NEXT_REDIRECT:/ase-next/people/users",
|
||||
);
|
||||
const first = await invokeRequestConsumers();
|
||||
|
||||
expect(first.shell).toBeDefined();
|
||||
expect(first.page).toBeDefined();
|
||||
expect(first.commandContext.actor).toEqual({
|
||||
id: 42,
|
||||
username: "operator",
|
||||
@@ -123,6 +125,7 @@ describe("getHousekeepingCapabilityContext", () => {
|
||||
const second = await invokeRequestConsumers();
|
||||
|
||||
expect(second.shell).toBeDefined();
|
||||
expect(second.page).toBeDefined();
|
||||
expect(second.commandContext.actor).toEqual({
|
||||
id: 84,
|
||||
username: "second-operator",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingSurface } from "../contracts";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import { HousekeepingShell } from "./housekeeping-shell";
|
||||
|
||||
@@ -147,6 +148,7 @@ const componentBoundaryRules = [
|
||||
function renderShell(
|
||||
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
|
||||
availableDomains: readonly HousekeepingNavigationDomain[] = domains,
|
||||
surface: HousekeepingSurface = "preview",
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
@@ -154,6 +156,7 @@ function renderShell(
|
||||
activeDomainId={activeDomainId}
|
||||
domains={availableDomains}
|
||||
labels={labels}
|
||||
surface={surface}
|
||||
>
|
||||
<p>Deck body</p>
|
||||
</HousekeepingShell>,
|
||||
@@ -230,6 +233,13 @@ describe("HousekeepingShell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("only marks the preview surface as preview", () => {
|
||||
expect(renderShell()).toContain(">Preview<");
|
||||
expect(renderShell("operations", domains, "canonical")).not.toContain(
|
||||
">Preview<",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw or render contextual items when a valid active domain is absent", () => {
|
||||
const domainsWithoutSystem = domains.filter(
|
||||
(domain) => domain.id !== "system",
|
||||
|
||||
@@ -54,9 +54,11 @@ export function HousekeepingShell({
|
||||
</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">
|
||||
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||
{labels.preview}
|
||||
</span>
|
||||
{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} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user