feat: add datasource and definition graph contracts

This commit is contained in:
2026-07-28 12:42:49 +02:00
parent d36bb94335
commit 8b1910b5b7
13 changed files with 1297 additions and 4 deletions

View File

@@ -16,6 +16,7 @@
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
@@ -191,6 +192,23 @@
}
}
},
"../../govoplan-datasources/webui": {
"name": "@govoplan/datasources-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-docs/webui": {
"name": "@govoplan/docs-webui",
"version": "0.1.10",
@@ -1115,6 +1133,10 @@
"resolved": "../../govoplan-dataflow/webui",
"link": true
},
"node_modules/@govoplan/datasources-webui": {
"resolved": "../../govoplan-datasources/webui",
"link": true
},
"node_modules/@govoplan/docs-webui": {
"resolved": "../../govoplan-docs/webui",
"link": true

View File

@@ -14,6 +14,10 @@
"./app": {
"types": "./src/app.ts",
"import": "./src/app.ts"
},
"./definition-graph": {
"types": "./src/definitionGraph.ts",
"import": "./src/definitionGraph.ts"
}
},
"scripts": {
@@ -26,7 +30,7 @@
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
@@ -42,6 +46,7 @@
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",

View File

@@ -9,6 +9,7 @@ const packageByModule = {
campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui",
dataflow: "@govoplan/dataflow-webui",
datasources: "@govoplan/datasources-webui",
docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui",
@@ -29,6 +30,8 @@ const cases = [
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] },
{ name: "dataflow-only", modules: ["dataflow"] },
{ name: "datasources-only", modules: ["datasources"] },
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
@@ -41,7 +44,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "dataflow", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -0,0 +1,147 @@
export type DefinitionGraphPosition = {
x: number;
y: number;
};
export type DefinitionGraphPort = {
id: string;
label: string;
required: boolean;
multiple: boolean;
minimum_connections: number;
};
export type DefinitionGraphConfigField = {
id: string;
label: string;
kind: string;
required: boolean;
description?: string | null;
options: [string, string][];
};
export type DefinitionGraphNodeType = {
type: string;
category: string;
category_label: string;
label: string;
description: string;
icon: string;
input_ports: DefinitionGraphPort[];
output_ports: DefinitionGraphPort[];
config_fields: DefinitionGraphConfigField[];
default_config: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type DefinitionGraphNode = {
id: string;
type: string;
label: string;
position: DefinitionGraphPosition;
config: Record<string, unknown>;
};
export type DefinitionGraphEdge = {
id: string;
source: string;
target: string;
source_port?: string;
target_port?: string;
};
export type DefinitionGraph = {
schema_version: number;
nodes: DefinitionGraphNode[];
edges: DefinitionGraphEdge[];
};
export type DefinitionGraphConnection = {
source: string;
target: string;
sourcePort?: string | null;
targetPort?: string | null;
};
export function createDefinitionGraphNode<TNode extends DefinitionGraphNode = DefinitionGraphNode>(
type: string,
position: DefinitionGraphPosition,
library: DefinitionGraphNodeType[]
): TNode {
const definition = library.find((item) => item.type === type);
return {
id: `${type.replace(/\./g, "-")}-${crypto.randomUUID()}`,
type,
label: definition?.label ?? type,
position,
config: structuredClone(definition?.default_config ?? {})
} as TNode;
}
export function definitionConnectionError(
graph: DefinitionGraph,
library: DefinitionGraphNodeType[],
connection: DefinitionGraphConnection,
options: { allowCycles?: boolean } = {}
): string | null {
if (!connection.source || !connection.target) return "Both endpoints are required.";
if (connection.source === connection.target) return "A node cannot connect to itself.";
const source = graph.nodes.find((node) => node.id === connection.source);
const target = graph.nodes.find((node) => node.id === connection.target);
if (!source || !target) return "The connection references an unknown node.";
const sourceDefinition = library.find((item) => item.type === source.type);
const targetDefinition = library.find((item) => item.type === target.type);
if (!sourceDefinition || !targetDefinition) return "The node type is not in this library.";
const sourcePort = connection.sourcePort ?? "output";
const targetPort = connection.targetPort ?? "input";
if (!sourceDefinition.output_ports.some((port) => port.id === sourcePort)) {
return "The source port is not available.";
}
const port = targetDefinition.input_ports.find((item) => item.id === targetPort);
if (!port) return "The target port is not available.";
if (graph.edges.some(
(edge) =>
edge.source === connection.source
&& edge.target === connection.target
&& (edge.source_port ?? "output") === sourcePort
&& (edge.target_port ?? "input") === targetPort
)) {
return "This connection already exists.";
}
if (!port.multiple && graph.edges.some(
(edge) =>
edge.target === connection.target
&& (edge.target_port ?? "input") === targetPort
)) {
return "The target port accepts only one connection.";
}
if (!options.allowCycles && wouldCreateDefinitionCycle(
graph,
connection.source,
connection.target
)) {
return "This connection would create a cycle.";
}
return null;
}
export function wouldCreateDefinitionCycle(
graph: DefinitionGraph,
source: string,
target: string
): boolean {
const outgoing = new Map<string, string[]>();
graph.edges.forEach((edge) => {
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]);
});
const pending = [target];
const visited = new Set<string>();
while (pending.length) {
const nodeId = pending.pop();
if (!nodeId || visited.has(nodeId)) continue;
if (nodeId === source) return true;
visited.add(nodeId);
pending.push(...(outgoing.get(nodeId) ?? []));
}
return false;
}

View File

@@ -1,4 +1,4 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, type LucideIcon } from "lucide-react";
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
import {
@@ -49,6 +49,7 @@ const iconByName: Record<string, LucideIcon> = {
campaign: Mails,
"clipboard-pen-line": ClipboardPenLine,
dashboard: LayoutDashboard,
"database-zap": DatabaseZap,
file: Folder,
files: Folder,
folder: Folder,

View File

@@ -0,0 +1,68 @@
import {
createDefinitionGraphNode,
definitionConnectionError,
type DefinitionGraph,
type DefinitionGraphNodeType
} from "../src/definitionGraph";
const library: DefinitionGraphNodeType[] = [
{
type: "start",
category: "control",
category_label: "Control",
label: "Start",
description: "Start",
icon: "play",
input_ports: [],
output_ports: [
{ id: "output", label: "Output", required: true, multiple: false, minimum_connections: 1 }
],
config_fields: [],
default_config: { mode: "manual" }
},
{
type: "end",
category: "control",
category_label: "Control",
label: "End",
description: "End",
icon: "circle-stop",
input_ports: [
{ id: "input", label: "Input", required: true, multiple: false, minimum_connections: 1 }
],
output_ports: [],
config_fields: [],
default_config: {}
}
];
const graph: DefinitionGraph = {
schema_version: 1,
nodes: [
{ id: "start", type: "start", label: "Start", position: { x: 0, y: 0 }, config: {} },
{ id: "end", type: "end", label: "End", position: { x: 100, y: 0 }, config: {} }
],
edges: []
};
if (definitionConnectionError(
graph,
library,
{ source: "start", target: "end" }
) !== null) {
throw new Error("Expected a valid library-driven connection.");
}
graph.edges.push({ id: "edge", source: "start", target: "end" });
if (!definitionConnectionError(
graph,
library,
{ source: "start", target: "end" }
)) {
throw new Error("Expected duplicate connection validation.");
}
const created = createDefinitionGraphNode("start", { x: 40, y: 50 }, library);
if (created.config.mode !== "manual" || created.position.x !== 40) {
throw new Error("Expected node creation to clone library defaults.");
}

View File

@@ -18,9 +18,11 @@
"tests/module-capabilities.test.ts",
"tests/privacy-policy.test.ts",
"tests/help-context.test.ts",
"tests/definition-graph.test.ts",
"src/platform/moduleLogic.ts",
"src/utils/helpContext.ts",
"src/features/privacy/policyLogic.ts",
"src/definitionGraph.ts",
"src/types.ts"
]
}

View File

@@ -18,6 +18,7 @@ const defaultWebModulePackages = [
"@govoplan/calendar-webui",
"@govoplan/campaign-webui",
"@govoplan/dataflow-webui",
"@govoplan/datasources-webui",
"@govoplan/dashboard-webui",
"@govoplan/docs-webui",
"@govoplan/files-webui",
@@ -89,6 +90,7 @@ export default defineConfig({
dedupe: ["react", "react-dom", "react-router-dom"],
alias: [
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) },
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
]
},
@@ -102,6 +104,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dataflow/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-datasources/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),