Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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 |
|
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 |
|
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 |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
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 |
|
After Width: | Height: | Size: 4.5 KiB |
|
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 |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[] };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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>;
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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"
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CompleteSetupResponse = {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||