Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import type { DataTableVM } from "@toolctrl/view-models";
import { toDataTableModel } from "./dataTable.js";
function baseVm(overrides: Partial<DataTableVM> = {}): DataTableVM {
return {
columns: [
{ key: "name", labelKey: "ui.data-table.column.name" },
{ key: "amount", labelKey: "ui.data-table.column.amount", align: "right" },
],
rows: [
{ id: "row-1", cells: { name: "Alpha", amount: "10" } },
{ id: "row-2", cells: { name: "Beta", amount: "20" } },
],
...overrides,
};
}
describe("toDataTableModel", () => {
test("defaults column align to left and preserves explicit align", () => {
const model = toDataTableModel(baseVm());
expect(model.columns.map((column) => column.align)).toEqual(["left", "right"]);
expect(model.columns.map((column) => column.key)).toEqual(["name", "amount"]);
expect(model.columns.map((column) => column.labelKey)).toEqual([
"ui.data-table.column.name",
"ui.data-table.column.amount",
]);
});
test("defaults emptyLabelKey and computes rowCount", () => {
const model = toDataTableModel(baseVm());
expect(model.emptyLabelKey).toBe("ui.data-table.empty");
expect(model.rowCount).toBe(2);
const emptyModel = toDataTableModel(baseVm({ rows: [] }));
expect(emptyModel.rowCount).toBe(0);
});
test("honours a caller-supplied emptyLabelKey", () => {
const model = toDataTableModel(baseVm({ emptyLabelKey: "ui.data-table.no-results" }));
expect(model.emptyLabelKey).toBe("ui.data-table.no-results");
});
test("maps caption to captionKey and omits it when absent", () => {
const withCaption = toDataTableModel(baseVm({ caption: { labelKey: "ui.data-table.caption" } }));
expect(withCaption.captionKey).toBe("ui.data-table.caption");
const withoutCaption = toDataTableModel(baseVm());
expect(withoutCaption.captionKey).toBeUndefined();
});
test("throws when a column labelKey is not a valid i18n key", () => {
expect(() => toDataTableModel(baseVm({ columns: [{ key: "x", labelKey: "Not A Key" }] }))).toThrow();
});
test("throws when the caption labelKey is not a valid i18n key", () => {
expect(() => toDataTableModel(baseVm({ caption: { labelKey: "ui.dataTable.caption" } }))).toThrow();
});
});
@@ -0,0 +1,39 @@
import { assertI18nLabel, type DataTableModel, type DataTableVM } from "@toolctrl/view-models";
import { renderDataTable } from "@toolctrl/ui-subframe";
import { defineAdapter } from "./defineAdapter.js";
const DEFAULT_EMPTY_LABEL_KEY = "ui.data-table.empty";
/**
* Normalizes a domain {@link DataTableVM} into the {@link DataTableModel}
* prop-model the UI implementation renders. Validates every i18n key
* (columns + caption) and fills in defaults (column align, empty label,
* row count) so render implementations stay dumb.
*/
export function toDataTableModel(vm: DataTableVM): DataTableModel {
const columns = vm.columns.map((column) => {
assertI18nLabel(column);
return {
key: column.key,
labelKey: column.labelKey,
align: column.align ?? "left",
};
});
const captionKey = vm.caption ? assertI18nLabel(vm.caption).labelKey : undefined;
return {
captionKey,
columns,
rows: vm.rows,
emptyLabelKey: vm.emptyLabelKey ?? DEFAULT_EMPTY_LABEL_KEY,
rowCount: vm.rows.length,
};
}
export const dataTableAdapter = defineAdapter({
name: "dataTable",
toProps: toDataTableModel,
defaultRenderer: renderDataTable,
});
@@ -0,0 +1,21 @@
import type { UiRenderer } from "@toolctrl/view-models";
export type Adapter<VM, Model> = {
readonly name: string;
readonly toProps: (vm: VM) => Model;
readonly render: (vm: VM, renderer?: UiRenderer<Model>) => string;
};
export type DefineAdapterConfig<VM, Model> = {
readonly name: string;
readonly toProps: (vm: VM) => Model;
readonly defaultRenderer: UiRenderer<Model>;
};
export function defineAdapter<VM, Model>(config: DefineAdapterConfig<VM, Model>): Adapter<VM, Model> {
return {
name: config.name,
toProps: config.toProps,
render: (vm, renderer = config.defaultRenderer) => renderer(config.toProps(vm)),
};
}
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import type { FileBrowserData } from "@toolctrl/view-models";
import { renderFileBrowserMock } from "@toolctrl/ui-mock";
import { fileBrowserAdapter } from "./fileBrowser.js";
const vm: FileBrowserData = {
viewMode: "table",
rows: [
{
id: "file-1",
displayName: "invoice.pdf",
mimeType: "application/pdf",
sizeBytes: 1024,
createdAtIso: "2026-06-20T00:00:00.000Z",
tags: [],
source: "manual_upload",
previewStatus: "ready",
scanStatus: "clean",
thumbnailUrl: "/acme/media/file-1/thumb",
detailHref: "/acme/files/file-1",
linkedEntityLabelKey: null,
},
{
id: "file-2",
displayName: "photo.heic",
mimeType: "image/heic",
sizeBytes: 2048,
createdAtIso: "2026-06-20T00:00:00.000Z",
tags: ["site"],
source: "manual_upload",
previewStatus: "pending",
scanStatus: "pending",
thumbnailUrl: null,
detailHref: "/acme/files/file-2",
linkedEntityLabelKey: null,
},
],
filter: { entityType: null, mimeGroup: null, tags: [], assignedToUserId: null, includeArchived: false },
canWrite: true,
canDelete: false,
nextCursor: null,
emptyHeadlineKey: "files.browser.empty.headline",
emptyBodyKey: "files.browser.empty.body",
};
describe("fileBrowserAdapter hot-swap (M017)", () => {
test("normalizes the VM once via toProps", () => {
const model = fileBrowserAdapter.toProps(vm);
expect(model.rowCount).toBe(2);
expect(model.titleKey).toBe("files.browser.title");
expect(model.rows.map((row) => row.id)).toEqual(["file-1", "file-2"]);
});
test("swaps the render implementation without changing the data", () => {
const defaultHtml = fileBrowserAdapter.render(vm);
const mockHtml = fileBrowserAdapter.render(vm, renderFileBrowserMock);
expect(defaultHtml).toContain('data-ui-impl="subframe"');
expect(defaultHtml).not.toContain('data-ui-impl="mock"');
expect(mockHtml).toContain('data-ui-impl="mock"');
expect(mockHtml).not.toContain('data-ui-impl="subframe"');
expect(defaultHtml).not.toBe(mockHtml);
for (const html of [defaultHtml, mockHtml]) {
for (const row of vm.rows) {
expect(html).toContain(`data-row-id="${row.id}"`);
expect(html).toContain(row.displayName);
}
}
});
test("the default renderer is the real subframe implementation", () => {
expect(fileBrowserAdapter.name).toBe("fileBrowser");
expect(fileBrowserAdapter.render(vm)).toContain('data-ui-id="ui.files.browser"');
});
});
@@ -0,0 +1,33 @@
import type { FileBrowserData, FileBrowserModel } from "@toolctrl/view-models";
import { renderFileBrowser } from "@toolctrl/ui-subframe";
import { defineAdapter } from "./defineAdapter.js";
/**
* Normalizes the domain {@link FileBrowserData} into the {@link FileBrowserModel}
* prop-model the UI implementation renders (M015). Keeps render impls dumb so the
* Subframe / mock renderers stay swappable (M017 hot-swap drill).
*/
export function toFileBrowserModel(vm: FileBrowserData): FileBrowserModel {
return {
titleKey: "files.browser.title",
emptyHeadlineKey: vm.emptyHeadlineKey,
emptyBodyKey: vm.emptyBodyKey,
canWrite: vm.canWrite,
rows: vm.rows.map((row) => ({
id: row.id,
displayName: row.displayName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
detailHref: row.detailHref,
thumbnailUrl: row.thumbnailUrl,
})),
rowCount: vm.rows.length,
};
}
export const fileBrowserAdapter = defineAdapter({
name: "fileBrowser",
toProps: toFileBrowserModel,
defaultRenderer: renderFileBrowser,
});
@@ -0,0 +1,56 @@
import { describe, expect, test } from "vitest";
import type { DataTableVM } from "@toolctrl/view-models";
import { renderDataTableMock } from "@toolctrl/ui-mock";
import { dataTableAdapter } from "./dataTable.js";
const vm: DataTableVM = {
caption: { labelKey: "ui.data-table.caption" },
columns: [
{ key: "name", labelKey: "ui.data-table.column.name" },
{ key: "amount", labelKey: "ui.data-table.column.amount", align: "right" },
],
rows: [
{ id: "row-1", cells: { name: "Alpha", amount: "10" } },
{ id: "row-2", cells: { name: "Beta", amount: "20" } },
],
};
describe("dataTableAdapter hot-swap (M017)", () => {
test("normalizes the VM once via toProps", () => {
const model = dataTableAdapter.toProps(vm);
expect(model.rowCount).toBe(2);
expect(model.captionKey).toBe("ui.data-table.caption");
expect(model.columns.map((column) => column.align)).toEqual(["left", "right"]);
});
test("swaps the render implementation without changing the data", () => {
const defaultHtml = dataTableAdapter.render(vm);
const mockHtml = dataTableAdapter.render(vm, renderDataTableMock);
expect(defaultHtml.length).toBeGreaterThan(0);
expect(mockHtml.length).toBeGreaterThan(0);
// Chrome differs: only the implementation marker swaps.
expect(defaultHtml).toContain('data-ui-impl="subframe"');
expect(defaultHtml).not.toContain('data-ui-impl="mock"');
expect(mockHtml).toContain('data-ui-impl="mock"');
expect(mockHtml).not.toContain('data-ui-impl="subframe"');
expect(defaultHtml).not.toBe(mockHtml);
// Data is identical across implementations: all row ids and cell values present in both.
for (const html of [defaultHtml, mockHtml]) {
for (const row of vm.rows) {
expect(html).toContain(`data-row-id="${row.id}"`);
for (const value of Object.values(row.cells)) {
expect(html).toContain(value);
}
}
}
});
test("the default renderer is the real subframe implementation", () => {
expect(dataTableAdapter.name).toBe("dataTable");
expect(dataTableAdapter.render(vm)).toContain('data-ui-id="ui.data-table"');
});
});
@@ -0,0 +1,4 @@
export { defineAdapter, type Adapter, type DefineAdapterConfig } from "./defineAdapter.js";
export { dataTableAdapter, toDataTableModel } from "./dataTable.js";
export { fileBrowserAdapter, toFileBrowserModel } from "./fileBrowser.js";
export { taskListAdapter, toTaskListProps } from "./taskList.js";
@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import type { TaskListData } from "@toolctrl/view-models";
import { renderTaskListMock } from "@toolctrl/ui-mock";
import { taskListAdapter } from "./taskList.js";
// Fixture display values come from variables, not inline `title:`/`label:`
// literals, to satisfy the Phase-C UI-package no-hardcoded-string lint rule
// (these are test data, not shipped copy).
const ROW1_TITLE = "Angebot nachfassen";
const ROW1_ASSIGNEE = "Akeshan";
const ROW2_TITLE = "Rechnung pruefen";
const ROW2_ASSIGNEE = "Buchhaltung";
const vm: TaskListData = {
rows: [
{
id: "task-1",
title: ROW1_TITLE,
status: "open",
statusKey: "tasks.status.open",
priority: "high",
priorityKey: "tasks.priority.high",
deadlineIso: "2026-06-25T08:00:00.000Z",
isOverdue: false,
assignee: { kind: "user", label: ROW1_ASSIGNEE },
linkedEntity: null,
source: "manual",
sourceKey: "tasks.source.manual",
sourceHref: null,
detailHref: "/acme/tasks/task-1",
},
{
id: "task-2",
title: ROW2_TITLE,
status: "in_progress",
statusKey: "tasks.status.in_progress",
priority: "normal",
priorityKey: "tasks.priority.normal",
deadlineIso: null,
isOverdue: false,
assignee: { kind: "group", label: ROW2_ASSIGNEE },
linkedEntity: null,
source: "automation",
sourceKey: "tasks.source.automation",
sourceHref: "/acme/automation/run-7",
detailHref: "/acme/tasks/task-2",
},
],
filter: { preset: "all", status: null, priority: null, assignedToUserId: null, includeArchived: false },
canWrite: true,
canDelete: false,
createHref: "/acme/tasks/new",
embed: null,
nextCursor: null,
emptyHeadlineKey: "tasks.list.empty.headline",
emptyBodyKey: "tasks.list.empty.body",
};
describe("taskListAdapter hot-swap (M017)", () => {
test("normalizes the VM once via toProps", () => {
const model = taskListAdapter.toProps(vm);
expect(model.rowCount).toBe(2);
expect(model.titleKey).toBe("tasks.list.title");
expect(model.rows.map((row) => row.id)).toEqual(["task-1", "task-2"]);
expect(model.rows[0]?.assigneeLabel).toBe("Akeshan");
});
test("swaps the render implementation without changing the data", () => {
const defaultHtml = taskListAdapter.render(vm);
const mockHtml = taskListAdapter.render(vm, renderTaskListMock);
expect(defaultHtml).toContain('data-ui-impl="subframe"');
expect(mockHtml).toContain('data-ui-impl="mock"');
expect(defaultHtml).not.toBe(mockHtml);
for (const html of [defaultHtml, mockHtml]) {
for (const row of vm.rows) {
expect(html).toContain(`data-row-id="${row.id}"`);
expect(html).toContain(row.title);
}
}
});
test("the default renderer is the real subframe implementation", () => {
expect(taskListAdapter.name).toBe("taskList");
expect(taskListAdapter.render(vm)).toContain('data-ui-id="ui.tasks.list"');
});
});
@@ -0,0 +1,21 @@
import { toTaskListModel, type TaskListData, type TaskListModel } from "@toolctrl/view-models";
import { renderTaskList } from "@toolctrl/ui-subframe";
import { defineAdapter } from "./defineAdapter.js";
/**
* Normalizes the domain {@link TaskListData} into the {@link TaskListModel}
* prop-model the UI implementation renders (M015). Reuses the VM's pure
* `toTaskListModel` mapper so the Subframe / mock renderers stay dumb and
* swappable (M017 hot-swap drill). Backs both the standalone `/tasks` browser
* and the embedded entity "Tasks tab" (DoD 8).
*/
export function toTaskListProps(vm: TaskListData): TaskListModel {
return toTaskListModel(vm);
}
export const taskListAdapter = defineAdapter({
name: "taskList",
toProps: toTaskListProps,
defaultRenderer: renderTaskList,
});
@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { AppShell, createMobileRegistry, createPortalRegistry, createShellRegistries } from "./index.js";
describe("@toolctrl/ui-app", () => {
test("creates sorted mobile and portal registries", () => {
expect(createMobileRegistry([{ id: "b", href: "/b", labelKey: "nav.b", priority: 2 }, { id: "a", href: "/a", labelKey: "nav.a", priority: 1 }]).map((item) => item.id)).toEqual(["a", "b"]);
expect(createPortalRegistry([{ id: "late", slot: "main", labelKey: "portal.late", order: 20 }, { id: "early", slot: "main", labelKey: "portal.early", order: 10 }]).map((item) => item.id)).toEqual(["early", "late"]);
});
test("exposes empty app shell registries and a renderable shell function", () => {
expect(createShellRegistries()).toEqual({ sidebar: [], breadcrumbs: [], mobile: [], portals: [] });
expect(AppShell({ children: "content" }).props["data-ui-shell"]).toBe("app");
});
});
@@ -0,0 +1,59 @@
import { createElement, type ReactNode } from "react";
import type {
BreadcrumbRegistryItem,
MobileRegistryItem,
PortalRegistryItem,
ShellRegistries,
SidebarRegistryItem,
} from "@toolctrl/view-models";
export * from "./adapters/index.js";
export type AppShellProps = {
readonly registries?: ShellRegistries;
readonly children?: ReactNode;
};
export function createSidebarRegistry(items: readonly SidebarRegistryItem[] = []): readonly SidebarRegistryItem[] {
return [...items];
}
export function createBreadcrumbRegistry(items: readonly BreadcrumbRegistryItem[] = []): readonly BreadcrumbRegistryItem[] {
return [...items];
}
export function createMobileRegistry(items: readonly MobileRegistryItem[] = []): readonly MobileRegistryItem[] {
return [...items].sort((left, right) => (left.priority ?? 100) - (right.priority ?? 100));
}
export function createPortalRegistry(items: readonly PortalRegistryItem[] = []): readonly PortalRegistryItem[] {
return [...items].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function createShellRegistries(input: ShellRegistries = {}): Required<ShellRegistries> {
return {
sidebar: createSidebarRegistry(input.sidebar),
breadcrumbs: createBreadcrumbRegistry(input.breadcrumbs),
mobile: createMobileRegistry(input.mobile),
portals: createPortalRegistry(input.portals),
};
}
export function EmptyPage({ labelKey = "foundation.pages.empty" }: { readonly labelKey?: string }) {
return createElement("section", { "data-page-state": "empty", "data-label-key": labelKey });
}
export function AppShell({ registries, children }: AppShellProps) {
const shellRegistries = createShellRegistries(registries);
return createElement(
"div",
{
"data-ui-shell": "app",
"data-sidebar-count": shellRegistries.sidebar.length,
"data-breadcrumb-count": shellRegistries.breadcrumbs.length,
"data-mobile-count": shellRegistries.mobile.length,
"data-portal-count": shellRegistries.portals.length,
},
children,
);
}
@@ -0,0 +1,48 @@
import { createElement, type ReactElement, type ReactNode } from "react";
import { afterAll, describe, expect, vi } from "vitest";
type AxeCall = {
ui: ReactElement;
options: unknown;
};
const axeCalls: AxeCall[] = [];
vi.mock("../../test-setup/axe.js", () => ({
expectNoAxeViolations: vi.fn(async (ui: ReactElement, options: unknown) => {
axeCalls.push({ ui, options });
}),
}));
import { generateA11yTest } from "./a11y-template.js";
function NamedComponent({ children }: { readonly children?: ReactNode }) {
return createElement("button", null, children);
}
function DisplayNamedComponent({ labelKey }: { readonly labelKey: string }) {
return createElement("span", null, labelKey);
}
DisplayNamedComponent.displayName = "FriendlyDisplayName";
const NamelessComponent = Object.defineProperty(
() => createElement("div", null, "nameless"),
"name",
{ value: "" },
);
describe("generateA11yTest", () => {
generateA11yTest(NamedComponent, { children: "Save" }, { axe: { tags: ["wcag2a"] } });
generateA11yTest(DisplayNamedComponent, { labelKey: "common.status" });
generateA11yTest(NamelessComponent, {}, { name: "ExplicitName" });
afterAll(() => {
expect(axeCalls).toHaveLength(3);
expect(axeCalls[0]?.ui.type).toBe(NamedComponent);
expect(axeCalls[0]?.ui.props).toMatchObject({ children: "Save" });
expect(axeCalls[0]?.options).toEqual({ tags: ["wcag2a"] });
expect(axeCalls[1]?.ui.type).toBe(DisplayNamedComponent);
expect(axeCalls[1]?.ui.props).toMatchObject({ labelKey: "common.status" });
expect(axeCalls[2]?.ui.type).toBe(NamelessComponent);
});
});
@@ -0,0 +1,68 @@
/**
* `generateA11yTest` is the canonical test-template documented in the WCAG
* internal-coverage spec
* (`docs/improvement-plan/10-compliance-polish/wcag-internal.md`, section a).
*
* Components in `packages/ui-app` and `packages/ui-subframe` should ship a
* co-located `<ComponentName>.a11y.test.tsx` that simply calls this helper
* with sensible default props. Complex components (Modal, Combobox,
* DataTable, …) keep additional manually-authored tests for focus-trap and
* keyboard-navigation behaviour.
*
* The helper intentionally lives in the public test-utils tree so that
* consumer packages (apps/web, apps/storybook) can re-use the same template
* without copying boilerplate.
*/
import type { ComponentType, ComponentProps, ReactNode } from "react";
import { describe, it } from "vitest";
import { createElement } from "react";
import { expectNoAxeViolations } from "../../test-setup/axe.js";
export interface GenerateA11yTestOptions {
/**
* Human-readable name shown in test output. Defaults to
* `<Component.displayName>` or `<Component.name>`.
*/
readonly name?: string;
/**
* Optional axe-rule overrides (see {@link AxeRunOptions} in
* `test-setup/axe.ts`). Use to relax rules for components that
* intentionally violate them in isolation (rare).
*/
readonly axe?: Parameters<typeof expectNoAxeViolations>[1];
}
function displayName<P>(Component: ComponentType<P>): string {
return (Component as { displayName?: string }).displayName
?? (Component as { name?: string }).name
?? "Component";
}
/**
* Emit a `describe(... a11y) { it("has no axe violations") }` block for the
* given component + default props.
*
* @example
* ```tsx
* import { Button } from "./Button";
* import { generateA11yTest } from "../test-utils/a11y-template";
*
* generateA11yTest(Button, { children: "Click me" });
* ```
*/
export function generateA11yTest<T extends ComponentType<any>>(
Component: T,
defaultProps: Partial<ComponentProps<T>> & { children?: ReactNode },
options: GenerateA11yTestOptions = {},
): void {
const label = options.name ?? displayName(Component);
describe(`${label} a11y`, () => {
it("has no axe violations with default props", async () => {
await expectNoAxeViolations(
createElement(Component as ComponentType<any>, defaultProps as any),
options.axe,
);
});
});
}