WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53

Closed
Simo wants to merge 68 commits from codex/housekeeping-rebuild-stepwise into main
pull from: codex/housekeeping-rebuild-stepwise
17 changed files with 466 additions and 6 deletions
Showing only changes of commit 543607a80e - Show all commits

No files matched your search

@@ -26,6 +26,7 @@ export const contentManifest = {
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -16,6 +16,7 @@ export const economyManifest = {
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -20,6 +20,7 @@ export const hotelManifest = {
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -11,6 +11,7 @@ export const operationsManifest = {
iconId: "inbox",
previewHref: "/ase-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -34,6 +34,7 @@ export const peopleManifest = {
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -21,6 +21,7 @@ export const systemManifest = {
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -89,6 +89,7 @@ const manifest: HousekeepingDomainManifest = {
iconId: "users",
previewHref: "/ase-next/people",
capability,
landingRouteId: null,
routes: [],
searchProviders: [searchProvider],
inboxSources: [inboxSource],
@@ -4,10 +4,12 @@ import type { HousekeepingInboxSource } from "./inbox";
import type { HousekeepingSearchProvider } from "./search";
import type { HousekeepingWidgetDefinition } from "./widget";
export type HousekeepingPreviewHref = `/ase-next${"" | `/${string}`}`;
export interface HousekeepingRouteDefinition {
id: string;
labelKey: string;
href: string;
href: HousekeepingPreviewHref;
capability: CapabilityRequirement;
matchPrefixes?: readonly string[];
}
@@ -19,6 +21,7 @@ export interface HousekeepingDomainManifest {
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
previewHref: `/ase-next/${HousekeepingDomainId}`;
capability: CapabilityRequirement;
landingRouteId: string | null;
routes: readonly HousekeepingRouteDefinition[];
searchProviders: readonly HousekeepingSearchProvider[];
inboxSources: readonly HousekeepingInboxSource[];
@@ -8,6 +8,7 @@ export {
export type { HousekeepingCommand } from "./command";
export type {
HousekeepingDomainManifest,
HousekeepingPreviewHref,
HousekeepingRouteDefinition,
} from "./domain";
export type {
@@ -25,6 +25,7 @@ describe("housekeeping navigation", () => {
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(PERMS.MOD_CFH_VIEW),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -37,6 +38,7 @@ describe("housekeeping navigation", () => {
iconId: "gem",
previewHref: "/ase-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
@@ -71,6 +73,7 @@ describe("housekeeping navigation", () => {
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(PERMS.USERS_VIEW),
landingRouteId: "users",
routes: [
{
id: "users",
@@ -96,6 +99,7 @@ describe("housekeeping navigation", () => {
iconId: "settings",
previewHref: "/ase-next/system",
capability: anyCapability(PERMS.SETTINGS_VIEW),
landingRouteId: null,
routes: [],
searchProviders: [],
inboxSources: [],
File diff suppressed because it is too large. Load diff
@@ -80,6 +80,20 @@ export function createHousekeepingRegistry(
validateNonEmpty(route.labelKey, "route label key");
validateCapability(route.capability);
}
if (manifest.routes.length === 0) {
if (manifest.landingRouteId !== null) {
throw new Error(
`landing route without routes: ${manifest.landingRouteId}`,
);
}
} else if (manifest.landingRouteId === null) {
throw new Error(`missing landing route: ${manifest.id}`);
} else if (
!manifest.routes.some((route) => route.id === manifest.landingRouteId)
) {
throw new Error(`invalid landing route: ${manifest.landingRouteId}`);
}
}
return { domains: Object.freeze([...manifests]) };
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { anyCapability, type HousekeepingDomainManifest } from "../contracts";
import { createHousekeepingRegistry } from "../registry";
import { matchHousekeepingRoute } from "./match-route";
const peopleManifest = {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(PERMS.USERS_VIEW),
landingRouteId: "people.users",
routes: [
{
id: "people.users",
labelKey: "pages.housekeeping.people.users.title",
href: "/ase-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "people.user-settings",
labelKey: "pages.housekeeping.people.userSettings.title",
href: "/ase-next/people/users/settings",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "people.user-detail",
labelKey: "pages.housekeeping.people.userDetail.title",
href: "/ase-next/people/users/:id",
capability: anyCapability(PERMS.USERS_VIEW),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
const registry = createHousekeepingRegistry([peopleManifest]);
describe("matchHousekeepingRoute", () => {
it("matches concrete and dynamic preview routes", () => {
expect(matchHousekeepingRoute(registry, "/ase-next/people/users")).toEqual({
routeId: "people.users",
domain: "people",
params: {},
canonicalHref: "/ase-next/people/users",
});
expect(
matchHousekeepingRoute(registry, "/ase-next/people/users/42"),
).toEqual({
routeId: "people.user-detail",
domain: "people",
params: { id: "42" },
canonicalHref: "/ase-next/people/users/42",
});
});
it("prefers a literal route over a dynamic route", () => {
expect(
matchHousekeepingRoute(registry, "/ase-next/people/users/settings"),
).toMatchObject({
routeId: "people.user-settings",
params: {},
});
});
it("decodes a parameter exactly once", () => {
expect(
matchHousekeepingRoute(registry, "/ase-next/people/users/%34%32"),
).toMatchObject({
routeId: "people.user-detail",
params: { id: "42" },
canonicalHref: "/ase-next/people/users/42",
});
});
it.each([
"/ase-next/people",
"/ase-next/people/unknown",
"/ase-next/people/users/",
"/ase-next/people/users?rank=7",
"/ase-next/people/users#active",
"/ase-next/people\\users",
"/ase-next/people//users",
"/ase-next/people/./users",
"/ase-next/people/users/..",
"/ase-next/people/users/%2F",
"/ase-next/people/users/%252F",
"/ase-next/people/users/%00",
])("rejects an unregistered canonical path: %s", (pathname) => {
expect(matchHousekeepingRoute(registry, pathname)).toBeNull();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { HousekeepingDomainId } from "../../migration/types";
import type {
HousekeepingCapabilityContext,
HousekeepingPreviewHref,
} from "../contracts";
export interface HousekeepingRouteMatch {
routeId: string;
domain: HousekeepingDomainId;
params: Readonly<Record<string, string>>;
canonicalHref: HousekeepingPreviewHref;
}
export interface HousekeepingRouteRenderInput {
context: HousekeepingCapabilityContext;
match: HousekeepingRouteMatch;
searchParams?: Readonly<
Record<string, string | readonly string[] | undefined>
>;
translate: (key: string) => string;
}
export interface HousekeepingRouteHandler {
routeId: string;
render(input: HousekeepingRouteRenderInput): Promise<ReactNode>;
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { anyCapability, type HousekeepingDomainManifest } from "../contracts";
import { createHousekeepingRegistry } from "../registry";
import type { HousekeepingRouteHandler } from "./route-handler";
import { createHousekeepingRouteRuntime } from "./runtime";
const peopleManifest = {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
previewHref: "/ase-next/people",
capability: anyCapability(PERMS.USERS_VIEW),
landingRouteId: "people.users",
routes: [
{
id: "people.users",
labelKey: "pages.housekeeping.people.users.title",
href: "/ase-next/people/users",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "people.user-detail",
labelKey: "pages.housekeeping.people.userDetail.title",
href: "/ase-next/people/users/:id",
capability: anyCapability(PERMS.USERS_VIEW),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
const registry = createHousekeepingRegistry([peopleManifest]);
const handler = (routeId: string): HousekeepingRouteHandler => ({
routeId,
render: async () => `Rendered ${routeId}`,
});
describe("createHousekeepingRouteRuntime", () => {
it("indexes handlers and delegates canonical route matching", () => {
const runtime = createHousekeepingRouteRuntime(registry, [
handler("people.users"),
handler("people.user-detail"),
]);
expect([...runtime.handlers]).toHaveLength(2);
expect(runtime.match("/ase-next/people/users/42")).toMatchObject({
routeId: "people.user-detail",
params: { id: "42" },
});
});
it("rejects a route without a handler", () => {
expect(() => createHousekeepingRouteRuntime(registry, [])).toThrow(
"missing route handler: people.users",
);
});
it("rejects duplicate handlers", () => {
expect(() =>
createHousekeepingRouteRuntime(registry, [
handler("people.users"),
handler("people.users"),
]),
).toThrow("duplicate route handler: people.users");
});
it("rejects a handler without a route", () => {
expect(() =>
createHousekeepingRouteRuntime(registry, [handler("people.unknown")]),
).toThrow("handler without route: people.unknown");
});
});
@@ -0,0 +1,47 @@
import type { HousekeepingRegistry } from "../registry";
import { matchHousekeepingRoute } from "./match-route";
import type {
HousekeepingRouteHandler,
HousekeepingRouteMatch,
} from "./route-handler";
export interface HousekeepingRouteRuntime {
registry: HousekeepingRegistry;
handlers: ReadonlyMap<string, HousekeepingRouteHandler>;
match(pathname: string): HousekeepingRouteMatch | null;
}
export function createHousekeepingRouteRuntime(
registry: HousekeepingRegistry,
handlers: readonly HousekeepingRouteHandler[],
): HousekeepingRouteRuntime {
const declaredRouteIds = new Set(
registry.domains.flatMap((domain) =>
domain.routes.map((route) => route.id),
),
);
const handlerMap = new Map<string, HousekeepingRouteHandler>();
for (const handler of handlers) {
if (handlerMap.has(handler.routeId)) {
throw new Error(`duplicate route handler: ${handler.routeId}`);
}
if (!declaredRouteIds.has(handler.routeId)) {
throw new Error(`handler without route: ${handler.routeId}`);
}
handlerMap.set(handler.routeId, handler);
}
for (const routeId of declaredRouteIds) {
if (!handlerMap.has(routeId)) {
throw new Error(`missing route handler: ${routeId}`);
}
}
return Object.freeze({
registry,
handlers: handlerMap,
match: (pathname: string) => matchHousekeepingRoute(registry, pathname),
});
}