Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["error", { allowConstantExport: true }],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-floating-promises": "error"
}
}
);
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="nl-BE">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f4f7f7">
<meta name="description" content="ItWorx Knowledge Hub">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>ItWorx Knowledge Hub</title>
</head>
<body>
<a class="skip-link" href="#main-content">Ga naar de inhoud</a>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@itworx/knowledge-hub-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"typecheck": "tsc -b --pretty false",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/language": "^6.12.4",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@lezer/highlight": "^1.2.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-codemirror": "^4.25.11",
"lucide-react": "^1.28.0",
"motion": "^12.43.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-resizable-panels": "^4.12.2",
"react-router": "^8.3.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.41.0",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title>ItWorx Knowledge Hub</title>
<rect width="64" height="64" rx="14" fill="#0f5b78"/>
<path d="M18 17h28v7H35v25h-7V24H18z" fill="#f4f7f7"/>
<circle cx="47" cy="46" r="6" fill="#f0b24d"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title>ItWorx Knowledge Hub</title>
<rect width="64" height="64" rx="14" fill="#0f5b78"/>
<path d="M18 17h28v7H35v25h-7V24H18z" fill="#f4f7f7"/>
<circle cx="47" cy="46" r="6" fill="#f0b24d"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="luma" x1="14" y1="10" x2="51" y2="53" gradientUnits="userSpaceOnUse">
<stop stop-color="#9EA5FF"/>
<stop offset="0.52" stop-color="#6570FF"/>
<stop offset="1" stop-color="#36D9B6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="#0B1020"/>
<path d="M19 15v29c0 3.3 2.7 6 6 6h25" fill="none" stroke="url(#luma)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="47" cy="19" r="5" fill="#36D9B6"/>
</svg>

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a" />
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="white" />
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg>

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0b1326"/>
<path d="M22 13h-8v38h8M42 13h8v38h-8" fill="none" stroke="#9bbcff" stroke-width="4" stroke-linecap="round"/>
<path d="M25 24h14M25 40h14M25 24l14 16M39 24L25 40" fill="none" stroke="#59d9c5" stroke-width="3" stroke-linecap="round"/>
<g fill="#e2e8f0" stroke="#0b1326" stroke-width="2">
<circle cx="25" cy="24" r="5"/>
<circle cx="39" cy="24" r="5"/>
<circle cx="25" cy="40" r="5"/>
<circle cx="39" cy="40" r="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="VacatureRadar">
<path d="M51.5 32A19.5 19.5 0 1 1 45.8 18.2" fill="none" stroke="#12aebe" stroke-width="4.5" stroke-linecap="round"/>
<path d="M32 32 47 17" fill="none" stroke="#19d3e6" stroke-width="4.5" stroke-linecap="round"/>
<circle cx="32" cy="32" r="3.75" fill="#12aebe"/>
<circle cx="20" cy="21" r="3.75" fill="#36c990" stroke="#f4f7f7" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+148
View File
@@ -0,0 +1,148 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import type { SessionResponse } from "./api/types";
function mockSessionFetch(session: SessionResponse) {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((input: string) => {
let body: unknown = session;
if (input.includes("/api/v1/pages/roots") || input.includes("/api/v1/projects") || input.includes("/api/v1/runbooks") || input.includes("/api/v1/activity")) {
body = { items: [], nextCursor: null };
} else if (input.includes("/api/v1/infrastructure/overview")) {
body = {
latestSnapshot: null,
hosts: [],
serviceCount: 0,
proposedServiceCount: 0,
missingServiceCount: 0,
unhealthyServiceCount: 0,
unprotectedServiceCount: 0
};
} else if (input.includes("/api/v1/infrastructure/services")) {
body = { items: [], nextCursor: null };
}
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body)
});
})
);
}
describe("App", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows the first-run setup form when the installation has no owner yet", async () => {
mockSessionFetch({
authenticated: false,
setupRequired: true,
userId: null,
displayName: null,
email: null,
workspaceId: null,
role: null
});
render(<App />);
expect(
await screen.findByRole("heading", { name: "ItWorx Knowledge Hub instellen" })
).toBeInTheDocument();
expect(screen.queryByLabelText("Installatiecode")).not.toBeInTheDocument();
});
it("shows the login form when setup is complete but no session exists", async () => {
mockSessionFetch({
authenticated: false,
setupRequired: false,
userId: null,
displayName: null,
email: null,
workspaceId: null,
role: null
});
render(<App />);
expect(await screen.findByRole("heading", { name: "Aanmelden" })).toBeInTheDocument();
expect(screen.getByLabelText("E-mailadres")).toBeInTheDocument();
});
it("shows the authenticated shell with the user's name and role once signed in", async () => {
mockSessionFetch({
authenticated: true,
setupRequired: false,
userId: "11111111-1111-1111-1111-111111111111",
displayName: "Eigenaar Testpersoon",
email: "eigenaar@itworx.test",
workspaceId: "22222222-2222-2222-2222-222222222222",
role: "Administrator"
});
render(<App />);
expect(await screen.findByRole("button", { name: "Accountmenu" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "ItWorx Knowledge Hub, home" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Hoofdnavigatie" })).toBeInTheDocument();
});
it("keeps the closed mobile navigation out of the accessibility tree", async () => {
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
mockSessionFetch({
authenticated: true,
setupRequired: false,
userId: "11111111-1111-1111-1111-111111111111",
displayName: "Eigenaar Testpersoon",
email: "eigenaar@itworx.test",
workspaceId: "22222222-2222-2222-2222-222222222222",
role: "Administrator"
});
render(<App />);
const openNavigation = await screen.findByRole("button", { name: "Navigatie openen" });
expect(screen.queryByRole("navigation", { name: "Hoofdnavigatie" })).not.toBeInTheDocument();
await userEvent.click(openNavigation);
expect(screen.getByRole("navigation", { name: "Hoofdnavigatie" })).toBeInTheDocument();
});
it("resizes and persists the desktop navigation with the keyboard", async () => {
mockSessionFetch({
authenticated: true,
setupRequired: false,
userId: "11111111-1111-1111-1111-111111111111",
displayName: "Eigenaar Testpersoon",
email: "eigenaar@itworx.test",
workspaceId: "22222222-2222-2222-2222-222222222222",
role: "Administrator"
});
render(<App />);
const resizer = await screen.findByRole("separator", { name: "Breedte hoofdnavigatie" });
fireEvent.keyDown(resizer, { key: "ArrowRight" });
expect(window.localStorage.getItem(
"itworx.preferences.v1.11111111-1111-1111-1111-111111111111.navigation-width",
)).toBe("240");
});
it("shows a clear API error state when the session cannot be loaded", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
render(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent("API niet bereikbaar");
});
});
+83
View File
@@ -0,0 +1,83 @@
import { BrowserRouter } from "react-router";
import { SessionProvider } from "./auth/SessionContext";
import { useSession } from "./auth/session-context";
import { TooltipProvider } from "./components/Tooltip";
import { ErrorBoundary } from "./notifications/ErrorBoundary";
import { OfflineBanner } from "./notifications/OfflineBanner";
import { ToastProvider } from "./notifications/ToastProvider";
import { PreferencesProvider } from "./preferences/PreferencesContext";
import { AuthenticatedRoutes } from "./router/AuthenticatedRoutes";
import { LoginPage } from "./pages/LoginPage";
import { SetupPage } from "./pages/SetupPage";
import { LoadingScreen, SessionErrorScreen } from "./pages/StatusScreens";
export function App() {
return (
<BrowserRouter>
{/* Global recoverable error boundary + notification queue (docs/adr/ADR-025-
v2-r1-data-integrity-remediation.md, audit finding M4) — outside
SessionProvider so even a crash while resolving the session itself still
shows a recoverable fallback instead of a blank page. OfflineBanner is
mounted at this same top level (V2-R3) so it is visible on every screen,
including pre-authentication and while the session is still resolving. */}
<ErrorBoundary label="ItWorx Knowledge Hub kon niet worden geladen">
<OfflineBanner />
<ToastProvider>
{/* Icon-only controls explain themselves on hover *and* keyboard focus
(OUX-3). One provider at the root keeps the open/close delay
consistent everywhere, including the pre-authentication screens. */}
<TooltipProvider>
<SessionProvider>
<SessionRouter />
</SessionProvider>
</TooltipProvider>
</ToastProvider>
</ErrorBoundary>
</BrowserRouter>
);
}
function SessionRouter() {
const { state } = useSession();
if (state.kind === "loading") {
return (
<PreferencesProvider storageScope="anonymous">
<LoadingScreen />
</PreferencesProvider>
);
}
if (state.kind === "error") {
return (
<PreferencesProvider storageScope="anonymous">
<SessionErrorScreen />
</PreferencesProvider>
);
}
if (state.session.setupRequired) {
return (
<PreferencesProvider storageScope="anonymous">
<SetupPage />
</PreferencesProvider>
);
}
if (!state.session.authenticated) {
return (
<PreferencesProvider storageScope="anonymous">
<LoginPage />
</PreferencesProvider>
);
}
return (
<PreferencesProvider
key={state.session.userId}
storageScope={state.session.userId!}
>
<AuthenticatedRoutes session={state.session} />
</PreferencesProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
export type ActivityItem = {
id: number;
actorKind: string;
actorId: string | null;
source: string;
action: string;
targetType: string | null;
targetId: string | null;
summary: string;
occurredAt: string;
};
export type ActivityListResponse = { items: ActivityItem[] };
+94
View File
@@ -0,0 +1,94 @@
export type AdminUser = {
id: string;
displayName: string;
email: string;
role: "Reader" | "Editor" | "Administrator";
enabled: boolean;
createdAt: string;
lastLoginAt: string | null;
};
export type BackupSummary = {
backupId: string;
createdAt: string;
totalSizeBytes: number;
tableCount: number;
totalRowCount: number;
attachmentCount: number;
schemaVersion: string;
productVersion: string;
};
export type BackupListResponse = { items: BackupSummary[] };
export type ServiceAccountTokenSummary = {
id: string;
publicId: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
};
export type ServiceAccountSummary = {
id: string;
name: string;
description: string;
enabled: boolean;
createdAt: string;
tokens: ServiceAccountTokenSummary[];
};
export type ServiceAccountListResponse = { items: ServiceAccountSummary[] };
export type CreatedServiceAccountToken = {
serviceAccountId: string;
tokenId: string;
rawToken: string;
};
export type IssueRecoveryTokenResponse = {
token: string;
expiresAt: string;
};
export type TrashPurgeResponse = {
purgedCount: number;
};
export type ImportPlanItem = {
sourceIdentity: string;
kind: string;
proposedTitle: string;
proposedParentSourceIdentity: string | null;
conflict: string;
conflictDetail: string | null;
};
export type ImportPreflightResponse = {
status: string;
jobId: string | null;
adapterName: string | null;
discoveredCount: number;
plannedCount: number;
items: ImportPlanItem[];
warnings: string[];
blockers: string[];
};
export type ExecuteImportResponse = {
status: string;
imported: number;
skipped: number;
failed: number;
sourceUnchangedAfterExecution: boolean;
diagnostics: string[];
};
export type RollbackImportResponse = {
status: string;
removedPages: number;
preservedPages: number;
removedAttachments: number;
};
+14
View File
@@ -0,0 +1,14 @@
export type AttachmentSummary = {
id: string;
filename: string;
mediaType: string;
deliveryPolicy: string;
byteLength: number;
description: string | null;
altText: string | null;
createdAt: string;
};
export type AttachmentListResponse = {
items: AttachmentSummary[];
};
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiGet, apiPost, ApiError, resetCsrfCache } from "./client";
// V2-R3 (docs/adr/ADR-027-v2-r3-context-inspector-and-daily-ux-completeness.md): a
// genuine network failure (offline, DNS failure, connection refused) makes fetch()
// itself throw a raw browser TypeError, not an HTTP error response — previously left
// completely unhandled here, surfacing that raw, non-actionable browser string to the
// user via ToastProvider's generic Error branch instead of a clear Dutch message.
describe("api client network-failure handling", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("wraps a raw fetch rejection (offline/DNS/connection-refused) into a clear ApiError, not the raw browser message", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const caught = await apiGet("/api/v1/pages/roots").catch((error: unknown) => error);
expect(caught).toBeInstanceOf(ApiError);
const apiError = caught as ApiError;
expect(apiError.status).toBe(0);
expect(apiError.message).toBe("Controleer uw internetverbinding en probeer het opnieuw.");
});
it("wraps a network failure on a mutating request (after the CSRF fetch would also fail) the same way", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const caught = await apiPost("/api/v1/pages", { title: "Test" }).catch((error: unknown) => error);
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(0);
});
it("still surfaces a real HTTP error response normally (unaffected by the network-failure wrapping)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 404,
headers: new Headers({ "content-type": "application/problem+json" }),
json: () => Promise.resolve({ title: "Niet gevonden", status: 404 })
})
);
const caught = await apiGet("/api/v1/pages/unknown").catch((error: unknown) => error);
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(404);
});
});
+174
View File
@@ -0,0 +1,174 @@
import type { CsrfTokenResponse, ProblemDetails } from "./types";
export class ApiError extends Error {
readonly problem: ProblemDetails;
readonly status: number;
constructor(status: number, problem: ProblemDetails) {
super(problem.detail ?? problem.title ?? `Aanvraag mislukt (${status}).`);
this.status = status;
this.problem = problem;
}
}
let cachedCsrf: CsrfTokenResponse | null = null;
/** `fetch()` itself throws a raw, generic `TypeError` (e.g. "Failed to fetch") when
* the browser has no network connection at all, DNS fails, or the connection is
* refused — never when the server actually responds with an error status, which is
* already handled separately below. Left uncaught, that raw browser-internal message
* would surface to the user as-is via `ToastProvider`'s generic `Error` branch
* (`notifications/toast-context.ts`), which is not an actionable Dutch message
* (V2-R3, docs/adr/ADR-027-v2-r3-context-inspector-and-daily-ux-completeness.md).
* `ApiError(0, ...)` — status `0` is the conventional "no real HTTP response was ever
* received" signal — lets every existing `ApiError`-aware call site (toasts, inline
* error banners) handle this the same way it already handles any other API failure,
* with a clear, consistent, offline-aware message instead of a raw browser string. */
function isAbortError(error: unknown): boolean {
return (error instanceof DOMException || error instanceof Error) && error.name === "AbortError";
}
async function safeFetch(path: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(path, init);
} catch (error: unknown) {
if (isAbortError(error)) {
throw error;
}
throw new ApiError(0, {
title: "Geen verbinding met de server.",
detail: "Controleer uw internetverbinding en probeer het opnieuw.",
status: 0
});
}
}
async function fetchCsrfToken(): Promise<CsrfTokenResponse> {
const response = await safeFetch("/api/v1/auth/csrf", { credentials: "same-origin" });
if (!response.ok) {
throw new Error("Kon geen CSRF-token ophalen.");
}
const token = (await response.json()) as CsrfTokenResponse;
cachedCsrf = token;
return token;
}
async function parseProblem(response: Response): Promise<ProblemDetails> {
try {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("json")) {
return (await response.json()) as ProblemDetails;
}
} catch {
// fall through to generic problem below
}
return { title: `Onverwachte serverfout (${response.status}).`, status: response.status };
}
/** GET requests never need a CSRF header — only cookie-authenticated mutations do. */
export async function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await safeFetch(path, signal ? { credentials: "same-origin", signal } : { credentials: "same-origin" });
if (!response.ok) {
throw new ApiError(response.status, await parseProblem(response));
}
return (await response.json()) as T;
}
async function mutate<T>(
method: string,
path: string,
body?: unknown,
extraHeaders?: Record<string, string>
): Promise<T> {
const csrf = cachedCsrf ?? (await fetchCsrfToken());
const buildHeaders = (token: CsrfTokenResponse): Record<string, string> => ({
"Content-Type": "application/json",
[token.headerName]: token.token,
...extraHeaders
});
let response = await safeFetch(path, {
method,
credentials: "same-origin",
headers: buildHeaders(csrf),
body: body === undefined ? null : JSON.stringify(body)
});
if (response.status === 400 && cachedCsrf) {
// The cached token may have gone stale (e.g. a new server-side antiforgery cookie
// after a full page reload elsewhere); refresh once and retry exactly once.
cachedCsrf = null;
const refreshed = await fetchCsrfToken();
response = await safeFetch(path, {
method,
credentials: "same-origin",
headers: buildHeaders(refreshed),
body: body === undefined ? null : JSON.stringify(body)
});
}
if (!response.ok) {
throw new ApiError(response.status, await parseProblem(response));
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
return (text.length === 0 ? undefined : JSON.parse(text)) as T;
}
/** Multipart file upload, with the same CSRF handshake as every other mutation —
* never a JSON body, so this cannot reuse `mutate`. */
export async function apiUpload<T>(path: string, file: File, extraHeaders?: Record<string, string>): Promise<T> {
const csrf = cachedCsrf ?? (await fetchCsrfToken());
const buildForm = () => {
const form = new FormData();
form.set("file", file, file.name);
return form;
};
let response = await safeFetch(path, {
method: "POST",
credentials: "same-origin",
headers: { [csrf.headerName]: csrf.token, ...extraHeaders },
body: buildForm()
});
if (response.status === 400 && cachedCsrf) {
cachedCsrf = null;
const refreshed = await fetchCsrfToken();
response = await safeFetch(path, {
method: "POST",
credentials: "same-origin",
headers: { [refreshed.headerName]: refreshed.token, ...extraHeaders },
body: buildForm()
});
}
if (!response.ok) {
throw new ApiError(response.status, await parseProblem(response));
}
const text = await response.text();
return (text.length === 0 ? undefined : JSON.parse(text)) as T;
}
export const apiPost = <T>(path: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T> =>
mutate<T>("POST", path, body, extraHeaders);
export const apiPut = <T>(path: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T> =>
mutate<T>("PUT", path, body, extraHeaders);
export const apiPatch = <T>(path: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T> => mutate<T>("PATCH", path, body, extraHeaders);
export const apiDelete = <T>(path: string): Promise<T> => mutate<T>("DELETE", path);
/** Builds the `If-Match` header value from a bare ETag as returned in API response
* bodies (see docs/09-api-contract.md — "strong ETag plus If-Match"). */
export const ifMatchHeader = (eTag: string): Record<string, string> => ({ "If-Match": `"${eTag}"` });
export function resetCsrfCache(): void {
cachedCsrf = null;
}
+187
View File
@@ -0,0 +1,187 @@
export type InventorySnapshotSummary = {
id: string;
sourceKey: string;
sourceHash: string;
capturedAt: string;
importedAt: string;
hostCount: number;
serviceCount: number;
};
export type InfrastructureHostSummary = {
id: string;
sourceKey: string;
hostname: string;
operatingSystem: string;
kernelVersion: string;
architecture: string;
logicalCpuCount: number;
totalMemoryBytes: number;
presence: string;
lastSeenAt: string;
};
export type InfrastructureOverview = {
latestSnapshot: InventorySnapshotSummary | null;
hosts: InfrastructureHostSummary[];
serviceCount: number;
proposedServiceCount: number;
missingServiceCount: number;
unhealthyServiceCount: number;
unprotectedServiceCount: number;
};
export type InventoryChange = {
id: string;
snapshotId: string;
targetKind: string;
targetId: string;
sourceKey: string;
changeKind: string;
changedFields: string[];
recordedAt: string;
};
export type ImportCuratedInventoryResponse = {
inventory: {
snapshotId: string;
alreadyImported: boolean;
addedServices: number;
updatedServices: number;
missingServices: number;
preservedEditorialProfiles: number;
preservedOverrides: number;
};
createdEditorialProfiles: number;
confirmedServices: number;
archivedServices: number;
};
export type FactProvenance = {
sourceKey: string;
confidence: string;
lastSnapshotId: string;
observedAt: string;
};
export type InfrastructureServiceSummary = {
id: string;
sourceKey: string;
name: string;
detectedName: string;
category: string;
summary: string;
runtimeKind: string;
runtimeStatus: string;
health: string;
presence: string;
confidence: string;
imageReference: string;
projectId: string | null;
projectName?: string | null;
backupStatus?: string;
primaryEndpointUrl?: string | null;
isFavorite: boolean;
isHidden: boolean;
componentCount: number;
endpointCount: number;
volumeCount: number;
databaseCount: number;
lastSeenAt: string;
};
/** Cursor-paginated envelope for `GET /infrastructure/services` (V2-R2) — matches the
* `{ items, nextCursor }` shape already used by knowledge pages/projects lists. */
export type InfrastructureServiceListResponse = {
items: InfrastructureServiceSummary[];
nextCursor: string | null;
/** Services matching the current filters across the whole catalog, not just this
* page. Counted server-side before cursor pagination (OUX-4). */
totalCount: number;
};
export type EditorialProfile = {
id: string;
displayName: string;
summary: string;
category: string;
purpose: string;
ownerId: string | null;
projectId: string | null;
repositoryUrl: string;
liveUrl: string;
backupStatus: string;
monitoringUrl: string;
maintenanceNotes: string;
knownRisks: string;
updateProcedure: string;
rollbackProcedure: string;
tags: string[];
isFavorite: boolean;
isHidden: boolean;
isArchived: boolean;
updatedAt: string;
eTag: string;
};
export type FactEntity = { id: string; provenance: FactProvenance };
export type ServiceComponentFact = FactEntity & { name: string; kind: string; runtimeId: string; status: string; imageReference: string };
export type EndpointFact = FactEntity & { scheme: string; host: string; publishedPort: number | null; targetPort: number | null; url: string; isPublic: boolean };
export type NetworkFact = FactEntity & { name: string; driver: string; scope: string; internal: boolean };
export type VolumeFact = FactEntity & { name: string; mountPath: string; storageClass: string; readOnly: boolean; sizeBytes: number | null };
export type InfrastructureStorageRow = VolumeFact & { serviceId: string; serviceName: string };
/** Cursor-paginated envelope for `GET /infrastructure/storage` (V2-R5) — matches the
* `{ items, nextCursor }` shape already used by `InfrastructureServiceListResponse`.
* Breaking change from the prior bare-array response. */
export type InfrastructureStorageListResponse = {
items: InfrastructureStorageRow[];
nextCursor: string | null;
};
export type DatabaseFact = FactEntity & { name: string; engine: string; version: string; host: string; port: number | null; hostPortPublished: boolean };
export type DependencyFact = FactEntity & { dependsOnServiceId: string | null; dependsOnSourceKey: string; kind: string };
export type HealthFact = FactEntity & { state: string; checkKind: string; responseTimeMilliseconds: number | null };
export type DeploymentFact = FactEntity & { repositoryUrl: string; commit: string; imageReference: string; imageDigest: string; deployedAt: string | null };
export type BackupFact = FactEntity & { method: string; schedule: string; lastSuccessfulAt: string | null; restoreTested: boolean };
export type ManualOverride = {
id: string;
fieldName: string;
value: unknown;
reason: string;
createdBy: string;
updatedAt: string;
};
/** Mirrors ManualOverrideCatalog.Validate on the server
* (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3) — fetched,
* not hardcoded, so the UI can never independently drift from what the backend will
* actually accept. */
export type ManualOverrideFieldCatalogEntry = {
fieldName: string;
displayLabel: string;
valueKind: "Text" | "Enum";
maxStringLength: number;
allowedEnumValues: string[] | null;
};
export type ManualOverrideFieldCatalogResponse = {
targetKind: string;
fields: ManualOverrideFieldCatalogEntry[];
};
export type InfrastructureServiceDetail = {
service: InfrastructureServiceSummary;
editorialProfile: EditorialProfile | null;
editorialETag: string;
overrides: ManualOverride[];
components: ServiceComponentFact[];
endpoints: EndpointFact[];
networks: NetworkFact[];
volumes: VolumeFact[];
databases: DatabaseFact[];
dependencies: DependencyFact[];
healthObservations: HealthFact[];
deployments: DeploymentFact[];
backups: BackupFact[];
};
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, resetCsrfCache } from "./client";
import { getIngestionProposal, getIngestionProposals, publishProposal, uploadProposalSource } from "./intake";
describe("intake API client", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("encodes bounded server-side queue filters and cursor pagination and propagates the abort signal", async () => {
const controller = new AbortController();
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ items: [], page: 2, pageSize: 50, total: 0, correlationId: "corr" }));
vi.stubGlobal("fetch", fetchMock);
await getIngestionProposals({ status: "ReadyForReview", projectId: "project-1", submitterId: "user-1", minAgeDays: 3, risk: "high", documentType: "runbook", pageSize: 500 }, controller.signal, "cursor-2");
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/ingestion-proposals?status=ReadyForReview&projectId=project-1&submitterId=user-1&minAgeDays=3&risk=high&documentType=runbook&limit=100&cursor=cursor-2",
{ credentials: "same-origin", signal: controller.signal },
);
});
it("sends proposal ETags and idempotency keys on publication", async () => {
let publishedPath: RequestInfo | URL | undefined;
let publishedInit: RequestInit | undefined;
let calls = 0;
const fetchMock = vi.fn((path: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
calls += 1;
if (calls === 2) {
publishedPath = path;
publishedInit = init;
}
return Promise.resolve(calls === 1
? jsonResponse({ token: "csrf", headerName: "X-CSRF" })
: jsonResponse(detail()));
});
vi.stubGlobal("fetch", fetchMock);
await publishProposal("p-1", { decision: "approve" }, "rev-2", "idem-1");
expect(publishedPath).toBe("/api/v1/ingestion-proposals/p-1/publish");
const headers = new Headers(publishedInit?.headers);
expect(headers.get("If-Match")).toBe("\"rev-2\"");
expect(headers.get("Idempotency-Key")).toBe("idem-1");
expect(headers.get("X-CSRF")).toBe("csrf");
});
it("uploads a source as multipart with CSRF, ETag and idempotency headers", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ token: "csrf", headerName: "X-CSRF" })).mockResolvedValueOnce(jsonResponse(detail()));
vi.stubGlobal("fetch", fetchMock);
const file = new File(["source"], "source.txt", { type: "text/plain" });
await uploadProposalSource("p-1", file, "rev-2", "upload-1");
const [path, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit];
expect(path).toBe("/api/v1/ingestion-proposals/p-1/sources");
const headers = new Headers(init.headers);
expect(headers.get("X-CSRF")).toBe("csrf");
expect(headers.get("If-Match")).toBe('"rev-2"');
expect(headers.get("Idempotency-Key")).toBe("upload-1");
expect(headers.has("Content-Type")).toBe(false);
expect(init.body).toBeInstanceOf(FormData);
expect((init.body as FormData).get("file")).toStrictEqual(file);
});
it("maps RFC 7807 conflicts without losing the server correlation identifier", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(problemResponse(412, { type: "https://itworx.local/problems/ingestion-concurrency-conflict", title: "Versieconflict.", status: 412, correlationId: "corr-412" })));
const caught: unknown = await getIngestionProposal("p-1").catch((error: unknown): unknown => error);
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(412);
expect((caught as ApiError).problem.correlationId).toBe("corr-412");
});
it("propagates an AbortError instead of presenting cancellation as an offline failure", async () => {
const cancellation = new DOMException("Request was cancelled.", "AbortError");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(cancellation));
const caught: unknown = await getIngestionProposal("p-1").catch((error: unknown): unknown => error);
expect(caught).toBe(cancellation);
expect(caught).not.toBeInstanceOf(ApiError);
});
});
function detail() {
return { id: "p-1", correlationId: "corr", status: "Approved", revisionNumber: 2, manifest: "Manifest", retryCount: 0, maxPublishRetries: 3, isRetryEligible: false, sources: [], revisions: [], etag: "rev-2" };
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}
function problemResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/problem+json" } });
}
+54
View File
@@ -0,0 +1,54 @@
import { apiGet, apiPatch, apiPost, apiUpload, ifMatchHeader } from "./client";
import type { DecisionRequest, IngestionProposalDetail, IngestionQueueFilters, PaginatedIngestionProposals, ProposalPatch, PublishRequest, PublishResult, RetryRequest } from "./intakeTypes";
import type { ProblemDetails } from "./intakeTypes";
const root = "/api/v1/ingestion-proposals";
const clamp = (value: number, lower: number, upper: number) => Math.max(lower, Math.min(upper, Math.trunc(value)));
/**
* Converts an RFC 7807 response into reviewed Dutch copy. Deliberately never
* forwards server-provided titles or details: the intake boundary can mention
* submitted source material, URLs or credentials that do not belong in the UI.
*/
export function problemMessage(problem: ProblemDetails): { title: string; action: "refresh" | "retry" | "return" } {
switch (problem.status) {
case 401: return { title: "Aanmelden vereist", action: "return" };
case 403: return { title: "Geen toegang tot dit voorstel", action: "return" };
case 404: return { title: "Voorstel niet gevonden", action: "return" };
case 409:
case 412: return { title: "Voorstel is gewijzigd", action: "refresh" };
case 422: return { title: "Voorstel kan niet worden verwerkt", action: "return" };
case 429: return { title: "Intake tijdelijk vertraagd", action: "retry" };
default: return { title: "Intake tijdelijk niet beschikbaar", action: "retry" };
}
}
/** The server decides retry eligibility; the UI only adds local safety guards. */
export function isRetryEligible(detail: IngestionProposalDetail): boolean {
return detail.status === "Failed"
&& detail.isRetryEligible
&& detail.permissions.canEdit
&& Boolean(detail.etag?.trim());
}
export function getIngestionProposals(filters: IngestionQueueFilters, signal?: AbortSignal, cursor?: string): Promise<PaginatedIngestionProposals> {
const query = new URLSearchParams();
if (filters.status) query.set("status", filters.status);
if (filters.projectId) query.set("projectId", filters.projectId);
if (filters.submitterId) query.set("submitterId", filters.submitterId);
if (filters.minAgeDays !== undefined) query.set("minAgeDays", String(clamp(filters.minAgeDays, 0, 3650)));
if (filters.risk) query.set("risk", filters.risk);
if (filters.documentType) query.set("documentType", filters.documentType);
query.set("limit", String(clamp(filters.pageSize, 1, 100)));
if (cursor) query.set("cursor", cursor);
return apiGet(`${root}?${query}`, signal);
}
export const getIngestionProposal = (id: string, signal?: AbortSignal): Promise<IngestionProposalDetail> => apiGet(`${root}/${encodeURIComponent(id)}`, signal);
export const patchProposal = (id: string, patch: ProposalPatch, etag: string) => apiPatch<IngestionProposalDetail>(`${root}/${encodeURIComponent(id)}`, patch, ifMatchHeader(etag));
export const decideProposal = (id: string, decision: "request-changes" | "approve" | "reject", body: DecisionRequest, etag: string) => apiPost<IngestionProposalDetail>(`${root}/${encodeURIComponent(id)}/${decision}`, body, ifMatchHeader(etag));
export const publishProposal = (id: string, body: PublishRequest, etag: string, idempotencyKey: string): Promise<PublishResult> => apiPost<PublishResult>(`${root}/${encodeURIComponent(id)}/publish`, body, { ...ifMatchHeader(etag), "Idempotency-Key": idempotencyKey });
export const retryProposal = (id: string, body: RetryRequest, etag: string, idempotencyKey: string) => apiPost<IngestionProposalDetail>(`${root}/${encodeURIComponent(id)}/retry`, body, { ...ifMatchHeader(etag), "Idempotency-Key": idempotencyKey });
export type CreateManualProposal = { manifest: string; correlationId: string; publication: { title: string; markdown: string; documentType: string; documentStatus: string; mutationKind: "CreatePage" } };
export const createManualProposal = (body: CreateManualProposal, idempotencyKey: string) => apiPost<IngestionProposalDetail>(root, body, { "Idempotency-Key": idempotencyKey });
export const uploadProposalSource = (id: string, file: File, etag: string, idempotencyKey: string) => apiUpload<IngestionProposalDetail>(`${root}/${encodeURIComponent(id)}/sources`, file, { ...ifMatchHeader(etag), "Idempotency-Key": idempotencyKey });
export const submitManualProposal = (id: string, etag: string) => apiPost<IngestionProposalDetail>(`${root}/${encodeURIComponent(id)}/submit`, {}, ifMatchHeader(etag));
+23
View File
@@ -0,0 +1,23 @@
export type IngestionProposalStatus = "Received" | "Analyzing" | "ReadyForReview" | "ChangesRequested" | "Approved" | "Publishing" | "Published" | "Rejected" | "Failed";
export type IngestionMutationKind = "CreatePage" | "UpdatePage";
export type IngestionFeatureFlags = { ingestionProposals: boolean };
export type IngestionQueueFilters = { status?: IngestionProposalStatus; projectId?: string; submitterId?: string; minAgeDays?: number; risk?: "low" | "medium" | "high"; documentType?: string; pageSize: number };
export type ProblemDetails = { type?: string; title?: string; detail?: string; status?: number; correlationId?: string; errors?: Record<string, string[]> };
export type IngestionSourceResult = { id: string; fileName: string; mimeType: string; byteSize: number; digestSha256: string; scanResult: string; attachmentReferenceId: string };
export type IngestionCitation = { label: string; pageId?: string | null; excerpt?: string | null; confidence?: number | null };
export type IngestionWarning = { code: string; message: string; severity?: "low" | "medium" | "high" | null };
export type IngestionDiff = { before?: string | null; after?: string | null; summary?: string | null };
export type IngestionAttachment = { id: string; fileName: string; mimeType: string; byteSize: number };
export type IngestionDestination = { pageId: string | null; parentPageId: string | null; title: string | null };
export type IngestionMetadata = { documentType: string | null; documentStatus: string | null; projectId: string | null; risk: "low" | "medium" | "high" | null };
export type IngestionReviewerHistory = { revisionNumber: number; status: IngestionProposalStatus; operation: string; actorId: string; occurredAt: string; comment?: string | null };
export type IngestionProposalCandidate = { pageId: string | null; title: string | null; kind: string | null };
export type IngestionProposalSummary = { id: string; correlationId: string; status: IngestionProposalStatus; revisionNumber: number; sourceCount: number; submitterId: string; createdAt: string; destination: IngestionDestination | null; metadata: IngestionMetadata; mutationKind: IngestionMutationKind | null };
export type IngestionProposalDetail = IngestionProposalSummary & { manifest: string; analysis?: string | null; reviewerComment?: string | null; retryCount: number; maxPublishRetries: number; isRetryEligible: boolean; failedLifecycleStatus?: IngestionProposalStatus | null; failedProcessingStage?: string | null; failureErrorType?: string | null; failureMessage?: string | null; sources: IngestionSourceResult[]; revisions: IngestionReviewerHistory[]; etag: string; citations: IngestionCitation[]; warnings: IngestionWarning[]; duplicateCandidates: IngestionProposalCandidate[]; conflictCandidates: IngestionProposalCandidate[]; diff: IngestionDiff | null; attachments: IngestionSourceResult[]; expectedTargetETag: string | null; permissions: { canEdit: boolean; canDecide: boolean; canPublish: boolean }; publishedPageId?: string | null; publishedPageRevisionNumber?: number | null };
export type PaginatedIngestionProposals = { items: IngestionProposalSummary[]; nextCursor: string | null; page: number; pageSize: number; total: number; correlationId: string };
export type ProposalPatch = { manifest?: string; rationale?: string; analysis?: string };
export type DecisionRequest = { comment?: string };
export type PublishRequest = { decision: "approve" };
/** The publish endpoint returns the updated dossier; citation is the returned page/revision plus correlation ID. */
export type PublishResult = IngestionProposalDetail;
export type RetryRequest = Record<string, never>;
+194
View File
@@ -0,0 +1,194 @@
export type PageSummary = {
id: string;
parentPageId: string | null;
title: string;
slug: string;
documentType: string;
documentStatus: string;
hasChildren: boolean;
updatedAt: string;
};
export type PageChildrenResponse = {
items: PageSummary[];
nextCursor: string | null;
};
export type BreadcrumbEntry = {
id: string;
title: string;
};
export type PageDetail = {
id: string;
workspaceId: string;
parentPageId: string | null;
title: string;
slug: string;
markdown: string;
renderedHtml: string;
documentType: string;
documentStatus: string;
currentRevisionNumber: number;
tags: string[];
breadcrumb: BreadcrumbEntry[];
createdBy: string | null;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
trashedAt: string | null;
eTag: string;
};
export type CreatePageRequest = {
parentPageId: string | null;
title: string;
documentType: string | null;
markdown: string | null;
templateKey: string | null;
tags?: string[] | null;
};
export type UpdatePageRequest = {
title: string;
markdown: string;
documentType: string;
documentStatus: string;
changeSummary: string | null;
tags?: string[] | null;
};
export type PageRevisionSummary = {
revisionNumber: number;
title: string;
documentType: string;
documentStatus: string;
authoredBy: string | null;
changeSummary: string | null;
createdAt: string;
};
export type PageRevisionDetailResponse = {
revisionNumber: number;
title: string;
markdown: string;
renderedHtml: string;
documentType: string;
documentStatus: string;
authoredBy: string | null;
changeSummary: string | null;
createdAt: string;
};
export type BacklinkEntry = {
id: string;
title: string;
slug: string;
documentType: string;
};
export type BacklinksResponse = {
items: BacklinkEntry[];
};
export type SearchResultItem = {
id: string;
parentPageId: string | null;
title: string;
slug: string;
documentType: string;
documentStatus: string;
tags: string[];
updatedAt: string;
snippet: string;
};
export type SearchResponse = {
items: SearchResultItem[];
nextCursor: string | null;
};
export type TrashPurgeResponse = {
purgedCount: number;
};
export type MovePageRequest = {
newParentPageId: string | null;
afterSiblingId: string | null;
};
export type TemplateSummary = {
key: string;
name: string;
documentType: string;
description: string;
};
export type DraftResponse = {
draftKey: string;
pageId: string | null;
baseRevisionNumber: number | null;
title: string;
markdown: string;
updatedAt: string;
expiresAt: string;
baseRevisionIsStale: boolean;
tags?: string[] | null;
documentType?: string | null;
documentStatus?: string | null;
changeSummary?: string | null;
};
export type SaveDraftRequest = {
pageId: string | null;
baseRevisionNumber: number | null;
title: string;
markdown: string;
tags?: string[] | null;
documentType?: string | null;
documentStatus?: string | null;
changeSummary?: string | null;
};
export type BeginPageCreationSessionRequest = {
parentPageId: string | null;
};
export type PageCreationSessionResponse = {
sessionId: string;
expiresAt: string;
};
export type CommitPageCreationSessionRequest = {
title: string;
documentType: string | null;
markdown: string | null;
tags?: string[] | null;
};
export type DiscardPageCreationSessionResponse = {
attachmentsRemoved: number;
};
export const documentTypeLabels: Record<string, string> = {
WikiPage: "Wikipagina",
StudyNote: "Studienotitie",
CourseModule: "Cursusmodule",
ProjectDocument: "Projectdocument",
ArchitectureDecision: "Architectuurbeslissing",
Runbook: "Runbook",
Troubleshooting: "Probleemoplossing",
Reference: "Naslag",
ChangeLog: "Wijzigingslog",
SessionNote: "Sessienotitie",
Checklist: "Checklist"
};
export const documentStatusLabels: Record<string, string> = {
Draft: "Concept",
Active: "Actief",
NeedsReview: "Te herzien",
Deprecated: "Verouderd",
Archived: "Gearchiveerd"
};
+345
View File
@@ -0,0 +1,345 @@
export type ProjectSummary = {
id: string;
name: string;
slug: string;
summary: string;
status: string;
priority: string;
ownerId: string;
repositoryUrl: string | null;
liveUrl: string | null;
updatedAt: string;
iconAttachmentId: string | null;
};
export type ProjectListResponse = {
items: ProjectSummary[];
nextCursor: string | null;
/** Projects matching the current filters across the whole portfolio, not just this
* page. Counted server-side before cursor pagination (OUX-4). */
totalCount: number;
};
export type ProjectCatalogTotals = {
totalCount: number;
activeCount: number;
};
export type ProjectDocumentSummary = {
id: string;
pageId: string;
pageTitle: string;
pageSlug: string;
pageDocumentType: string;
role: string;
};
export type ProjectUpdateSummary = {
id: string;
category: string;
title: string;
summaryMarkdown: string;
verificationStatus: string;
actorId: string;
occurredAt: string;
commitSha: string | null;
releaseTag: string | null;
issueIdentifier: string | null;
url: string | null;
pageId: string | null;
decisionRecordId: string | null;
milestoneId: string | null;
attachmentReferenceId: string | null;
supersedesUpdateId: string | null;
};
export type ProjectUpdatesResponse = {
items: ProjectUpdateSummary[];
nextCursor: string | null;
};
export type ProjectMilestoneSummary = {
id: string;
name: string;
state: string;
targetDate: string | null;
completionDate: string | null;
summary: string;
successCriteria: string;
eTag: string;
};
export type ProjectDecisionSummary = {
id: string;
decisionNumber: number;
status: string;
summary: string;
};
export type ProjectDetail = {
id: string;
name: string;
slug: string;
summary: string;
status: string;
priority: string;
currentStateMarkdown: string;
ownerId: string;
startDate: string | null;
targetDate: string | null;
completionDate: string | null;
repositoryUrl: string | null;
liveUrl: string | null;
documentationUrl: string | null;
issueTrackerUrl: string | null;
technologies: string[];
documents: ProjectDocumentSummary[];
recentUpdates: ProjectUpdateSummary[];
milestones: ProjectMilestoneSummary[];
decisions: ProjectDecisionSummary[];
createdAt: string;
updatedAt: string;
eTag: string;
iconAttachmentId: string | null;
};
export type CreateProjectRequest = {
name: string;
summary: string;
priority: string;
currentStateMarkdown: string;
ownerId: string;
};
export type UpdateProjectRequest = {
name: string;
summary: string;
priority: string;
currentStateMarkdown: string;
ownerId: string;
startDate: string | null;
targetDate: string | null;
completionDate: string | null;
repositoryUrl: string | null;
liveUrl: string | null;
documentationUrl: string | null;
issueTrackerUrl: string | null;
technologies: string[] | null;
};
export type CreateProjectUpdateRequest = {
category: string;
title: string;
summaryMarkdown: string;
verificationStatus: string;
occurredAt: string | null;
commitSha: string | null;
releaseTag: string | null;
issueIdentifier: string | null;
url: string | null;
pageId: string | null;
decisionRecordId: string | null;
milestoneId: string | null;
attachmentReferenceId: string | null;
supersedesUpdateId: string | null;
};
export type CreateProjectMilestoneRequest = {
name: string;
targetDate: string | null;
summary: string;
successCriteria: string;
};
export type UpdateProjectMilestoneRequest = {
name: string;
targetDate: string | null;
summary: string;
successCriteria: string;
state: string;
};
export type LinkProjectDocumentRequest = {
pageId: string;
role: string;
};
export type DecisionLineageEntry = {
id: string;
decisionNumber: number;
status: string;
summary: string;
};
export type DecisionDetail = {
id: string;
workspaceId: string;
projectId: string | null;
pageId: string;
decisionNumber: number;
status: string;
decisionDate: string;
title: string;
markdown: string;
renderedHtml: string;
summary: string;
supersedes: DecisionLineageEntry | null;
supersededBy: DecisionLineageEntry | null;
createdAt: string;
updatedAt: string;
eTag: string;
};
export type DecisionSummary = {
id: string;
projectId: string | null;
decisionNumber: number;
status: string;
title: string;
decisionDate: string;
summary: string;
updatedAt: string;
};
export type DecisionListResponse = {
items: DecisionSummary[];
nextCursor: string | null;
};
export type CreateDecisionRequest = {
parentPageId: string | null;
projectId: string | null;
title: string;
markdown: string | null;
summary: string;
supersedesDecisionId: string | null;
};
export type RunbookDetail = {
id: string;
pageId: string;
owner: string;
riskLevel: string;
status: string;
requiresMaintenanceWindow: boolean;
estimatedDurationMinutes: number | null;
reviewDueAt: string | null;
lastValidatedAt: string | null;
validatedBy: string | null;
isStale: boolean;
title: string;
markdown: string;
renderedHtml: string;
currentRevisionNumber: number;
createdAt: string;
updatedAt: string;
eTag: string;
};
export type RunbookSummary = {
id: string;
pageId: string;
title: string;
riskLevel: string;
status: string;
isStale: boolean;
lastValidatedAt: string | null;
updatedAt: string;
};
export type RunbookListResponse = {
items: RunbookSummary[];
nextCursor: string | null;
};
export type CreateRunbookRequest = {
parentPageId: string | null;
title: string;
markdown: string | null;
templateKey: string | null;
riskLevel: string;
requiresMaintenanceWindow: boolean;
estimatedDurationMinutes: number | null;
};
export type UpdateRunbookProfileRequest = {
owner: string;
riskLevel: string;
status: string;
requiresMaintenanceWindow: boolean;
estimatedDurationMinutes: number | null;
reviewDueAt: string | null;
};
export const projectStatusLabels: Record<string, string> = {
Concept: "Concept",
Planned: "Gepland",
Active: "Actief",
Blocked: "Geblokkeerd",
Maintenance: "Onderhoud",
Paused: "Gepauzeerd",
Completed: "Afgerond",
Archived: "Gearchiveerd"
};
export const projectPriorityLabels: Record<string, string> = {
Low: "Laag",
Medium: "Gemiddeld",
High: "Hoog",
Critical: "Kritiek"
};
export const decisionStatusLabels: Record<string, string> = {
Proposed: "Voorgesteld",
Accepted: "Geaccepteerd",
Rejected: "Afgewezen",
Superseded: "Vervangen",
Withdrawn: "Ingetrokken"
};
export const runbookRiskLabels: Record<string, string> = {
Low: "Laag",
Medium: "Gemiddeld",
High: "Hoog",
Critical: "Kritiek"
};
export const runbookStatusLabels: Record<string, string> = {
Draft: "Concept",
Active: "Actief",
Deprecated: "Verouderd"
};
export const projectUpdateCategoryLabels: Record<string, string> = {
Progress: "Voortgang",
Feature: "Functionaliteit",
Fix: "Oplossing",
Deployment: "Uitrol",
Validation: "Validatie",
Release: "Release",
Decision: "Beslissing",
Risk: "Risico",
Blocker: "Blokkade",
Documentation: "Documentatie",
Maintenance: "Onderhoud"
};
export const projectUpdateVerificationLabels: Record<string, string> = {
Reported: "Gemeld",
Verified: "Geverifieerd",
Superseded: "Vervangen"
};
export const projectDocumentRoleLabels: Record<string, string> = {
Overview: "Overzicht",
CurrentState: "Huidige stand",
Architecture: "Architectuur",
Deployment: "Uitrol",
Infrastructure: "Infrastructuur",
Security: "Beveiliging",
Testing: "Testen",
Troubleshooting: "Probleemoplossing",
Release: "Release",
UserDocumentation: "Gebruikersdocumentatie",
Other: "Overig"
};
+4
View File
@@ -0,0 +1,4 @@
export type CompleteSetupResponse = {
workspaceId: string;
userId: string;
};
+28
View File
@@ -0,0 +1,28 @@
export type SessionResponse = {
authenticated: boolean;
setupRequired: boolean;
userId: string | null;
displayName: string | null;
email: string | null;
workspaceId: string | null;
role: "Reader" | "Editor" | "Administrator" | null;
features?: { ingestionProposals: boolean } | null;
};
export type SetupStatusResponse = {
setupRequired: boolean;
};
export type ProblemDetails = {
type?: string;
title?: string;
detail?: string;
status?: number;
correlationId?: string;
errors?: Record<string, string[]>;
};
export type CsrfTokenResponse = {
token: string;
headerName: string;
};
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { apiGet } from "../api/client";
import type { SessionResponse } from "../api/types";
import { SessionContext, type SessionState } from "./session-context";
async function loadSession(): Promise<SessionState> {
try {
const session = await apiGet<SessionResponse>("/api/v1/auth/session");
return { kind: "ready", session };
} catch {
return { kind: "error" };
}
}
export function SessionProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SessionState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
// Data fetched on mount to hydrate this dedicated session/query layer; setState
// happens after the async gap (never synchronously in the effect body), and the
// cancellation flag guards against setting state after unmount.
loadSession()
.then((next) => {
if (!cancelled) {
setState(next);
}
})
.catch(() => {
if (!cancelled) {
setState({ kind: "error" });
}
});
return () => {
cancelled = true;
};
}, []);
const value = useMemo(
() => ({
state,
refresh: async () => {
const next = await loadSession();
setState(next);
}
}),
[state]
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
+22
View File
@@ -0,0 +1,22 @@
import { createContext, useContext } from "react";
import type { SessionResponse } from "../api/types";
export type SessionState =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; session: SessionResponse };
export type SessionContextValue = {
state: SessionState;
refresh: () => Promise<void>;
};
export const SessionContext = createContext<SessionContextValue | null>(null);
export function useSession(): SessionContextValue {
const context = useContext(SessionContext);
if (!context) {
throw new Error("useSession must be used within a SessionProvider.");
}
return context;
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
/** Shared shell for the two pre-authentication screens (Login, first-run Setup).
* Restyled in V2-R3 (docs/adr/ADR-027-v2-r3-context-inspector-and-daily-ux-
* completeness.md) — a two-column split layout on wider viewports, echoing the
* Stitch login reference's composition (branded panel + form panel) without
* reproducing its illustration asset (no generation capability for that in this
* environment; a real, honest difference, not silently claimed as pixel-parity).
* Collapses to a single form-only column on narrow/mobile viewports, matching every
* other V2 responsive breakpoint's behavior. */
export function AuthShell({ children }: { children: ReactNode }) {
return (
<div className="auth-shell">
<div className="auth-mobile-brand" aria-hidden="true">
<span className="product-mark"><span /></span>
<span><strong>ItWorx</strong><small>Knowledge Hub</small></span>
</div>
<div className="auth-brand-panel" aria-hidden="true">
<div className="auth-brand-mark">
<span className="product-mark" aria-hidden="true">
<span />
</span>
<div className="brand-copy">
<strong>ItWorx</strong>
<small>Knowledge Hub</small>
</div>
</div>
<p className="auth-brand-tagline">
Eén traceerbare bron voor infrastructuur, projecten en technische kennis —
feiten, redactionele context en handmatige correcties blijven altijd
zichtbaar van elkaar gescheiden.
</p>
<p className="auth-brand-footnote">Beveiligde, lokaal gehoste omgeving.</p>
</div>
<div className="auth-form-panel">{children}</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, useLocation } from "react-router";
import { CommandPalette } from "./CommandPalette";
function LocationProbe() {
const location = useLocation();
return <output aria-label="Huidig pad">{location.pathname}</output>;
}
describe("CommandPalette", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows fixed navigation destinations without any query", async () => {
vi.stubGlobal("fetch", vi.fn());
const user = userEvent.setup();
render(
<MemoryRouter>
<CommandPalette isAdministrator={false} />
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Opdrachten en zoeken openen" }));
expect(await screen.findByRole("option", { name: /Projecten/ })).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Opdrachtresultaten" })).toHaveAttribute("tabindex", "0");
// A non-Administrator never sees the Beheer destination, matching the server-side
// /admin route guard rather than only hiding it client-side.
expect(screen.queryByRole("option", { name: /^Beheer/ })).not.toBeInTheDocument();
});
it("searches real knowledge content, not just the fixed destination list", async () => {
const fetchMock = vi.fn().mockImplementation((input: string) => {
if (typeof input === "string" && input.startsWith("/api/v1/search")) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () =>
Promise.resolve({
items: [
{
id: "page-1",
parentPageId: null,
title: "PostgreSQL back-upprocedure",
slug: "postgresql-back-upprocedure",
documentType: "Runbook",
documentStatus: "Published",
tags: [],
updatedAt: "2026-08-02T00:00:00Z",
snippet: "",
},
],
nextCursor: null,
}),
});
}
return Promise.resolve({ ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve({ items: [], nextCursor: null }) });
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(
<MemoryRouter>
<CommandPalette isAdministrator={true} />
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Opdrachten en zoeken openen" }));
await user.type(screen.getByLabelText("Zoeken en opdrachten"), "PostgreSQL");
expect(await screen.findByRole("option", { name: /PostgreSQL back-upprocedure/ })).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/search?q=PostgreSQL"),
expect.anything(),
);
});
it("shows no results message when neither destinations nor knowledge content match", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve({ items: [], nextCursor: null }),
}),
);
const user = userEvent.setup();
render(
<MemoryRouter>
<CommandPalette isAdministrator={false} />
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Opdrachten en zoeken openen" }));
await user.type(screen.getByLabelText("Zoeken en opdrachten"), "zzzz-geen-overeenkomst");
expect(screen.getByRole("status")).toHaveTextContent("Zoeken");
expect(screen.queryByText("Geen overeenkomsten.")).not.toBeInTheDocument();
expect(await screen.findByText("Geen overeenkomsten.")).toBeInTheDocument();
});
it("moves the active option with arrow keys and opens it with Enter", async () => {
vi.stubGlobal("fetch", vi.fn());
const user = userEvent.setup();
render(
<MemoryRouter>
<CommandPalette isAdministrator={false} />
<LocationProbe />
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Opdrachten en zoeken openen" }));
const input = screen.getByLabelText("Zoeken en opdrachten");
expect(screen.getByRole("option", { name: /^Home/ })).toHaveAttribute("aria-selected", "true");
await user.type(input, "{ArrowDown}{Enter}");
expect(screen.getByLabelText("Huidig pad")).toHaveTextContent("/infrastructure");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("distinguishes a failed knowledge search from a real empty result and offers retry", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("zoekdienst onbeschikbaar"));
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(
<MemoryRouter>
<CommandPalette isAdministrator={false} />
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Opdrachten en zoeken openen" }));
await user.type(screen.getByLabelText("Zoeken en opdrachten"), "zzzz-storing");
expect(await screen.findByRole("alert")).toHaveTextContent("Kennis zoeken is tijdelijk niet beschikbaar");
expect(screen.queryByText("Geen overeenkomsten.")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Opnieuw proberen" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,266 @@
import * as Dialog from "@radix-ui/react-dialog";
import {
BookOpenText, Box, FolderKanban, Gauge, HardDrive, Search, Server, Settings,
ShieldCheck, TerminalSquare, X
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { apiGet } from "../api/client";
import type { SearchResponse, SearchResultItem } from "../api/knowledgeTypes";
const destinations = [
{ label: "Home", description: "Operationeel overzicht", path: "/", icon: Gauge },
{ label: "Infrastructuur", description: "Hosts, capaciteit en relaties", path: "/infrastructure", icon: Server },
{ label: "Services", description: "Applicaties en stacks", path: "/infrastructure/services", icon: Box },
{ label: "Opslag", description: "Volumes en capaciteit", path: "/infrastructure/storage", icon: HardDrive },
{ label: "Projecten", description: "Projectworkspaces", path: "/projects", icon: FolderKanban },
{ label: "Runbooks", description: "Veilige operationele procedures", path: "/runbooks", icon: TerminalSquare },
{ label: "Kennisbank", description: "Vrije technische documentatie", path: "/knowledge", icon: BookOpenText },
{ label: "Zoeken", description: "Doorzoek alle kennis", path: "/search", icon: Search },
{ label: "Beheer", description: "Gebruikers en beveiliging", path: "/admin", icon: ShieldCheck },
{ label: "Instellingen", description: "Thema, dichtheid en navigatie", path: "/settings", icon: Settings }
] as const;
// A palette-only query long enough to be worth a real search request — matches the
// editor's own internal-link-autocomplete threshold (PageEditorPage.tsx).
const minSearchLength = 1;
export function CommandPalette({ isAdministrator }: { isAdministrator: boolean }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [knowledgeResultSet, setKnowledgeResultSet] = useState<{ query: string; items: SearchResultItem[] }>({
query: "",
items: [],
});
const [searchingQuery, setSearchingQuery] = useState<string | null>(null);
const [failedQuery, setFailedQuery] = useState<string | null>(null);
const [searchAttempt, setSearchAttempt] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(0);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setOpen((current) => !current);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Real content search, not just the fixed navigation-destination list below — this
// is what makes the command palette a genuine "search reachable from every
// workspace" (V2_PRODUCT_REQUIREMENTS.md §6), matching the locked Stitch reference
// (03-global-search-command.png shows real infrastructure/project/service results,
// not a static command list). Found missing during the V2-M3 LeafWiki-floor audit.
useEffect(() => {
const trimmed = query.trim();
// No setState here for the below-threshold/closed case: nothing to cancel yet,
// and rendering below only ever displays a result set when the query
// currently qualifies (see displayedKnowledgeResults), so a stale array from a
// previous open simply never reaches the screen instead of needing an eager
// effect-body reset (react-hooks/set-state-in-effect — see [[v2-m2-gotchas]]).
if (!open || trimmed.length < minSearchLength) {
return;
}
let cancelled = false;
const timeout = window.setTimeout(() => {
setSearchingQuery(trimmed);
setFailedQuery(null);
apiGet<SearchResponse>(`/api/v1/search?q=${encodeURIComponent(trimmed)}&limit=6`)
.then((response) => {
if (!cancelled) setKnowledgeResultSet({ query: trimmed, items: response.items });
})
.catch(() => {
if (!cancelled) setFailedQuery(trimmed);
})
.finally(() => {
if (!cancelled) setSearchingQuery(null);
});
}, 200);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [open, query, searchAttempt]);
const trimmedQuery = query.trim();
const displayedKnowledgeResults =
trimmedQuery.length >= minSearchLength && knowledgeResultSet.query === trimmedQuery
? knowledgeResultSet.items
: [];
const searchFailed = failedQuery === trimmedQuery;
// Treat the debounce window as pending too. Otherwise a new query briefly says
// "Geen overeenkomsten" before its request has even started, which is a false
// empty state and especially confusing with a slower keyboard or screen reader.
const searching =
trimmedQuery.length >= minSearchLength
&& !searchFailed
&& (knowledgeResultSet.query !== trimmedQuery || searchingQuery === trimmedQuery);
const destinationResults = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase("nl-BE");
return destinations.filter((destination) =>
(isAdministrator || destination.path !== "/admin")
&& (!normalized || `${destination.label} ${destination.description}`.toLocaleLowerCase("nl-BE").includes(normalized))
);
}, [isAdministrator, query]);
function go(path: string) {
void navigate(path);
setOpen(false);
setQuery("");
setSelectedIndex(0);
}
const hasAnyResults = destinationResults.length > 0 || displayedKnowledgeResults.length > 0;
const resultCount = destinationResults.length + displayedKnowledgeResults.length;
const activeIndex = resultCount > 0 ? Math.min(selectedIndex, resultCount - 1) : -1;
const activePath = activeIndex < 0
? null
: activeIndex < destinationResults.length
? destinationResults[activeIndex]?.path ?? null
: (() => {
const item = displayedKnowledgeResults[activeIndex - destinationResults.length];
return item ? `/knowledge/${item.id}/${item.slug}` : null;
})();
const activeOptionId = activeIndex >= 0 ? `command-option-${activeIndex}` : undefined;
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (resultCount === 0) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
setSelectedIndex((current) => {
const normalized = Math.min(current, resultCount - 1);
return (normalized + direction + resultCount) % resultCount;
});
return;
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
setSelectedIndex(event.key === "Home" ? 0 : resultCount - 1);
return;
}
if (event.key === "Enter" && activePath) {
event.preventDefault();
go(activePath);
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className="command-trigger" aria-label="Opdrachten en zoeken openen">
<Search aria-hidden="true" size={16} />
<span>Ga naar of zoek…</span>
<kbd>Ctrl K</kbd>
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="command-dialog" onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}>
<Dialog.Title className="sr-only">Opdrachtenpalet</Dialog.Title>
<Dialog.Description className="sr-only">Zoek in kennis, projecten en services, of navigeer snel naar een productonderdeel.</Dialog.Description>
<div className="command-input-row">
<Search aria-hidden="true" size={20} />
<input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleInputKeyDown}
placeholder="Zoek in kennis of typ een opdracht…"
aria-label="Zoeken en opdrachten"
aria-controls="command-result-list"
aria-activedescendant={activeOptionId}
/>
<Dialog.Close className="icon-button" aria-label="Sluiten"><X aria-hidden="true" size={18} /></Dialog.Close>
</div>
<div
className="command-results"
role="region"
aria-label="Opdrachtresultaten"
tabIndex={0}
>
<div id="command-result-list" role="listbox" aria-label="Resultaten" aria-busy={searching}>
{destinationResults.length > 0 && (
<div className="command-result-group" role="group" aria-label="Bestemmingen">
{destinationResults.map((destination, index) => {
const Icon = destination.icon;
return (
<button
key={destination.path}
id={`command-option-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
tabIndex={-1}
className="command-result"
onClick={() => go(destination.path)}
onFocus={() => setSelectedIndex(index)}
>
<span className="command-result-icon"><Icon aria-hidden="true" size={18} /></span>
<span><strong>{destination.label}</strong><small>{destination.description}</small></span>
<span className="command-enter" aria-hidden="true">↵</span>
</button>
);
})}
</div>
)}
{displayedKnowledgeResults.length > 0 && (
<div className="command-result-group" role="group" aria-label="Kennisbank">
<p className="command-result-group-label">Kennisbank</p>
{displayedKnowledgeResults.map((item, itemIndex) => {
const index = destinationResults.length + itemIndex;
return (
<button
key={item.id}
id={`command-option-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
tabIndex={-1}
className="command-result"
onClick={() => go(`/knowledge/${item.id}/${item.slug}`)}
onFocus={() => setSelectedIndex(index)}
>
<span className="command-result-icon"><BookOpenText aria-hidden="true" size={18} /></span>
<span><strong>{item.title}</strong><small>{item.documentType}</small></span>
<span className="command-enter" aria-hidden="true">↵</span>
</button>
);
})}
</div>
)}
</div>
{searching && <div className="empty-inline" role="status">Zoeken…</div>}
{!searching && searchFailed && (
<div className="command-search-error" role="alert">
<span>Kennis zoeken is tijdelijk niet beschikbaar. Bestemmingen blijven beschikbaar.</span>
<button
type="button"
className="secondary-action"
onClick={() => {
setFailedQuery(null);
setSearchAttempt((current) => current + 1);
}}
>
Opnieuw proberen
</button>
</div>
)}
{!searching && !searchFailed && !hasAnyResults && <div className="empty-inline">Geen overeenkomsten.</div>}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -0,0 +1,159 @@
import type { ColumnDef } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { DataTable } from "./DataTable";
type Row = { name: string; size: number; kind: string };
const columns: ColumnDef<Row>[] = [
{ accessorKey: "name", header: "Naam" },
{ accessorKey: "size", header: "Omvang" },
{ accessorKey: "kind", header: "Klasse" },
];
const data = [
{ name: "PostgreSQL", size: 20, kind: "database" },
{ name: "Attachments", size: 10, kind: "files" },
];
describe("DataTable", () => {
afterEach(() => window.localStorage.clear());
it("filters, sorts and persists column visibility", async () => {
const user = userEvent.setup();
render(
<DataTable
data={data}
columns={columns}
label="Testmatrix"
storageKey="test"
exportFilename="test.csv"
emptyMessage="Geen rijen"
/>,
);
await user.type(
screen.getByLabelText("Testmatrix doorzoeken"),
"PostgreSQL",
);
expect(screen.getByText("PostgreSQL")).toBeInTheDocument();
expect(screen.queryByText("Attachments")).not.toBeInTheDocument();
await user.clear(screen.getByLabelText("Testmatrix doorzoeken"));
expect(await screen.findByText("Attachments")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Omvang" }));
expect(screen.getByRole("columnheader", { name: "Omvang" })).toHaveAttribute("aria-sort", "descending");
expect(screen.getAllByRole("row")[1]).toHaveTextContent("PostgreSQL");
await user.click(screen.getByText("Kolommen"));
await user.click(screen.getByLabelText("Klasse"));
expect(
screen.queryByRole("columnheader", { name: "Klasse" }),
).not.toBeInTheDocument();
expect(
window.localStorage.getItem("itworx.table.v1.test.columns"),
).toContain('"kind":false');
});
it("saves, restores and removes named views with table density", async () => {
const user = userEvent.setup();
render(<DataTable data={data} columns={columns} label="Testmatrix" storageKey="test" exportFilename="test.csv" emptyMessage="Geen rijen" />);
await user.type(screen.getByLabelText("Testmatrix doorzoeken"), "PostgreSQL");
await user.click(screen.getByRole("button", { name: "Tabeldichtheid: comfortabel" }));
await user.click(screen.getByText("Weergaven"));
await user.type(screen.getByLabelText("Naam weergave"), "Database compact");
await user.click(screen.getByRole("button", { name: "Huidige weergave opslaan" }));
await user.clear(screen.getByLabelText("Testmatrix doorzoeken"));
await user.click(screen.getByRole("button", { name: "Tabeldichtheid: compact" }));
await user.click(screen.getByRole("button", { name: "Database compact" }));
expect(screen.getByLabelText("Testmatrix doorzoeken")).toHaveValue("PostgreSQL");
expect(screen.getByRole("button", { name: "Tabeldichtheid: compact" })).toBeInTheDocument();
expect(window.localStorage.getItem("itworx.table.v1.test.views")).toContain("Database compact");
await user.click(screen.getByRole("button", { name: "Database compact verwijderen" }));
expect(screen.queryByRole("button", { name: "Database compact" })).not.toBeInTheDocument();
});
// V2-M6: "matrices remain usable (sortable/filterable, no horizontal page scroll)
// at realistic service/project counts" — the existing tests above only ever used a
// 2-row/3-column fixture. This proves the same real behaviors (filter, sort, column
// visibility) still hold correctly at a scale closer to a real infrastructure
// matrix, not just that the component doesn't crash with more rows.
describe("at a realistic matrix scale (many rows and columns)", () => {
type MatrixRow = { name: string; host: string; port: number; status: string; owner: string; region: string };
const matrixColumns: ColumnDef<MatrixRow>[] = [
{ accessorKey: "name", header: "Service" },
{ accessorKey: "host", header: "Host" },
{ accessorKey: "port", header: "Poort" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "owner", header: "Eigenaar" },
{ accessorKey: "region", header: "Regio" },
];
const rowCount = 240;
const matrixData: MatrixRow[] = Array.from({ length: rowCount }, (_, index) => ({
name: `service-${index.toString().padStart(3, "0")}`,
host: `host-${index % 12}`,
port: 30000 + index,
status: index % 7 === 0 ? "Degraded" : "Healthy",
owner: `team-${index % 5}`,
region: index % 3 === 0 ? "eu-west" : "eu-central",
}));
function renderMatrix() {
return render(
<DataTable
data={matrixData}
columns={matrixColumns}
label="Servicematrix"
storageKey="matrix-scale"
exportFilename="matrix.csv"
emptyMessage="Geen services"
/>,
);
}
it("reports the true total row count and every row is reachable, not truncated or paginated away", () => {
renderMatrix();
expect(screen.getByText(`${rowCount} van ${rowCount} rijen`)).toBeInTheDocument();
expect(screen.getAllByRole("row")).toHaveLength(rowCount + 1); // +1 header row
});
it("filters correctly to the real matching subset at scale", async () => {
const user = userEvent.setup();
renderMatrix();
await user.type(screen.getByLabelText("Servicematrix doorzoeken"), "Degraded");
const expectedMatches = matrixData.filter((row) => row.status === "Degraded").length;
expect(screen.getByText(`${expectedMatches} van ${rowCount} rijen`)).toBeInTheDocument();
expect(screen.queryByText("service-001")).not.toBeInTheDocument(); // Healthy, filtered out
expect(screen.getByText("service-000")).toBeInTheDocument(); // index 0 is Degraded
});
it("sorts a numeric column correctly across the full row set, not just the first page", async () => {
const user = userEvent.setup();
renderMatrix();
// Matches the pre-existing "Omvang" sort test above: the first click sorts
// descending, not ascending.
await user.click(screen.getByRole("button", { name: "Poort" }));
expect(screen.getByRole("columnheader", { name: "Poort" })).toHaveAttribute("aria-sort", "descending");
const rows = screen.getAllByRole("row");
// Row 1 is the header; the highest port (service with the last index) sorts first.
expect(rows[1]).toHaveTextContent(`service-${(rowCount - 1).toString().padStart(3, "0")}`);
expect(rows[rows.length - 1]).toHaveTextContent("service-000");
});
it("hides a column across all rows when toggled off, without breaking the remaining columns", async () => {
const user = userEvent.setup();
renderMatrix();
await user.click(screen.getByText("Kolommen"));
await user.click(screen.getByLabelText("Regio"));
expect(screen.queryByRole("columnheader", { name: "Regio" })).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Service" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Eigenaar" })).toBeInTheDocument();
expect(screen.getByText(`${rowCount} van ${rowCount} rijen`)).toBeInTheDocument();
});
});
});
+253
View File
@@ -0,0 +1,253 @@
import { BookmarkPlus, Download, Rows3, Search, SlidersHorizontal, Trash2 } from "lucide-react";
import { useDeferredValue, useEffect, useState } from "react";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table";
type DataTableProps<TData> = {
data: TData[];
columns: ColumnDef<TData>[];
label: string;
storageKey: string;
exportFilename: string;
emptyMessage: string;
};
type TableDensity = "comfortable" | "compact";
type SavedView = { name: string; query: string; sorting: SortingState; columnVisibility: VisibilityState; density: TableDensity };
function readJson<T>(key: string, fallback: T): T {
try { return JSON.parse(window.localStorage.getItem(key) ?? "null") as T ?? fallback; } catch { return fallback; }
}
export function DataTable<TData>({
data,
columns,
label,
storageKey,
exportFilename,
emptyMessage,
}: DataTableProps<TData>) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [sorting, setSorting] = useState<SortingState>([]);
const [density, setDensity] = useState<TableDensity>(() => readJson(`itworx.table.v1.${storageKey}.density`, "comfortable"));
const [viewName, setViewName] = useState("");
const [savedViews, setSavedViews] = useState<SavedView[]>(() => readJson(`itworx.table.v1.${storageKey}.views`, []));
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
try {
return JSON.parse(
window.localStorage.getItem(
`itworx.table.v1.${storageKey}.columns`,
) ?? "{}",
) as VisibilityState;
} catch {
return {};
}
},
);
// TanStack Table intentionally returns an imperative table instance; React Compiler
// skips this component, while the explicit state inputs below keep rendering correct.
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter: deferredQuery, columnVisibility },
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
useEffect(() => {
window.localStorage.setItem(
`itworx.table.v1.${storageKey}.columns`,
JSON.stringify(columnVisibility),
);
}, [columnVisibility, storageKey]);
useEffect(() => {
window.localStorage.setItem(`itworx.table.v1.${storageKey}.density`, JSON.stringify(density));
window.localStorage.setItem(`itworx.table.v1.${storageKey}.views`, JSON.stringify(savedViews));
}, [density, savedViews, storageKey]);
function saveView() {
const name = viewName.trim();
if (!name) return;
const view = { name, query, sorting, columnVisibility, density };
setSavedViews((current) => [...current.filter((item) => item.name !== name), view]);
setViewName("");
}
function applyView(view: SavedView) {
setQuery(view.query); setSorting(view.sorting); setColumnVisibility(view.columnVisibility); setDensity(view.density);
}
function exportCsv() {
const visibleColumns = table.getVisibleLeafColumns();
const rows = table.getRowModel().rows;
const csv = [
visibleColumns
.map((column) =>
csvValue(columnLabel(column.columnDef.header, column.id)),
)
.join(","),
...rows.map((row) =>
visibleColumns
.map((column) => csvValue(row.getValue(column.id)))
.join(","),
),
].join("\r\n");
const url = URL.createObjectURL(
new Blob([csv], { type: "text/csv;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = exportFilename;
anchor.click();
URL.revokeObjectURL(url);
}
return (
<section className={`data-surface table-density-${density}`} aria-label={label}>
<div className="table-toolbar">
<label className="filter-input table-search">
<Search aria-hidden="true" size={15} />
<span className="sr-only">{label} doorzoeken</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Doorzoek de matrix…"
/>
</label>
<span>
{table.getRowModel().rows.length} van {data.length} rijen
</span>
<details className="column-picker">
<summary className="button secondary">
<SlidersHorizontal aria-hidden="true" size={15} />
Kolommen
</summary>
<div>
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<label key={column.id}>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>
{columnLabel(column.columnDef.header, column.id)}
</label>
))}
</div>
</details>
<details className="saved-view-picker">
<summary className="button secondary"><BookmarkPlus aria-hidden="true" size={15} />Weergaven</summary>
<div><label>Naam weergave<input value={viewName} onChange={(event) => setViewName(event.target.value)} /></label><button className="button primary" type="button" disabled={!viewName.trim()} onClick={saveView}>Huidige weergave opslaan</button>{savedViews.length === 0 && <span>Nog geen opgeslagen weergaven.</span>}{savedViews.map((view) => <div className="saved-view-row" key={view.name}><button type="button" onClick={() => applyView(view)}>{view.name}</button><button className="icon-button" type="button" aria-label={`${view.name} verwijderen`} onClick={() => setSavedViews((current) => current.filter((item) => item.name !== view.name))}><Trash2 aria-hidden="true" size={14} /></button></div>)}</div>
</details>
<button className="button secondary" type="button" aria-label={`Tabeldichtheid: ${density === "compact" ? "compact" : "comfortabel"}`} onClick={() => setDensity((value) => value === "compact" ? "comfortable" : "compact")}><Rows3 aria-hidden="true" size={15} />{density === "compact" ? "Compact" : "Comfortabel"}</button>
<button className="button secondary" type="button" onClick={exportCsv}>
<Download aria-hidden="true" size={15} />
CSV exporteren
</button>
</div>
{/* tabIndex={0}: a horizontally scrollable container with no other focusable
content is otherwise unreachable by keyboard — a real axe
"scrollable-region-focusable" (WCAG 2.1.1/2.1.3) violation found on a narrow
viewport during the V2-M4 audit, not by inspection. No extra role/aria-label
here: the enclosing <section aria-label={label}> above already names this
area, and adding a second identically-named "region" would just create a
confusing duplicate landmark for screen reader users. */}
<div className="table-scroll" tabIndex={0}>
<table className="data-table">
<thead>
{table.getHeaderGroups().map((group) => (
<tr key={group.id}>
{group.headers.map((header) => (
<th
key={header.id}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: "none"
}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<span aria-hidden="true">
{header.column.getIsSorted() === "asc"
? " ↑"
: header.column.getIsSorted() === "desc"
? " ↓"
: ""}
</span>
</button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{table.getRowModel().rows.length === 0 && (
<div className="empty-inline">{emptyMessage}</div>
)}
</section>
);
}
function csvValue(value: unknown): string {
const raw =
value === null || value === undefined
? ""
: typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? `${value}`
: (JSON.stringify(value) ?? "");
return `"${raw.replaceAll('"', '""')}"`;
}
function columnLabel(header: unknown, fallback: string) {
return typeof header === "string" || typeof header === "number"
? `${header}`
: fallback;
}
@@ -0,0 +1,118 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import { ProjectCreateDialog } from "./ProjectDialogs";
import { ProjectDecisionDialog } from "./ProjectWorkspaceActions";
import { RunbookCreateDialog } from "./RunbookCreateDialog";
function response(body: unknown) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body))
});
}
describe("entity creation dialogs", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("creates a project from the V2 portfolio interface", async () => {
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/projects" && init?.method === "POST") return response({ id: "project-1", name: "Atlas" });
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const onCreated = vi.fn();
const user = userEvent.setup();
render(<ProjectCreateDialog currentUserId="user-1" onCreated={onCreated} />);
await user.click(screen.getByRole("button", { name: "Nieuw project" }));
await user.type(screen.getByLabelText("Naam"), "Atlas");
await user.type(screen.getByLabelText("Samenvatting"), "Infrastructuurproject");
await user.click(screen.getByRole("button", { name: "Project aanmaken" }));
expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "project-1", name: "Atlas" }));
});
it("creates a runbook with operational metadata", async () => {
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/runbooks" && init?.method === "POST") return response({ id: "runbook-1", title: "PostgreSQL herstellen" });
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const onCreated = vi.fn();
const user = userEvent.setup();
render(<RunbookCreateDialog onCreated={onCreated} />);
await user.click(screen.getByRole("button", { name: "Nieuw runbook" }));
await user.type(screen.getByLabelText("Titel"), "PostgreSQL herstellen");
await user.click(screen.getByLabelText("Onderhoudsvenster vereist"));
await user.click(screen.getByRole("button", { name: "Runbook aanmaken" }));
expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "runbook-1" }));
});
it("creates a project-bound architecture decision", async () => {
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/decisions" && init?.method === "POST") return response({ id: "decision-1", projectId: "project-1", title: "Kies PostgreSQL" });
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const onCreated = vi.fn();
const user = userEvent.setup();
render(<ProjectDecisionDialog projectId="project-1" existingDecisions={[]} onCreated={onCreated} />);
await user.click(screen.getByRole("button", { name: "Beslissing" }));
await user.type(screen.getByLabelText("Titel"), "Kies PostgreSQL");
await user.type(screen.getByLabelText("Samenvatting"), "Duurzame relationele opslag.");
await user.click(screen.getByRole("button", { name: "Beslissing aanmaken" }));
expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "decision-1", projectId: "project-1" }));
});
it("only offers currently Accepted decisions as a supersession target, and sends the chosen id", async () => {
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/decisions" && init?.method === "POST") {
const body = JSON.parse(init.body as string) as { supersedesDecisionId: string | null };
expect(body.supersedesDecisionId).toBe("decision-accepted");
return response({ id: "decision-2", projectId: "project-1", title: "Kies MySQL in plaats van PostgreSQL" });
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const onCreated = vi.fn();
const user = userEvent.setup();
render(
<ProjectDecisionDialog
projectId="project-1"
existingDecisions={[
{ id: "decision-accepted", decisionNumber: 1, status: "Accepted", summary: "Kies PostgreSQL" },
{ id: "decision-proposed", decisionNumber: 2, status: "Proposed", summary: "Kies Redis" }
]}
onCreated={onCreated}
/>
);
await user.click(screen.getByRole("button", { name: "Beslissing" }));
const supersedesField = screen.getByLabelText("Vervangt beslissing (optioneel)");
expect(screen.getByRole("option", { name: /ADR-1: Kies PostgreSQL/ })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /ADR-2: Kies Redis/ })).not.toBeInTheDocument();
await user.type(screen.getByLabelText("Titel"), "Kies MySQL in plaats van PostgreSQL");
await user.type(screen.getByLabelText("Samenvatting"), "Herziene keuze na loadtest.");
await user.selectOptions(supersedesField, "decision-accepted");
await user.click(screen.getByRole("button", { name: "Beslissing aanmaken" }));
expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "decision-2" }));
});
});
@@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import { InventoryReviewPanel } from "./InventoryReviewPanel";
describe("InventoryReviewPanel", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("shows secret-free changed fields with snapshot provenance", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((input: string) => {
const body = input.includes("/services?")
? { items: [], nextCursor: null }
: [{ id: "change-1", snapshotId: "snapshot-12345678", targetKind: "Service", targetId: "service-1", sourceKey: "docker:knowledge-hub", changeKind: "Updated", changedFields: ["runtimeStatus", "imageReference"], recordedAt: "2026-08-02T00:00:00Z" }];
return Promise.resolve({ ok: true, status: 200, headers: new Headers({ "content-type": "application/json" }), json: () => Promise.resolve(body) });
}),
);
render(<InventoryReviewPanel />);
expect(await screen.findByText("docker:knowledge-hub")).toBeInTheDocument();
expect(screen.getByText("runtimeStatus, imageReference")).toBeInTheDocument();
expect(screen.getByText("Service · snapshot snapshot")).toBeInTheDocument();
});
it("lets an administrator accept a proposed service", async () => {
let accepted = false;
const proposedService = {
id: "service-1",
sourceKey: "docker:new-service",
name: "Nieuwe service",
detectedName: "new-service",
runtimeKind: "container",
imageReference: "example/new-service:1",
presence: "Proposed",
};
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ token: "csrf", headerName: "X-CSRF-TOKEN" }),
});
}
if (input.endsWith("/review") && init?.method === "POST") {
accepted = true;
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('{"serviceId":"service-1","presence":"Present"}'),
});
}
const body = input.includes("/services?")
? { items: accepted ? [] : [proposedService], nextCursor: null }
: [];
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
});
});
vi.stubGlobal("fetch", fetchMock);
render(<InventoryReviewPanel />);
expect(await screen.findByText("Nieuwe service")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Bevestigen" }));
expect(await screen.findByText("Geen openstaande wijzigingen")).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/infrastructure/services/service-1/review",
expect.objectContaining({ method: "POST" }),
);
});
});
@@ -0,0 +1,217 @@
import { AlertTriangle, CheckCircle2, GitCompareArrows } from "lucide-react";
import { useEffect, useState } from "react";
import { ApiError, apiGet, apiPost } from "../api/client";
import type {
InfrastructureServiceListResponse,
InfrastructureServiceSummary,
InventoryChange,
} from "../api/infrastructureTypes";
import { StatusBadge } from "./ProductPrimitives";
type ReviewState =
| { kind: "loading" }
| { kind: "error"; message: string }
| {
kind: "ready";
changes: InventoryChange[];
proposals: InfrastructureServiceSummary[];
};
async function loadReviewState(): Promise<Extract<ReviewState, { kind: "ready" }>> {
const [changes, services] = await Promise.all([
apiGet<InventoryChange[]>("/api/v1/infrastructure/changes?limit=100"),
// Proposed/hidden services are a review queue, expected to stay small relative to
// the confirmed catalog — a single generous page covers realistic scale (V2-R2).
apiGet<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&limit=200",
),
]);
return {
kind: "ready",
changes,
proposals: services.items.filter((service) => service.presence === "Proposed"),
};
}
export function InventoryReviewPanel() {
const [state, setState] = useState<ReviewState>({ kind: "loading" });
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [rejectingId, setRejectingId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void loadReviewState()
.then((nextState) => {
if (!cancelled) setState(nextState);
})
.catch((caught: unknown) => {
if (!cancelled) {
setState({
kind: "error",
message:
caught instanceof ApiError
? caught.message
: "Inventariswijzigingen konden niet worden geladen.",
});
}
});
return () => {
cancelled = true;
};
}, []);
async function review(
service: InfrastructureServiceSummary,
decision: "Accept" | "Reject",
reason?: string,
) {
setReviewingId(service.id);
try {
await apiPost(`/api/v1/infrastructure/services/${service.id}/review`, {
decision,
reason: reason?.trim() || null,
});
setRejectingId(null);
setState(await loadReviewState());
} catch (caught) {
setState({
kind: "error",
message:
caught instanceof ApiError
? caught.message
: "De inventarisbeslissing kon niet worden opgeslagen.",
});
} finally {
setReviewingId(null);
}
}
return (
<section
className="status-card inventory-review"
aria-labelledby="inventory-review-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Inventariscontrole</p>
<h2 id="inventory-review-title">Wijzigingen sinds snapshots</h2>
<p>
Bevestig nieuwe services expliciet. De ledger bevat alleen veldnamen en
identifiers; geen geheime bronwaarden.
</p>
</div>
<GitCompareArrows aria-hidden="true" size={20} />
</div>
{state.kind === "loading" && <p role="status">Wijzigingen worden geladen…</p>}
{state.kind === "error" && (
<div className="form-error-summary" role="alert">
{state.message}
</div>
)}
{state.kind === "ready" && (
<>
{state.proposals.length > 0 && (
<div className="inventory-proposals" aria-label="Voorgestelde services">
<h3>Te beoordelen services</h3>
{state.proposals.map((service) => (
<article key={service.id}>
<div>
<strong>{service.name || service.detectedName}</strong>
<small>{service.sourceKey}</small>
<p>
{service.runtimeKind || "Onbekend type"} · {service.imageReference || "Geen image"}
</p>
</div>
{rejectingId === service.id ? (
<form
className="proposal-reject-form"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const reason = data.get("reason");
void review(service, "Reject", typeof reason === "string" ? reason : "");
}}
>
<label>
Reden voor afwijzing
<input name="reason" required autoFocus />
</label>
<button className="button danger" type="submit" disabled={reviewingId === service.id}>
Afwijzing bevestigen
</button>
<button className="button secondary" type="button" onClick={() => setRejectingId(null)}>
Annuleren
</button>
</form>
) : (
<div className="proposal-actions">
<button
className="button primary"
type="button"
disabled={reviewingId === service.id}
onClick={() => void review(service, "Accept")}
>
Bevestigen
</button>
<button
className="button secondary"
type="button"
disabled={reviewingId === service.id}
onClick={() => setRejectingId(service.id)}
>
Afwijzen
</button>
</div>
)}
</article>
))}
</div>
)}
{state.changes.length === 0 && state.proposals.length === 0 && (
<div className="positive-empty">
<CheckCircle2 aria-hidden="true" size={22} />
<strong>Geen openstaande wijzigingen</strong>
<span>Er is geen onbeoordeelde snapshotdrift geregistreerd.</span>
</div>
)}
{state.changes.length > 0 && (
<div className="inventory-change-list">
{state.changes.map((change) => (
<article key={change.id}>
<span
className={
change.changeKind === "Missing"
? "inventory-change-icon warning"
: "inventory-change-icon"
}
>
{change.changeKind === "Missing" ? (
<AlertTriangle aria-hidden="true" size={16} />
) : (
<GitCompareArrows aria-hidden="true" size={16} />
)}
</span>
<div>
<strong>{change.sourceKey}</strong>
<small>
{change.targetKind} · snapshot {change.snapshotId.slice(0, 8)}
</small>
<p>{change.changedFields.join(", ") || "object"}</p>
</div>
<div>
<StatusBadge value={change.changeKind} />
<time dateTime={change.recordedAt}>
{new Date(change.recordedAt).toLocaleString("nl-BE")}
</time>
</div>
</article>
))}
</div>
)}
</>
)}
</section>
);
}
@@ -0,0 +1,91 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import { LeafWikiImportPanel } from "./LeafWikiImportPanel";
function response(body: unknown) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body))
});
}
describe("LeafWikiImportPanel", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("blocks importing when preflight reports blockers, and offers no import button", async () => {
const fetchMock = vi.fn().mockImplementation((input: string) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/admin/imports/preflight") {
return response({
status: "NoApplicableAdapter",
jobId: null,
adapterName: null,
discoveredCount: 0,
plannedCount: 0,
items: [],
warnings: [],
blockers: ["Geen adapter kan deze bron interpreteren."]
});
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<LeafWikiImportPanel />);
await user.type(screen.getByLabelText("Relatief pad onder de importmap"), "leeg-pad");
await user.click(screen.getByRole("button", { name: "Preflight uitvoeren" }));
expect(await screen.findByText("Geen adapter kan deze bron interpreteren.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Importeren" })).not.toBeInTheDocument();
});
it("runs preflight, execute and rollback end to end when there are no blockers", async () => {
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/admin/imports/preflight") {
return response({
status: "Ready",
jobId: "job-1",
adapterName: "filesystem-markdown",
discoveredCount: 1,
plannedCount: 1,
items: [{ sourceIdentity: "welkom.md", kind: "Markdown", proposedTitle: "Welkom", proposedParentSourceIdentity: null, conflict: "None", conflictDetail: null }],
warnings: [],
blockers: []
});
}
if (typeof input === "string" && input.startsWith("/api/v1/admin/imports/job-1/execute")) {
expect(init?.method).toBe("POST");
return response({ status: "Success", imported: 1, skipped: 0, failed: 0, sourceUnchangedAfterExecution: true, diagnostics: [] });
}
if (input === "/api/v1/admin/imports/job-1/rollback") {
return response({ status: "Success", removedPages: 1, preservedPages: 0, removedAttachments: 0 });
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<LeafWikiImportPanel />);
await user.type(screen.getByLabelText("Relatief pad onder de importmap"), "leafwiki-export");
await user.click(screen.getByRole("button", { name: "Preflight uitvoeren" }));
expect(await screen.findByText(/1 item\(s\) gevonden/)).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "Welkom" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Importeren" }));
expect(await screen.findByText(/1 geïmporteerd/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Importtaak ongedaan maken" }));
expect(await screen.findByText(/1 pagina's verwijderd/)).toBeInTheDocument();
});
});
@@ -0,0 +1,241 @@
import { useId, useState } from "react";
import { apiPost, ApiError } from "../api/client";
import { newUuid } from "../lib/uuid";
import type {
ExecuteImportResponse,
ImportPreflightResponse,
RollbackImportResponse
} from "../api/adminTypes";
type State =
| { kind: "idle" }
| { kind: "preflighting" }
| { kind: "preflighted"; sourceRelativePath: string; result: ImportPreflightResponse }
| { kind: "executing"; sourceRelativePath: string; jobId: string }
| { kind: "executed"; jobId: string; result: ExecuteImportResponse }
| { kind: "rollingBack"; jobId: string }
| { kind: "rolledBack"; result: RollbackImportResponse };
/**
* LeafWiki import, closed for the admin UI in V2-M7 (previously API-first only —
* see CURRENT_STATE.md "What is not yet complete" and ADR-013). Deliberately a
* single sequential panel, not a multi-page wizard: preflight → review plan/
* blockers/warnings → execute (only once no blockers remain) → optionally roll
* back. Never touches a real LeafWiki source directly — it only ever reads
* whatever an operator has placed under the server-side configured imports root
* (`KnowledgeHub:Paths:Imports`), matching docs/17-leafwiki-migration.md exactly.
*/
export function LeafWikiImportPanel() {
const [state, setState] = useState<State>({ kind: "idle" });
const [sourceRelativePath, setSourceRelativePath] = useState("");
const [error, setError] = useState<string | null>(null);
const pathInputId = useId();
async function runPreflight() {
setError(null);
setState({ kind: "preflighting" });
try {
const result = await apiPost<ImportPreflightResponse>("/api/v1/admin/imports/preflight", {
sourceRelativePath,
idempotencyKey: newUuid()
});
setState({ kind: "preflighted", sourceRelativePath, result });
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "Preflight is mislukt.");
setState({ kind: "idle" });
}
}
async function runExecute(jobId: string, path: string, preflightResult: ImportPreflightResponse) {
setError(null);
setState({ kind: "executing", sourceRelativePath: path, jobId });
try {
const result = await apiPost<ExecuteImportResponse>(
`/api/v1/admin/imports/${jobId}/execute?sourceRelativePath=${encodeURIComponent(path)}`
);
setState({ kind: "executed", jobId, result });
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "Importeren is mislukt.");
setState({ kind: "preflighted", sourceRelativePath: path, result: preflightResult });
}
}
async function runRollback(jobId: string) {
setError(null);
setState({ kind: "rollingBack", jobId });
try {
const result = await apiPost<RollbackImportResponse>(`/api/v1/admin/imports/${jobId}/rollback`);
setState({ kind: "rolledBack", result });
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "Ongedaan maken is mislukt.");
}
}
function reset() {
setError(null);
setState({ kind: "idle" });
setSourceRelativePath("");
}
return (
<section className="status-card admin-section" aria-labelledby="leafwiki-import-title">
<div className="section-heading">
<div>
<p className="eyebrow">Migratie</p>
<h2 id="leafwiki-import-title">LeafWiki-import</h2>
<p>
Leest alleen-lezen vanaf een map die de beheerder vooraf onder de
geconfigureerde importmap heeft geplaatst. De bron wordt nooit gewijzigd;
niets wordt overschreven of stilzwijgend samengevoegd.
</p>
</div>
</div>
{error && <div className="form-error-summary" role="alert">{error}</div>}
{(state.kind === "idle" || state.kind === "preflighting") && (
<form
className="admin-form"
onSubmit={(event) => {
event.preventDefault();
void runPreflight();
}}
>
<div className="field">
<label htmlFor={pathInputId}>Relatief pad onder de importmap</label>
<input
id={pathInputId}
value={sourceRelativePath}
onChange={(event) => setSourceRelativePath(event.target.value)}
placeholder="bijv. leafwiki-export-2026-08"
disabled={state.kind === "preflighting"}
/>
</div>
<button type="submit" className="primary-action page-action-button" disabled={state.kind === "preflighting"}>
{state.kind === "preflighting" ? "Preflight wordt uitgevoerd…" : "Preflight uitvoeren"}
</button>
</form>
)}
{state.kind === "preflighted" && (
<div className="import-preflight-result">
<p>
Adapter <strong>{state.result.adapterName ?? "onbekend"}</strong>: {state.result.discoveredCount} item(s)
gevonden, {state.result.plannedCount} gepland om te importeren.
</p>
{state.result.blockers.length > 0 && (
<div className="form-error-summary" role="alert">
<strong>Blokkerende problemen — importeren is niet mogelijk:</strong>
<ul>
{state.result.blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
</div>
)}
{state.result.warnings.length > 0 && (
<div className="form-error-summary" role="status">
<strong>Waarschuwingen:</strong>
<ul>
{state.result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
{state.result.items.length > 0 && (
// .data-table's <th> is position: sticky, which needs a scoped
// overflow:auto ancestor (matching DataTable.tsx's own established
// pattern) — without it, sticky positions relative to the page's own
// scroll instead, so the header floats over and intercepts clicks on
// whatever content comes after the table (found by a real mobile
// Playwright run failing to click "Importeren", not by inspection).
// tabIndex={0} is the same real scrollable-region-focusable
// (WCAG 2.1.1/2.1.3) fix already applied in DataTable.tsx (V2-M4).
<div className="table-scroll" tabIndex={0}>
<table className="data-table" aria-label="Importplan">
<thead>
<tr>
<th>Bron</th>
<th>Soort</th>
<th>Voorgestelde titel</th>
<th>Conflict</th>
</tr>
</thead>
<tbody>
{state.result.items.map((item) => (
<tr key={item.sourceIdentity}>
<td><code>{item.sourceIdentity}</code></td>
<td>{item.kind}</td>
<td>{item.proposedTitle}</td>
<td>{item.conflict === "None" ? "—" : item.conflict}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="admin-token-actions">
{state.result.jobId && state.result.blockers.length === 0 && (
<button
type="button"
className="primary-action"
onClick={() => void runExecute(state.result.jobId!, state.sourceRelativePath, state.result)}
>
Importeren
</button>
)}
<button type="button" className="secondary-action" onClick={reset}>
Annuleren
</button>
</div>
</div>
)}
{state.kind === "executing" && <p role="status">Importeren wordt uitgevoerd…</p>}
{state.kind === "executed" && (
<div className="import-preflight-result">
<p>
Status: <strong>{state.result.status}</strong> — {state.result.imported} geïmporteerd,{" "}
{state.result.skipped} overgeslagen, {state.result.failed} mislukt.
</p>
{state.result.diagnostics.length > 0 && (
<ul>
{state.result.diagnostics.map((diagnostic) => (
<li key={diagnostic}>{diagnostic}</li>
))}
</ul>
)}
<div className="admin-token-actions">
<button type="button" className="secondary-action" onClick={() => void runRollback(state.jobId)}>
Importtaak ongedaan maken
</button>
<button type="button" className="secondary-action" onClick={reset}>
Nieuwe import starten
</button>
</div>
</div>
)}
{state.kind === "rollingBack" && <p role="status">Ongedaan maken…</p>}
{state.kind === "rolledBack" && (
<div className="form-success" role="status">
<p>
Ongedaan gemaakt: {state.result.removedPages} pagina&apos;s verwijderd,{" "}
{state.result.preservedPages} behouden (sindsdien bewerkt),{" "}
{state.result.removedAttachments} bijlagen verwijderd.
</p>
<button type="button" className="secondary-action" onClick={reset}>
Nieuwe import starten
</button>
</div>
)}
</section>
);
}
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import { MarkdownContent } from "./MarkdownContent";
function response(body: unknown) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
}
describe("MarkdownContent", () => {
beforeEach(() => {
resetCsrfCache();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows the empty-state message for blank markdown without calling the API", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
render(<MarkdownContent markdown=" " emptyMessage="Nog niets beschreven." />);
expect(screen.getByText("Nog niets beschreven.")).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
it("renders headings, links, lists and code through the real server-sanitized pipeline", async () => {
const markdown = "# Titel\n\n[link](https://example.test)\n\n- item\n\n`code`";
const fetchMock = vi.fn().mockImplementation((input: string) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
return response({
renderedHtml:
'<h1>Titel</h1><p><a href="https://example.test">link</a></p><ul><li>item</li></ul><p><code>code</code></p>',
});
});
vi.stubGlobal("fetch", fetchMock);
render(<MarkdownContent markdown={markdown} emptyMessage="Leeg." />);
expect(await screen.findByRole("heading", { level: 1, name: "Titel" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "link" })).toHaveAttribute("href", "https://example.test");
expect(screen.getByRole("list")).toBeInTheDocument();
expect(screen.getByText("code").tagName).toBe("CODE");
const previewCall = fetchMock.mock.calls.find(([input]) => input === "/api/v1/pages/preview");
expect(previewCall).toBeDefined();
expect(JSON.parse((previewCall![1] as RequestInit).body as string)).toEqual({ markdown });
});
it("renders exactly the server's already-sanitized HTML, doing no additional client-side sanitization itself", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((input: string) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
// A real server response would already have stripped a raw <script> tag —
// this proves the component trusts and renders exactly what the server
// returns, never re-parsing or altering it client-side.
return response({ renderedHtml: "<p>onschadelijke tekst</p>" });
}),
);
render(<MarkdownContent markdown={'<script>alert("xss")</script> gewone tekst'} emptyMessage="Leeg." />);
expect(await screen.findByText("onschadelijke tekst")).toBeInTheDocument();
expect(document.querySelector("script[src], script:not([type])")).toBeNull();
});
it("falls back to plain escaped text when the render request fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((input: string) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
return Promise.reject(new Error("network down"));
}),
);
render(<MarkdownContent markdown="Ruwe tekst als fallback" emptyMessage="Leeg." />);
expect(await screen.findByText("Ruwe tekst als fallback")).toBeInTheDocument();
});
});
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { apiPost } from "../api/client";
type PreviewMarkdownResponse = { renderedHtml: string };
type MarkdownContentProps = {
markdown: string;
emptyMessage: string;
className?: string;
};
/**
* Renders a free-text Markdown field (project `currentStateMarkdown`, an update's
* `summaryMarkdown`, etc.) through the exact same server-side, sanitized rendering
* pipeline real wiki pages use — `POST /api/v1/pages/preview`
* (docs/adr/ADR-029-v2-r5-final-hardening.md). Never `dangerouslySetInnerHTML`s
* unsanitized text: the HTML this component sets always came back from the server's
* own Markdig + HtmlSanitizer pass, the same trust boundary `PageViewPage.tsx`/
* `PageEditorPage.tsx`'s live preview already rely on.
*/
export function MarkdownContent({ markdown, emptyMessage, className }: MarkdownContentProps) {
const trimmed = markdown.trim();
const [result, setResult] = useState<
{ kind: "loading" } | { kind: "error" } | { kind: "ready"; html: string }
>({ kind: "loading" });
useEffect(() => {
if (!trimmed) {
return;
}
let cancelled = false;
apiPost<PreviewMarkdownResponse>("/api/v1/pages/preview", { markdown })
.then((response) => {
if (!cancelled) setResult({ kind: "ready", html: response.renderedHtml });
})
.catch(() => {
if (!cancelled) setResult({ kind: "error" });
});
return () => {
cancelled = true;
};
}, [markdown, trimmed]);
if (!trimmed) {
return <p className="field-hint">{emptyMessage}</p>;
}
if (result.kind === "loading") {
return <p className="field-hint" role="status">Inhoud wordt weergegeven…</p>;
}
if (result.kind === "error") {
// Falls back to plain, safely React-escaped text — never raw HTML, and never a
// blank/broken panel just because the render call failed.
return <p>{markdown}</p>;
}
return <div className={className ?? "page-content"} dangerouslySetInnerHTML={{ __html: result.html }} />;
}
@@ -0,0 +1,100 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OwnerPicker } from "./OwnerPicker";
function jsonResponse(body: unknown) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
});
}
describe("OwnerPicker", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("searches workspace members by name or email and selects one via the keyboard", async () => {
const fetchMock = vi.fn().mockImplementation((input: string) => {
if (typeof input === "string" && input.startsWith("/api/v1/workspace/members")) {
return jsonResponse({
items: [{ id: "user-1", displayName: "Jens Az", email: "jens@itworx.test" }],
});
}
return jsonResponse({ items: [] });
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<OwnerPicker name="ownerId" aria-label="Eigenaar zoeken" />);
const input = screen.getByRole("combobox", { name: "Eigenaar zoeken" });
await user.click(input);
await user.type(input, "Jens");
expect(await screen.findByRole("option", { name: /Jens Az/ })).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/workspace/members?q=Jens"),
expect.anything(),
);
await user.keyboard("{ArrowDown}{Enter}");
expect(input).toHaveValue("Jens Az (jens@itworx.test)");
// The hidden field is what the surrounding <form> actually submits.
const hidden = document.querySelector('input[type="hidden"][name="ownerId"]') as HTMLInputElement;
expect(hidden.value).toBe("user-1");
});
it("shows an empty state when no member matches, and clears a selection", async () => {
const fetchMock = vi.fn().mockImplementation((input: string) => {
if (typeof input === "string" && input.includes("q=geen-overeenkomst")) {
return jsonResponse({ items: [] });
}
return jsonResponse({ items: [{ id: "user-1", displayName: "Jens Az", email: "jens@itworx.test" }] });
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<OwnerPicker name="ownerId" defaultValue="user-1" aria-label="Eigenaar zoeken" />);
expect(await screen.findByRole("button", { name: "Eigenaar wissen" })).toBeInTheDocument();
const input = screen.getByRole("combobox", { name: "Eigenaar zoeken" });
await user.click(input);
await user.type(input, "geen-overeenkomst");
expect(await screen.findByText("Geen gebruikers gevonden.")).toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Eigenaar wissen" }));
const hidden = document.querySelector('input[type="hidden"][name="ownerId"]') as HTMLInputElement;
expect(hidden.value).toBe("");
});
it("resolves an already-set owner ID to a human-readable label instead of showing the raw GUID", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(() =>
jsonResponse({ items: [{ id: "user-2", displayName: "Tweede Beheerder", email: "tweede@itworx.test" }] }),
),
);
render(<OwnerPicker name="ownerId" defaultValue="user-2" aria-label="Eigenaar zoeken" />);
const input = await screen.findByRole("combobox", { name: "Eigenaar zoeken" });
expect(input).toHaveValue("Tweede Beheerder (tweede@itworx.test)");
});
it("is fully disabled when the disabled prop is set", () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => jsonResponse({ items: [] })));
render(<OwnerPicker name="ownerId" disabled aria-label="Eigenaar zoeken" />);
expect(screen.getByRole("combobox", { name: "Eigenaar zoeken" })).toBeDisabled();
});
});
+200
View File
@@ -0,0 +1,200 @@
import { Check, Loader2, X } from "lucide-react";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { apiGet } from "../api/client";
export type WorkspaceMember = { id: string; displayName: string; email: string };
type WorkspaceMemberListResponse = { items: WorkspaceMember[] };
type OwnerPickerProps = {
/** Form field name the selected owner's ID is submitted under (the existing
* FormData-based submit handlers in ServiceDetailPage.tsx / project dialogs keep
* working unchanged via the hidden input this component renders). */
name: string;
/** The currently-set owner's ID, if any — resolved to a human-readable label on
* mount so the field never shows a raw GUID. */
defaultValue?: string | null | undefined;
disabled?: boolean;
"aria-label"?: string;
};
/**
* Accessible, searchable workspace-member picker
* (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 7) — replaces manual
* `OwnerId`/GUID entry. Searches `GET /api/v1/workspace/members` (name or email),
* scoped server-side to the current workspace's enabled members only. Fully keyboard
* operable (ARIA combobox pattern: ArrowUp/ArrowDown/Enter/Escape), with loading,
* empty, clear and disabled states.
*/
export function OwnerPicker({ name, defaultValue, disabled, ...ariaProps }: OwnerPickerProps) {
const listboxId = useId();
const [selectedId, setSelectedId] = useState<string | null>(defaultValue ?? null);
const [selectedLabel, setSelectedLabel] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [members, setMembers] = useState<WorkspaceMember[] | null>(null);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
// Resolve an already-set owner's ID to a human-readable label — never show the raw
// GUID. Falls back to showing the ID itself only if the member can't be resolved
// (e.g. since disabled/removed from the workspace) rather than showing nothing.
useEffect(() => {
if (!defaultValue) {
return;
}
let cancelled = false;
apiGet<WorkspaceMemberListResponse>("/api/v1/workspace/members")
.then((response) => {
if (cancelled) return;
const match = response.items.find((item) => item.id === defaultValue);
setSelectedLabel(match ? `${match.displayName} (${match.email})` : defaultValue);
})
.catch(() => {
if (!cancelled) setSelectedLabel(defaultValue);
});
return () => {
cancelled = true;
};
}, [defaultValue]);
useEffect(() => {
if (!open) return;
let cancelled = false;
const timeout = window.setTimeout(() => {
setLoading(true);
apiGet<WorkspaceMemberListResponse>(`/api/v1/workspace/members?q=${encodeURIComponent(query.trim())}`)
.then((response) => {
if (cancelled) return;
setMembers(response.items);
setActiveIndex(response.items.length > 0 ? 0 : -1);
})
.catch(() => {
if (!cancelled) setMembers([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [open, query]);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
function select(member: WorkspaceMember) {
setSelectedId(member.id);
setSelectedLabel(`${member.displayName} (${member.email})`);
setOpen(false);
setQuery("");
}
function clear() {
setSelectedId(null);
setSelectedLabel(null);
setQuery("");
}
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (disabled) return;
if (!open) {
if (event.key === "ArrowDown" || event.key === "Enter") {
event.preventDefault();
setOpen(true);
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((current) => Math.min(current + 1, (members?.length ?? 1) - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) => Math.max(current - 1, 0));
} else if (event.key === "Enter") {
event.preventDefault();
const active = members && activeIndex >= 0 ? members[activeIndex] : undefined;
if (active) select(active);
} else if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
}
}
return (
<div className="owner-picker" ref={containerRef}>
<div className="owner-picker-control">
<input
id={`${listboxId}-input`}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={open && activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined}
aria-label={ariaProps["aria-label"]}
disabled={disabled}
value={open ? query : (selectedLabel ?? "")}
placeholder="Zoek op naam of e-mail…"
autoComplete="off"
onFocus={() => {
setOpen(true);
setQuery("");
}}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onKeyDown}
/>
{loading && open && <Loader2 aria-hidden="true" size={14} className="owner-picker-spinner" />}
{selectedId && !open && (
<button
type="button"
className="icon-button"
aria-label="Eigenaar wissen"
disabled={disabled}
onClick={clear}
>
<X aria-hidden="true" size={14} />
</button>
)}
</div>
{open && (
<ul id={listboxId} role="listbox" aria-label="Gebruikers" className="owner-picker-listbox">
{!loading && members && members.length === 0 && (
<li className="owner-picker-empty" role="status">Geen gebruikers gevonden.</li>
)}
{members?.map((member, index) => (
<li
key={member.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? "active" : undefined}
// onMouseDown (not onClick) fires before the input's onBlur/outside-click
// handler would otherwise close the list first.
onMouseDown={(event) => {
event.preventDefault();
select(member);
}}
>
<span>
<strong>{member.displayName}</strong>
<small>{member.email}</small>
</span>
{member.id === selectedId && <Check aria-hidden="true" size={14} />}
</li>
))}
</ul>
)}
<input type="hidden" name={name} value={selectedId ?? ""} />
</div>
);
}
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import type { PageDetail } from "../api/knowledgeTypes";
import { PageActions } from "./PageActions";
const page: PageDetail = {
id: "11111111-1111-1111-1111-111111111111",
workspaceId: "22222222-2222-2222-2222-222222222222",
parentPageId: null,
title: "Unraid-overzicht",
slug: "unraid-overzicht",
markdown: "# Unraid",
renderedHtml: "<h1>Unraid</h1>",
documentType: "WikiPage",
documentStatus: "Active",
currentRevisionNumber: 3,
tags: ["infrastructuur"],
breadcrumb: [],
createdBy: null,
updatedBy: null,
createdAt: "2026-08-02T00:00:00Z",
updatedAt: "2026-08-02T00:00:00Z",
publishedAt: "2026-08-02T00:00:00Z",
trashedAt: null,
eTag: "page-etag",
};
function response(body: unknown) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
}
describe("PageActions", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("marks a page as favorite while preserving its content and ETag contract", async () => {
const onUpdated = vi.fn();
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") {
return response({ token: "csrf", headerName: "X-CSRF-Token" });
}
if (input === `/api/v1/pages/${page.id}` && init?.method === "PUT") {
expect((init.headers as Record<string, string>)["If-Match"]).toBe('"page-etag"');
expect(JSON.parse(init.body as string)).toMatchObject({
markdown: "# Unraid",
tags: ["infrastructuur", "favorite"],
});
return response({ ...page, tags: ["infrastructuur", "favorite"], eTag: "next-etag" });
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(
<PageActions
page={page}
onEdit={vi.fn()}
onCreateChild={vi.fn()}
onNavigate={vi.fn()}
onUpdated={onUpdated}
onTrash={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Meer pagina-acties" }));
await user.click(screen.getByRole("menuitem", { name: "Favoriet maken" }));
expect(onUpdated).toHaveBeenCalledWith(expect.objectContaining({ eTag: "next-etag" }));
});
it("updates page metadata without rewriting markdown", async () => {
const onUpdated = vi.fn();
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") {
return response({ token: "csrf", headerName: "X-CSRF-Token" });
}
if (input === `/api/v1/pages/${page.id}` && init?.method === "PUT") {
expect(JSON.parse(init.body as string)).toMatchObject({ title: "Unraid server", markdown: "# Unraid" });
return response({ ...page, title: "Unraid server", eTag: "metadata-etag" });
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(
<PageActions
page={page}
onEdit={vi.fn()}
onCreateChild={vi.fn()}
onNavigate={vi.fn()}
onUpdated={onUpdated}
onTrash={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Metadata" }));
const title = screen.getByLabelText("Titel");
await user.clear(title);
await user.type(title, "Unraid server");
await user.click(screen.getByRole("button", { name: "Metadata opslaan" }));
expect(onUpdated).toHaveBeenCalledWith(expect.objectContaining({ title: "Unraid server" }));
});
});
@@ -0,0 +1,93 @@
import * as Dialog from "@radix-ui/react-dialog";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { Archive, ChevronDown, Copy, FilePlus2, FolderInput, Link2, MoreHorizontal, Pencil, Star, Trash2, X } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { apiGet, apiPost, apiPut, ifMatchHeader } from "../api/client";
import type { PageChildrenResponse, PageDetail } from "../api/knowledgeTypes";
import { documentStatusLabels, documentTypeLabels } from "../api/knowledgeTypes";
import { copyText } from "../lib/clipboard";
type PageActionsProps = {
page: PageDetail;
onEdit: () => void;
onCreateChild: () => void;
onNavigate: (pageId: string) => void;
onUpdated: (page: PageDetail) => void;
onTrash: () => void;
};
export function PageActions({ page, onEdit, onCreateChild, onNavigate, onUpdated, onTrash }: PageActionsProps) {
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
async function update(status: string, tags = page.tags) {
setBusy(true); setError(null);
try {
const updated = await apiPut<PageDetail>(`/api/v1/pages/${page.id}`, {
title: page.title, markdown: page.markdown, documentType: page.documentType,
documentStatus: status, changeSummary: "Snelle pagina-actie.", tags
}, ifMatchHeader(page.eTag));
onUpdated(updated);
} catch (caught) { setError(caught instanceof Error ? caught.message : "De pagina-actie is mislukt."); } finally { setBusy(false); }
}
async function duplicate() {
setBusy(true); setError(null);
try {
const created = await apiPost<PageDetail>("/api/v1/pages", {
parentPageId: page.parentPageId, title: `${page.title} — kopie`, documentType: page.documentType,
markdown: page.markdown, templateKey: null, tags: page.tags
});
onNavigate(created.id);
} catch (caught) { setError(caught instanceof Error ? caught.message : "Dupliceren is mislukt."); } finally { setBusy(false); }
}
async function copyPermalink() {
setError(null);
try {
if (!(await copyText(window.location.href))) {
throw new Error("Het klembord is niet beschikbaar in deze browser.");
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Permalink kopiëren is mislukt.");
}
}
const favorite = page.tags.includes("favorite");
return <><div className="document-action-bar">
<button type="button" className="button primary" onClick={onEdit}><Pencil aria-hidden="true" size={15} />Bewerken<kbd aria-hidden="true">Ctrl/⌘ E</kbd></button>
<button type="button" className="button secondary" onClick={onCreateChild}><FilePlus2 aria-hidden="true" size={15} />Subpagina</button>
<PageMetadataDialog key={page.eTag} page={page} onUpdated={onUpdated} />
<DropdownMenu.Root><DropdownMenu.Trigger className="button secondary" disabled={busy} aria-label="Meer pagina-acties"><MoreHorizontal aria-hidden="true" size={16} /><ChevronDown aria-hidden="true" size={13} /></DropdownMenu.Trigger><DropdownMenu.Portal><DropdownMenu.Content className="dropdown-content" align="end" sideOffset={6}>
<DropdownMenu.Item className="dropdown-item" onSelect={() => { void update(page.documentStatus, favorite ? page.tags.filter((tag) => tag !== "favorite") : [...page.tags, "favorite"]); }}><Star aria-hidden="true" size={15} />{favorite ? "Uit favorieten" : "Favoriet maken"}</DropdownMenu.Item>
<DropdownMenu.Item className="dropdown-item" onSelect={() => { void duplicate(); }}><Copy aria-hidden="true" size={15} />Dupliceren</DropdownMenu.Item>
<DropdownMenu.Item className="dropdown-item" onSelect={() => { void copyPermalink(); }}><Link2 aria-hidden="true" size={15} />{copied ? "Permalink gekopieerd" : "Permalink kopiëren"}</DropdownMenu.Item>
<PageMoveItem page={page} onUpdated={onUpdated} />
<DropdownMenu.Separator className="dropdown-separator" />
{page.documentStatus !== "Archived" && <DropdownMenu.Item className="dropdown-item" onSelect={() => { void update("Archived"); }}><Archive aria-hidden="true" size={15} />Archiveren</DropdownMenu.Item>}
{!page.trashedAt && <DropdownMenu.Item className="dropdown-item danger" onSelect={onTrash}><Trash2 aria-hidden="true" size={15} />Naar prullenbak</DropdownMenu.Item>}
</DropdownMenu.Content></DropdownMenu.Portal></DropdownMenu.Root>
</div>{error && <div className="form-error-summary" role="alert">{error}</div>}</>;
}
function PageMetadataDialog({ page, onUpdated }: { page: PageDetail; onUpdated: (page: PageDetail) => void }) {
const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const value = (name: string) => { const entry = form.get(name); return typeof entry === "string" ? entry.trim() : ""; };
try {
const updated = await apiPut<PageDetail>(`/api/v1/pages/${page.id}`, { title: value("title"), markdown: page.markdown, documentType: value("documentType"), documentStatus: value("documentStatus"), changeSummary: "Paginametadata bijgewerkt.", tags: value("tags").split(",").map((tag) => tag.trim()).filter(Boolean) }, ifMatchHeader(page.eTag));
onUpdated(updated); setOpen(false);
} catch (caught) { setError(caught instanceof Error ? caught.message : "Metadata opslaan is mislukt."); } finally { setSaving(false); }
}
return <Dialog.Root open={open} onOpenChange={setOpen}><Dialog.Trigger asChild><button type="button" className="button secondary">Metadata</button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="editorial-dialog"><div className="dialog-heading"><div><p className="eyebrow">Kennisdocument</p><Dialog.Title>Paginametadata</Dialog.Title><Dialog.Description>Wijzig titel, type, status en labels met behoud van inhoud en revisiehistorie.</Dialog.Description></div><Dialog.Close className="icon-button" aria-label="Sluiten"><X size={18} /></Dialog.Close></div>{error && <div className="form-error-summary" role="alert">{error}</div>}<form onSubmit={(event) => { void submit(event); }}><div className="form-grid"><label className="full">Titel<input name="title" required defaultValue={page.title} /></label><label>Documenttype<select name="documentType" defaultValue={page.documentType}>{Object.entries(documentTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>Status<select name="documentStatus" defaultValue={page.documentStatus}>{Object.entries(documentStatusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label className="full">Labels<input name="tags" defaultValue={page.tags.join(", ")} /></label></div><div className="dialog-actions"><Dialog.Close className="button secondary" type="button">Annuleren</Dialog.Close><button className="button primary" type="submit" disabled={saving}>{saving ? "Opslaan…" : "Metadata opslaan"}</button></div></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
function PageMoveItem({ page, onUpdated }: { page: PageDetail; onUpdated: (page: PageDetail) => void }) {
const [open, setOpen] = useState(false); const [roots, setRoots] = useState<PageChildrenResponse["items"]>([]); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
useEffect(() => { if (open) void apiGet<PageChildrenResponse>("/api/v1/pages/roots?limit=100").then((response) => setRoots(response.items.filter((item) => item.id !== page.id))).catch(() => setError("Bestemmingen konden niet worden geladen.")); }, [open, page.id]);
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const entry = form.get("parentPageId"); const parentPageId = typeof entry === "string" && entry ? entry : null; try { const moved = await apiPost<PageDetail>(`/api/v1/pages/${page.id}/move`, { newParentPageId: parentPageId, afterSiblingId: null }, ifMatchHeader(page.eTag)); onUpdated(moved); setOpen(false); } catch (caught) { setError(caught instanceof Error ? caught.message : "Verplaatsen is mislukt."); } finally { setSaving(false); } }
return <Dialog.Root open={open} onOpenChange={setOpen}><Dialog.Trigger asChild><DropdownMenu.Item className="dropdown-item" onSelect={(event) => event.preventDefault()}><FolderInput aria-hidden="true" size={15} />Verplaatsen</DropdownMenu.Item></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="editorial-dialog"><div className="dialog-heading"><div><p className="eyebrow">Documentstructuur</p><Dialog.Title>Pagina verplaatsen</Dialog.Title><Dialog.Description>Kies de kennisbankroot of een hoofdpagina. Drag-and-drop is niet vereist.</Dialog.Description></div><Dialog.Close className="icon-button" aria-label="Sluiten"><X size={18} /></Dialog.Close></div>{error && <div className="form-error-summary" role="alert">{error}</div>}<form onSubmit={(event) => { void submit(event); }}><div className="form-grid"><label className="full">Bestemming<select name="parentPageId" defaultValue={page.parentPageId ?? ""}><option value="">Kennisbankroot</option>{roots.map((root) => <option key={root.id} value={root.id}>{root.title}</option>)}</select></label></div><div className="dialog-actions"><Dialog.Close className="button secondary" type="button">Annuleren</Dialog.Close><button className="button primary" type="submit" disabled={saving}>{saving ? "Verplaatsen…" : "Pagina verplaatsen"}</button></div></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
@@ -0,0 +1,30 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { statusValueLabels } from "./statusLabels";
export function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return <header className="workspace-header"><div><p className="eyebrow">{eyebrow}</p><h1>{title}</h1>{description && <p className="workspace-description">{description}</p>}</div>{actions && <div className="header-actions">{actions}</div>}</header>;
}
export function StatusBadge({ value, label }: { value: string; label?: string }) {
const tone = /healthy|active|present|running|verified|completed/i.test(value) ? "positive"
: /degraded|proposed|paused|review|medium/i.test(value) ? "warning"
: /unhealthy|missing|blocked|critical|failed|stopped/i.test(value) ? "critical" : "neutral";
// Explicit label wins; otherwise fall back to the shared Dutch translation table
// (statusLabels.ts) before ever showing the raw backend enum value.
return <span className={`status-badge ${tone}`}><span className="status-indicator" aria-hidden="true" />{label ?? statusValueLabels[value] ?? value}</span>;
}
/**
* `to` is optional on purpose (OUX-4): a KPI becomes clickable only where a route
* genuinely answers the number it shows. A card that navigates somewhere vaguely
* related is worse than one that does not navigate at all.
*/
export function MetricCard({ label, value, detail, icon: Icon, tone = "default", to }: { label: string; value: string | number; detail: string; icon: LucideIcon; tone?: "default" | "warning" | "critical" | "positive"; to?: string }) {
const body = <><div className="metric-icon"><Icon aria-hidden="true" size={18} /></div><div><span className="metric-label">{label}</span><strong>{value}</strong><small>{detail}</small></div></>;
if (to) {
return <Link to={to} className={`metric-card ${tone} metric-card-link`}>{body}</Link>;
}
return <article className={`metric-card ${tone}`}>{body}</article>;
}
@@ -0,0 +1,378 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Pencil, Plus, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import { apiPost, apiPut, apiUpload, ifMatchHeader } from "../api/client";
import type {
CreateProjectRequest,
ProjectDetail,
UpdateProjectRequest,
} from "../api/projectTypes";
import { projectPriorityLabels } from "../api/projectTypes";
type ProjectCreateDialogProps = {
currentUserId: string;
onCreated: (project: ProjectDetail) => void;
};
export function ProjectCreateDialog({
currentUserId,
onCreated,
}: ProjectCreateDialogProps) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError(null);
const form = new FormData(event.currentTarget);
const request: CreateProjectRequest = {
name: text(form, "name"),
summary: text(form, "summary"),
priority: text(form, "priority"),
currentStateMarkdown: text(form, "currentStateMarkdown"),
ownerId: currentUserId,
};
try {
const created = await apiPost<ProjectDetail>("/api/v1/projects", request);
setOpen(false);
onCreated(created);
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "Project aanmaken is mislukt.",
);
} finally {
setSaving(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="button primary" type="button">
<Plus aria-hidden="true" size={16} />
Nieuw project
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="editorial-dialog">
<DialogHeading
eyebrow="Portfolio"
title="Nieuw project"
description="Maak een ontworpen projectworkspace; details blijven later volledig bewerkbaar."
/>
{error && (
<div className="form-error-summary" role="alert">
{error}
</div>
)}
<form
onSubmit={(event) => {
void submit(event);
}}
>
<div className="form-grid">
<label className="full">
Naam
<input name="name" required autoFocus />
</label>
<label className="full">
Samenvatting
<textarea name="summary" rows={3} />
</label>
<label>
Prioriteit
<select name="priority" defaultValue="Medium">
{Object.entries(projectPriorityLabels).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</label>
<label className="full">
Huidige stand
<textarea
name="currentStateMarkdown"
rows={5}
placeholder="Beschrijf doel, actuele status en eerstvolgende stap."
/>
</label>
</div>
<DialogActions saving={saving} submitLabel="Project aanmaken" />
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function ProjectEditDialog({
project,
onSaved,
}: {
project: ProjectDetail;
onSaved: (project: ProjectDetail) => void;
}) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError(null);
const form = new FormData(event.currentTarget);
const icon = form.get("projectIcon");
const iconFile = icon instanceof File && icon.size > 0 ? icon : null;
if (iconFile && iconFile.size > 2 * 1024 * 1024) {
setError("Het projecticoon mag maximaal 2 MiB groot zijn.");
setSaving(false);
return;
}
const request: UpdateProjectRequest = {
name: text(form, "name"),
summary: text(form, "summary"),
priority: text(form, "priority"),
currentStateMarkdown: text(form, "currentStateMarkdown"),
ownerId: project.ownerId,
startDate: nullable(form, "startDate"),
targetDate: nullable(form, "targetDate"),
completionDate: nullable(form, "completionDate"),
repositoryUrl: nullable(form, "repositoryUrl"),
liveUrl: nullable(form, "liveUrl"),
documentationUrl: nullable(form, "documentationUrl"),
issueTrackerUrl: nullable(form, "issueTrackerUrl"),
technologies: text(form, "technologies")
.split(",")
.map((value) => value.trim())
.filter(Boolean),
};
try {
const updated = await apiPut<ProjectDetail>(
`/api/v1/projects/${project.id}`,
request,
ifMatchHeader(project.eTag),
);
// Keep the form's next If-Match current even when the separate image upload
// fails after the rest of the project details have already been saved.
onSaved(updated);
let saved = updated;
if (iconFile) {
const iconResult = await apiUpload<{ iconAttachmentId: string; eTag: string }>(
`/api/v1/projects/${project.id}/icon`,
iconFile,
ifMatchHeader(updated.eTag),
);
saved = { ...updated, iconAttachmentId: iconResult.iconAttachmentId, eTag: iconResult.eTag };
onSaved(saved);
}
setOpen(false);
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "Project opslaan is mislukt.",
);
} finally {
setSaving(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="button secondary" type="button">
<Pencil aria-hidden="true" size={15} />
Project bewerken
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="editorial-dialog">
<DialogHeading
eyebrow="Redactionele projectlaag"
title="Project bewerken"
description="Werk context, links en planning bij zonder deploymentfacts te wijzigen."
/>
{error && (
<div className="form-error-summary" role="alert">
{error}
</div>
)}
<form
onSubmit={(event) => {
void submit(event);
}}
>
<div className="form-grid">
<label>
Naam
<input name="name" required defaultValue={project.name} />
</label>
<label>
Prioriteit
<select name="priority" defaultValue={project.priority}>
{Object.entries(projectPriorityLabels).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</label>
<label className="full">
Projectlogo
<input
name="projectIcon"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
/>
<small>PNG, JPEG, GIF of WebP · maximaal 2 MiB. Een geüpload logo vervangt het huidige logo.</small>
</label>
<label className="full">
Samenvatting
<textarea
name="summary"
rows={3}
defaultValue={project.summary}
/>
</label>
<label className="full">
Huidige stand
<textarea
name="currentStateMarkdown"
rows={5}
defaultValue={project.currentStateMarkdown}
/>
</label>
<label>
Repository
<input
name="repositoryUrl"
type="url"
defaultValue={project.repositoryUrl ?? ""}
/>
</label>
<label>
Live URL
<input
name="liveUrl"
type="url"
defaultValue={project.liveUrl ?? ""}
/>
</label>
<label>
Documentatie-URL
<input
name="documentationUrl"
type="url"
defaultValue={project.documentationUrl ?? ""}
/>
</label>
<label>
Issue tracker
<input
name="issueTrackerUrl"
type="url"
defaultValue={project.issueTrackerUrl ?? ""}
/>
</label>
<label>
Startdatum
<input
name="startDate"
type="date"
defaultValue={project.startDate ?? ""}
/>
</label>
<label>
Doeldatum
<input
name="targetDate"
type="date"
defaultValue={project.targetDate ?? ""}
/>
</label>
<label>
Einddatum
<input
name="completionDate"
type="date"
defaultValue={project.completionDate ?? ""}
/>
</label>
<label className="full">
Technologieën
<input
name="technologies"
defaultValue={project.technologies.join(", ")}
placeholder="React, ASP.NET Core, PostgreSQL"
/>
</label>
</div>
<DialogActions saving={saving} submitLabel="Wijzigingen opslaan" />
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function DialogHeading({
eyebrow,
title,
description,
}: {
eyebrow: string;
title: string;
description: string;
}) {
return (
<div className="dialog-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</div>
<Dialog.Close className="icon-button" aria-label="Sluiten">
<X aria-hidden="true" size={18} />
</Dialog.Close>
</div>
);
}
function DialogActions({
saving,
submitLabel,
}: {
saving: boolean;
submitLabel: string;
}) {
return (
<div className="dialog-actions">
<Dialog.Close className="button secondary" type="button">
Annuleren
</Dialog.Close>
<button className="button primary" type="submit" disabled={saving}>
{saving ? "Opslaan…" : submitLabel}
</button>
</div>
);
}
function text(form: FormData, name: string) {
const value = form.get(name);
return typeof value === "string" ? value.trim() : "";
}
function nullable(form: FormData, name: string) {
return text(form, name) || null;
}
@@ -0,0 +1,101 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import type { ProjectDetail } from "../api/projectTypes";
import { ProjectDocumentDialog, ProjectDocumentUnlinkButton, ProjectMilestoneDialog, ProjectStatusControl, ProjectUpdateDialog } from "./ProjectWorkspaceActions";
const project = { id: "project-1", status: "Active", eTag: "project-etag" } as ProjectDetail;
function response(body: unknown) { return Promise.resolve({ ok: true, status: 200, headers: new Headers({ "content-type": "application/json" }), json: () => Promise.resolve(body), text: () => Promise.resolve(JSON.stringify(body)) }); }
describe("project workspace actions", () => {
afterEach(() => { resetCsrfCache(); vi.unstubAllGlobals(); });
it("changes project status with optimistic concurrency", async () => {
const onChanged = vi.fn();
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/projects/project-1/status") { expect((init?.headers as Record<string, string>)["If-Match"]).toBe('"project-etag"'); expect(JSON.parse(init?.body as string)).toEqual({ status: "Maintenance" }); return response({}); }
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<ProjectStatusControl project={project} onChanged={onChanged} />);
await user.selectOptions(screen.getByLabelText("Projectstatus wijzigen"), "Maintenance");
expect(onChanged).toHaveBeenCalledOnce();
});
it("adds a project update through the workspace", async () => {
const onCreated = vi.fn();
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/projects/project-1/updates") { expect(init?.method).toBe("POST"); return response({ id: "update-1" }); }
throw new Error(`Unexpected fetch: ${input}`);
}));
const user = userEvent.setup();
render(<ProjectUpdateDialog projectId="project-1" onCreated={onCreated} />);
await user.click(screen.getByRole("button", { name: "Update" }));
await user.type(screen.getByLabelText("Titel"), "V2 staging groen");
await user.click(screen.getByRole("button", { name: "Update toevoegen" }));
expect(onCreated).toHaveBeenCalledOnce();
});
it("adds a milestone with explicit success criteria", async () => {
const onCreated = vi.fn();
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/projects/project-1/milestones") { expect(JSON.parse(init?.body as string)).toMatchObject({ name: "Productierelease", successCriteria: "Alle gates groen" }); return response({ id: "milestone-1" }); }
throw new Error(`Unexpected fetch: ${input}`);
}));
const user = userEvent.setup();
render(<ProjectMilestoneDialog projectId="project-1" onCreated={onCreated} />);
await user.click(screen.getByRole("button", { name: "Mijlpaal" }));
await user.type(screen.getByLabelText("Naam"), "Productierelease");
await user.type(screen.getByLabelText("Succescriteria"), "Alle gates groen");
await user.click(screen.getByRole("button", { name: "Mijlpaal toevoegen" }));
expect(onCreated).toHaveBeenCalledOnce();
});
it("searches and links an existing knowledge page to a project", async () => {
const onLinked = vi.fn();
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input.startsWith("/api/v1/search?")) return response({ items: [{ id: "page-1", parentPageId: null, title: "Deployment", slug: "deployment", documentType: "ProjectDocument", documentStatus: "Active", tags: [], updatedAt: "2026-08-02T00:00:00Z", snippet: "" }], nextCursor: null });
if (input === "/api/v1/projects/project-1/documents") { expect(JSON.parse(init?.body as string)).toEqual({ pageId: "page-1", role: "Deployment" }); return response({ id: "document-1" }); }
throw new Error(`Unexpected fetch: ${input}`);
}));
const user = userEvent.setup();
render(<ProjectDocumentDialog projectId="project-1" linkedPageIds={[]} onLinked={onLinked} />);
await user.click(screen.getByRole("button", { name: "Document koppelen" }));
await user.type(screen.getByLabelText("Zoek kennispagina"), "deploy");
await user.click(screen.getByRole("button", { name: "Zoeken" }));
await user.selectOptions(await screen.findByLabelText("Kennispagina"), "page-1");
await user.selectOptions(screen.getByLabelText("Documentrol"), "Deployment");
await user.click(screen.getByRole("button", { name: "Document koppelen" }));
expect(onLinked).toHaveBeenCalledOnce();
});
it("unlinks project documentation through the guarded endpoint", async () => {
const onUnlinked = vi.fn();
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/projects/documents/document-1") { expect(init?.method).toBe("DELETE"); return response({}); }
throw new Error(`Unexpected fetch: ${input}`);
}));
const user = userEvent.setup();
render(<ProjectDocumentUnlinkButton document={{ id: "document-1", pageId: "page-1", pageTitle: "Deployment", pageSlug: "deployment", pageDocumentType: "ProjectDocument", role: "Deployment" }} onUnlinked={onUnlinked} />);
await user.click(screen.getByRole("button", { name: "Deployment ontkoppelen" }));
expect(onUnlinked).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,72 @@
import * as Dialog from "@radix-ui/react-dialog";
import { FilePlus2, Flag, Gavel, Link2Off, Plus, Radio, Search, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import { apiDelete, apiGet, apiPost, apiPut, ifMatchHeader } from "../api/client";
import type { SearchResponse, SearchResultItem } from "../api/knowledgeTypes";
import type { CreateDecisionRequest, DecisionDetail, ProjectDecisionSummary, ProjectDetail, ProjectDocumentSummary, ProjectMilestoneSummary } from "../api/projectTypes";
import { projectDocumentRoleLabels, projectStatusLabels, projectUpdateCategoryLabels, projectUpdateVerificationLabels } from "../api/projectTypes";
const statusTransitions: Record<string, string[]> = {
Concept: ["Planned", "Paused", "Archived"],
Planned: ["Active", "Paused", "Archived"],
Active: ["Blocked", "Maintenance", "Completed", "Paused", "Archived"],
Blocked: ["Active", "Paused", "Archived"],
Maintenance: ["Active", "Completed", "Paused", "Archived"],
Completed: ["Paused", "Archived"],
Paused: ["Concept", "Planned", "Active", "Blocked", "Maintenance", "Archived"],
Archived: [],
};
export function ProjectStatusControl({ project, onChanged }: { project: ProjectDetail; onChanged: () => void }) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const transitions = statusTransitions[project.status] ?? [];
if (transitions.length === 0) return null;
return <div className="project-status-control"><label>Status wijzigen<select aria-label="Projectstatus wijzigen" defaultValue="" disabled={saving} onChange={(event) => { const status = event.target.value; if (!status) return; setSaving(true); setError(null); void apiPost(`/api/v1/projects/${project.id}/status`, { status }, ifMatchHeader(project.eTag)).then(onChanged).catch((caught: unknown) => setError(caught instanceof Error ? caught.message : "Status wijzigen is mislukt.")).finally(() => setSaving(false)); }}><option value="">Kies volgende status…</option>{transitions.map((status) => <option key={status} value={status}>{projectStatusLabels[status] ?? status}</option>)}</select></label>{error && <span role="alert">{error}</span>}</div>;
}
export function ProjectUpdateDialog({ projectId, onCreated }: { projectId: string; onCreated: () => void }) {
const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const value = (name: string) => { const item = form.get(name); return typeof item === "string" ? item.trim() : ""; }; try { await apiPost(`/api/v1/projects/${projectId}/updates`, { category: value("category"), title: value("title"), summaryMarkdown: value("summaryMarkdown"), verificationStatus: value("verificationStatus"), occurredAt: null, commitSha: value("commitSha") || null, releaseTag: value("releaseTag") || null, issueIdentifier: null, url: value("url") || null, pageId: null, decisionRecordId: null, milestoneId: null, attachmentReferenceId: null, supersedesUpdateId: null }); setOpen(false); onCreated(); } catch (caught) { setError(caught instanceof Error ? caught.message : "Projectupdate toevoegen is mislukt."); } finally { setSaving(false); } }
return <Dialog.Root open={open} onOpenChange={setOpen}><Dialog.Trigger asChild><button className="button secondary" type="button"><Plus aria-hidden="true" size={15} />Update</button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="editorial-dialog"><DialogHeading icon={<Radio size={17} />} title="Projectupdate toevoegen" description="Leg voortgang, deployment, validatie of risico vast in de projecttijdlijn." />{error && <div className="form-error-summary" role="alert">{error}</div>}<form onSubmit={(event) => { void submit(event); }}><div className="form-grid"><label>Categorie<select name="category" defaultValue="Progress">{Object.entries(projectUpdateCategoryLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>Verificatie<select name="verificationStatus" defaultValue="Reported">{Object.entries(projectUpdateVerificationLabels).filter(([value]) => value !== "Superseded").map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label className="full">Titel<input name="title" required maxLength={200} /></label><label className="full">Samenvatting<textarea name="summaryMarkdown" rows={4} /></label><label>Commit<input name="commitSha" /></label><label>Release-tag<input name="releaseTag" /></label><label className="full">Referentie-URL<input name="url" type="url" /></label></div><DialogActions saving={saving} label="Update toevoegen" /></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
export function ProjectDecisionDialog({ projectId, existingDecisions, onCreated }: { projectId: string; existingDecisions: ProjectDecisionSummary[]; onCreated: (decision: DecisionDetail) => void }) {
const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
// Only an Accepted decision is a valid supersession target: AcceptDecisionUseCase
// requires the target to still be Accepted at the moment *this* decision is itself
// accepted, so offering a Proposed/Rejected/Withdrawn/already-Superseded decision
// here would let an editor pick a choice that is guaranteed to fail acceptance later.
const supersedeCandidates = existingDecisions.filter((decision) => decision.status === "Accepted");
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const value = (name: string) => { const item = form.get(name); return typeof item === "string" ? item.trim() : ""; }; const request: CreateDecisionRequest = { parentPageId: null, projectId, title: value("title"), markdown: value("markdown"), summary: value("summary"), supersedesDecisionId: value("supersedesDecisionId") || null }; try { const created = await apiPost<DecisionDetail>("/api/v1/decisions", request); setOpen(false); onCreated(created); } catch (caught) { setError(caught instanceof Error ? caught.message : "Beslissing aanmaken is mislukt."); } finally { setSaving(false); } }
return <Dialog.Root open={open} onOpenChange={setOpen}><Dialog.Trigger asChild><button className="button secondary" type="button"><Plus aria-hidden="true" size={15} />Beslissing</button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="editorial-dialog"><DialogHeading icon={<Gavel size={17} />} title="Nieuwe beslissing" description="Leg context, keuze en gevolgen vast als projectgebonden ADR." />{error && <div className="form-error-summary" role="alert">{error}</div>}<form onSubmit={(event) => { void submit(event); }}><div className="form-grid"><label className="full">Titel<input name="title" required /></label><label className="full">Samenvatting<textarea name="summary" required rows={3} /></label><label className="full">Besluit (Markdown)<textarea name="markdown" required rows={10} defaultValue={"## Context\n\n## Besluit\n\n## Gevolgen\n"} /></label><label className="full">Vervangt beslissing (optioneel)<select name="supersedesDecisionId" defaultValue="">
<option value="">Geen — dit is een nieuwe beslissing</option>
{supersedeCandidates.map((decision) => <option key={decision.id} value={decision.id}>ADR-{decision.decisionNumber}: {decision.summary}</option>)}
</select></label></div><DialogActions saving={saving} label="Beslissing aanmaken" /></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
export function ProjectMilestoneDialog({ projectId, onCreated }: { projectId: string; onCreated: () => void }) {
const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setSaving(true); setError(null); const form = new FormData(event.currentTarget); const value = (name: string) => { const item = form.get(name); return typeof item === "string" ? item.trim() : ""; }; try { await apiPost(`/api/v1/projects/${projectId}/milestones`, { name: value("name"), targetDate: value("targetDate") || null, summary: value("summary"), successCriteria: value("successCriteria") }); setOpen(false); onCreated(); } catch (caught) { setError(caught instanceof Error ? caught.message : "Mijlpaal toevoegen is mislukt."); } finally { setSaving(false); } }
return <Dialog.Root open={open} onOpenChange={setOpen}><Dialog.Trigger asChild><button className="button secondary" type="button"><Plus aria-hidden="true" size={15} />Mijlpaal</button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="editorial-dialog"><DialogHeading icon={<Flag size={17} />} title="Nieuwe mijlpaal" description="Maak resultaat en succescriteria expliciet voor de projectroadmap." />{error && <div className="form-error-summary" role="alert">{error}</div>}<form onSubmit={(event) => { void submit(event); }}><div className="form-grid"><label className="full">Naam<input name="name" required /></label><label>Doeldatum<input name="targetDate" type="date" /></label><label className="full">Samenvatting<textarea name="summary" rows={3} /></label><label className="full">Succescriteria<textarea name="successCriteria" rows={3} /></label></div><DialogActions saving={saving} label="Mijlpaal toevoegen" /></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
export function ProjectDocumentDialog({ projectId, linkedPageIds, onLinked }: { projectId: string; linkedPageIds: string[]; onLinked: () => void }) {
const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState<SearchResultItem[]>([]); const [selectedPageId, setSelectedPageId] = useState(""); const [searching, setSearching] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
async function search() { setSearching(true); setError(null); try { const response = await apiGet<SearchResponse>(`/api/v1/search?q=${encodeURIComponent(query)}&limit=20`); setResults(response.items.filter((item) => !linkedPageIds.includes(item.id))); } catch (caught) { setError(caught instanceof Error ? caught.message : "Zoeken is mislukt."); } finally { setSearching(false); } }
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); if (!selectedPageId) return; setSaving(true); setError(null); const form = new FormData(event.currentTarget); const role = form.get("role"); try { await apiPost<ProjectDocumentSummary>(`/api/v1/projects/${projectId}/documents`, { pageId: selectedPageId, role: typeof role === "string" ? role : "Other" }); setOpen(false); onLinked(); } catch (caught) { setError(caught instanceof Error ? caught.message : "Document koppelen is mislukt."); } finally { setSaving(false); } }
return <Dialog.Root open={open} onOpenChange={setOpen}><Dialog.Trigger asChild><button className="button secondary" type="button"><FilePlus2 aria-hidden="true" size={15} />Document koppelen</button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="editorial-dialog"><DialogHeading icon={<FilePlus2 size={17} />} title="Projectdocument koppelen" description="Zoek een bestaande kennispagina en geef haar rol in dit project aan." />{error && <div className="form-error-summary" role="alert">{error}</div>}<div className="project-document-search"><label>Zoek kennispagina<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} /></label><button className="button secondary" type="button" disabled={!query.trim() || searching} onClick={() => { void search(); }}><Search aria-hidden="true" size={15} />{searching ? "Zoeken…" : "Zoeken"}</button></div><form onSubmit={(event) => { void submit(event); }}><div className="form-grid"><label className="full">Pagina<select aria-label="Kennispagina" value={selectedPageId} required onChange={(event) => setSelectedPageId(event.target.value)}><option value="">Selecteer een resultaat…</option>{results.map((item) => <option key={item.id} value={item.id}>{item.title} · {item.documentType}</option>)}</select></label><label className="full">Documentrol<select name="role" defaultValue="Other">{Object.entries(projectDocumentRoleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label></div><DialogActions saving={saving} label="Document koppelen" /></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
export function ProjectDocumentUnlinkButton({ document, onUnlinked }: { document: ProjectDocumentSummary; onUnlinked: () => void }) {
const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
return <span className="inline-action-state"><button className="icon-button" type="button" disabled={busy} aria-label={`${document.pageTitle} ontkoppelen`} onClick={() => { setBusy(true); setError(null); void apiDelete(`/api/v1/projects/documents/${document.id}`).then(onUnlinked).catch((caught: unknown) => setError(caught instanceof Error ? caught.message : "Ontkoppelen is mislukt.")).finally(() => setBusy(false)); }}><Link2Off aria-hidden="true" size={15} /></button>{error && <span className="sr-only" role="alert">{error}</span>}</span>;
}
export function MilestoneStateControl({ milestone, onChanged }: { milestone: ProjectMilestoneSummary; onChanged: () => void }) {
const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null);
return <div className="milestone-state"><select aria-label={`Status van ${milestone.name}`} value={milestone.state} disabled={saving} onChange={(event) => { const state = event.target.value; setSaving(true); setError(null); void apiPut(`/api/v1/projects/milestones/${milestone.id}`, { name: milestone.name, targetDate: milestone.targetDate, summary: milestone.summary, successCriteria: milestone.successCriteria, state }, ifMatchHeader(milestone.eTag)).then(onChanged).catch((caught: unknown) => setError(caught instanceof Error ? caught.message : "Mijlpaal bijwerken is mislukt.")).finally(() => setSaving(false)); }}>{["Planned", "InProgress", "Completed", "Cancelled"].map((state) => <option key={state} value={state}>{state}</option>)}</select>{error && <span role="alert">{error}</span>}</div>;
}
function DialogHeading({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) { return <div className="dialog-heading"><div><p className="eyebrow">Projectworkspace</p><Dialog.Title>{icon}{title}</Dialog.Title><Dialog.Description>{description}</Dialog.Description></div><Dialog.Close className="icon-button" aria-label="Sluiten"><X aria-hidden="true" size={18} /></Dialog.Close></div>; }
function DialogActions({ saving, label }: { saving: boolean; label: string }) { return <div className="dialog-actions"><Dialog.Close className="button secondary" type="button">Annuleren</Dialog.Close><button className="button primary" type="submit" disabled={saving}>{saving ? "Opslaan…" : label}</button></div>; }
@@ -0,0 +1,142 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Plus, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import { apiPost } from "../api/client";
import type { CreateRunbookRequest, RunbookDetail } from "../api/projectTypes";
import { runbookRiskLabels } from "../api/projectTypes";
export function RunbookCreateDialog({
onCreated,
}: {
onCreated: (runbook: RunbookDetail) => void;
}) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError(null);
const form = new FormData(event.currentTarget);
const value = (name: string) => {
const entry = form.get(name);
return typeof entry === "string" ? entry.trim() : "";
};
const duration = value("estimatedDurationMinutes");
const request: CreateRunbookRequest = {
parentPageId: null,
title: value("title"),
markdown: value("markdown"),
templateKey: null,
riskLevel: value("riskLevel"),
requiresMaintenanceWindow: form.get("requiresMaintenanceWindow") === "on",
estimatedDurationMinutes: duration ? Number(duration) : null,
};
try {
const created = await apiPost<RunbookDetail>("/api/v1/runbooks", request);
setOpen(false);
onCreated(created);
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "Runbook aanmaken is mislukt.",
);
} finally {
setSaving(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="button primary" type="button">
<Plus aria-hidden="true" size={16} />
Nieuw runbook
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="editorial-dialog">
<div className="dialog-heading">
<div>
<p className="eyebrow">Operations</p>
<Dialog.Title>Nieuw runbook</Dialog.Title>
<Dialog.Description>
Leg prerequisites, procedure, verificatie en rollback als
uitvoerbare documentatie vast.
</Dialog.Description>
</div>
<Dialog.Close className="icon-button" aria-label="Sluiten">
<X aria-hidden="true" size={18} />
</Dialog.Close>
</div>
{error && (
<div className="form-error-summary" role="alert">
{error}
</div>
)}
<form
onSubmit={(event) => {
void submit(event);
}}
>
<div className="form-grid">
<label className="full">
Titel
<input name="title" required autoFocus />
</label>
<label>
Risiconiveau
<select name="riskLevel" defaultValue="Medium">
{Object.entries(runbookRiskLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label>
Geschatte duur (minuten)
<input
name="estimatedDurationMinutes"
type="number"
min="1"
max="10080"
/>
</label>
<label className="full">
Procedure (Markdown)
<textarea
name="markdown"
required
rows={12}
defaultValue={
"## Voorwaarden\n\n## Stappen\n\n1. \n\n## Verificatie\n\n## Rollback\n"
}
/>
</label>
<label className="checkbox-label full">
<input type="checkbox" name="requiresMaintenanceWindow" />
Onderhoudsvenster vereist
</label>
</div>
<div className="dialog-actions">
<Dialog.Close className="button secondary" type="button">
Annuleren
</Dialog.Close>
<button
className="button primary"
type="submit"
disabled={saving}
>
{saving ? "Aanmaken…" : "Runbook aanmaken"}
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -0,0 +1,116 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetCsrfCache } from "../api/client";
import { ServiceOverrideManager } from "./ServiceOverrideManager";
function response(body: unknown, status = 200) {
return Promise.resolve({
ok: status < 400,
status,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(body),
text: () => Promise.resolve(status === 204 ? "" : JSON.stringify(body)),
});
}
const catalogResponse = {
targetKind: "Service",
fields: [
{ fieldName: "detectedName", displayLabel: "Gedetecteerde naam", valueKind: "Text", maxStringLength: 300, allowedEnumValues: null },
{ fieldName: "health", displayLabel: "Healthstatus", valueKind: "Enum", maxStringLength: 0, allowedEnumValues: ["Unknown", "Healthy", "Degraded", "Unhealthy", "Stopped"] },
],
};
describe("ServiceOverrideManager", () => {
afterEach(() => {
resetCsrfCache();
vi.unstubAllGlobals();
});
it("fetches the server-authoritative override field catalog rather than a hardcoded list", async () => {
const fetchMock = vi.fn().mockImplementation((input: string) => {
if (input === "/api/v1/infrastructure/overrides/catalog?targetKind=Service") return response(catalogResponse);
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
render(<ServiceOverrideManager serviceId="service-1" overrides={[]} onChanged={vi.fn()} />);
await screen.findByRole("button", { name: /Override toevoegen/ });
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/infrastructure/overrides/catalog?targetKind=Service",
expect.objectContaining({ credentials: "same-origin" })
);
});
it("creates a reasoned override for a text field without changing the source fact", async () => {
const onChanged = vi.fn();
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/infrastructure/overrides/catalog?targetKind=Service") return response(catalogResponse);
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/infrastructure/services/service-1/overrides") {
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body as string)).toEqual({ fieldName: "detectedName", value: "itworx-knowledge-hub", reason: "Automatische naam was onduidelijk" });
return response({ id: "override-1" });
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<ServiceOverrideManager serviceId="service-1" overrides={[]} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: /Override toevoegen/ }));
await user.selectOptions(screen.getByLabelText("Fact"), "detectedName");
await user.type(screen.getByLabelText("Gecorrigeerde waarde"), "itworx-knowledge-hub");
await user.type(screen.getByLabelText("Reden"), "Automatische naam was onduidelijk");
await user.click(screen.getByRole("button", { name: "Override opslaan" }));
expect(onChanged).toHaveBeenCalledOnce();
});
it("renders an enum-typed field's value picker as a select of only the allowed values, never free text", async () => {
const onChanged = vi.fn();
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/infrastructure/overrides/catalog?targetKind=Service") return response(catalogResponse);
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/infrastructure/services/service-1/overrides") {
expect(JSON.parse(init?.body as string)).toEqual({ fieldName: "health", value: "Degraded", reason: "Healthcheck gevalideerd" });
return response({ id: "override-2" });
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<ServiceOverrideManager serviceId="service-1" overrides={[]} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: /Override toevoegen/ }));
await user.selectOptions(screen.getByLabelText("Fact"), "health");
const valueSelect = screen.getByLabelText("Gecorrigeerde waarde");
expect(valueSelect.tagName).toBe("SELECT");
await user.selectOptions(valueSelect, "Degraded");
await user.type(screen.getByLabelText("Reden"), "Healthcheck gevalideerd");
await user.click(screen.getByRole("button", { name: "Override opslaan" }));
expect(onChanged).toHaveBeenCalledOnce();
});
it("removes an override through the explicit accessible action", async () => {
const onChanged = vi.fn();
const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/v1/infrastructure/overrides/catalog?targetKind=Service") return response(catalogResponse);
if (input === "/api/v1/auth/csrf") return response({ token: "csrf", headerName: "X-CSRF-Token" });
if (input === "/api/v1/infrastructure/overrides/override-1") {
expect(init?.method).toBe("DELETE");
return response(undefined, 204);
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<ServiceOverrideManager serviceId="service-1" overrides={[{ id: "override-1", fieldName: "health", value: "Healthy", reason: "Gecontroleerd", createdBy: "user-1", updatedAt: "2026-08-02T00:00:00Z" }]} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: "Override voor health verwijderen" }));
expect(onChanged).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,155 @@
import * as Dialog from "@radix-ui/react-dialog";
import { RotateCcw, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { apiDelete, apiGet, apiPost } from "../api/client";
import type { ManualOverride, ManualOverrideFieldCatalogEntry } from "../api/infrastructureTypes";
import { factConfidenceLabels, infrastructureHealthLabels } from "./statusLabels";
import { overrideDisplayValue } from "./infrastructureFormat";
/** Labels for known enum-typed override fields' allowed values — the catalog itself
* only carries the raw allowed value tokens (server-authoritative); this is purely
* cosmetic Dutch presentation on top of it. */
const enumValueLabelsByField: Record<string, Record<string, string>> = {
health: infrastructureHealthLabels,
confidence: factConfidenceLabels,
};
export function ServiceOverrideManager({
serviceId,
overrides,
onChanged,
}: {
serviceId: string;
overrides: ManualOverride[];
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetched, not hardcoded (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md,
// audit finding H3 — "Houd de catalogus centraal"): this is the same catalog the
// server itself validates against, so the UI can never offer a field or value shape
// the backend would then reject.
const [catalog, setCatalog] = useState<ManualOverrideFieldCatalogEntry[]>([]);
const [selectedField, setSelectedField] = useState<string>("");
useEffect(() => {
let cancelled = false;
apiGet<{ targetKind: string; fields: ManualOverrideFieldCatalogEntry[] }>(
"/api/v1/infrastructure/overrides/catalog?targetKind=Service"
)
.then((response) => {
if (cancelled) return;
setCatalog(response.fields);
setSelectedField((current) => current || response.fields[0]?.fieldName || "");
})
.catch(() => undefined);
return () => { cancelled = true; };
}, []);
const fieldLabel = (fieldName: string) => catalog.find((field) => field.fieldName === fieldName)?.displayLabel ?? fieldName;
const selectedDefinition = catalog.find((field) => field.fieldName === selectedField);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError(null);
const form = new FormData(event.currentTarget);
const value = (name: string) => {
const entry = form.get(name);
return typeof entry === "string" ? entry.trim() : "";
};
try {
await apiPost(`/api/v1/infrastructure/services/${serviceId}/overrides`, {
fieldName: value("fieldName"),
value: value("value"),
reason: value("reason") || null,
});
setOpen(false);
onChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Override opslaan is mislukt.");
} finally {
setSaving(false);
}
}
async function remove(overrideId: string) {
setSaving(true);
setError(null);
try {
await apiDelete(`/api/v1/infrastructure/overrides/${overrideId}`);
onChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Override verwijderen is mislukt.");
} finally {
setSaving(false);
}
}
return (
<section className="surface-panel override-manager">
<div className="section-heading">
<div>
<p className="eyebrow">Handmatige correcties</p>
<h2>Overrides</h2>
</div>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="button secondary" type="button" disabled={catalog.length === 0}>
<SlidersHorizontal aria-hidden="true" size={15} /> Override toevoegen
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="editorial-dialog">
<div className="dialog-heading">
<div>
<p className="eyebrow">Bronfact corrigeren</p>
<Dialog.Title>Handmatige override</Dialog.Title>
<Dialog.Description>
De originele inventariswaarde blijft herleidbaar en een volgende synchronisatie bewaart deze correctie.
</Dialog.Description>
</div>
<Dialog.Close className="icon-button" aria-label="Sluiten"><X size={18} /></Dialog.Close>
</div>
{error && <div className="form-error-summary" role="alert">{error}</div>}
<form onSubmit={(event) => { void submit(event); }}>
<div className="form-grid">
<label>
Fact
<select name="fieldName" required value={selectedField} onChange={(event) => setSelectedField(event.target.value)}>
{catalog.map((field) => <option key={field.fieldName} value={field.fieldName}>{field.displayLabel}</option>)}
</select>
</label>
{selectedDefinition?.valueKind === "Enum" ? (
<label>
Gecorrigeerde waarde
<select name="value" required>
{(selectedDefinition.allowedEnumValues ?? []).map((value) => (
<option key={value} value={value}>{enumValueLabelsByField[selectedDefinition.fieldName]?.[value] ?? value}</option>
))}
</select>
</label>
) : (
<label>
Gecorrigeerde waarde
<input name="value" required maxLength={selectedDefinition?.maxStringLength || undefined} />
</label>
)}
<label className="full">Reden<textarea name="reason" rows={3} required /></label>
</div>
<div className="dialog-actions">
<Dialog.Close className="button secondary" type="button">Annuleren</Dialog.Close>
<button className="button primary" type="submit" disabled={saving}>{saving ? "Opslaan…" : "Override opslaan"}</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
{error && !open && <div className="form-error-summary" role="alert">{error}</div>}
{overrides.length === 0 ? <div className="empty-inline">Geen actieve overrides. Alle getoonde facts volgen de inventarisbron.</div> : <ul className="override-list">{overrides.map((override) => <li key={override.id}><div><strong>{fieldLabel(override.fieldName)}</strong><span>{overrideDisplayValue(override.value)}</span><small>{override.reason || "Geen reden vastgelegd"}</small></div><button type="button" className="icon-button" disabled={saving} aria-label={`Override voor ${override.fieldName} verwijderen`} onClick={() => { void remove(override.id); }}><RotateCcw aria-hidden="true" size={16} /></button></li>)}</ul>}
</section>
);
}
@@ -0,0 +1,54 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Tooltip, TooltipProvider } from "./Tooltip";
function renderTooltip(enabled = true) {
return render(
<TooltipProvider>
<button type="button">Eerste</button>
<Tooltip label="Contextpaneel sluiten" enabled={enabled}>
<button type="button" aria-label="Contextpaneel sluiten">
<span aria-hidden="true">i</span>
</button>
</Tooltip>
</TooltipProvider>,
);
}
async function focusTheIconButton() {
const user = userEvent.setup();
await user.tab();
await user.tab();
return user;
}
describe("Tooltip", () => {
it("reveals the label on keyboard focus, which a native title attribute never does", async () => {
renderTooltip();
expect(screen.queryByRole("tooltip")).toBeNull();
await focusTheIconButton();
expect(screen.getByRole("button", { name: "Contextpaneel sluiten" })).toHaveFocus();
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent("Contextpaneel sluiten"),
);
});
it("never doubles up the trigger's own accessible name", async () => {
renderTooltip();
await focusTheIconButton();
await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument());
expect(screen.getAllByRole("button", { name: "Contextpaneel sluiten" })).toHaveLength(1);
});
it("stays out of the way when the control already shows its own visible text", async () => {
renderTooltip(false);
await focusTheIconButton();
expect(screen.getByRole("button", { name: "Contextpaneel sluiten" })).toHaveFocus();
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
+52
View File
@@ -0,0 +1,52 @@
import * as RadixTooltip from "@radix-ui/react-tooltip";
import type { ReactElement, ReactNode } from "react";
/**
* The audit found that between 761px and 1024px the primary navigation collapses to
* bare icons whose only explanation was a native `title` attribute. A `title` is not a
* real answer: it never appears for keyboard users, it is unreliable for touch, and its
* delay is not controllable. This wraps Radix's tooltip, which renders on hover *and*
* on keyboard focus, so an icon-only control explains itself the same way regardless of
* input device.
*
* The trigger keeps its own `aria-label`; the tooltip is presentational
* (`aria-hidden` via Radix's own behaviour once the trigger is already labelled), so
* screen-reader users hear the name once rather than twice.
*/
export function TooltipProvider({ children }: { children: ReactNode }) {
return (
<RadixTooltip.Provider delayDuration={250} skipDelayDuration={400}>
{children}
</RadixTooltip.Provider>
);
}
export function Tooltip({
label,
side = "right",
enabled = true,
children,
}: {
label: string;
side?: "top" | "right" | "bottom" | "left";
/** When the control already shows its own visible text, a tooltip is just noise. */
enabled?: boolean;
children: ReactElement;
}) {
if (!enabled) return children;
return (
<RadixTooltip.Root>
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
<RadixTooltip.Portal>
<RadixTooltip.Content
className="tooltip-content"
side={side}
sideOffset={8}
>
{label}
<RadixTooltip.Arrow className="tooltip-arrow" width={10} height={5} />
</RadixTooltip.Content>
</RadixTooltip.Portal>
</RadixTooltip.Root>
);
}
@@ -0,0 +1,5 @@
export function overrideDisplayValue(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "—";
return JSON.stringify(value);
}
@@ -0,0 +1,6 @@
import { Inbox } from "lucide-react";
import type { JSX } from "react";
export function EmptyState({ message = "Geen voorstellen in de wachtrij." }: { message?: string }): JSX.Element {
return <div className="ingestion-state" role="status" aria-live="polite" aria-atomic="true"><Inbox aria-hidden="true" size={20} /><span>{message}</span></div>;
}
@@ -0,0 +1,11 @@
import { RefreshCw, TriangleAlert } from "lucide-react";
import type { JSX } from "react";
import { useId } from "react";
import type { ProblemDetails } from "../../api/intakeTypes";
export function ErrorState({ error, onRetry }: { error: ProblemDetails; onRetry?: () => void }): JSX.Element {
const descriptionId = useId();
const message = error.detail ?? error.title ?? "De intakewachtrij is niet bereikbaar.";
return <div className="ingestion-state error" role="alert" aria-live="assertive" aria-atomic="true" aria-describedby={descriptionId}><TriangleAlert aria-hidden="true" size={20} /><span id={descriptionId}>{message}</span>{onRetry && <button type="button" className="button secondary" onClick={onRetry}><RefreshCw aria-hidden="true" size={15} />Intakewachtrij opnieuw laden</button>}</div>;
}
@@ -0,0 +1,5 @@
import type { JSX } from "react";
export function IngestionProgress({ step, total, label }: { step: number; total: number; label: string }): JSX.Element {
return <p className="ingestion-progress" data-testid="ingestion-progress" role="status" aria-live="polite">Stap {step} van {total}: {label}</p>;
}
@@ -0,0 +1,69 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import type { IngestionProposalSummary } from "../../api/intakeTypes";
import { IngestionQueueCard } from "./IngestionQueueCard";
const proposal = (id: string): IngestionProposalSummary => ({
id,
correlationId: `correlation-${id}`,
status: "ReadyForReview",
revisionNumber: 1,
sourceCount: 1,
submitterId: "reviewer-1",
createdAt: "2026-09-20T10:00:00Z",
destination: { pageId: null, parentPageId: null, title: `Voorstel ${id}` },
metadata: { documentType: "runbook", documentStatus: null, projectId: null, risk: "low" },
mutationKind: "CreatePage",
});
function renderCard(props: Partial<React.ComponentProps<typeof IngestionQueueCard>> = {}) {
return render(<MemoryRouter><IngestionQueueCard enabled items={[]} loading={false} {...props} /></MemoryRouter>);
}
describe("IngestionQueueCard", () => {
it("does not render when the server session feature is disabled", () => {
renderCard({ enabled: false });
expect(screen.queryByRole("region", { name: "Kennisintake" })).not.toBeInTheDocument();
});
it.each([
[[], "Geen voorstellen in de wachtrij"],
[[proposal("one")], "1 voorstel in de wachtrij"],
[[proposal("one"), proposal("two")], "2 voorstellen in de wachtrij"],
] as const)("reports the queue count", (items, summary) => {
renderCard({ items: [...items] });
expect(screen.getByRole("region", { name: "Kennisintake" })).toHaveTextContent(summary);
});
it("uses a polite live announcement while the queue is loading", () => {
renderCard({ loading: true });
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(screen.getByRole("status")).toHaveTextContent("Intakewachtrij wordt geladen");
});
it("uses associated error text and a named retry action", async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
renderCard({ error: { title: "Dienst niet bereikbaar", detail: "Probeer het later opnieuw." }, onRetry });
const alert = screen.getByRole("alert");
expect(alert).toHaveAttribute("aria-describedby");
expect(alert).toHaveTextContent("Probeer het later opnieuw.");
await user.click(screen.getByRole("button", { name: "Intakewachtrij opnieuw laden" }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("links to the canonical intake route and can receive keyboard focus", () => {
renderCard({ items: [proposal("one")] });
const link = screen.getByRole("link", { name: "Open intake" });
expect(link).toHaveAttribute("href", "/knowledge/intake");
link.focus();
expect(link).toHaveFocus();
});
});
@@ -0,0 +1,23 @@
import { ArrowRight } from "lucide-react";
import type { JSX } from "react";
import { Link } from "react-router";
import type { IngestionProposalSummary, ProblemDetails } from "../../api/intakeTypes";
import { EmptyState } from "./EmptyState";
import { ErrorState } from "./ErrorState";
import { LoadingState } from "./LoadingState";
import { StatusBadge } from "./StatusBadge";
export function IngestionQueueCard({ enabled, items, loading, error, onRetry }: { enabled: boolean; items: IngestionProposalSummary[]; loading: boolean; error?: ProblemDetails; onRetry?: () => void }): JSX.Element {
if (!enabled) return <></>;
const count = items.length;
const queueSummary = count === 0 ? "Geen voorstellen in de wachtrij" : `${count} ${count === 1 ? "voorstel" : "voorstellen"} in de wachtrij`;
return <section className="surface-panel ingestion-queue-card" aria-label="Kennisintake">
<div className="section-heading">
<div><p className="eyebrow">Veilige verwerking</p><h2>Kennisintake</h2></div>
<Link className="ingestion-queue-card__link" to="/knowledge/intake">Open intake<ArrowRight aria-hidden="true" size={15} /></Link>
</div>
{loading ? <LoadingState /> : error ? onRetry ? <ErrorState error={error} onRetry={onRetry} /> : <ErrorState error={error} /> : count === 0 ? <EmptyState message={queueSummary} /> : <><p className="ingestion-queue-card__summary" aria-live="polite" aria-atomic="true">{queueSummary}</p><ul className="ingestion-queue-card__list">{items.slice(0, 3).map((item) => <li key={item.id}><div><strong>{item.destination?.title ?? "Naamloos voorstel"}</strong><small>{item.sourceCount} {item.sourceCount === 1 ? "bron" : "bronnen"}</small></div><StatusBadge status={item.status} /></li>)}</ul></>}
</section>;
}
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { IngestionQueueFilters as QueueFilters } from "../../api/intakeTypes";
import { IngestionQueueFilters } from "./IngestionQueueFilters";
import { parseIngestionQueueFilters } from "./ingestionQueueFilterState";
const defaults: QueueFilters = { pageSize: 25 };
describe("IngestionQueueFilters", () => {
it("bounds and allowlists URL values before they can reach the queue API", () => {
// Break caught: an offset URL could move a changing review queue between pages.
expect(parseIngestionQueueFilters("?page=9999&pageSize=0&status=Received&risk=unsafe&minAgeDays=99999&projectId=%20project-1%20")).toEqual({
pageSize: 1,
status: "Received",
projectId: "project-1",
minAgeDays: 3650,
});
});
it("keeps the cursor-free filter state when filters change and clears all optional values", async () => {
// Break caught: retaining a cleared value keeps a stale server constraint active.
const onChange = vi.fn();
render(<IngestionQueueFilters value={{ ...defaults, status: "Received", projectId: "project-1" }} onChange={onChange} />);
const user = userEvent.setup();
await user.selectOptions(screen.getByLabelText("Status filteren"), "ReadyForReview");
expect(onChange).toHaveBeenLastCalledWith({ ...defaults, status: "ReadyForReview", projectId: "project-1" });
await user.click(screen.getByRole("button", { name: "Alle filters wissen" }));
expect(onChange).toHaveBeenLastCalledWith(defaults);
});
});
@@ -0,0 +1,24 @@
import type { JSX } from "react";
import type { IngestionProposalStatus, IngestionQueueFilters as QueueFilters } from "../../api/intakeTypes";
import { boundedIngestionQueueText, ingestionQueueMaxAgeDays, ingestionQueueMaxTextLength, ingestionQueueStatuses } from "./ingestionQueueFilterState";
const statusLabels: Record<IngestionProposalStatus, string> = { Received: "Ontvangen", Analyzing: "Wordt geanalyseerd", ReadyForReview: "Klaar voor beoordeling", ChangesRequested: "Wijzigingen gevraagd", Approved: "Goedgekeurd", Publishing: "Wordt gepubliceerd", Published: "Gepubliceerd", Rejected: "Afgewezen", Failed: "Mislukt" };
function updateFilters(value: QueueFilters, change: Partial<QueueFilters>): QueueFilters { return { ...value, ...change }; }
function withoutFilter(value: QueueFilters, key: "status" | "projectId" | "submitterId" | "minAgeDays" | "risk" | "documentType"): QueueFilters {
const next = { ...value };
delete next[key];
return next;
}
export function IngestionQueueFilters({ value, onChange }: { value: QueueFilters; onChange: (next: QueueFilters) => void }): JSX.Element {
const hasFilters = Boolean(value.status || value.projectId || value.submitterId || value.minAgeDays !== undefined || value.risk || value.documentType);
return <form className="ingestion-queue-filters" aria-label="Intakefilters" onSubmit={(event) => event.preventDefault()}>
<label className="select-filter">Status<select aria-label="Status filteren" value={value.status ?? ""} onChange={(event) => { const status = event.currentTarget.value as IngestionProposalStatus | ""; onChange(status ? updateFilters(value, { status }) : withoutFilter(value, "status")); }}><option value="">Alle statussen</option>{ingestionQueueStatuses.map((status) => <option key={status} value={status}>{statusLabels[status]}</option>)}</select></label>
<label className="filter-input">Project<input aria-label="Project filteren" value={value.projectId ?? ""} maxLength={ingestionQueueMaxTextLength} onChange={(event) => { const projectId = boundedIngestionQueueText(event.currentTarget.value); onChange(projectId ? updateFilters(value, { projectId }) : withoutFilter(value, "projectId")); }} /></label>
<label className="filter-input">Indiener<input aria-label="Indiener filteren" value={value.submitterId ?? ""} maxLength={ingestionQueueMaxTextLength} onChange={(event) => { const submitterId = boundedIngestionQueueText(event.currentTarget.value); onChange(submitterId ? updateFilters(value, { submitterId }) : withoutFilter(value, "submitterId")); }} /></label>
<label className="filter-input">Minimumleeftijd<input aria-label="Minimumleeftijd in dagen" type="number" min="0" max={ingestionQueueMaxAgeDays} value={value.minAgeDays ?? ""} onChange={(event) => { const rawValue = event.currentTarget.value; const minAgeDays = rawValue === "" ? undefined : Math.max(0, Math.min(ingestionQueueMaxAgeDays, Number.parseInt(rawValue, 10) || 0)); onChange(minAgeDays === undefined ? withoutFilter(value, "minAgeDays") : updateFilters(value, { minAgeDays })); }} /></label>
<label className="select-filter">Risico<select aria-label="Risico filteren" value={value.risk ?? ""} onChange={(event) => { const risk = event.currentTarget.value as QueueFilters["risk"] | ""; onChange(risk ? updateFilters(value, { risk }) : withoutFilter(value, "risk")); }}><option value="">Alle risiconiveaus</option><option value="low">Laag</option><option value="medium">Gemiddeld</option><option value="high">Hoog</option></select></label>
<label className="filter-input">Documenttype<input aria-label="Documenttype filteren" value={value.documentType ?? ""} maxLength={ingestionQueueMaxTextLength} onChange={(event) => { const documentType = boundedIngestionQueueText(event.currentTarget.value); onChange(documentType ? updateFilters(value, { documentType }) : withoutFilter(value, "documentType")); }} /></label>
{hasFilters && <button className="button secondary" type="button" onClick={() => onChange({ pageSize: value.pageSize })}>Alle filters wissen</button>}
</form>;
}
@@ -0,0 +1,13 @@
import type { JSX } from "react";
import { Link } from "react-router";
import type { IngestionProposalSummary, PaginatedIngestionProposals } from "../../api/intakeTypes";
import { StatusBadge } from "./StatusBadge";
function destinationTitleFor(row: IngestionProposalSummary): string | undefined { return row.destination?.title?.trim() || undefined; }
function titleFor(row: IngestionProposalSummary): string { return destinationTitleFor(row) ?? `Voorstel ${row.id}`; }
function ageLabel(createdAt: string): string { const timestamp = new Date(createdAt).getTime(); if (timestamp !== timestamp) return "Niet beschikbaar"; const days = Math.max(0, Math.floor((Date.now() - timestamp) / 86_400_000)); return `${days} ${days === 1 ? "dag" : "dagen"} oud`; }
function riskLabel(risk: IngestionProposalSummary["metadata"]["risk"]): string { return risk === "low" ? "Laag" : risk === "medium" ? "Gemiddeld" : risk === "high" ? "Hoog" : "Niet beschikbaar"; }
function effectLabel(kind: IngestionProposalSummary["mutationKind"]): string { return kind === "CreatePage" ? "Nieuwe pagina" : kind === "UpdatePage" ? "Bestaande pagina bijwerken" : "Niet beschikbaar"; }
export function IngestionQueueTable({ page, onRefresh, onLoadMore, loadingMore }: { page: PaginatedIngestionProposals; onRefresh: () => void; onLoadMore: () => void; loadingMore: boolean }): JSX.Element {
const loadedSummary = `${page.items.length} van ${page.total} ${page.total === 1 ? "voorstel" : "voorstellen"} geladen`;
return <section className="surface-panel ingestion-queue-table-panel" aria-label="Intakevoorstellen"><div className="section-heading"><div><p className="eyebrow">Beoordelingswachtrij</p><h2>Voorstellen</h2></div><button type="button" className="button secondary" onClick={onRefresh}>Vernieuwen</button></div><div className="ingestion-table-scroll"><table className="ingestion-queue-table" aria-label="Intakevoorstellen"><thead><tr><th scope="col">Voorstel</th><th scope="col">Status</th><th scope="col">Bronnen</th><th scope="col">Effect</th><th scope="col">Bestemming</th><th scope="col">Risico / vertrouwen</th><th scope="col">Leeftijd</th><th scope="col">Indiener</th></tr></thead><tbody>{page.items.map((row) => { const destinationTitle = destinationTitleFor(row); const title = titleFor(row); return <tr key={row.id}><th scope="row"><Link to={`/knowledge/intake/${encodeURIComponent(row.id)}`} aria-label={`Open voorstel ${title}`}>{title}</Link></th><td><StatusBadge status={row.status} /></td><td>{row.sourceCount} {row.sourceCount === 1 ? "bron" : "bronnen"}</td><td>{effectLabel(row.mutationKind)}</td><td>{destinationTitle ?? "Niet beschikbaar"}</td><td><span>{riskLabel(row.metadata.risk)}</span><small>Vertrouwen: Niet beschikbaar</small></td><td>{ageLabel(row.createdAt)}</td><td>{row.submitterId || "Niet beschikbaar"}</td></tr>; })}</tbody></table></div><nav className="ingestion-pagination" aria-label="Meer intakevoorstellen"><span aria-live="polite">{loadedSummary}</span>{page.nextCursor && <button type="button" className="button secondary" onClick={onLoadMore} disabled={loadingMore}>{loadingMore ? "Bezig met laden…" : "Meer voorstellen laden"}</button>}</nav></section>;
}
@@ -0,0 +1,6 @@
import { LoaderCircle } from "lucide-react";
import type { JSX } from "react";
export function LoadingState({ label = "Intakewachtrij wordt geladen…" }: { label?: string }): JSX.Element {
return <div className="ingestion-state" role="status" aria-live="polite" aria-atomic="true"><LoaderCircle className="spin" aria-hidden="true" size={20} /><span>{label}</span></div>;
}
@@ -0,0 +1,88 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../../api/client";
import type { CreateManualProposal } from "../../api/intake";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
import { ManualDocumentIntake } from "./ManualDocumentIntake";
const { create, upload, submit, get } = vi.hoisted(() => ({
create: vi.fn<(body: CreateManualProposal, key: string) => Promise<IngestionProposalDetail>>(),
upload: vi.fn<(id: string, file: File, etag: string, key: string) => Promise<IngestionProposalDetail>>(),
submit: vi.fn<(id: string, etag: string) => Promise<IngestionProposalDetail>>(),
get: vi.fn<(id: string) => Promise<IngestionProposalDetail>>(),
}));
vi.mock("../../api/intake", () => ({
problemMessage: () => ({ title: "Intake tijdelijk vertraagd", action: "retry" }),
createManualProposal: create,
uploadProposalSource: upload,
submitManualProposal: submit,
getIngestionProposal: get,
}));
const detail = (sources: number, status = "Received") => ({
id: "proposal-1", etag: sources ? "etag-uploaded" : "etag-created", status,
sources: Array.from({ length: sources }, () => ({ id: "source-1" })),
}) as IngestionProposalDetail;
describe("ManualDocumentIntake", () => {
afterEach(() => { vi.clearAllMocks(); });
it("creates, uploads and explicitly submits one document for review", async () => {
create.mockResolvedValue(detail(0)); upload.mockResolvedValue(detail(1)); submit.mockResolvedValue(detail(1, "ReadyForReview"));
const onSubmitted = vi.fn();
const user = userEvent.setup();
render(<MemoryRouter><ManualDocumentIntake onSubmitted={onSubmitted} /></MemoryRouter>);
await user.type(screen.getByRole("textbox", { name: "Documenttitel" }), "Routerhandleiding");
await user.selectOptions(screen.getByRole("combobox", { name: "Documenttype" }), "Runbook");
await user.type(screen.getByRole("textbox", { name: "Inhoud of toelichting" }), "Samenvatting");
await user.upload(screen.getByLabelText("Bestand"), new File(["original"], "router.pdf", { type: "application/pdf" }));
fireEvent.submit(screen.getByRole("button", { name: "Document toevoegen" }).closest("form")!);
await screen.findByText("Document staat klaar voor beoordeling.");
expect(create.mock.calls[0]?.[0].publication).toMatchObject({ title: "Routerhandleiding", markdown: "Samenvatting", documentType: "Runbook", mutationKind: "CreatePage" });
expect(typeof create.mock.calls[0]?.[1]).toBe("string");
expect(upload.mock.calls[0]?.[0]).toBe("proposal-1");
expect(upload.mock.calls[0]?.[1]).toBeInstanceOf(File);
expect(upload.mock.calls[0]?.[2]).toBe("etag-created");
expect(submit).toHaveBeenCalledWith("proposal-1", "etag-uploaded");
expect(onSubmitted).toHaveBeenCalledOnce();
expect(screen.getByRole("link", { name: "Bekijk voorstel" })).toHaveAttribute("href", "/knowledge/intake/proposal-1");
});
it("resumes a failed upload without creating a duplicate proposal", async () => {
create.mockResolvedValue(detail(0)); get.mockResolvedValue(detail(0)); upload.mockRejectedValueOnce(new ApiError(429, { status: 429 })).mockResolvedValueOnce(detail(1)); submit.mockResolvedValue(detail(1, "ReadyForReview"));
const user = userEvent.setup();
render(<MemoryRouter><ManualDocumentIntake onSubmitted={vi.fn()} /></MemoryRouter>);
await user.type(screen.getByRole("textbox", { name: "Documenttitel" }), "Herstelplan");
await user.type(screen.getByRole("textbox", { name: "Inhoud of toelichting" }), "Herstelstappen");
await user.upload(screen.getByLabelText("Bestand"), new File(["plan"], "plan.txt", { type: "text/plain" }));
fireEvent.submit(screen.getByRole("button", { name: "Document toevoegen" }).closest("form")!);
await screen.findByRole("alert");
await user.click(screen.getByRole("button", { name: "Indienen hervatten" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(create).toHaveBeenCalledOnce();
});
it("reconciles a committed upload and submit after their responses are lost", async () => {
create.mockResolvedValue(detail(0));
get.mockResolvedValueOnce(detail(1)).mockResolvedValueOnce(detail(1, "ReadyForReview"));
upload.mockRejectedValueOnce(new Error("connection lost"));
submit.mockRejectedValueOnce(new Error("connection lost"));
const user = userEvent.setup();
render(<MemoryRouter><ManualDocumentIntake onSubmitted={vi.fn()} /></MemoryRouter>);
await user.type(screen.getByRole("textbox", { name: "Documenttitel" }), "Herstelplan");
await user.type(screen.getByRole("textbox", { name: "Inhoud of toelichting" }), "Herstelstappen");
await user.upload(screen.getByLabelText("Bestand"), new File(["plan"], "plan.txt", { type: "text/plain" }));
fireEvent.submit(screen.getByRole("button", { name: "Document toevoegen" }).closest("form")!);
await screen.findByRole("alert");
await user.click(screen.getByRole("button", { name: "Indienen hervatten" }));
await screen.findByRole("alert");
await user.click(screen.getByRole("button", { name: "Indienen hervatten" }));
await screen.findByText("Document staat klaar voor beoordeling.");
expect(create).toHaveBeenCalledOnce();
expect(upload).toHaveBeenCalledOnce();
expect(submit).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,84 @@
import { useRef, useState, type FormEvent } from "react";
import { Link } from "react-router";
import { createManualProposal, getIngestionProposal, problemMessage, submitManualProposal, uploadProposalSource } from "../../api/intake";
import { ApiError } from "../../api/client";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
const maxSourceBytes = 100 * 1024 * 1024;
const supported = /\.(pdf|docx|doc|md|markdown|txt)$/i;
const newKey = () => globalThis.crypto?.randomUUID?.() ?? `manual-${Date.now()}-${Math.random().toString(36).slice(2)}`;
export function ManualDocumentIntake({ onSubmitted }: { onSubmitted: () => void }) {
const [title, setTitle] = useState("");
const [markdown, setMarkdown] = useState("");
const [documentType, setDocumentType] = useState("Reference");
const [file, setFile] = useState<File | null>(null);
const [draft, setDraft] = useState<IngestionProposalDetail | null>(null);
const [completed, setCompleted] = useState<IngestionProposalDetail | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const createKey = useRef(newKey());
const uploadKey = useRef(newKey());
const correlationId = useRef(newKey());
const reset = () => {
setTitle(""); setMarkdown(""); setDocumentType("Reference"); setFile(null); setDraft(null); setCompleted(null); setError("");
createKey.current = newKey(); uploadKey.current = newKey(); correlationId.current = newKey();
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!file || !title.trim()) return;
if (!supported.test(file.name) || file.size > maxSourceBytes || file.size === 0) {
setError("Kies een PDF, Word-, Markdown- of tekstbestand van maximaal 100 MB.");
return;
}
setPending(true); setError("");
try {
let current = draft;
if (!current) {
const isText = /\.(md|markdown|txt)$/i.test(file.name) && file.size <= 1024 * 1024;
const content = markdown.trim() || (isText ? await file.text() : "Origineel document beschikbaar als bijlage.");
current = await createManualProposal({
manifest: `Handmatig document: ${title.trim()}`,
correlationId: correlationId.current,
publication: { title: title.trim(), markdown: content, documentType, documentStatus: "Draft", mutationKind: "CreatePage" },
}, createKey.current);
setDraft(current);
}
// A prior request may have committed even when its response was lost.
// Refresh before retrying an upload or submit so the current ETag and
// already-staged source determine the next safe operation.
if (draft) {
current = await getIngestionProposal(current.id);
setDraft(current);
}
if (current.status === "ReadyForReview") {
setCompleted(current); onSubmitted(); return;
}
if (current.sources.length === 0) {
current = await uploadProposalSource(current.id, file, current.etag, uploadKey.current);
setDraft(current);
}
current = await submitManualProposal(current.id, current.etag);
setDraft(current); setCompleted(current); onSubmitted();
} catch (cause) {
setError(cause instanceof ApiError ? problemMessage(cause.problem).title : "Het document kon niet worden ingediend. Probeer opnieuw.");
} finally { setPending(false); }
};
return <section className="surface-panel manual-intake" aria-labelledby="manual-intake-title">
<div className="section-heading"><div><p className="eyebrow">Document toevoegen</p><h2 id="manual-intake-title">Nieuw document</h2></div></div>
<p>Voeg een bron toe als bijlage en controleer de conceptpagina voordat u publiceert.</p>
{completed ? <div role="status" className="positive-empty"><strong>Document staat klaar voor beoordeling.</strong><Link to={`/knowledge/intake/${encodeURIComponent(completed.id)}`}>Bekijk voorstel</Link><button type="button" className="button secondary" onClick={reset}>Nog een document toevoegen</button></div> :
<form className="manual-intake-form" onSubmit={(event) => void submit(event)}>
<label>Documenttitel<input required maxLength={200} value={title} disabled={pending || Boolean(draft)} onChange={(event) => setTitle(event.target.value)} /></label>
<label>Documenttype<select value={documentType} disabled={pending || Boolean(draft)} onChange={(event) => setDocumentType(event.target.value)}><option value="Reference">Referentie</option><option value="WikiPage">Wikipagina</option><option value="StudyNote">Studienota</option><option value="ProjectDocument">Projectdocument</option><option value="Runbook">Runbook</option></select></label>
<label>Bestand<input required type="file" accept=".pdf,.docx,.doc,.md,.markdown,.txt" disabled={pending || Boolean(draft)} onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label>
<label>Inhoud of toelichting<textarea rows={4} value={markdown} disabled={pending || Boolean(draft)} onChange={(event) => setMarkdown(event.target.value)} placeholder="Optioneel. Kleine tekst- en Markdownbestanden worden automatisch overgenomen." /></label>
<p className="form-help">PDF en Word blijven als origineel bestand bewaard. Voeg een korte toelichting toe voor de kennisbankpagina.</p>
{error && <p role="alert" className="form-error">{error}</p>}
<div className="manual-intake-actions"><button className="button primary" type="submit" disabled={pending || !file || !title.trim()}>{pending ? "Bezig met toevoegen…" : draft ? "Indienen hervatten" : "Document toevoegen"}</button>{draft && <Link to={`/knowledge/intake/${encodeURIComponent(draft.id)}`}>Open conceptvoorstel</Link>}</div>
</form>}
</section>;
}
@@ -0,0 +1,26 @@
import type { JSX } from "react";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
import { IngestionProgress } from "./IngestionProgress";
type ReviewStep = "sources" | "evidence" | "decision";
const steps: Array<{ id: ReviewStep; label: string }> = [
{ id: "sources", label: "Bronnen" },
{ id: "evidence", label: "Onderbouwing" },
{ id: "decision", label: "Beslissing" },
];
export function MobileReviewPanels({ detail, step, onStepChange }: { detail: IngestionProposalDetail; step: ReviewStep; onStepChange: (step: ReviewStep) => void }): JSX.Element {
const index = steps.findIndex((item) => item.id === step);
const current = steps[index] ?? { id: "sources" as const, label: "Bronnen" };
const previous = steps[index - 1];
const next = steps[index + 1];
return <nav className="mobile-review-navigator" aria-label="Stappen voor voorstelbeoordeling">
<IngestionProgress step={index + 1} total={steps.length} label={current.label} />
<p className="mobile-review-summary">Voorstelrevisie {detail.revisionNumber} · {detail.sources.length} bron{detail.sources.length === 1 ? "" : "nen"}</p>
<div className="mobile-review-controls">
<button className="button secondary" type="button" disabled={!previous} onClick={() => previous && onStepChange(previous.id)}>Vorige stap</button>
<button className="button primary" type="button" disabled={!next} onClick={() => next && onStepChange(next.id)}>Volgende stap</button>
</div>
</nav>;
}
@@ -0,0 +1,9 @@
import type { IngestionDiff, IngestionMutationKind } from "../../api/intakeTypes";
export function ProposalContentDiff({ diff, mutationKind }: { diff: IngestionDiff | null; mutationKind: IngestionMutationKind | null }) {
return <section className="surface-panel ingestion-panel" aria-labelledby="proposal-diff-heading"><h2 id="proposal-diff-heading">Voorgestelde inhoud</h2>
<p>{mutationKind === "UpdatePage" ? "Bestaande pagina bijwerken" : mutationKind === "CreatePage" ? "Nieuwe pagina maken" : "Effect niet beschikbaar"}</p>
{diff?.summary && <p>{diff.summary}</p>}
{diff ? <div className="proposal-diff"><article><h3>Huidige inhoud</h3><pre>{diff.before || (mutationKind === "CreatePage" ? "Nieuwe pagina heeft nog geen bestaande inhoud." : "Huidige pagina-inhoud is niet beschikbaar.")}</pre></article><article><h3>Nieuwe inhoud</h3><pre>{diff.after || "Geen voorgestelde inhoud"}</pre></article></div> : <p className="empty-inline">Geen inhoudsverschil beschikbaar.</p>}
</section>;
}
@@ -0,0 +1,15 @@
import { useState, type RefObject } from "react";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
export type ProposalAction = "requestChanges" | "approve" | "reject" | "publish" | "retry";
export function ProposalDecisionPane({ detail, canMutate, pending, onAction, publishTriggerRef }: { detail: IngestionProposalDetail; canMutate: (action: ProposalAction) => boolean; pending: boolean; onAction: (action: ProposalAction, comment?: string) => Promise<void>; publishTriggerRef?: RefObject<HTMLButtonElement | null> }) {
const [comment, setComment] = useState("");
const sourceFailure = detail.sources.some((source) => /fail|error|reject|malware|blocked/i.test(source.scanResult));
const blockedPublish = !canMutate("publish");
return <section className="surface-panel ingestion-panel proposal-decision-pane" aria-labelledby="proposal-decision-heading"><h2 id="proposal-decision-heading">Beslissing</h2>
{sourceFailure && <p className="ingestion-guard-message" role="alert">Bron vereist voor publicatie: minstens één bron heeft geen veilig scanresultaat.</p>}
{!detail.etag?.trim() && <p className="ingestion-guard-message" role="alert">Een actuele voorstelversie is vereist.</p>}
<label>Opmerking voor de beoordeling<textarea value={comment} onChange={(event) => setComment(event.target.value)} disabled={pending} /></label>
<div className="proposal-actions"><button className="button secondary" type="button" disabled={!canMutate("requestChanges") || pending} onClick={() => { void onAction("requestChanges", comment); }}>Wijzigingen vragen</button><button className="button secondary" type="button" disabled={!canMutate("approve") || pending} onClick={() => { void onAction("approve", comment); }}>Goedkeuren</button><button className="button secondary" type="button" disabled={!canMutate("reject") || pending} onClick={() => { void onAction("reject", comment); }}>Afwijzen</button><button ref={publishTriggerRef} className="button primary" type="button" disabled={blockedPublish || pending} onClick={() => { void onAction("publish"); }}>Publiceren</button><button className="button secondary" type="button" disabled={!canMutate("retry") || pending} onClick={() => { void onAction("retry"); }}>Opnieuw analyseren</button></div>
</section>;
}
@@ -0,0 +1,10 @@
import { MarkdownContent } from "../MarkdownContent";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
export function ProposalEvidencePane({ detail }: { detail: IngestionProposalDetail }) {
return <section className="ingestion-evidence-pane" aria-label="Bewijs en analyse">
<section className="surface-panel ingestion-panel"><h2>Analyse en onderbouwing</h2><p className="field-hint">Machineanalyse (alleen-lezen)</p><MarkdownContent markdown={detail.analysis ?? ""} emptyMessage="Geen machineanalyse beschikbaar." /><p className="field-hint">Beoordelaarsnotitie</p><p>{detail.reviewerComment || "Nog geen beoordelaarsnotitie."}</p></section>
<section className="surface-panel ingestion-panel"><h2>Citaties</h2>{detail.citations.length ? <ul className="proposal-citations">{detail.citations.map((citation) => <li key={`${citation.label}-${citation.pageId ?? "none"}`}><strong>{citation.label}</strong>{citation.excerpt && <span>{citation.excerpt}</span>}{citation.confidence != null && <span>Vertrouwen: {Math.round(citation.confidence * 100)}%</span>}</li>)}</ul> : <p className="empty-inline">Geen citaties beschikbaar.</p>}</section>
<section className="surface-panel ingestion-panel"><h2>Waarschuwingen en conflicten</h2>{detail.warnings.length || detail.duplicateCandidates.length || detail.conflictCandidates.length ? <ul className="proposal-warnings">{detail.warnings.map((warning) => <li key={`${warning.code}-${warning.message}`}>{warning.message}</li>)}{detail.duplicateCandidates.map((candidate) => <li key={`duplicate-${candidate.pageId ?? candidate.title}`}>Mogelijk duplicaat: {candidate.title || "Niet beschikbaar"}</li>)}{detail.conflictCandidates.map((candidate) => <li key={`conflict-${candidate.pageId ?? candidate.title}`}>Tegenstrijdige pagina: {candidate.title || "Niet beschikbaar"}</li>)}</ul> : <p className="empty-inline">Geen waarschuwingen of conflicten.</p>}</section>
</section>;
}
@@ -0,0 +1,18 @@
import { useRef, useState } from "react";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
type ProposalMetadataFormProps = { detail: IngestionProposalDetail; disabled: boolean; onSave: (patch: { manifest?: string; rationale?: string }) => Promise<void> };
export function ProposalMetadataForm({ detail, disabled, onSave }: ProposalMetadataFormProps) {
return <ProposalMetadataFormFields key={detail.etag} detail={detail} disabled={disabled} onSave={onSave} />;
}
function ProposalMetadataFormFields({ detail, disabled, onSave }: ProposalMetadataFormProps) {
const [manifest, setManifest] = useState(detail.manifest); const [rationale, setRationale] = useState(detail.reviewerComment ?? ""); const [saving, setSaving] = useState(false); const [manifestError, setManifestError] = useState(false); const manifestRef = useRef<HTMLTextAreaElement>(null);
const dirty = manifest !== detail.manifest || rationale !== (detail.reviewerComment ?? "");
const submit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); if (!manifest.trim()) { setManifestError(true); manifestRef.current?.focus(); return; } if (!dirty || disabled || saving) return; setSaving(true); void onSave({ ...(manifest !== detail.manifest ? { manifest } : {}), ...(rationale !== (detail.reviewerComment ?? "") ? { rationale } : {}) }).finally(() => setSaving(false)); };
return <section className="surface-panel ingestion-panel" aria-labelledby="proposal-metadata-heading"><h2 id="proposal-metadata-heading">Bestemming en beoordeling</h2>
<dl className="fact-list"><div><dt>Bestemming</dt><dd>{detail.destination?.title || "Niet beschikbaar"}</dd></div><div><dt>Documenttype</dt><dd>{detail.metadata.documentType || "Niet beschikbaar"}</dd></div><div><dt>Documentstatus</dt><dd>{detail.metadata.documentStatus || "Niet beschikbaar"}</dd></div><div><dt>Verwachte paginaversie</dt><dd>Verwachte paginaversie: {detail.expectedTargetETag || "Niet van toepassing"}</dd></div></dl>
<form className="proposal-edit-form" onSubmit={submit}><label>Voorstel (beoordelaar)<textarea ref={manifestRef} value={manifest} disabled={disabled} aria-invalid={manifestError || undefined} aria-describedby={manifestError ? "proposal-manifest-error" : undefined} onChange={(event) => { setManifest(event.target.value); if (manifestError && event.target.value.trim()) setManifestError(false); }} /></label>{manifestError && <p id="proposal-manifest-error" role="alert">Een voorstel mag niet leeg zijn.</p>}<label>Onderbouwing (beoordelaar)<textarea value={rationale} disabled={disabled} onChange={(event) => setRationale(event.target.value)} /></label><button className="button secondary" type="submit" disabled={disabled || !dirty || saving}>{saving ? "Opslaan…" : "Wijzigingen opslaan"}</button>{dirty && <p role="status">Niet-opgeslagen beoordelaarswijzigingen.</p>}</form>
</section>;
}
@@ -0,0 +1,7 @@
import { Link } from "react-router";
import type { IngestionProposalDetail } from "../../api/intakeTypes";
import { StatusBadge } from "./StatusBadge";
export function ProposalQueuePane({ detail }: { detail: IngestionProposalDetail }) {
return <aside className="surface-panel ingestion-panel proposal-queue-pane" aria-label="Voorsteloverzicht"><p className="eyebrow">Kennisintake</p><h1>Voorstel beoordelen</h1><StatusBadge status={detail.status} /><dl className="fact-list"><div><dt>Voorstelrevisie</dt><dd>{detail.revisionNumber}</dd></div><div><dt>Bronnen</dt><dd>{detail.sources.length}</dd></div><div><dt>Referentie</dt><dd>{detail.correlationId}</dd></div></dl><Link className="button secondary" to="/knowledge/intake">Terug naar wachtrij</Link></aside>;
}
@@ -0,0 +1,7 @@
import type { IngestionReviewerHistory } from "../../api/intakeTypes";
export function ProposalReviewHistory({ revisions }: { revisions: IngestionReviewerHistory[] }) {
return <section className="surface-panel ingestion-panel" aria-labelledby="proposal-history-heading"><h2 id="proposal-history-heading">Beoordelingsgeschiedenis</h2>
{revisions.length === 0 ? <p className="empty-inline">Nog geen beoordelingsgeschiedenis.</p> : <ol className="proposal-history">{revisions.map((revision) => <li key={`${revision.revisionNumber}-${revision.operation}`}><strong>Revisie {revision.revisionNumber}</strong><span>{revision.operation}</span><span>{revision.status} · {new Date(revision.occurredAt).toLocaleString("nl-BE")}</span>{revision.comment && <span>Opmerking: {revision.comment}</span>}</li>)}</ol>}
</section>;
}
@@ -0,0 +1,11 @@
import type { IngestionSourceResult } from "../../api/intakeTypes";
export function ProposalSourceList({ sources }: { sources: IngestionSourceResult[] }) {
return <section className="surface-panel ingestion-panel" aria-labelledby="proposal-sources-heading">
<h2 id="proposal-sources-heading">Bronnen en herkomst</h2>
{sources.length === 0 ? <p className="empty-inline">Geen bronbeschrijvingen beschikbaar.</p> : <ul className="proposal-source-list">{sources.map((source) => <li key={source.id}>
<strong>{source.fileName}</strong><span>{source.mimeType} · {source.byteSize.toLocaleString("nl-BE")} bytes</span>
<span>Scanresultaat: {source.scanResult}</span><code>SHA-256: {source.digestSha256}</code>
</li>)}</ul>}
</section>;
}
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { IngestionProposalStatus } from "../../api/intakeTypes";
import { StatusBadge } from "./StatusBadge";
const statuses: Array<[IngestionProposalStatus, string]> = [
["Received", "Ontvangen"],
["Analyzing", "Wordt geanalyseerd"],
["ReadyForReview", "Klaar voor beoordeling"],
["ChangesRequested", "Wijzigingen gevraagd"],
["Approved", "Goedgekeurd"],
["Publishing", "Wordt gepubliceerd"],
["Published", "Gepubliceerd"],
["Rejected", "Afgewezen"],
["Failed", "Mislukt"],
];
describe("StatusBadge", () => {
it.each(statuses)("renders the Dutch text and icon cue for %s", (status, label) => {
render(<StatusBadge status={status} />);
expect(screen.getByRole("status")).toHaveTextContent(label);
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
});
it("uses an explicit label without dropping the non-colour icon cue", () => {
render(<StatusBadge status="Received" label="Nieuw binnengekomen" />);
expect(screen.getByRole("status")).toHaveTextContent("Nieuw binnengekomen");
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
});
it("falls back safely for an additive status", () => {
render(<StatusBadge status={"QueuedByFutureVersion" as IngestionProposalStatus} />);
expect(screen.getByRole("status")).toHaveTextContent("Onbekende status");
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
});
});
@@ -0,0 +1,28 @@
import { BookOpenCheck, CheckCircle2, CircleX, ClipboardCheck, Inbox, LoaderCircle, MessageSquareWarning, TriangleAlert, Upload, type LucideIcon } from "lucide-react";
import type { JSX } from "react";
import type { IngestionProposalStatus } from "../../api/intakeTypes";
type StatusPresentation = { label: string; icon: LucideIcon; tone: "neutral" | "warning" | "positive" | "critical" };
const presentations: Record<IngestionProposalStatus, StatusPresentation> = {
Received: { label: "Ontvangen", icon: Inbox, tone: "neutral" },
Analyzing: { label: "Wordt geanalyseerd", icon: LoaderCircle, tone: "warning" },
ReadyForReview: { label: "Klaar voor beoordeling", icon: ClipboardCheck, tone: "warning" },
ChangesRequested: { label: "Wijzigingen gevraagd", icon: MessageSquareWarning, tone: "warning" },
Approved: { label: "Goedgekeurd", icon: CheckCircle2, tone: "positive" },
Publishing: { label: "Wordt gepubliceerd", icon: Upload, tone: "warning" },
Published: { label: "Gepubliceerd", icon: BookOpenCheck, tone: "positive" },
Rejected: { label: "Afgewezen", icon: CircleX, tone: "critical" },
Failed: { label: "Mislukt", icon: TriangleAlert, tone: "critical" },
};
const unknownPresentation: StatusPresentation = { label: "Onbekende status", icon: TriangleAlert, tone: "neutral" };
export function StatusBadge({ status, label }: { status: IngestionProposalStatus; label?: string }): JSX.Element {
// The API enum is additive. A newer server must not leak its raw enum into the UI
// or prevent a reviewer from reading the rest of a queue row.
const presentation = presentations[status] ?? unknownPresentation;
const Icon = presentation.icon;
return <span className={`ingestion-status-badge ${presentation.tone}`} role="status" data-status={status}><Icon aria-hidden="true" role="img" size={15} />{label ?? presentation.label}</span>;
}
@@ -0,0 +1,38 @@
import type { IngestionProposalStatus, IngestionQueueFilters } from "../../api/intakeTypes";
export const ingestionQueueStatuses: readonly IngestionProposalStatus[] = ["Received", "Analyzing", "ReadyForReview", "ChangesRequested", "Approved", "Publishing", "Published", "Rejected", "Failed"];
export const ingestionQueueRisks = ["low", "medium", "high"] as const;
export const ingestionQueueDefaults = { pageSize: 25 } as const;
export const ingestionQueueMaxAgeDays = 3650;
export const ingestionQueueMaxTextLength = 128;
function boundedInteger(value: string | null, fallback: number, minimum: number, maximum: number): number {
const parsed = Number(value);
return Number.isInteger(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback;
}
export function boundedIngestionQueueText(value: string | null): string | undefined {
const normalized = value?.trim().slice(0, ingestionQueueMaxTextLength);
return normalized || undefined;
}
/** Parses untrusted, shareable URLs into the exact bounded contract accepted by the queue client. */
export function parseIngestionQueueFilters(search: string): IngestionQueueFilters {
const params = new URLSearchParams(search);
const status = params.get("status");
const risk = params.get("risk");
const minAgeDays = params.has("minAgeDays") ? boundedInteger(params.get("minAgeDays"), 0, 0, ingestionQueueMaxAgeDays) : undefined;
const projectId = boundedIngestionQueueText(params.get("projectId"));
const submitterId = boundedIngestionQueueText(params.get("submitterId"));
const documentType = boundedIngestionQueueText(params.get("documentType"));
return {
pageSize: boundedInteger(params.get("pageSize"), ingestionQueueDefaults.pageSize, 1, 100),
...(ingestionQueueStatuses.includes(status as IngestionProposalStatus) ? { status: status as IngestionProposalStatus } : {}),
...(ingestionQueueRisks.includes(risk as (typeof ingestionQueueRisks)[number]) ? { risk: risk as (typeof ingestionQueueRisks)[number] } : {}),
...(projectId ? { projectId } : {}),
...(submitterId ? { submitterId } : {}),
...(documentType ? { documentType } : {}),
...(minAgeDays !== undefined ? { minAgeDays } : {}),
};
}
@@ -0,0 +1,98 @@
import {
decisionStatusLabels,
projectStatusLabels,
runbookStatusLabels,
} from "../api/projectTypes";
/** Backend enum values (docs/07-domain-model.md, InfrastructureEnums.cs,
* ProjectMilestoneState.cs) that reach the UI as raw untranslated strings unless a
* caller explicitly maps them. StatusBadge falls back to this table so every status
* value it renders is Dutch by default, not just the domains a page author happened
* to remember to translate — found during the V2-M2 design-system audit: several V2
* pages (dashboard widgets, project cards, service/infrastructure status rows)
* displayed raw English enum values ("Planned", "Healthy", "Missing") side by side
* with otherwise fully Dutch copy on the same screen. */
export const infrastructureHealthLabels: Record<string, string> = {
Unknown: "Onbekend",
Healthy: "Gezond",
Degraded: "Verminderd",
Unhealthy: "Ongezond",
Stopped: "Gestopt",
};
const infrastructurePresenceLabels: Record<string, string> = {
Proposed: "Voorgesteld",
Present: "Aanwezig",
Missing: "Niet aangetroffen",
Archived: "Gearchiveerd",
};
const milestoneStateLabels: Record<string, string> = {
Planned: "Gepland",
InProgress: "Bezig",
Completed: "Afgerond",
Cancelled: "Geannuleerd",
};
/** Must stay in exact sync with the server-side allowlist
* (EditorialProfileValidator.AllowedCategories/AllowedBackupStatuses,
* docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2) — the same
* hardcoded-but-parallel convention already used for documentTypeLabels/
* documentStatusLabels above, not a fetched catalog (unlike the ManualOverride field
* catalog, whose own instructions specifically required a shared/fetched source of
* truth; these two fields did not). */
export const infrastructureCategoryOptions = [
"Infrastructure", "Networking", "Storage", "Security", "Monitoring", "Automation",
"Knowledge", "Media", "Productivity", "Development", "Backup", "Other"
] as const;
export const infrastructureCategoryLabels: Record<string, string> = {
Infrastructure: "Infrastructuur",
Networking: "Netwerk",
Storage: "Opslag",
Security: "Beveiliging",
Monitoring: "Monitoring",
Automation: "Automatisering",
Knowledge: "Kennis",
Media: "Media",
Productivity: "Productiviteit",
Development: "Ontwikkeling",
Backup: "Back-up",
Other: "Overig",
};
export const infrastructureBackupStatusOptions = ["Unknown", "None", "Manual", "Automated", "AutomatedTested"] as const;
export const infrastructureBackupStatusLabels: Record<string, string> = {
Unknown: "Onbekend",
None: "Geen",
Manual: "Handmatig",
Automated: "Geautomatiseerd",
AutomatedTested: "Geautomatiseerd en geverifieerd",
};
/** FactConfidence (InfrastructureEnums.cs) — used both as a plain confidence label
* and as the allowed-value set for the "confidence" ManualOverride field. */
export const factConfidenceLabels: Record<string, string> = {
Low: "Laag",
Medium: "Gemiddeld",
High: "Hoog",
};
/** Client-only synthetic status values that are not a backend enum member — e.g.
* RunbooksOverviewPage/DashboardPage substitute this in place of the real status
* when a runbook has gone stale, matching the "Herziening nodig" copy already shown
* as the adjacent detail text for the same condition. */
const syntheticStatusLabels: Record<string, string> = {
NeedsReview: "Herziening nodig",
};
export const statusValueLabels: Record<string, string> = {
...infrastructureHealthLabels,
...infrastructurePresenceLabels,
...milestoneStateLabels,
...syntheticStatusLabels,
...projectStatusLabels,
...decisionStatusLabels,
...runbookStatusLabels,
};
+159
View File
@@ -0,0 +1,159 @@
/* Pre-authentication screens (Login, Setup) and the bare full-page status screen.
Split out of the first-generation `src/styles.css`. The two-column split shell itself
was restyled in V2-R3 (docs/adr/ADR-027-v2-r3-context-inspector-and-daily-ux-
completeness.md, closing the "login/setup screens remain V1-styled" gap explicitly
excluded from V2-M2's scope); it kept living in the V1 stylesheet only because that is
where it started. Single column (form only) below 900px, matching every other V2
responsive breakpoint. */
.auth-shell {
display: grid;
grid-template-columns: 1fr;
min-height: 100vh;
background: var(--background);
}
.auth-brand-panel {
display: none;
}
.auth-mobile-brand { display: flex; align-items: center; gap: .65rem; padding: var(--space-5) var(--space-5) 0; color: var(--text); }.auth-mobile-brand > span:last-child { display: grid; line-height: 1.05; }.auth-mobile-brand strong { font-size: var(--text-label); }.auth-mobile-brand small { color: var(--text-faint); font-size: var(--text-caption); letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; }
@media (min-width: 900px) {
.auth-shell {
/* Keep the two-column composition intentional on wide operator displays.
Without this cap the branded half grew indefinitely while the form was
stranded at the far edge of an ultrawide monitor. */
grid-template-columns: minmax(0, 820px) minmax(380px, 460px);
justify-content: center;
}
.auth-brand-panel {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: 1px solid var(--border-subtle);
background:
radial-gradient(ellipse at 76% 50%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 52%),
linear-gradient(145deg, var(--surface-low), var(--background) 76%);
padding: var(--space-8);
}
/* Restore the branded login art without making the sign-in flow depend on a
downloaded image. The soft orbital diagram echoes the product's system maps,
while the low-contrast grid keeps the tagline and logo as the focus. */
.auth-brand-panel::before {
position: absolute;
z-index: 0;
top: 50%;
right: -3.5rem;
width: min(48vw, 410px);
aspect-ratio: 1;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, var(--surface) 0 9%, transparent 9.5%),
radial-gradient(circle at 28% 32%, var(--viz-secondary) 0 4px, transparent 5px),
radial-gradient(circle at 77% 63%, var(--accent) 0 6px, transparent 7px),
radial-gradient(circle at 68% 18%, var(--success) 0 4px, transparent 5px),
repeating-radial-gradient(circle, transparent 0 39px, color-mix(in srgb, var(--accent) 17%, transparent) 40px 41px),
radial-gradient(circle, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%);
box-shadow: 0 0 0 30px color-mix(in srgb, var(--accent) 2%, transparent), 0 0 0 70px color-mix(in srgb, var(--accent) 2%, transparent);
opacity: .62;
content: "";
transform: translateY(-50%);
pointer-events: none;
}
.auth-brand-panel::after {
position: absolute;
z-index: 0;
inset: 0;
background-image:
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
background-size: 28px 28px;
content: "";
mask-image: linear-gradient(90deg, transparent 10%, black 100%);
pointer-events: none;
}
.auth-brand-panel > * {
position: relative;
z-index: 1;
}
.auth-mobile-brand { display: none; }
}
.auth-brand-mark {
display: flex;
align-items: center;
gap: 0.7rem;
}
.auth-brand-mark .product-mark {
width: 42px;
height: 42px;
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 18%, transparent);
}
.auth-brand-tagline {
max-width: 32ch;
margin: 0;
color: var(--text-muted);
font-size: var(--text-heading-sm);
line-height: 1.5;
}
.auth-brand-footnote {
margin: 0;
color: var(--text-faint);
font-size: var(--text-meta);
}
.auth-form-panel {
display: grid;
place-items: center;
padding: 2rem;
background: radial-gradient(ellipse at 50% 48%, color-mix(in srgb, var(--accent) 5%, transparent), transparent 58%);
}
.auth-card {
position: relative;
width: min(100%, 28rem);
border: 1px solid var(--border-subtle);
border-radius: 1rem;
background: linear-gradient(155deg, color-mix(in srgb, var(--accent) 3%, var(--surface)), var(--surface) 42%);
box-shadow: var(--shadow-raised);
padding: clamp(1.5rem, 4vw, 2.5rem);
}
.auth-card h1 {
margin: 0.2rem 0 0.75rem;
font-size: var(--text-heading-lg);
letter-spacing: -0.02em;
}
.auth-intro {
margin: 0 0 1.5rem;
color: var(--muted);
}
.status-screen {
display: grid;
min-height: 100vh;
place-items: center;
padding: 2rem;
}
/* OUX-6: the recovery path and the Caps Lock warning are the two things a locked-out
operator needs, so neither may be a decoration that only appears on hover. */
.auth-recovery-hint { margin-top: var(--space-4); border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); line-height: var(--leading-normal); }
.caps-lock-hint { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); color: var(--warning); font-weight: 650; }
.caps-lock-hint::before { content: ""; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 2px; background: var(--warning); }
+22
View File
@@ -0,0 +1,22 @@
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--background); color: var(--text); font-family: var(--font-ui); font-size: 16px; font-synthesis: none; text-rendering: optimizeLegibility; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--background); color: var(--text); }
button, input, textarea, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: var(--accent); text-underline-offset: 0.2em; }
a:hover { color: var(--accent-strong); }
img, svg { display: block; }
h1, h2, h3, p { margin-block-start: 0; }
h1 { margin-block-end: var(--space-3); font-size: var(--text-display); line-height: 1.1; letter-spacing: -0.035em; }
h2 { margin-block-end: var(--space-3); font-size: var(--text-heading-sm); line-height: 1.35; letter-spacing: -0.015em; }
h3 { margin-block-end: var(--space-2); font-size: var(--text-body); line-height: 1.4; }
p { color: var(--text-muted); line-height: 1.65; }
code, kbd { font-family: var(--font-mono); }
code { overflow-wrap: anywhere; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: var(--focus-ring); }
::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text); }
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
.skip-link { position: fixed; z-index: 1000; top: var(--space-3); left: var(--space-3); transform: translateY(-160%); border-radius: var(--radius-md); background: var(--accent); color: var(--background); padding: var(--space-3) var(--space-4); font-weight: 700; transition: transform var(--motion-standard) var(--ease-product); }
.skip-link:focus { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }
@@ -0,0 +1,190 @@
.button { min-height: var(--control-height); display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); padding: 0.55rem 0.85rem; font-size: var(--text-label); font-weight: 680; line-height: 1; text-decoration: none; cursor: pointer; transition: background var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); }
.button:hover { transform: translateY(-1px); }
.button.primary { background: var(--accent); color: var(--background); }
.button.primary:hover { background: var(--accent-strong); color: var(--background); }
.button.secondary { border-color: var(--border); background: var(--surface); color: var(--text); }
.button.secondary:hover { border-color: var(--accent); background: var(--surface-low); color: var(--text); }
.button:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.icon-button { width: 36px; height: 36px; display: inline-grid; place-items: center; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--text-muted); cursor: pointer; }
.icon-button:hover { border-color: var(--border-subtle); background: var(--surface-low); color: var(--text); }
.eyebrow { margin: 0 0 var(--space-2); color: var(--accent); font-size: var(--text-caption); font-weight: 760; letter-spacing: var(--tracking-eyebrow); line-height: 1.4; text-transform: uppercase; }
.status-badge, .source-chip, .priority-badge, .count-badge { display: inline-flex; align-items: center; gap: 0.4rem; width: fit-content; min-height: 24px; border-radius: var(--radius-pill); padding: 0.25rem 0.55rem; font-size: var(--text-caption); font-weight: 720; line-height: 1; white-space: nowrap; }
.status-badge { border: 1px solid var(--border-subtle); background: var(--surface-low); color: var(--text-muted); }
.status-indicator { width: 6px; height: 6px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent); }
.status-badge.positive { border-color: color-mix(in srgb, var(--success) 35%, transparent); background: var(--success-soft); color: var(--success); }
.status-badge.warning { border-color: color-mix(in srgb, var(--warning) 35%, transparent); background: var(--warning-soft); color: var(--warning); }
.status-badge.critical { border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); color: var(--danger); }
.source-chip { border: 1px solid var(--border-subtle); background: var(--surface-low); color: var(--text-muted); }
.source-chip.automated { border-color: color-mix(in srgb, var(--accent) 28%, var(--border-subtle)); color: var(--accent); }
.source-chip.overridden { border-color: color-mix(in srgb, var(--warning) 30%, var(--border-subtle)); color: var(--warning); }
.surface-panel, .data-surface { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface) 96%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, white 5%, transparent) inset; }
.surface-panel { padding: var(--card-padding); }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-4); }
.section-heading h2 { margin-bottom: 0; }
.section-heading > a, .section-heading > span { font-size: var(--text-label); font-weight: 650; }
/* auto-fit/minmax instead of a fixed repeat(4, ...) — reflows fluidly at any
effective width (including browser zoom, which stretches CSS pixels without
necessarily tripping the discrete @media breakpoints below) instead of staying
locked to 4 equal columns until an exact breakpoint is crossed. */
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-4); }
.metric-card { position: relative; display: flex; gap: var(--space-3); min-height: 112px; overflow: hidden; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); padding: var(--card-padding); }
.metric-card::after { content: ""; position: absolute; inset: auto -18px -34px auto; width: 90px; height: 90px; border: 1px solid color-mix(in srgb, var(--accent) 12%, transparent); border-radius: 50%; }
.metric-card.warning::after { border-color: color-mix(in srgb, var(--warning) 20%, transparent); }
.metric-card.critical::after { border-color: color-mix(in srgb, var(--danger) 20%, transparent); }
.metric-icon { width: 34px; height: 34px; display: grid; flex: 0 0 auto; place-items: center; border-radius: var(--radius-md); background: var(--accent-soft); color: var(--accent); }
.metric-card > div:last-child { display: grid; gap: .2rem; min-width: 0; }
.metric-label { color: var(--text-muted); font-size: var(--text-caption); font-weight: 700; letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; }
.metric-card strong { font-size: var(--text-heading-lg); letter-spacing: -.035em; }
.metric-card small { color: var(--text-faint); font-size: var(--text-meta); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-8); margin-bottom: var(--space-6); }
.workspace-header > div:first-child { max-width: 780px; }
.workspace-description { max-width: 70ch; margin-bottom: 0; font-size: var(--text-body); }
.header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.breadcrumb-line { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-4); color: var(--text-faint); font-size: var(--text-meta); }
.breadcrumb-line a { color: var(--text-muted); text-decoration: none; }
/* Decision/Runbook detail views receive an onBack callback rather than a real route
(see DecisionWorkspacePage/RunbookWorkspacePage) — style their plain <button> the
same as every other breadcrumb-line <a> so the two are visually indistinguishable. */
.breadcrumb-line button { border: 0; background: transparent; color: var(--text-muted); font: inherit; cursor: pointer; padding: 0; }
.breadcrumb-line button:hover, .breadcrumb-line button:focus-visible { color: var(--text); text-decoration: underline; }
.fact-list { display: grid; margin: 0; }
.fact-list > div { display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.4fr); gap: var(--space-3); border-bottom: 1px solid var(--border-subtle); padding: .72rem 0; }
.fact-list > div:last-child { border-bottom: 0; }
.fact-list dt { color: var(--text-faint); font-size: var(--text-meta); }
.fact-list dd { margin: 0; font-size: var(--text-label); font-weight: 600; overflow-wrap: anywhere; }
.entity-list, .signal-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.entity-list li, .signal-list li { min-height: var(--row-height); display: flex; align-items: center; gap: var(--space-3); border-bottom: 1px solid var(--border-subtle); padding: .65rem 0; }
.entity-list li:last-child, .signal-list li:last-child { border-bottom: 0; }
.entity-list li > div, .signal-list li > a { display: grid; flex: 1; min-width: 0; color: var(--text); text-decoration: none; }
.entity-list small, .signal-list span { overflow: hidden; color: var(--text-faint); font-size: var(--text-caption); text-overflow: ellipsis; white-space: nowrap; }
.entity-icon { width: 30px; height: 30px; display: grid; flex: 0 0 auto; place-items: center; border-radius: var(--radius-md); background: var(--surface-low); color: var(--accent); }
.dialog-overlay { position: fixed; z-index: 100; inset: 0; background: rgba(2, 8, 14, .68); backdrop-filter: blur(4px); animation: overlay-in var(--motion-standard) var(--ease-product); }
.dropdown-content, .command-dialog, .editorial-dialog { z-index: 110; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-overlay); color: var(--text); box-shadow: var(--shadow-raised); }
.dropdown-content { min-width: 210px; padding: var(--space-2); }
.dropdown-item { display: flex; align-items: center; gap: var(--space-2); border-radius: var(--radius-md); padding: .65rem .7rem; font-size: var(--text-label); outline: none; cursor: pointer; }
.dropdown-item[data-highlighted] { background: var(--surface-low); }
.dropdown-item.danger { color: var(--danger); }
.dropdown-separator { height: 1px; margin: var(--space-2) 0; background: var(--border-subtle); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
.form-grid label { display: grid; gap: var(--space-2); color: var(--text-muted); font-size: var(--text-label); font-weight: 650; }
.form-grid label.full { grid-column: 1 / -1; }
.form-grid input:not([type="checkbox"]), .form-grid textarea, .filter-input input { width: 100%; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--background); color: var(--text); padding: .65rem .75rem; }
.checkbox-label { display: flex !important; grid-column: 1 / -1; grid-template-columns: auto 1fr; align-items: center; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); border-top: 1px solid var(--border-subtle); margin-top: var(--space-5); padding-top: var(--space-4); }
.empty-inline { color: var(--text-faint); font-size: var(--text-meta); }
.inline-recovery { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); border: 1px solid color-mix(in srgb, var(--danger) 28%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--danger-soft); padding: var(--space-3); color: var(--danger); font-size: var(--text-meta); }.inline-recovery .secondary-action { flex: 0 0 auto; }
.large-empty { min-height: 360px; display: grid; place-items: center; align-content: center; border: 1px dashed var(--border); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface) 76%, transparent); padding: var(--space-10); text-align: center; }
.large-empty > svg { color: var(--accent); margin-bottom: var(--space-4); }
.large-empty h1, .large-empty h2 { margin-bottom: var(--space-2); }
.large-empty p { max-width: 54ch; }
.skeleton { overflow: hidden; background: var(--surface-low); position: relative; }
.skeleton::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface-highest) 55%, transparent), transparent); animation: shimmer 1.3s infinite; }
.state-panel { min-height: 320px; display: grid; place-items: center; align-content: center; gap: var(--space-3); color: var(--text-muted); text-align: center; }
.state-panel.error { color: var(--danger); }
.state-panel h1 { margin: 0; font: inherit; font-size: var(--text-prose); font-weight: 700; }
.ingestion-status-badge { display: inline-flex; align-items: center; gap: var(--space-1); width: fit-content; min-height: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-pill); background: var(--surface-low); color: var(--text-muted); padding: .25rem .55rem; font-size: var(--text-caption); font-weight: 720; line-height: 1; white-space: nowrap; }
.ingestion-status-badge.positive { border-color: color-mix(in srgb, var(--success) 35%, transparent); background: var(--success-soft); color: var(--success); }
.ingestion-status-badge.warning { border-color: color-mix(in srgb, var(--warning) 35%, transparent); background: var(--warning-soft); color: var(--warning); }
.ingestion-status-badge.critical { border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); color: var(--danger); }
.ingestion-queue-card { border-color: var(--intake-border); background: var(--intake-surface); }.ingestion-queue-card__link { display: inline-flex; align-items: center; gap: var(--space-1); min-height: var(--control-height); color: var(--accent-strong); font-size: var(--text-label); font-weight: 700; }.ingestion-queue-card__summary { margin: 0 0 var(--space-2); color: var(--text-muted); font-size: var(--text-meta); }.ingestion-queue-card__list { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }.ingestion-queue-card__list li { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); border-top: 1px solid var(--intake-border); padding-top: var(--space-2); }.ingestion-queue-card__list div { display: grid; gap: .1rem; min-width: 0; }.ingestion-queue-card__list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ingestion-queue-card__list small { color: var(--text-faint); font-size: var(--text-meta); }.ingestion-state { display: flex; align-items: center; justify-content: center; gap: var(--space-2); min-height: 72px; color: var(--text-muted); font-size: var(--text-body); text-align: center; }.ingestion-state.error { flex-wrap: wrap; color: var(--danger); }.ingestion-state.error .button { margin-left: var(--space-2); }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes shimmer { to { transform: translateX(100%); } }
@keyframes overlay-in { from { opacity: 0; } }
/* Global toast/notification queue and recoverable error-boundary fallback
(docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding M4). */
.toast-viewport { position: fixed; z-index: 80; right: var(--space-5); bottom: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); width: min(380px, calc(100vw - var(--space-8))); }
.toast { display: flex; align-items: flex-start; gap: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-lg, 0 8px 24px rgba(0,0,0,.18)); padding: var(--space-4); }
.toast-body { flex: 1; min-width: 0; display: grid; gap: var(--space-1); }
.toast-body p { margin: 0; color: var(--text-muted); font-size: var(--text-label); }
.toast-correlation { color: var(--text-faint) !important; font-size: var(--text-caption) !important; }
.toast-correlation code { font-size: inherit; }
.toast-retry { margin-top: var(--space-2); align-self: flex-start; }
.toast-success { border-color: color-mix(in srgb, var(--success) 35%, var(--border)); }
.toast-success > svg { color: var(--success); }
.toast-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); background: color-mix(in srgb, var(--danger) 6%, var(--surface)); }
.toast-error > svg { color: var(--danger); }
.toast-info > svg { color: var(--accent); }
/* Deliberately normal document flow, not position: fixed/sticky — it is mounted once
at the very top of App.tsx, above AppShell's own sticky topbar. Being a real block
in the flow means the sticky topbar naturally sticks just below it while both are
in view, and the banner simply scrolls away with the rest of the page afterward,
with no manual offset/z-index bookkeeping needed to avoid overlapping it. */
.offline-banner { display: flex; align-items: center; justify-content: center; gap: var(--space-2); border-bottom: 1px solid color-mix(in srgb, var(--warning) 30%, var(--border)); background: var(--warning-soft); padding: var(--space-2) var(--space-4); color: var(--warning); text-align: center; font-size: var(--text-meta); font-weight: 600; }
.offline-banner svg { flex: 0 0 auto; }
.error-boundary-fallback { min-height: 240px; display: grid; place-items: center; align-content: center; gap: var(--space-3); padding: var(--space-8); text-align: center; }
.error-boundary-fallback p { max-width: 52ch; color: var(--text-muted); }
.link-button { border: 0; background: none; padding: 0; color: var(--accent); font: inherit; cursor: pointer; text-decoration: underline; }
.link-button:disabled { color: var(--text-faint); cursor: default; text-decoration: none; }
/* Tooltip for icon-only controls (OUX-3). Deliberately uses --text-meta rather than a
smaller "tooltip size": the whole point is that the label is readable. */
.tooltip-content { z-index: 200; max-width: 22rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: var(--shadow-raised); padding: .35rem .6rem; color: var(--text); font-size: var(--text-meta); font-weight: 600; line-height: var(--leading-snug); }
.tooltip-arrow { fill: var(--surface-raised); }
/* A KPI that navigates has to look and behave like a control, not like a card that
happens to move the page (OUX-4). */
.metric-card-link { text-decoration: none; transition: border-color var(--motion-fast), transform var(--motion-fast); }
.metric-card-link:hover { border-color: var(--accent); transform: translateY(-1px); }
/* Bounded intake queue: semantic table remains scrollable rather than truncating
operational columns; filters collapse to sequential, touch-sized controls. */
.ingestion-queue-page { display: grid; gap: var(--space-5); }
.manual-intake { display: grid; gap: var(--space-3); }
.manual-intake > p { margin: 0; color: var(--text-muted); }
.manual-intake-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.manual-intake-form label { display: grid; gap: var(--space-1); color: var(--text-muted); font-size: var(--text-label); font-weight: 700; }
.manual-intake-form label:has(textarea), .manual-intake-form .form-help, .manual-intake-form .form-error, .manual-intake-actions { grid-column: 1 / -1; }
.manual-intake-form input, .manual-intake-form select, .manual-intake-form textarea { width: 100%; min-width: 0; min-height: var(--control-height); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); padding: var(--space-2); font: inherit; }
.manual-intake-form textarea { resize: vertical; }
.manual-intake-form .form-help { margin: 0; color: var(--text-faint); font-size: var(--text-meta); }
.manual-intake-form .form-error { margin: 0; color: var(--danger); }
.manual-intake-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
@media (max-width: 640px) { .manual-intake-form { grid-template-columns: 1fr; } }
.ingestion-queue-filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: end; gap: var(--space-3); border: 1px solid var(--intake-border); border-radius: var(--radius-lg); background: var(--intake-surface); padding: var(--card-padding); }
.ingestion-queue-filters label { display: grid; gap: var(--space-1); color: var(--text-muted); font-size: var(--text-label); font-weight: 700; }
.ingestion-queue-filters input, .ingestion-queue-filters select { min-width: 0; min-height: var(--control-height); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); padding: 0 var(--space-2); font: inherit; }
.ingestion-queue-table-panel { display: grid; gap: var(--space-4); }
.ingestion-table-scroll { overflow-x: auto; }
.ingestion-queue-table { width: 100%; border-collapse: collapse; font-size: var(--text-meta); }
.ingestion-queue-table th, .ingestion-queue-table td { border-top: 1px solid var(--border-subtle); padding: var(--space-3); text-align: left; vertical-align: middle; }
.ingestion-queue-table thead th { border-top: 0; color: var(--text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-eyebrow); }
.ingestion-queue-table tbody th { min-width: 12rem; font-size: var(--text-label); }
.ingestion-queue-table a { color: var(--accent-strong); font-weight: 720; }
.ingestion-queue-table td small { display: block; margin-top: var(--space-1); color: var(--text-faint); }
.ingestion-pagination { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3); color: var(--text-muted); font-size: var(--text-meta); }
.ingestion-queue-error { display: grid; gap: var(--space-2); }
.ingestion-correlation { margin: 0; color: var(--text-faint); font-family: var(--font-mono); font-size: var(--text-caption); }
.ingestion-review-page { display: grid; gap: var(--space-5); }
.ingestion-review-grid { display: grid; grid-template-columns: minmax(15rem, .72fr) minmax(28rem, 1.8fr) minmax(16rem, .78fr); align-items: start; gap: var(--space-4); }
.ingestion-review-main, .ingestion-evidence-pane { display: grid; gap: var(--space-4); min-width: 0; }
.proposal-queue-pane, .proposal-decision-pane { display: grid; gap: var(--space-4); }
.proposal-source-list, .proposal-citations, .proposal-warnings, .proposal-history { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.proposal-source-list li, .proposal-citations li, .proposal-warnings li, .proposal-history li { display: grid; gap: var(--space-1); border-bottom: 1px solid var(--border-subtle); padding: var(--space-2) 0; overflow-wrap: anywhere; }
.proposal-source-list li:last-child, .proposal-citations li:last-child, .proposal-warnings li:last-child, .proposal-history li:last-child { border-bottom: 0; }
.proposal-source-list span, .proposal-citations span, .proposal-history span { color: var(--text-muted); font-size: var(--text-meta); }
.proposal-source-list code { overflow-wrap: anywhere; color: var(--text-faint); font-size: var(--text-caption); }
.proposal-diff { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.proposal-diff article { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-low); padding: var(--space-3); }
.proposal-diff h3 { margin-bottom: var(--space-2); font-size: var(--text-label); }
.proposal-diff pre { max-height: 22rem; margin: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--text-meta)/var(--leading-normal) var(--font-mono); }
.proposal-edit-form { display: grid; gap: var(--space-3); margin-top: var(--space-4); }
.proposal-edit-form label, .proposal-decision-pane > label { display: grid; gap: var(--space-1); color: var(--text-muted); font-size: var(--text-label); font-weight: 650; }
.proposal-edit-form textarea, .proposal-decision-pane textarea { min-height: 7rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--background); color: var(--text); padding: var(--space-2); font: inherit; }
.proposal-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.ingestion-guard-message, .ingestion-review-problem, .ingestion-publication-result { border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); border-radius: var(--radius-md); background: var(--warning-soft); padding: var(--space-3); color: var(--text); }
.ingestion-review-problem { display: grid; gap: var(--space-2); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); background: var(--danger-soft); }
.ingestion-review-feedback { margin: 0; border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); border-radius: var(--radius-md); background: var(--success-soft); padding: var(--space-3); color: var(--success); }
.ingestion-publication-result { border-color: color-mix(in srgb, var(--success) 35%, var(--border)); background: var(--success-soft); }
.ingestion-publication-result h2, .ingestion-publication-result p { margin: 0; }
.proposal-publish-dialog { position: fixed; top: 50%; left: 50%; width: min(34rem, calc(100vw - var(--space-6))); max-height: calc(100dvh - var(--space-6)); overflow: auto; transform: translate(-50%, -50%); padding: var(--space-5); }
@media (max-width: 1180px) { .ingestion-review-grid { grid-template-columns: minmax(13rem, .7fr) minmax(0, 1.6fr); }.proposal-decision-pane { grid-column: 1 / -1; } }
@media (max-width: 760px) { .ingestion-review-grid, .proposal-diff { grid-template-columns: 1fr; }.proposal-actions .button { flex: 1 1 12rem; }.proposal-queue-pane { order: -1; } }
@media (max-width: 980px) { .ingestion-queue-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 640px) { .ingestion-queue-filters { grid-template-columns: 1fr; }.ingestion-queue-filters .button { width: 100%; }.ingestion-pagination { justify-content: space-between; gap: var(--space-2); }.ingestion-pagination .button { flex: 1; }.ingestion-queue-table th, .ingestion-queue-table td { padding: var(--space-2); } }
.mobile-review-title { display: none; }
@media (max-width: 760px) { .mobile-review-title { display: block; } }
@media (max-width: 760px) { .ingestion-review-page:has([data-mobile-step="sources"][data-mobile-active="true"]) .mobile-review-title { display: none; } }
@@ -0,0 +1,336 @@
/* Knowledge document surface — the reading and browsing chrome around a page's prose:
breadcrumb, title block, labels, the rendered Markdown itself, provenance, and the
attachment/backlink/revision panels beneath it. Plus the search-results list, which
renders the same entities in list form.
Split out of the first-generation `src/styles.css` (V1), which loaded globally
alongside this design system and quietly co-owned several of these elements. Nothing
here is legacy by intent: these are the live styles for screens the V2 rebuild never
restyled, now living where the rest of the design system lives. */
/* Every direct child of the document column shares one left edge and one max-width.
The V1 rule used `width: min(100%, 68rem); margin-inline: auto`, which centered each
child in its own box, so blocks with a narrower intrinsic cap (the 75ch prose measure)
or their own `margin` shorthand (.tag-list, the action bar) each landed on a different
left edge — measured live at 877 / 1133 / 1353 px within one document. Width and
centering of the column itself belong to `.workspace-page, .knowledge-main` in
shell.css. */
.knowledge-main > * {
width: 100%;
max-width: 68rem;
margin-inline: 0;
}
.breadcrumb {
margin-bottom: 1rem;
}
.breadcrumb ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
font-size: var(--text-body);
color: var(--muted);
}
.breadcrumb li:not(:last-child)::after {
content: "/";
margin-left: 0.4rem;
color: var(--line);
}
.breadcrumb button {
border: 0;
background: transparent;
color: var(--blue);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
margin-bottom: 2rem;
}
.page-heading > div {
flex: 1 1 auto;
min-width: 0;
}
.page-heading h1 {
max-width: 30ch;
margin: 0.2rem 0 0.65rem;
font-size: var(--text-page-title);
line-height: 1.08;
letter-spacing: -0.045em;
}
.page-heading p:last-child {
max-width: 65ch;
margin: 0;
color: var(--muted);
font-size: var(--text-heading-sm);
}
.status-chip {
justify-self: end;
border: 1px solid color-mix(in srgb, var(--green) 35%, var(--line));
border-radius: 999px;
background: var(--green-soft);
color: var(--green);
padding: 0.35rem 0.65rem;
font-size: var(--text-meta);
font-weight: 700;
}
.status-chip.warning {
border-color: color-mix(in srgb, var(--warning) 35%, transparent);
background: var(--warning-soft);
color: var(--warning);
}
@media (max-width: 560px) {
.page-heading {
flex-direction: column;
gap: 1rem;
}
.page-heading h1 {
max-width: 100%;
font-size: var(--text-display);
}
.status-chip {
justify-self: start;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0 0 1.25rem;
padding: 0;
list-style: none;
}
.tag-chip {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-muted);
color: var(--muted);
padding: 0.2rem 0.65rem;
font-size: var(--text-label);
}
.knowledge-layer {
border: 1px solid currentColor;
border-radius: 999px;
padding-inline: 0.45rem;
}
.layer-source { color: #667085; }
.layer-curated { color: #087e8b; }
.layer-study-aid { color: #7a4bb7; }
/* --- Rendered Markdown ---------------------------------------------------------------
Server-sanitized HTML, so these element selectors are the only styling hook available.
editor.css narrows the measure further inside the live-preview pane. */
.page-content {
max-width: 75ch;
line-height: 1.65;
}
/* `.knowledge-main > *` sets a shared width for the document chrome. Keep the
rendered prose at the 70–85 character measure specified by the reading design;
the more specific direct-child rule otherwise lets the chrome width override it. */
.knowledge-main > .page-content {
max-width: min(100%, 78ch);
}
.page-content :is(h1, h2, h3, h4) {
line-height: 1.25;
scroll-margin-top: 5rem;
}
.page-content pre {
overflow-x: auto;
border-radius: 0.6rem;
background: var(--surface-muted);
padding: 0.9rem 1rem;
}
.page-content table {
display: block;
overflow-x: auto;
border-collapse: collapse;
}
.page-content th,
.page-content td {
border: 1px solid var(--line);
padding: 0.4rem 0.65rem;
text-align: left;
}
/* --- Provenance ---------------------------------------------------------------------- */
.provenance-panel {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--surface) 92%, var(--blue));
padding: 0.9rem 1rem;
margin-block: 1rem;
}
.provenance-panel p {
margin-block: 0.35rem 0;
}
.provenance-panel summary {
cursor: pointer;
font-weight: 700;
}
.provenance-grid {
display: grid;
gap: 0.7rem;
margin-block-end: 0;
}
.provenance-grid dt {
color: var(--muted);
font-size: var(--text-meta);
font-weight: 700;
text-transform: uppercase;
}
.provenance-grid dd {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
}
/* --- Attachments, backlinks, revisions ------------------------------------------------ */
.page-supplemental {
display: grid;
gap: 0.75rem;
margin-top: 2rem;
}
.attachments,
.backlinks,
.revision-history {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--surface);
padding: 0.9rem 1.1rem;
}
.attachments summary,
.backlinks summary,
.revision-history summary {
cursor: pointer;
font-weight: 700;
}
.attachments .field {
margin-top: 0.75rem;
}
.attachments-list,
.backlinks-list,
.revision-list {
display: grid;
gap: 0.5rem;
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
}
.attachments-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.attachment-meta {
color: var(--muted);
font-size: var(--text-label);
}
.revision-list li {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
}
.revision-meta {
color: var(--muted);
font-size: var(--text-label);
}
.revision-preview {
margin-top: 1rem;
border-top: 1px solid var(--line);
padding-top: 1rem;
}
/* --- Search results ------------------------------------------------------------------- */
.search-form {
display: grid;
gap: 1rem;
max-width: 42rem;
margin-bottom: 1.5rem;
}
.search-results {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.search-result-item {
display: grid;
gap: 0.2rem;
width: 100%;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--surface);
padding: 0.85rem 1rem;
text-align: left;
}
.search-result-title {
font-weight: 700;
}
.search-result-snippet {
color: var(--muted);
font-size: var(--text-body);
}
.search-result-snippet strong {
color: var(--text);
}
.search-result-context {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
color: var(--muted);
font-size: var(--text-meta);
}
+13
View File
@@ -0,0 +1,13 @@
.editor-workspace { min-width: 0; min-height: calc(100vh - var(--topbar-height)); display: grid; grid-template-rows: auto auto auto auto minmax(420px, 1fr) auto; background: var(--background); }.editor-workspace.fullscreen { position: fixed; z-index: 90; inset: 0; min-height: 100vh; }
.editor-header { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: var(--space-6); border-bottom: 1px solid var(--border-subtle); background: var(--surface); padding: var(--space-3) var(--space-5); }.editor-header > div:first-child { min-width: 0; flex: 1; }.editor-title-input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font-size: var(--text-heading-md); font-weight: 720; letter-spacing: -.02em; }.editor-header-actions { display: flex; align-items: center; gap: var(--space-2); }.save-state { color: var(--text-faint); font-size: var(--text-caption); }.save-state.dirty { color: var(--warning); }.save-state.saved { color: var(--success); }
.editor-banner { display: flex; align-items: center; justify-content: center; gap: var(--space-4); border-bottom: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border-subtle)); background: var(--warning-soft); padding: var(--space-3) var(--space-5); color: var(--warning); font-size: var(--text-meta); }.editor-banner p { margin: 0; color: inherit; }.editor-banner.conflict { background: var(--danger-soft); color: var(--danger); }.managed-content-notice { display: flex; align-items: baseline; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border-subtle)); background: var(--accent-soft); padding: var(--space-2) var(--space-5); color: var(--accent-strong); font-size: var(--text-meta); }.managed-content-notice p { margin: 0; color: inherit; font-size: inherit; }
.editor-toolbar { min-height: 44px; display: flex; align-items: center; gap: 2px; overflow-x: auto; border-bottom: 1px solid var(--border-subtle); background: var(--surface); padding: var(--space-1) var(--space-4); }.editor-toolbar button { width: 32px; height: 32px; display: grid; flex: 0 0 auto; place-items: center; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--text-muted); cursor: pointer; }.editor-toolbar button:hover { border-color: var(--border-subtle); background: var(--surface-low); color: var(--text); }.editor-toolbar button:disabled { opacity: .4; }.editor-toolbar > span { width: 1px; height: 22px; flex: 0 0 auto; background: var(--border-subtle); margin: 0 var(--space-2); }
.editor-controls { min-height: 46px; display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); border-bottom: 1px solid var(--border-subtle); background: var(--surface-low); padding: var(--space-1) var(--space-4); }.editor-mode-switch { display: flex; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); padding: 2px; }.editor-mode-switch button { min-height: 29px; display: flex; align-items: center; gap: var(--space-1); border: 0; border-radius: 3px; background: transparent; color: var(--text-muted); padding: 0 .65rem; font-size: var(--text-label); cursor: pointer; }.editor-mode-switch button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 700; }.editor-metadata-compact { display: flex; gap: var(--space-3); }.editor-metadata-compact label { display: flex; align-items: center; gap: var(--space-2); color: var(--text-faint); font-size: var(--text-label); }.editor-metadata-compact select { height: 30px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); padding: 0 .45rem; font-size: var(--text-label); }
.editor-canvas { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 210px; overflow: hidden; background: var(--surface); }.editor-resizable-group { min-width: 0; min-height: 0; }.editor-code-pane, .editor-preview-pane { min-width: 0; height: 100%; overflow: auto; }.editor-code-pane .cm-editor { height: 100%; color: var(--text); }.editor-code-pane .cm-editor.cm-focused { outline: 0; }.editor-splitter { position: relative; width: 1px; background: var(--border); }.editor-splitter::after { content: ""; position: absolute; z-index: 2; inset: 0 -4px; }.editor-splitter:hover, .editor-splitter:focus-visible, .editor-splitter[data-resize-handle-active] { background: var(--accent); box-shadow: 0 0 0 1px var(--accent); }.editor-preview-pane { position: relative; background: var(--background); padding: var(--space-6) clamp(1.25rem, 4vw, 4rem); }.editor-preview-pane .page-content { max-width: 78ch; margin: 0 auto; }.preview-label { position: sticky; z-index: 2; top: calc(var(--space-6) * -1); display: flex; align-items: center; gap: var(--space-2); width: fit-content; border: 1px solid var(--border-subtle); border-radius: var(--radius-pill); background: var(--surface-overlay); color: var(--text-faint); margin: 0 auto var(--space-5); padding: .3rem .6rem; font-size: var(--text-caption); }.preview-label span { color: var(--accent); }/* Docked beside the canvas on a wide screen; an off-canvas drawer below 1024px
(see responsive.css). `hidden` is what closes it, so the rule below has to be
explicit -- the drawer's own `display: grid` would otherwise win. */
.editor-outline[hidden] { display: none; }
.editor-outline-toggle { display: none; }
.editor-outline > div .outline-close { display: none; }
.editor-outline { min-width: 0; overflow: auto; border-left: 1px solid var(--border-subtle); background: var(--surface-low); padding: var(--space-4); }.editor-outline > div { display: flex; align-items: center; gap: var(--space-2); color: var(--text-muted); font-size: var(--text-meta); }.editor-outline > span { display: block; margin-top: var(--space-4); color: var(--text-faint); font-size: var(--text-caption); }.editor-outline ol { margin: var(--space-3) 0 0; padding: 0; list-style: none; }.editor-outline button { width: 100%; overflow: hidden; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--text-muted); padding-block: .35rem; font-size: var(--text-label); text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }.editor-outline button:hover { color: var(--accent); }
.editor-footer { min-height: 54px; display: flex; align-items: center; gap: var(--space-4); border-top: 1px solid var(--border-subtle); background: var(--surface); padding: var(--space-2) var(--space-4); }.editor-footer label { display: flex; flex: 1; align-items: center; gap: var(--space-2); color: var(--text-faint); font-size: var(--text-label); }.editor-footer input { min-width: 0; height: 30px; flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--background); color: var(--text); padding: 0 .55rem; }.editor-footer > span { color: var(--text-faint); font-size: var(--text-caption); white-space: nowrap; }
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff. Show more