Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2582a278e | ||
|
|
6e03338b13 | ||
|
|
bf77ac2a8d | ||
|
|
4aa19840bc | ||
|
|
fa0bdde839 | ||
|
|
8e5415dfe7 | ||
|
|
d5214c641c | ||
|
|
0fd744a2db | ||
|
|
f49ad8eff4 | ||
|
|
244c0ee4c1 | ||
|
|
a071692e75 | ||
|
|
55bbd15ecc | ||
|
|
8305bd5c5b |
@@ -33,3 +33,21 @@ in [the interface pattern audit](docs/INTERFACE_PATTERN_MIGRATION.md).
|
|||||||
cd webui && npm run typecheck
|
cd webui && npm run typecheck
|
||||||
cd webui && npm run test:interface-pattern
|
cd webui && npm run test:interface-pattern
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/workflow-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/workflow-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/workflow-webui",
|
||||||
|
"version": "0.1.23",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-workflow"
|
name = "govoplan-workflow"
|
||||||
version = "0.1.17"
|
version = "0.1.23"
|
||||||
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
|
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.17",
|
"govoplan-core>=0.1.45",
|
||||||
"govoplan-workflow-engine>=0.1.17",
|
"govoplan-workflow-engine>=0.1.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Optional GovOPlaN Workflow editor and compatibility package."""
|
"""Optional GovOPlaN Workflow editor and compatibility package."""
|
||||||
|
|
||||||
__version__ = "0.1.17"
|
__version__ = "0.1.23"
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
ModuleInterfaceRequirement,
|
ModuleInterfaceRequirement,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
|
ProductAreaContribution,
|
||||||
ViewSurface,
|
ViewSurface,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
semantic_documentation_subject_capability,
|
||||||
|
)
|
||||||
|
from govoplan_workflow.backend.semantic_subjects import (
|
||||||
|
WorkflowSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
from govoplan_workflow_engine.backend.manifest import (
|
from govoplan_workflow_engine.backend.manifest import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
DEFINITION_READ_SCOPE,
|
DEFINITION_READ_SCOPE,
|
||||||
@@ -24,7 +35,14 @@ from govoplan_workflow_engine.backend.manifest import (
|
|||||||
|
|
||||||
MODULE_ID = "workflow"
|
MODULE_ID = "workflow"
|
||||||
MODULE_NAME = "Workflow"
|
MODULE_NAME = "Workflow"
|
||||||
MODULE_VERSION = "0.1.17"
|
MODULE_VERSION = "0.1.23"
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_subjects(
|
||||||
|
context: ModuleContext,
|
||||||
|
) -> WorkflowSemanticDocumentationSubjectProvider:
|
||||||
|
return WorkflowSemanticDocumentationSubjectProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
@@ -32,9 +50,28 @@ manifest = ModuleManifest(
|
|||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("workflow_engine",),
|
dependencies=("workflow_engine",),
|
||||||
|
optional_dependencies=("docs",),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
|
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=SEMANTIC_SUBJECT_CAPABILITY,
|
||||||
|
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
capability_factories={
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Workflow semantic-documentation subjects",
|
||||||
|
summary=(
|
||||||
|
"Lists currently authorized workflow definitions and stable "
|
||||||
|
"step lineages with documentation-safe review fingerprints."
|
||||||
|
),
|
||||||
|
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
),
|
||||||
|
},
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="workflow.definition_graph",
|
name="workflow.definition_graph",
|
||||||
@@ -81,6 +118,17 @@ manifest = ModuleManifest(
|
|||||||
order=74,
|
order=74,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="work",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.work",
|
||||||
|
icon="list-checks",
|
||||||
|
description="i18n:govoplan-core.product_area.work_description",
|
||||||
|
surface_ids=("workflow.nav.workflow", "workflow.route.workflow"),
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="workflow.widget.open-work",
|
id="workflow.widget.open-work",
|
||||||
@@ -92,6 +140,139 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.workspace-layout",
|
||||||
|
title="Workflow workspace actions",
|
||||||
|
summary="Find collection-wide commands in their consistent workspace position.",
|
||||||
|
body="Reload and New workflow use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. Graph editing, validation, saving, publication, and execution remain separate pane-local actions. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=5,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Workflows: Aktionen im Arbeitsbereich",
|
||||||
|
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
|
||||||
|
"body": "Neu laden und Neuer Workflow stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. Graphbearbeitung, Validierung, Speichern, Veröffentlichung und Ausführung bleiben getrennte Aktionen im jeweiligen Bereich. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
|
||||||
|
}},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.external-handoff-inspection",
|
||||||
|
title="Inspect and return to external work hand-offs",
|
||||||
|
summary=(
|
||||||
|
"Follow the exact linked module work while Workflow retains "
|
||||||
|
"its durable position and immutable reference."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"The runs dialog shows the current external hand-off state, exact "
|
||||||
|
"revision reference, safe linked-work action, timeout, and the "
|
||||||
|
"immutable Workflow evidence trail. Leaving the dialog or linked "
|
||||||
|
"Campaign page does not complete the step. Declared Campaign events "
|
||||||
|
"drive acceptance, reassignment, completion, rejection, and "
|
||||||
|
"cancellation without browser polling. A configured focused View "
|
||||||
|
"narrows the available surfaces while the step is active. If Views "
|
||||||
|
"is absent, the linked Campaign action remains available and the "
|
||||||
|
"dialog explains the reduced presentation. Missing optional Tasks or "
|
||||||
|
"Notifications is also shown without changing the hand-off outcome."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "workflow_designer", "campaign_manager"),
|
||||||
|
related_modules=(
|
||||||
|
"workflow_engine",
|
||||||
|
"campaigns",
|
||||||
|
"views",
|
||||||
|
"tasks",
|
||||||
|
"notifications",
|
||||||
|
),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(INSTANCE_READ_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Externe Arbeitsübergaben prüfen und zu ihnen zurückkehren",
|
||||||
|
"summary": "Der genau verknüpften Arbeit im Zielmodul folgen, während Workflow seine dauerhafte Position und unveränderliche Referenz beibehält.",
|
||||||
|
"body": (
|
||||||
|
"Der Läufe-Dialog zeigt den aktuellen Zustand der externen Übergabe, die genaue Revisionsreferenz, die sichere Aktion zur verknüpften Arbeit, den Zeitablauf und die unveränderliche Workflow-Nachweisspur. "
|
||||||
|
"Das Verlassen des Dialogs oder der verknüpften Campaign-Seite schließt den Schritt nicht ab. Deklarierte Campaign-Ereignisse steuern Annahme, Neuzuweisung, Abschluss, Ablehnung und Abbruch ohne Browser-Abfragen. "
|
||||||
|
"Eine konfigurierte fokussierte View grenzt die verfügbaren Oberflächen ein, solange der Schritt aktiv ist. Fehlt Views, bleibt die verknüpfte Campaign-Aktion verfügbar und der Dialog erläutert die reduzierte Darstellung. "
|
||||||
|
"Auch fehlende optionale Module Tasks oder Notifications werden angezeigt, ohne das Ergebnis der Übergabe zu verändern."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"workflow.external-handoff",
|
||||||
|
"workflow.instances",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=74,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.semantic-documentation",
|
||||||
|
title="Document configured workflow and step meaning",
|
||||||
|
summary=(
|
||||||
|
"Attach tenant-owned semantic guidance to an authorized workflow "
|
||||||
|
"definition or stable step without changing execution."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"When Docs is installed, Workflow supplies documentation-safe "
|
||||||
|
"subjects for each definition the current actor may view and for "
|
||||||
|
"each step in its current immutable revision. Step identity survives "
|
||||||
|
"label and canvas-position changes. Removing and later recreating a "
|
||||||
|
"step with the same graph ID creates a new lineage, leaving prior "
|
||||||
|
"documentation explicitly orphaned. Relevant changes to step type, "
|
||||||
|
"label, hierarchy, safe configuration, or connected flow semantics "
|
||||||
|
"change the review fingerprint; layout-only movement does not. "
|
||||||
|
"Semantic prose can explain purpose, non-purpose, entry and exit "
|
||||||
|
"conditions, handoffs, exceptions, ownership, stewardship, and "
|
||||||
|
"examples, but it cannot alter validation, activation, policy, or "
|
||||||
|
"Workflow Engine execution. Workflow rechecks tenant scope and the "
|
||||||
|
"current per-definition governance decision for discovery and direct "
|
||||||
|
"resolution. If Docs is absent, editing and static help continue."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=(
|
||||||
|
"user",
|
||||||
|
"workflow_designer",
|
||||||
|
"process_owner",
|
||||||
|
"module_admin",
|
||||||
|
),
|
||||||
|
related_modules=("docs", "workflow_engine", "policy"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Semantic documentation authoring",
|
||||||
|
href="/docs/semantic",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Workflow editor and engine boundary",
|
||||||
|
href="govoplan-workflow/docs/ENGINE_EDITOR_SPLIT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Bedeutung konfigurierter Workflows und Schritte dokumentieren",
|
||||||
|
"summary": "Mandanteneigene semantische Hinweise einer berechtigten Workflow-Definition oder einem stabilen Schritt zuordnen, ohne die Ausführung zu verändern.",
|
||||||
|
"body": (
|
||||||
|
"Wenn Docs installiert ist, stellt Workflow dokumentationssichere Subjekte für jede Definition bereit, die der aktuelle Akteur sehen darf, sowie für jeden Schritt ihrer aktuellen unveränderlichen Revision. "
|
||||||
|
"Die Schrittidentität bleibt bei Änderungen von Bezeichnung und Position auf der Zeichenfläche erhalten. Wird ein Schritt entfernt und später mit derselben Graph-ID neu angelegt, entsteht eine neue Abstammung; frühere Dokumentation bleibt ausdrücklich verwaist. "
|
||||||
|
"Relevante Änderungen an Schritttyp, Bezeichnung, Hierarchie, sicherer Konfiguration oder der Semantik verbundener Flüsse ändern den Prüffingerabdruck; reine Layoutverschiebungen tun dies nicht. "
|
||||||
|
"Semantische Texte können Zweck, Nicht-Zweck, Ein- und Austrittsbedingungen, Übergaben, Ausnahmen, Verantwortung, Betreuung und Beispiele erläutern, aber weder Validierung, Aktivierung, Richtlinien noch die Ausführung durch Workflow Engine verändern. "
|
||||||
|
"Workflow prüft bei Ermittlung und direkter Auflösung erneut Mandantengrenze und aktuelle Governance-Entscheidung je Definition. Fehlt Docs, bleiben Bearbeitung und statische Hilfe verfügbar."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["workflow.semantic-documentation"],
|
||||||
|
},
|
||||||
|
order=75,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="workflow.editor",
|
id="workflow.editor",
|
||||||
title="Workflow editor and inspection workspace",
|
title="Workflow editor and inspection workspace",
|
||||||
@@ -104,7 +285,16 @@ manifest = ModuleManifest(
|
|||||||
"catalogue, immutable revision comparison, activation controls, "
|
"catalogue, immutable revision comparison, activation controls, "
|
||||||
"instance inspection, and governed override/reset experience. "
|
"instance inspection, and governed override/reset experience. "
|
||||||
"Definitions and instances remain owned and executed by the "
|
"Definitions and instances remain owned and executed by the "
|
||||||
"headless Workflow Engine module."
|
"headless Workflow Engine module. Deleting a flow or node changes only "
|
||||||
|
"the editable draft until that definition is saved; deleting a workflow "
|
||||||
|
"removes its definition revisions and requires explicit confirmation. "
|
||||||
|
"Retained execution and audit evidence remains governed by Workflow Engine. "
|
||||||
|
"Opening or reloading the editor does not execute a workflow. If the editor "
|
||||||
|
"cannot be loaded after a development update, preserve any unsaved work "
|
||||||
|
"before reloading the browser. Administrators should distinguish failed "
|
||||||
|
"frontend assets from definition API or access errors. Development and "
|
||||||
|
"browser-conformance servers use separate dependency caches; an older "
|
||||||
|
"server may need restarting after upgrading this configuration."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -117,6 +307,21 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Arbeitsbereich für Workflow-Bearbeitung und -Prüfung",
|
||||||
|
"summary": "Optionale Erstellung, Validierung, Revisionsprüfung und Betriebssteuerung für Workflow Engine.",
|
||||||
|
"body": (
|
||||||
|
"Workflow ergänzt den visuellen BPMN- und nativen Graph-Editor, den Definitionskatalog, den Vergleich unveränderlicher Revisionen, Aktivierungssteuerung, Instanzprüfung und gesteuerte Eingriffe zum Überschreiben oder Zurücksetzen. "
|
||||||
|
"Definitionen und Instanzen bleiben Eigentum des ohne eigene Bedienoberfläche betriebenen Moduls Workflow Engine und werden dort ausgeführt. Das Löschen eines Flusses oder Knotens verändert nur den bearbeitbaren Entwurf, bis die Definition gespeichert wird. Das Löschen eines Workflows entfernt dessen Definitionsrevisionen und verlangt eine ausdrückliche Bestätigung. Aufbewahrte Ausführungs- und Auditnachweise bleiben durch Workflow Engine geregelt. "
|
||||||
|
"Das Öffnen oder Neuladen des Editors führt keinen Workflow aus. Kann der Editor nach einer Entwicklungsaktualisierung nicht geladen werden, sichern Sie ungespeicherte Arbeit vor dem Neuladen des Browsers. Administratoren sollten fehlgeschlagene Oberflächendateien von Fehlern der Definitions-API oder Zugriffsfehlern unterscheiden. Entwicklungs- und Browser-Konformitätsserver verwenden getrennte Abhängigkeitscaches; ein älterer Server muss nach dieser Konfigurationsaktualisierung gegebenenfalls neu gestartet werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["workflow.action.delete-definition-parts"],
|
||||||
|
},
|
||||||
order=76,
|
order=76,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -126,8 +331,16 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
|
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
|
||||||
test_ref="tests/test_manifest.py",
|
test_ref="tests/test_manifest.py",
|
||||||
known_limits=("The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",),
|
known_limits=(
|
||||||
owned_concepts=("workflow editing surface", "workflow revision inspection", "workflow activation controls"),
|
"The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",
|
||||||
|
"Semantic documentation requires Docs for tenant-authored content; static Workflow help remains available without it.",
|
||||||
|
),
|
||||||
|
owned_concepts=(
|
||||||
|
"workflow editing surface",
|
||||||
|
"workflow revision inspection",
|
||||||
|
"workflow activation controls",
|
||||||
|
"workflow semantic documentation subjects",
|
||||||
|
),
|
||||||
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
|
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
|
||||||
security_docs=("docs/CONCEPT.md",),
|
security_docs=("docs/CONCEPT.md",),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
SemanticDocumentationBreadcrumb,
|
||||||
|
SemanticDocumentationSubjectAnchor,
|
||||||
|
SemanticDocumentationSubjectDescriptor,
|
||||||
|
SemanticDocumentationSubjectPage,
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
SemanticDocumentationSubjectReference,
|
||||||
|
SemanticDocumentationSubjectResolution,
|
||||||
|
semantic_documentation_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.redaction import redact_secret_values
|
||||||
|
from govoplan_workflow.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
from govoplan_workflow.backend.governance import definition_decision
|
||||||
|
from govoplan_workflow.backend.service import (
|
||||||
|
get_definition_revision,
|
||||||
|
list_definition_revisions,
|
||||||
|
list_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SUBJECT_KIND = "workflow_definition"
|
||||||
|
ADMIN_SCOPE = "workflow:instance:admin"
|
||||||
|
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||||
|
_MAX_SUBJECTS = 20_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LineageState:
|
||||||
|
node_ids: Mapping[str, str]
|
||||||
|
historical_node_ids: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSemanticDocumentationSubjectProvider:
|
||||||
|
provider_id = "workflow.semantic_subjects"
|
||||||
|
module_id = "workflow"
|
||||||
|
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def list_subjects(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: SemanticDocumentationSubjectQuery,
|
||||||
|
) -> SemanticDocumentationSubjectPage:
|
||||||
|
actor = _principal(principal, tenant_id=request.tenant_id)
|
||||||
|
if actor is None:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
db = _session(session)
|
||||||
|
subjects: list[SemanticDocumentationSubjectDescriptor] = []
|
||||||
|
for definition in list_definitions(db, tenant_id=request.tenant_id):
|
||||||
|
if not self._can_view(definition, actor):
|
||||||
|
continue
|
||||||
|
revision = get_definition_revision(db, definition=definition)
|
||||||
|
lineage = _lineage_state(db, definition)
|
||||||
|
subjects.extend(
|
||||||
|
_descriptors(
|
||||||
|
definition,
|
||||||
|
revision,
|
||||||
|
lineage,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(subjects) > _MAX_SUBJECTS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow semantic subject limit exceeded; narrow the query."
|
||||||
|
)
|
||||||
|
query = request.query.casefold().strip()
|
||||||
|
if query:
|
||||||
|
subjects = [
|
||||||
|
item
|
||||||
|
for item in subjects
|
||||||
|
if query in _search_text(item).casefold()
|
||||||
|
]
|
||||||
|
offset = _cursor_offset(request.cursor)
|
||||||
|
selected = tuple(subjects[offset : offset + request.limit])
|
||||||
|
next_offset = offset + len(selected)
|
||||||
|
has_more = next_offset < len(subjects)
|
||||||
|
return SemanticDocumentationSubjectPage(
|
||||||
|
subjects=selected,
|
||||||
|
next_cursor=str(next_offset) if has_more else None,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: SemanticDocumentationSubjectReference,
|
||||||
|
) -> SemanticDocumentationSubjectResolution | None:
|
||||||
|
actor = _principal(principal, tenant_id=reference.tenant_id)
|
||||||
|
if (
|
||||||
|
reference.module_id != self.module_id
|
||||||
|
or reference.subject_kind != SUBJECT_KIND
|
||||||
|
or actor is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
db = _session(session)
|
||||||
|
definition = db.scalar(
|
||||||
|
select(WorkflowDefinition).where(
|
||||||
|
WorkflowDefinition.id == reference.subject_id,
|
||||||
|
or_(
|
||||||
|
WorkflowDefinition.tenant_id == reference.tenant_id,
|
||||||
|
WorkflowDefinition.tenant_id.is_(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if definition is None or not self._can_view(definition, actor):
|
||||||
|
return None
|
||||||
|
if definition.deleted_at is not None:
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code="definition_deleted",
|
||||||
|
)
|
||||||
|
revision = get_definition_revision(db, definition=definition)
|
||||||
|
lineage = _lineage_state(db, definition)
|
||||||
|
descriptor = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in _descriptors(
|
||||||
|
definition,
|
||||||
|
revision,
|
||||||
|
lineage,
|
||||||
|
tenant_id=reference.tenant_id,
|
||||||
|
)
|
||||||
|
if item.reference.stable_key == reference.stable_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
anchor = reference.anchor
|
||||||
|
reason = "subject_missing"
|
||||||
|
if anchor is not None and anchor.kind == "step":
|
||||||
|
reason = (
|
||||||
|
"step_deleted"
|
||||||
|
if anchor.id in lineage.historical_node_ids
|
||||||
|
else "step_missing"
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code=reason,
|
||||||
|
)
|
||||||
|
changed = any(
|
||||||
|
expected is not None and expected != actual
|
||||||
|
for expected, actual in (
|
||||||
|
(reference.observed_revision, descriptor.reference.observed_revision),
|
||||||
|
(
|
||||||
|
reference.observed_fingerprint,
|
||||||
|
descriptor.reference.observed_fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="changed" if changed else "available",
|
||||||
|
subject=descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _can_view(
|
||||||
|
self,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> bool:
|
||||||
|
return definition_decision(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=self._registry,
|
||||||
|
action="view",
|
||||||
|
).allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptors(
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
revision: WorkflowDefinitionRevision,
|
||||||
|
lineage: _LineageState,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> tuple[SemanticDocumentationSubjectDescriptor, ...]:
|
||||||
|
route = f"/workflow?definition={quote(definition.id, safe='')}"
|
||||||
|
definition_fingerprint = semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"revision": definition.current_revision,
|
||||||
|
"content_hash": revision.content_hash,
|
||||||
|
"name": definition.name,
|
||||||
|
"description": definition.description,
|
||||||
|
"status": definition.status,
|
||||||
|
"active_revision": definition.active_revision,
|
||||||
|
"scope_type": definition.scope_type,
|
||||||
|
"definition_kind": definition.definition_kind,
|
||||||
|
"allow_start": definition.allow_start,
|
||||||
|
"allow_reuse": definition.allow_reuse,
|
||||||
|
"allow_automation": definition.allow_automation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = [
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
revision=str(definition.current_revision),
|
||||||
|
fingerprint=definition_fingerprint,
|
||||||
|
),
|
||||||
|
labels={"en": definition.name},
|
||||||
|
descriptions=(
|
||||||
|
{"en": definition.description} if definition.description else {}
|
||||||
|
),
|
||||||
|
route=route,
|
||||||
|
# The provider already applies the engine's READ-or-ADMIN and
|
||||||
|
# per-definition governance decision. The descriptor contract
|
||||||
|
# represents an AND-only scope list, so it cannot restate that
|
||||||
|
# disjunction without incorrectly excluding administrators.
|
||||||
|
required_scopes=(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
|
||||||
|
nodes = tuple(
|
||||||
|
item for item in graph.get("nodes", ()) if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
edges = tuple(
|
||||||
|
item for item in graph.get("edges", ()) if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
node_by_id = {str(item.get("id")): item for item in nodes if item.get("id")}
|
||||||
|
for node_id, node in node_by_id.items():
|
||||||
|
identity = lineage.node_ids[node_id]
|
||||||
|
fingerprint = _node_fingerprint(node, edges)
|
||||||
|
label = str(node.get("label") or node.get("type") or node_id)[:300]
|
||||||
|
breadcrumbs = [
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=definition.name,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
parent_id = str(node.get("parent_id") or "")
|
||||||
|
parent = node_by_id.get(parent_id)
|
||||||
|
if parent is not None and parent_id in lineage.node_ids:
|
||||||
|
breadcrumbs.append(
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=str(parent.get("label") or parent_id)[:300],
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="step",
|
||||||
|
id=lineage.node_ids[parent_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.append(
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="step", id=identity
|
||||||
|
),
|
||||||
|
revision=fingerprint,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
),
|
||||||
|
labels={"en": label},
|
||||||
|
descriptions={
|
||||||
|
"en": f"Configured {str(node.get('type') or 'workflow step')[:200]} step."
|
||||||
|
},
|
||||||
|
breadcrumbs=tuple(breadcrumbs),
|
||||||
|
route=route,
|
||||||
|
route_anchor=_route_anchor(node_id),
|
||||||
|
required_scopes=(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference(
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
revision: str,
|
||||||
|
fingerprint: str,
|
||||||
|
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||||
|
) -> SemanticDocumentationSubjectReference:
|
||||||
|
return SemanticDocumentationSubjectReference(
|
||||||
|
module_id="workflow",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
anchor=anchor,
|
||||||
|
observed_revision=revision,
|
||||||
|
observed_fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_state(
|
||||||
|
session: Session,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
) -> _LineageState:
|
||||||
|
revisions = sorted(
|
||||||
|
list_definition_revisions(session, definition=definition),
|
||||||
|
key=lambda item: item.revision,
|
||||||
|
)
|
||||||
|
active: dict[str, str] = {}
|
||||||
|
historical: set[str] = set()
|
||||||
|
for revision in revisions:
|
||||||
|
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
|
||||||
|
node_ids = {
|
||||||
|
str(item.get("id"))
|
||||||
|
for item in graph.get("nodes", ())
|
||||||
|
if isinstance(item, Mapping) and item.get("id")
|
||||||
|
}
|
||||||
|
active = {key: value for key, value in active.items() if key in node_ids}
|
||||||
|
for node_id in sorted(node_ids):
|
||||||
|
active.setdefault(node_id, _lineage_id(revision.id, node_id))
|
||||||
|
historical.add(active[node_id])
|
||||||
|
return _LineageState(
|
||||||
|
node_ids=active,
|
||||||
|
historical_node_ids=frozenset(historical),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_fingerprint(
|
||||||
|
node: Mapping[str, object],
|
||||||
|
edges: tuple[Mapping[str, object], ...],
|
||||||
|
) -> str:
|
||||||
|
node_id = str(node.get("id") or "")
|
||||||
|
connected = [
|
||||||
|
{
|
||||||
|
"id": edge.get("id"),
|
||||||
|
"type": edge.get("type"),
|
||||||
|
"label": edge.get("label"),
|
||||||
|
"source": edge.get("source"),
|
||||||
|
"target": edge.get("target"),
|
||||||
|
"source_port": edge.get("source_port"),
|
||||||
|
"target_port": edge.get("target_port"),
|
||||||
|
"config": redact_secret_values(edge.get("config") or {}),
|
||||||
|
}
|
||||||
|
for edge in edges
|
||||||
|
if node_id in {str(edge.get("source") or ""), str(edge.get("target") or "")}
|
||||||
|
]
|
||||||
|
connected.sort(key=lambda item: str(item["id"] or ""))
|
||||||
|
return semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"type": node.get("type"),
|
||||||
|
"label": node.get("label"),
|
||||||
|
"parent_id": node.get("parent_id"),
|
||||||
|
"process_id": node.get("process_id"),
|
||||||
|
"config": redact_secret_values(node.get("config") or {}),
|
||||||
|
"connections": connected,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_id(revision_id: str, node_id: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{revision_id}\x1f{node_id}".encode()).hexdigest()
|
||||||
|
return f"step-{digest[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _route_anchor(node_id: str) -> str:
|
||||||
|
route_anchor = f"workflow-node-{node_id}"
|
||||||
|
if len(route_anchor) <= 255:
|
||||||
|
return route_anchor
|
||||||
|
digest = hashlib.sha256(node_id.encode()).hexdigest()
|
||||||
|
return f"workflow-node-{digest[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
|
||||||
|
return " ".join(
|
||||||
|
(
|
||||||
|
item.reference.subject_id,
|
||||||
|
*item.labels.values(),
|
||||||
|
*item.descriptions.values(),
|
||||||
|
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> ApiPrincipal | None:
|
||||||
|
if not isinstance(principal, ApiPrincipal) or principal.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
if not (
|
||||||
|
principal.has(DEFINITION_READ_SCOPE)
|
||||||
|
or principal.has(ADMIN_SCOPE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return principal
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_offset(value: str | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if not value.isdigit() or int(value) < 0:
|
||||||
|
raise ValueError("Workflow semantic subject cursor is invalid.")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Workflow semantic subjects require a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SUBJECT_KIND",
|
||||||
|
"WorkflowSemanticDocumentationSubjectProvider",
|
||||||
|
]
|
||||||
+37
-1
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_workflow.backend.manifest import get_manifest
|
from govoplan_workflow.backend.manifest import get_manifest
|
||||||
@@ -10,6 +12,8 @@ class WorkflowManifestTests(unittest.TestCase):
|
|||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual("workflow", manifest.id)
|
self.assertEqual("workflow", manifest.id)
|
||||||
|
project = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text())
|
||||||
|
self.assertEqual(project["project"]["version"], manifest.version)
|
||||||
self.assertEqual(("workflow_engine",), manifest.dependencies)
|
self.assertEqual(("workflow_engine",), manifest.dependencies)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"workflow.definition_graph", "workflow.definition_catalogue", "workflow.bpmn_interchange"},
|
{"workflow.definition_graph", "workflow.definition_catalogue", "workflow.bpmn_interchange"},
|
||||||
@@ -22,12 +26,44 @@ class WorkflowManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual((), manifest.permissions)
|
self.assertEqual((), manifest.permissions)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertIsNone(manifest.route_factory)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertIsNone(manifest.migration_spec)
|
||||||
self.assertEqual({}, manifest.capability_factories)
|
self.assertIn("documentation.semantic_subjects.workflow", manifest.capability_factories)
|
||||||
|
self.assertIn("docs", manifest.optional_dependencies)
|
||||||
|
self.assertIn(
|
||||||
|
"documentation.semantic_subjects.workflow",
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"workflow.semantic-documentation",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"@govoplan/workflow-webui",
|
"@govoplan/workflow-webui",
|
||||||
manifest.frontend.package_name if manifest.frontend else None,
|
manifest.frontend.package_name if manifest.frontend else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(german.get(field) for field in ("title", "summary", "body")),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_editor_exposes_semantic_help_without_replacing_static_help(self) -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
page = (root / "webui/src/features/workflow/WorkflowPage.tsx").read_text()
|
||||||
|
inspector = (
|
||||||
|
root / "webui/src/features/workflow/WorkflowInspector.tsx"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
self.assertIn("workflow.semantic-documentation", {
|
||||||
|
topic.id for topic in get_manifest().documentation
|
||||||
|
})
|
||||||
|
self.assertIn('"semantic"', page)
|
||||||
|
self.assertIn('"workflow_definition"', page)
|
||||||
|
self.assertIn('target="_blank"', page)
|
||||||
|
self.assertIn('target="_blank"', inspector)
|
||||||
|
self.assertIn("workflow-node-${nodeId}", inspector)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_workflow.backend.semantic_subjects import (
|
||||||
|
WorkflowSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.schemas import (
|
||||||
|
WorkflowDefinitionCreateRequest,
|
||||||
|
WorkflowDefinitionUpdateRequest,
|
||||||
|
WorkflowEdge,
|
||||||
|
WorkflowGraph,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowPosition,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.service import (
|
||||||
|
create_definition,
|
||||||
|
delete_definition,
|
||||||
|
update_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
scopes: frozenset[str] = frozenset({"workflow:definition:read"}),
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="author-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=scopes,
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def graph(
|
||||||
|
*,
|
||||||
|
activity_label: str = "Review",
|
||||||
|
activity_x: float = 280,
|
||||||
|
include_activity: bool = True,
|
||||||
|
) -> WorkflowGraph:
|
||||||
|
nodes = [
|
||||||
|
WorkflowNode(
|
||||||
|
id="start",
|
||||||
|
type="workflow.start.manual",
|
||||||
|
label="Start",
|
||||||
|
position=WorkflowPosition(x=40, y=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if include_activity:
|
||||||
|
nodes.append(
|
||||||
|
WorkflowNode(
|
||||||
|
id="activity",
|
||||||
|
type="workflow.activity",
|
||||||
|
label=activity_label,
|
||||||
|
position=WorkflowPosition(x=activity_x, y=100),
|
||||||
|
config={
|
||||||
|
"instructions": "Check the submitted evidence.",
|
||||||
|
"api_secret": "must-not-leak",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes.append(
|
||||||
|
WorkflowNode(
|
||||||
|
id="complete",
|
||||||
|
type="workflow.end.completed",
|
||||||
|
label="Completed",
|
||||||
|
position=WorkflowPosition(x=520, y=100),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
edges = []
|
||||||
|
if include_activity:
|
||||||
|
edges.extend(
|
||||||
|
(
|
||||||
|
WorkflowEdge(id="start-activity", source="start", target="activity"),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="activity-complete",
|
||||||
|
source="activity",
|
||||||
|
target="complete",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
edges.append(
|
||||||
|
WorkflowEdge(id="start-complete", source="start", target="complete")
|
||||||
|
)
|
||||||
|
return WorkflowGraph(nodes=nodes, edges=edges)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSemanticSubjectTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=(
|
||||||
|
WorkflowDefinition.__table__,
|
||||||
|
WorkflowDefinitionRevision.__table__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = principal()
|
||||||
|
self.provider = WorkflowSemanticDocumentationSubjectProvider(None)
|
||||||
|
self.definition = create_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="author-1",
|
||||||
|
payload=WorkflowDefinitionCreateRequest(
|
||||||
|
name="Resident permit review",
|
||||||
|
description="Review an application before issuing the permit.",
|
||||||
|
graph=graph(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def subjects(self):
|
||||||
|
return self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=200,
|
||||||
|
),
|
||||||
|
).subjects
|
||||||
|
|
||||||
|
def activity(self):
|
||||||
|
return next(
|
||||||
|
item
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.route_anchor == "workflow-node-activity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def revise(self, updated_graph: WorkflowGraph) -> None:
|
||||||
|
update_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=self.definition.id,
|
||||||
|
actor_id="author-1",
|
||||||
|
payload=WorkflowDefinitionUpdateRequest(
|
||||||
|
name=self.definition.name,
|
||||||
|
description=self.definition.description,
|
||||||
|
graph=updated_graph,
|
||||||
|
expected_revision=self.definition.current_revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def test_exposes_safe_definition_and_step_descriptors(self) -> None:
|
||||||
|
subjects = self.subjects()
|
||||||
|
self.assertEqual(4, len(subjects))
|
||||||
|
definition = next(item for item in subjects if item.reference.anchor is None)
|
||||||
|
activity = self.activity()
|
||||||
|
|
||||||
|
self.assertEqual("Resident permit review", definition.labels["en"])
|
||||||
|
self.assertEqual("step", activity.reference.anchor.kind)
|
||||||
|
self.assertTrue(activity.reference.anchor.id.startswith("step-"))
|
||||||
|
self.assertEqual("/workflow?definition=" + self.definition.id, activity.route)
|
||||||
|
payload = activity.to_dict()
|
||||||
|
self.assertNotIn("config", payload)
|
||||||
|
self.assertNotIn("must-not-leak", str(payload))
|
||||||
|
|
||||||
|
def test_lineage_survives_layout_and_label_changes_with_review_fingerprint(self) -> None:
|
||||||
|
before = self.activity().reference
|
||||||
|
self.revise(graph(activity_x=900))
|
||||||
|
after_layout = self.activity().reference
|
||||||
|
self.assertEqual(before.stable_key, after_layout.stable_key)
|
||||||
|
self.assertEqual(
|
||||||
|
before.observed_fingerprint,
|
||||||
|
after_layout.observed_fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.revise(graph(activity_x=900, activity_label="Assess evidence"))
|
||||||
|
after_label = self.activity().reference
|
||||||
|
self.assertEqual(before.stable_key, after_label.stable_key)
|
||||||
|
self.assertNotEqual(
|
||||||
|
before.observed_fingerprint,
|
||||||
|
after_label.observed_fingerprint,
|
||||||
|
)
|
||||||
|
resolution = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=before,
|
||||||
|
)
|
||||||
|
self.assertEqual("changed", resolution.availability)
|
||||||
|
|
||||||
|
def test_deleted_and_recreated_step_id_gets_new_lineage(self) -> None:
|
||||||
|
old = self.activity().reference
|
||||||
|
self.revise(graph(include_activity=False))
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("step_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
self.revise(graph(activity_label="Recreated review"))
|
||||||
|
recreated = self.activity().reference
|
||||||
|
self.assertNotEqual(old.stable_key, recreated.stable_key)
|
||||||
|
still_deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("step_deleted", still_deleted.reason_code)
|
||||||
|
|
||||||
|
def test_resolution_rechecks_scope_tenant_governance_and_deletion(self) -> None:
|
||||||
|
reference = self.activity().reference
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
principal(scopes=frozenset()),
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
principal(tenant_id="tenant-2"),
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
admin = principal(scopes=frozenset({"workflow:instance:admin"}))
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
admin,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=self.definition.id,
|
||||||
|
actor_id="author-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("definition_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
def test_query_filters_and_pages_without_docs_runtime(self) -> None:
|
||||||
|
page = self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="review",
|
||||||
|
limit=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(page.subjects))
|
||||||
|
self.assertTrue(page.has_more)
|
||||||
|
second = self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="review",
|
||||||
|
limit=1,
|
||||||
|
cursor=page.next_cursor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(second.subjects))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/workflow-webui",
|
"name": "@govoplan/workflow-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.17",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type Edge,
|
type Edge,
|
||||||
type ReactFlowInstance
|
type ReactFlowInstance
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import { StatePanel } from "@govoplan/core-webui";
|
||||||
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
|
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
|
||||||
import type {
|
import type {
|
||||||
WorkflowDiagnostic,
|
WorkflowDiagnostic,
|
||||||
@@ -211,7 +212,7 @@ export default function WorkflowCanvas({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="workflow-canvas"
|
className="definition-graph-canvas workflow-canvas"
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (
|
if (
|
||||||
event.dataTransfer.types.includes(
|
event.dataTransfer.types.includes(
|
||||||
@@ -320,7 +321,7 @@ export default function WorkflowCanvas({
|
|||||||
<Controls showInteractive={false} />
|
<Controls showInteractive={false} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
{!graph.nodes.length ? (
|
{!graph.nodes.length ? (
|
||||||
<div className="workflow-canvas-empty">Drop a start node here</div>
|
<StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a start node here" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { BookOpen, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
ReferenceMultiSelect,
|
ReferenceMultiSelect,
|
||||||
|
StatePanel,
|
||||||
useViewSurfaces,
|
useViewSurfaces,
|
||||||
type ReferenceOptionProvider
|
type ReferenceOptionProvider
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
@@ -19,6 +22,8 @@ export default function WorkflowInspector({
|
|||||||
edge,
|
edge,
|
||||||
nodeLibrary,
|
nodeLibrary,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
semanticDefinitionId,
|
||||||
|
semanticStepAvailable,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdgeChange,
|
onEdgeChange,
|
||||||
@@ -28,6 +33,8 @@ export default function WorkflowInspector({
|
|||||||
edge: WorkflowGraphEdge | null;
|
edge: WorkflowGraphEdge | null;
|
||||||
nodeLibrary: WorkflowNodeType[];
|
nodeLibrary: WorkflowNodeType[];
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
semanticDefinitionId: string | null;
|
||||||
|
semanticStepAvailable: boolean;
|
||||||
onChange: (node: WorkflowGraphNode) => void;
|
onChange: (node: WorkflowGraphNode) => void;
|
||||||
onDelete: (nodeId: string) => void;
|
onDelete: (nodeId: string) => void;
|
||||||
onEdgeChange: (edge: WorkflowGraphEdge) => void;
|
onEdgeChange: (edge: WorkflowGraphEdge) => void;
|
||||||
@@ -87,13 +94,15 @@ export default function WorkflowInspector({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Flow inspector">
|
<aside className="workflow-inspector" aria-label="Flow inspector">
|
||||||
<div className="workflow-panel-heading">
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Flow</strong>
|
<strong>Flow</strong>
|
||||||
<small>{edge.type.replace(/^bpmn\./, "")}</small>
|
<small>{edge.type.replace(/^bpmn\./, "")}</small>
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
helpContextId="workflow.action.delete-definition-parts"
|
||||||
|
helpModuleId="workflow"
|
||||||
className="workflow-inspector-delete"
|
className="workflow-inspector-delete"
|
||||||
onClick={() => onEdgeDelete(edge.id)}
|
onClick={() => onEdgeDelete(edge.id)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
@@ -102,7 +111,7 @@ export default function WorkflowInspector({
|
|||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div className="workflow-inspector-fields">
|
<div className="workflow-inspector-fields">
|
||||||
<FormField label="Flow type">
|
<FormField label="Flow type">
|
||||||
<select
|
<select
|
||||||
@@ -184,8 +193,8 @@ export default function WorkflowInspector({
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||||
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
|
<ActionToolbar surface="section-header" className="workflow-panel-heading"><strong>Inspector</strong></ActionToolbar>
|
||||||
<div className="workflow-inspector-empty">No node selected</div>
|
<StatePanel size="compact" description="No node selected" />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,22 +209,43 @@ export default function WorkflowInspector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||||
<div className="workflow-panel-heading">
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Inspector</strong>
|
<strong>Inspector</strong>
|
||||||
<small>{definition?.label ?? node.type}</small>
|
<small>{definition?.label ?? node.type}</small>
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<span className="workflow-inspector-actions">
|
||||||
variant="ghost"
|
{semanticDefinitionId && semanticStepAvailable ? <>
|
||||||
className="workflow-inspector-delete"
|
<DocumentationHelpLink
|
||||||
onClick={() => onDelete(node.id)}
|
reference={{
|
||||||
disabled={readOnly}
|
contextId: semanticHelpContext(semanticDefinitionId, node.id),
|
||||||
aria-label="Delete node"
|
documentationType: "user"
|
||||||
title="Delete node"
|
}}
|
||||||
>
|
label="Open step meaning"
|
||||||
<Trash2 size={16} />
|
/>
|
||||||
</Button>
|
<a
|
||||||
</div>
|
className="btn btn-secondary"
|
||||||
|
href={semanticAuthoringHref(semanticDefinitionId, node.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BookOpen size={15} aria-hidden="true" /> Document meaning
|
||||||
|
</a>
|
||||||
|
</> : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
helpContextId="workflow.action.delete-definition-parts"
|
||||||
|
helpModuleId="workflow"
|
||||||
|
className="workflow-inspector-delete"
|
||||||
|
onClick={() => onDelete(node.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
aria-label="Delete node"
|
||||||
|
title="Delete node"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</ActionToolbar>
|
||||||
<div className="workflow-inspector-fields">
|
<div className="workflow-inspector-fields">
|
||||||
{error ? (
|
{error ? (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
@@ -330,3 +360,23 @@ function stringList(value: unknown): string[] {
|
|||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semanticHelpContext(definitionId: string, nodeId: string): string {
|
||||||
|
return [
|
||||||
|
"semantic",
|
||||||
|
"workflow",
|
||||||
|
"workflow_definition",
|
||||||
|
definitionId,
|
||||||
|
`workflow-node-${nodeId}`
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAuthoringHref(definitionId: string, nodeId: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: "workflow",
|
||||||
|
subjectKind: "workflow_definition",
|
||||||
|
subjectId: definitionId,
|
||||||
|
routeAnchor: `workflow-node-${nodeId}`
|
||||||
|
});
|
||||||
|
return `/docs/semantic?${params}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type Node,
|
type Node,
|
||||||
type NodeProps
|
type NodeProps
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import { DefinitionNodeIcon } from "@govoplan/core-webui";
|
||||||
import type { WorkflowNodeType } from "../../api/workflow";
|
import type { WorkflowNodeType } from "../../api/workflow";
|
||||||
|
|
||||||
export type WorkflowFlowNodeData = {
|
export type WorkflowFlowNodeData = {
|
||||||
@@ -103,6 +104,7 @@ const iconByName: Record<string, LucideIcon> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WorkflowNode({
|
export default function WorkflowNode({
|
||||||
|
id,
|
||||||
data,
|
data,
|
||||||
selected,
|
selected,
|
||||||
isConnectable
|
isConnectable
|
||||||
@@ -113,6 +115,7 @@ export default function WorkflowNode({
|
|||||||
const outputPorts = data.definition.output_ports;
|
const outputPorts = data.definition.output_ports;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`workflow-node-${id}`}
|
||||||
className={[
|
className={[
|
||||||
"workflow-node",
|
"workflow-node",
|
||||||
`workflow-node-${data.definition.category}`,
|
`workflow-node-${data.definition.category}`,
|
||||||
@@ -127,13 +130,13 @@ export default function WorkflowNode({
|
|||||||
id={port.id}
|
id={port.id}
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="workflow-node-handle"
|
className="definition-node-handle"
|
||||||
style={{ top: portPosition(index, inputPorts.length) }}
|
style={{ top: portPosition(index, inputPorts.length) }}
|
||||||
isConnectable={isConnectable}
|
isConnectable={isConnectable}
|
||||||
title={port.label}
|
title={port.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<span className="workflow-node-icon"><Icon size={17} /></span>
|
<DefinitionNodeIcon className="workflow-node-icon"><Icon size={17} /></DefinitionNodeIcon>
|
||||||
<span className="workflow-node-copy">
|
<span className="workflow-node-copy">
|
||||||
<strong>{data.label}</strong>
|
<strong>{data.label}</strong>
|
||||||
<small>{data.definition.label}</small>
|
<small>{data.definition.label}</small>
|
||||||
@@ -144,7 +147,7 @@ export default function WorkflowNode({
|
|||||||
id={port.id}
|
id={port.id}
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="workflow-node-handle"
|
className="definition-node-handle"
|
||||||
style={{ top: portPosition(index, outputPorts.length) }}
|
style={{ top: portPosition(index, outputPorts.length) }}
|
||||||
isConnectable={isConnectable}
|
isConnectable={isConnectable}
|
||||||
title={port.label}
|
title={port.label}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
BookOpen,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CopyPlus,
|
CopyPlus,
|
||||||
Download,
|
Download,
|
||||||
GitFork,
|
GitFork,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -23,18 +23,32 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ReactFlowProvider } from "@xyflow/react";
|
import { ReactFlowProvider } from "@xyflow/react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import {
|
import { ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
ContentGrid,
|
||||||
|
ContentSection,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
DefinitionPalette,
|
||||||
|
DefinitionPaletteGroup,
|
||||||
|
DefinitionPaletteItem,
|
||||||
|
FilterBar,
|
||||||
|
FloatingStatus,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
ReferenceSelect,
|
ReferenceSelect,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
isApiError,
|
isApiError,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -98,7 +112,7 @@ export default function WorkflowPage({
|
|||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
}) {
|
}) {
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestDiscard, requestNavigation } = useUnsavedChanges();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requestedDefinitionId = searchParams.get("definition");
|
const requestedDefinitionId = searchParams.get("definition");
|
||||||
const requestedRunId = searchParams.get("run");
|
const requestedRunId = searchParams.get("run");
|
||||||
@@ -582,43 +596,48 @@ export default function WorkflowPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="workflow-page">
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="workflow-page" label="Workflow workspace">
|
||||||
<div className="workflow-shell">
|
<WorkspaceActionBar
|
||||||
<aside className="workflow-definition-panel">
|
scope="workspace"
|
||||||
<div className="workflow-panel-toolbar">
|
variant="collection"
|
||||||
<strong>Workflows</strong>
|
refreshable
|
||||||
<span className="workflow-toolbar-actions">
|
reloadAction={{ onReload: () => void reload(draft?.id), loading: loading || working }}
|
||||||
<DocumentationHelpLink
|
contextActions={<>
|
||||||
reference={{ topicId: "workflow.editor", documentationType: "user" }}
|
<strong>Workflows</strong>
|
||||||
label="Open Workflow documentation"
|
{hasScope(auth, "workflow:instance:admin") ? (
|
||||||
/>
|
<IconButton
|
||||||
<IconButton
|
label="Reconcile module standards"
|
||||||
label="Refresh"
|
icon={<GitFork size={16} />}
|
||||||
icon={<RefreshCw size={16} />}
|
variant="ghost"
|
||||||
variant="ghost"
|
onClick={() => void reconcileStandards()}
|
||||||
onClick={() => void reload(draft?.id)}
|
disabled={loading || working}
|
||||||
disabled={loading || working}
|
/>
|
||||||
/>
|
) : null}
|
||||||
{hasScope(auth, "workflow:instance:admin") ? (
|
</>}
|
||||||
<IconButton
|
helpAction={<DocumentationHelpLink
|
||||||
label="Reconcile module standards"
|
reference={{ topicId: "workflow.editor", documentationType: "user" }}
|
||||||
icon={<GitFork size={16} />}
|
label="Open Workflow documentation"
|
||||||
variant="ghost"
|
/>}
|
||||||
onClick={() => void reconcileStandards()}
|
createAction={<IconButton
|
||||||
disabled={loading || working}
|
label="New workflow"
|
||||||
/>
|
icon={<Plus size={17} />}
|
||||||
) : null}
|
variant="primary"
|
||||||
<IconButton
|
onClick={createNew}
|
||||||
label="New workflow"
|
disabled={!canWrite}
|
||||||
icon={<Plus size={17} />}
|
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
|
||||||
variant="primary"
|
/>}
|
||||||
onClick={createNew}
|
/>
|
||||||
disabled={!canWrite}
|
<WorkspaceLayout
|
||||||
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
|
variant="split"
|
||||||
/>
|
primarySize="compact"
|
||||||
</span>
|
surface="contained"
|
||||||
</div>
|
primaryScrollable={false}
|
||||||
<div className="workflow-definition-search">
|
contentScrollable={false}
|
||||||
|
primaryLabel="Workflows"
|
||||||
|
contentLabel="Workflow editor"
|
||||||
|
contentClassName="workflow-workspace"
|
||||||
|
primary={<>
|
||||||
|
<FilterBar surface="panel">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -626,53 +645,48 @@ export default function WorkflowPage({
|
|||||||
placeholder="Search workflows"
|
placeholder="Search workflows"
|
||||||
aria-label="Search workflows"
|
aria-label="Search workflows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FilterBar>
|
||||||
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
|
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
|
||||||
<div className="workflow-definition-list">
|
<SelectionList variant="navigation" label="Workflows">
|
||||||
{visibleDefinitions.map((definition) => (
|
{visibleDefinitions.map((definition) => (
|
||||||
<button
|
<SelectionListItem
|
||||||
key={definition.id}
|
key={definition.id}
|
||||||
type="button"
|
selected={definition.id === draft?.id}
|
||||||
className={definition.id === draft?.id ? "is-selected" : ""}
|
|
||||||
onClick={() => selectDefinition(definition)}
|
onClick={() => selectDefinition(definition)}
|
||||||
>
|
>
|
||||||
<span>
|
<SelectionListItemContent
|
||||||
<strong>{definition.name}</strong>
|
title={definition.name}
|
||||||
<small>
|
description={[
|
||||||
{definition.key} · revision {definition.current_revision}
|
definition.key,
|
||||||
</small>
|
`revision ${definition.current_revision}`,
|
||||||
<small>
|
definition.governance.scope_type,
|
||||||
{definition.governance.scope_type} · {definition.governance.definition_kind}
|
definition.governance.definition_kind,
|
||||||
</small>
|
definition.standard
|
||||||
{definition.standard ? (
|
? `${definition.standard.kind === "baseline" ? "Standard" : "Override"} · ${definition.standard.origin_module_id}${definition.standard.update_available ? " · update available" : ""}`
|
||||||
<small>
|
: ""
|
||||||
{definition.standard.kind === "baseline"
|
].filter(Boolean).join(" · ")}
|
||||||
? `Standard · ${definition.standard.origin_module_id}`
|
/>
|
||||||
: `Override · ${definition.standard.origin_module_id}`}
|
|
||||||
{definition.standard.update_available ? " · update available" : ""}
|
|
||||||
</small>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={definition.status}
|
status={definition.status}
|
||||||
label={definition.status}
|
label={definition.status}
|
||||||
/>
|
/>
|
||||||
</button>
|
</SelectionListItem>
|
||||||
))}
|
))}
|
||||||
{!visibleDefinitions.length ? (
|
{!visibleDefinitions.length ? (
|
||||||
<div className="workflow-definition-empty">
|
<StatePanel size="compact" description="No workflow definitions" />
|
||||||
No workflow definitions
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</SelectionList>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</aside>
|
</>}
|
||||||
|
>
|
||||||
<section className="workflow-workspace">
|
|
||||||
{draft && displayedGraph ? (
|
{draft && displayedGraph ? (
|
||||||
<>
|
<>
|
||||||
<div className="workflow-workspace-toolbar">
|
<WorkspaceActionBar
|
||||||
<span className="workflow-identity-fields">
|
scope="editor-pane"
|
||||||
|
variant="editor"
|
||||||
|
state={working ? "saving" : dirty && !draft.name.trim() ? "invalid" : dirty ? "dirty" : "clean"}
|
||||||
|
className="workflow-workspace-toolbar"
|
||||||
|
contextActions={<span className="workflow-identity-fields">
|
||||||
<input
|
<input
|
||||||
className="workflow-name-input"
|
className="workflow-name-input"
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
@@ -694,8 +708,25 @@ export default function WorkflowPage({
|
|||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
aria-label="Workflow description"
|
aria-label="Workflow description"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>}
|
||||||
<span className="workflow-command-bar">
|
helpAction={draft.id ? <>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
contextId: semanticHelpContext(draft.id),
|
||||||
|
documentationType: "user"
|
||||||
|
}}
|
||||||
|
label="Open workflow meaning"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={semanticAuthoringHref(draft.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BookOpen size={16} aria-hidden="true" /> Document meaning
|
||||||
|
</a>
|
||||||
|
</> : undefined}
|
||||||
|
primaryActions={<span className="workflow-command-bar">
|
||||||
{draft.id ? (
|
{draft.id ? (
|
||||||
<select
|
<select
|
||||||
className="workflow-revision-select"
|
className="workflow-revision-select"
|
||||||
@@ -839,26 +870,28 @@ export default function WorkflowPage({
|
|||||||
<CheckCircle2 size={16} /> Activate
|
<CheckCircle2 size={16} /> Activate
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
</span>}
|
||||||
variant="primary"
|
destructiveActions={draft.id && draft.standard?.kind !== "baseline" ? (
|
||||||
onClick={() => void saveDraft()}
|
<IconButton
|
||||||
disabled={
|
label="Delete workflow"
|
||||||
!canEdit || !dirty || working || readOnly
|
helpContextId="workflow.action.delete-definition-parts"
|
||||||
}
|
helpModuleId="workflow"
|
||||||
>
|
icon={<Trash2 size={16} />}
|
||||||
<Save size={16} /> Save
|
variant="danger"
|
||||||
</Button>
|
onClick={() => setDeleteOpen(true)}
|
||||||
{draft.id && draft.standard?.kind !== "baseline" ? (
|
disabled={!canEdit || working}
|
||||||
<IconButton
|
/>
|
||||||
label="Delete workflow"
|
) : undefined}
|
||||||
icon={<Trash2 size={16} />}
|
discardAction={{
|
||||||
variant="danger"
|
label: <><RotateCcw size={16} /> Discard</>,
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick: () => requestDiscard(() => void reload(draft.id))
|
||||||
disabled={!canEdit || working}
|
}}
|
||||||
/>
|
saveAction={{
|
||||||
) : null}
|
label: <><Save size={16} /> Save</>,
|
||||||
</span>
|
onClick: () => void saveDraft(),
|
||||||
</div>
|
disabled: !canEdit || readOnly
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="workflow-alerts">
|
<div className="workflow-alerts">
|
||||||
{error ? (
|
{error ? (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
@@ -872,21 +905,14 @@ export default function WorkflowPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="workflow-editor">
|
<div className="workflow-editor">
|
||||||
<aside className="workflow-palette">
|
<DefinitionPalette label="Node library" description="Drag nodes onto the canvas">
|
||||||
<div className="workflow-panel-heading">
|
|
||||||
<span>
|
|
||||||
<strong>Node library</strong>
|
|
||||||
<small>Drag nodes onto the canvas</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="workflow-palette-items">
|
|
||||||
{paletteGroups.map((group) => (
|
{paletteGroups.map((group) => (
|
||||||
<section key={group.category} className="workflow-palette-group">
|
<DefinitionPaletteGroup key={group.category} label={group.label}>
|
||||||
<h3>{group.label}</h3>
|
|
||||||
{group.nodes.map((nodeType) => (
|
{group.nodes.map((nodeType) => (
|
||||||
<button
|
<DefinitionPaletteItem
|
||||||
key={nodeType.type}
|
key={nodeType.type}
|
||||||
type="button"
|
icon={<GitFork size={16} />}
|
||||||
|
label={nodeType.label}
|
||||||
draggable={!graphReadOnly}
|
draggable={!graphReadOnly}
|
||||||
disabled={graphReadOnly}
|
disabled={graphReadOnly}
|
||||||
onDragStart={(event) =>
|
onDragStart={(event) =>
|
||||||
@@ -894,17 +920,12 @@ export default function WorkflowPage({
|
|||||||
}
|
}
|
||||||
onClick={() => addNodeFromPalette(nodeType.type)}
|
onClick={() => addNodeFromPalette(nodeType.type)}
|
||||||
title={nodeType.description}
|
title={nodeType.description}
|
||||||
>
|
/>
|
||||||
<GitFork size={16} />
|
|
||||||
<span>{nodeType.label}</span>
|
|
||||||
<Plus size={13} className="workflow-palette-add" />
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</section>
|
</DefinitionPaletteGroup>
|
||||||
))}
|
))}
|
||||||
</div>
|
</DefinitionPalette>
|
||||||
</aside>
|
<div className="definition-editor-surface workflow-editor-surface">
|
||||||
<div className="workflow-editor-surface">
|
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<WorkflowCanvas
|
<WorkflowCanvas
|
||||||
graph={displayedGraph}
|
graph={displayedGraph}
|
||||||
@@ -926,6 +947,15 @@ export default function WorkflowPage({
|
|||||||
edge={selectedEdge}
|
edge={selectedEdge}
|
||||||
nodeLibrary={nodeLibrary}
|
nodeLibrary={nodeLibrary}
|
||||||
readOnly={graphReadOnly}
|
readOnly={graphReadOnly}
|
||||||
|
semanticDefinitionId={draft.id || null}
|
||||||
|
semanticStepAvailable={Boolean(
|
||||||
|
draft.id
|
||||||
|
&& !historicalRevision
|
||||||
|
&& selectedNode
|
||||||
|
&& savedDraft?.graph.nodes.some(
|
||||||
|
(node) => node.id === selectedNode.id
|
||||||
|
)
|
||||||
|
)}
|
||||||
onChange={(node) => {
|
onChange={(node) => {
|
||||||
if (!draft || graphReadOnly) return;
|
if (!draft || graphReadOnly) return;
|
||||||
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
||||||
@@ -936,7 +966,7 @@ export default function WorkflowPage({
|
|||||||
/>
|
/>
|
||||||
{diagnostics.length ? (
|
{diagnostics.length ? (
|
||||||
<div className="workflow-diagnostics">
|
<div className="workflow-diagnostics">
|
||||||
<div className="workflow-panel-heading">
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
<strong>Diagnostics</strong>
|
<strong>Diagnostics</strong>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={
|
status={
|
||||||
@@ -946,7 +976,7 @@ export default function WorkflowPage({
|
|||||||
}
|
}
|
||||||
label={String(diagnostics.length)}
|
label={String(diagnostics.length)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div>
|
<div>
|
||||||
{diagnostics.map((item, index) => (
|
{diagnostics.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
@@ -965,25 +995,21 @@ export default function WorkflowPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{working ? (
|
{working ? <FloatingStatus>Working...</FloatingStatus> : null}
|
||||||
<div className="workflow-working-indicator" role="status">
|
|
||||||
Working...
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="workflow-workspace-empty">
|
<StatePanel
|
||||||
<GitFork size={34} />
|
size="fill"
|
||||||
<strong>No workflow selected</strong>
|
icon={<GitFork size={34} />}
|
||||||
{canWrite ? (
|
title="No workflow selected"
|
||||||
|
actions={canWrite ? (
|
||||||
<Button variant="primary" onClick={createNew}>
|
<Button variant="primary" onClick={createNew}>
|
||||||
<Plus size={16} /> New workflow
|
<Plus size={16} /> New workflow
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : undefined}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
title="Delete workflow"
|
title="Delete workflow"
|
||||||
@@ -1052,10 +1078,36 @@ export default function WorkflowPage({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</WorkspaceFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semanticHelpContext(
|
||||||
|
definitionId: string,
|
||||||
|
routeAnchor?: string
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
"semantic",
|
||||||
|
"workflow",
|
||||||
|
"workflow_definition",
|
||||||
|
definitionId,
|
||||||
|
routeAnchor
|
||||||
|
].filter(Boolean).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAuthoringHref(
|
||||||
|
definitionId: string,
|
||||||
|
routeAnchor?: string
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: "workflow",
|
||||||
|
subjectKind: "workflow_definition",
|
||||||
|
subjectId: definitionId
|
||||||
|
});
|
||||||
|
if (routeAnchor) params.set("routeAnchor", routeAnchor);
|
||||||
|
return `/docs/semantic?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
function WorkflowStandardComparisonDialog({
|
function WorkflowStandardComparisonDialog({
|
||||||
open,
|
open,
|
||||||
settings,
|
settings,
|
||||||
@@ -1336,7 +1388,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="workflow-definition-toggles">
|
<ContentGrid columns={2} gap="default" collapseAt="narrow">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Visible to lower scopes"
|
label="Visible to lower scopes"
|
||||||
checked={draft.inheritToLowerScopes}
|
checked={draft.inheritToLowerScopes}
|
||||||
@@ -1361,7 +1413,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
disabled={!editable || draft.executionMode === "guided"}
|
disabled={!editable || draft.executionMode === "guided"}
|
||||||
onChange={(value) => onChange({ allowAutomation: value })}
|
onChange={(value) => onChange({ allowAutomation: value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ContentGrid>
|
||||||
{draft.governance?.automation_runtime_reason ? (
|
{draft.governance?.automation_runtime_reason ? (
|
||||||
<DismissibleAlert
|
<DismissibleAlert
|
||||||
tone="warning"
|
tone="warning"
|
||||||
@@ -1371,7 +1423,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
) : null}
|
) : null}
|
||||||
{draft.governance?.derived_from_definition_id ? (
|
{draft.governance?.derived_from_definition_id ? (
|
||||||
<section className="workflow-provenance">
|
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
|
||||||
<strong>Derived from</strong>
|
<strong>Derived from</strong>
|
||||||
<span>
|
<span>
|
||||||
{draft.governance.derived_from_definition_id}
|
{draft.governance.derived_from_definition_id}
|
||||||
@@ -1379,10 +1431,10 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
{draft.governance.derived_from_revision}
|
{draft.governance.derived_from_revision}
|
||||||
</span>
|
</span>
|
||||||
<code>{draft.governance.derived_from_hash}</code>
|
<code>{draft.governance.derived_from_hash}</code>
|
||||||
</section>
|
</ContentSection>
|
||||||
) : null}
|
) : null}
|
||||||
{provenance.length ? (
|
{provenance.length ? (
|
||||||
<section className="workflow-provenance">
|
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
|
||||||
<strong>Effective Policy path</strong>
|
<strong>Effective Policy path</strong>
|
||||||
{provenance.map((item, index) => (
|
{provenance.map((item, index) => (
|
||||||
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
|
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
|
||||||
@@ -1392,7 +1444,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
{!editable && draft.governance?.actions.edit?.reason ? (
|
{!editable && draft.governance?.actions.edit?.reason ? (
|
||||||
<small>{draft.governance.actions.edit.reason}</small>
|
<small>{draft.governance.actions.edit.reason}</small>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</ContentSection>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
XCircle
|
XCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
ContentGrid,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
@@ -284,6 +285,13 @@ export default function WorkflowRunsDialog({
|
|||||||
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
||||||
? currentStep.handoff.action_url
|
? currentStep.handoff.action_url
|
||||||
: "";
|
: "";
|
||||||
|
const unavailableOptionalCapabilities = Array.isArray(
|
||||||
|
currentStep?.handoff.unavailable_optional_capabilities
|
||||||
|
)
|
||||||
|
? currentStep.handoff.unavailable_optional_capabilities.filter(
|
||||||
|
(value): value is string => typeof value === "string" && Boolean(value)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const close = () => {
|
const close = () => {
|
||||||
dispatchWorkflowViewChanged(null);
|
dispatchWorkflowViewChanged(null);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -299,7 +307,7 @@ export default function WorkflowRunsDialog({
|
|||||||
onClose={close}
|
onClose={close}
|
||||||
footer={<Button onClick={close}>Close</Button>}
|
footer={<Button onClick={close}>Close</Button>}
|
||||||
>
|
>
|
||||||
<div className="workflow-runs-toolbar">
|
<ActionToolbar className="workflow-runs-toolbar">
|
||||||
<span>
|
<span>
|
||||||
<strong>Workflow instances</strong>
|
<strong>Workflow instances</strong>
|
||||||
<small>Revision-pinned runs and human handoffs</small>
|
<small>Revision-pinned runs and human handoffs</small>
|
||||||
@@ -325,7 +333,7 @@ export default function WorkflowRunsDialog({
|
|||||||
<Play size={16} /> Start
|
<Play size={16} /> Start
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
{error ? (
|
{error ? (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
@@ -389,6 +397,15 @@ export default function WorkflowRunsDialog({
|
|||||||
{selected.error}
|
{selected.error}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selected.view_context && !viewsRuntime ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="warning"
|
||||||
|
resetKey={`view-unavailable:${selected.id}:${selected.current_step_id ?? "none"}`}
|
||||||
|
>
|
||||||
|
The focused View is unavailable. Workflow position is preserved;
|
||||||
|
use the linked module action or ask an administrator to enable Views.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
{currentStep ? (
|
{currentStep ? (
|
||||||
<section className="workflow-run-handoff">
|
<section className="workflow-run-handoff">
|
||||||
<div>
|
<div>
|
||||||
@@ -416,13 +433,22 @@ export default function WorkflowRunsDialog({
|
|||||||
&& currentStep.handoff.instructions ? (
|
&& currentStep.handoff.instructions ? (
|
||||||
<p>{currentStep.handoff.instructions}</p>
|
<p>{currentStep.handoff.instructions}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{unavailableOptionalCapabilities.length ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="info"
|
||||||
|
resetKey={`optional:${currentStep.id}:${unavailableOptionalCapabilities.join(",")}`}
|
||||||
|
>
|
||||||
|
Optional integration unavailable: {unavailableOptionalCapabilities.join(", ")}.
|
||||||
|
The hand-off remains valid and resumable.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
{actionUrl ? (
|
{actionUrl ? (
|
||||||
<a href={actionUrl}>
|
<a href={actionUrl}>
|
||||||
Open linked Dataflow result <ExternalLink size={14} />
|
Open linked work <ExternalLink size={14} />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
{allowedActions.some((action) => action !== "cancel") ? (
|
{allowedActions.some((action) => action !== "cancel") ? (
|
||||||
<div className="workflow-run-action-form">
|
<ContentGrid columns={2} gap="compact" collapseAt="narrow" className="workflow-run-action-form">
|
||||||
<FormField label="Comment">
|
<FormField label="Comment">
|
||||||
<textarea
|
<textarea
|
||||||
value={comment}
|
value={comment}
|
||||||
@@ -466,7 +492,7 @@ export default function WorkflowRunsDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+17
-418
@@ -1,14 +1,3 @@
|
|||||||
.workflow-page {
|
|
||||||
position: relative;
|
|
||||||
height: calc(100vh - 115px);
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-dialog {
|
.workflow-definition-dialog {
|
||||||
width: min(620px, calc(100vw - 32px));
|
width: min(620px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
@@ -109,21 +98,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-toggles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-provenance {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-provenance span,
|
.workflow-provenance span,
|
||||||
.workflow-provenance small {
|
.workflow-provenance small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -136,34 +110,8 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.workflow-definition-toggles {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-page *,
|
|
||||||
.workflow-page *::before,
|
|
||||||
.workflow-page *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: var(--border-line);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-panel,
|
|
||||||
.workflow-workspace,
|
.workflow-workspace,
|
||||||
.workflow-editor,
|
.workflow-editor,
|
||||||
.workflow-editor-surface,
|
|
||||||
.workflow-canvas,
|
.workflow-canvas,
|
||||||
.workflow-definition-list-frame,
|
.workflow-definition-list-frame,
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
@@ -171,31 +119,6 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: var(--border-line);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-panel-toolbar,
|
|
||||||
.workflow-workspace-toolbar,
|
|
||||||
.workflow-panel-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
background: var(--panel-header);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-panel-toolbar {
|
|
||||||
min-height: 52px;
|
|
||||||
padding: 8px 10px 8px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-toolbar-actions,
|
.workflow-toolbar-actions,
|
||||||
.workflow-command-bar,
|
.workflow-command-bar,
|
||||||
.workflow-identity-fields {
|
.workflow-identity-fields {
|
||||||
@@ -204,98 +127,12 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-search {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-search input {
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 7px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list-frame {
|
.workflow-definition-list-frame {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-list {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 56px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 9px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button:hover,
|
|
||||||
.workflow-definition-list > button:focus-visible {
|
|
||||||
background: var(--primary-soft);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button.is-selected {
|
|
||||||
background: var(--primary-soft-strong);
|
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button > span:first-child {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list strong,
|
|
||||||
.workflow-definition-list small {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list strong {
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list small {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-empty,
|
|
||||||
.workflow-inspector-empty {
|
|
||||||
display: grid;
|
|
||||||
min-height: 100px;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-toolbar {
|
.workflow-workspace-toolbar {
|
||||||
min-height: 58px;
|
min-width: 0;
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-identity-fields {
|
.workflow-identity-fields {
|
||||||
@@ -364,17 +201,12 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette,
|
|
||||||
.workflow-inspector,
|
.workflow-inspector,
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette {
|
|
||||||
border-right: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -388,11 +220,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-panel-heading {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 8px 10px 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-panel-heading > span {
|
.workflow-panel-heading > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -416,132 +243,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette-items {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-rows: max-content;
|
|
||||||
align-content: start;
|
|
||||||
height: calc(100% - 44px);
|
|
||||||
gap: 4px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-rows: max-content;
|
|
||||||
align-content: start;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group + .workflow-palette-group {
|
|
||||||
margin-top: 6px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0;
|
|
||||||
padding: 3px 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 18px minmax(0, 1fr) 14px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
min-height: 38px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: grab;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 7px 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button:hover:not(:disabled),
|
|
||||||
.workflow-palette-items button:focus-visible:not(:disabled) {
|
|
||||||
background: var(--primary-soft);
|
|
||||||
color: var(--text-strong);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: .42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-add {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-editor-surface {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow {
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__background {
|
|
||||||
color: var(--line-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__controls,
|
|
||||||
.workflow-canvas .react-flow__minimap {
|
|
||||||
overflow: hidden;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__controls-button {
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__minimap-mask {
|
|
||||||
fill: color-mix(in srgb, var(--bg) 76%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__edge-path {
|
|
||||||
stroke: var(--line-dark);
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__edge.selected .react-flow__edge-path,
|
|
||||||
.workflow-canvas .react-flow__edge:hover .react-flow__edge-path {
|
|
||||||
stroke: var(--accent);
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas-empty {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--muted);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node {
|
.workflow-node {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -552,7 +253,7 @@
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--line-dark);
|
border: 1px solid var(--line-dark);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -606,7 +307,7 @@
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
flex-basis: 42px;
|
flex-basis: 42px;
|
||||||
border: 2px solid var(--text-strong);
|
border: 2px solid var(--text-strong);
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-round);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +329,7 @@
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
flex-basis: 42px;
|
flex-basis: 42px;
|
||||||
border: 2px solid var(--text-strong);
|
border: 2px solid var(--text-strong);
|
||||||
border-radius: 2px;
|
border-radius: var(--radius-hairline);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -671,17 +372,6 @@
|
|||||||
border-color: var(--danger-text);
|
border-color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-icon {
|
|
||||||
display: grid;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
flex: 0 0 28px;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node-copy {
|
.workflow-node-copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -706,21 +396,6 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-handle {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
border: 3px solid var(--panel);
|
|
||||||
background: var(--line-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node-handle:hover,
|
|
||||||
.workflow-node-handle.connectingto,
|
|
||||||
.workflow-node-handle.valid {
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
|
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
|
||||||
.workflow-edge-bpmn-association .react-flow__edge-path,
|
.workflow-edge-bpmn-association .react-flow__edge-path,
|
||||||
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
|
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
|
||||||
@@ -776,6 +451,18 @@
|
|||||||
color: var(--danger-text);
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-diagnostics {
|
.workflow-diagnostics {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: 38%;
|
max-height: 38%;
|
||||||
@@ -821,39 +508,6 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-workspace-empty {
|
|
||||||
display: grid;
|
|
||||||
height: 100%;
|
|
||||||
place-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-empty strong {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-empty .btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-working-indicator {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 10px;
|
|
||||||
border: var(--border-line-dark);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow-popover);
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-runs-dialog {
|
.workflow-runs-dialog {
|
||||||
width: min(1120px, calc(100vw - 32px));
|
width: min(1120px, calc(100vw - 32px));
|
||||||
height: min(760px, calc(100vh - 32px));
|
height: min(760px, calc(100vh - 32px));
|
||||||
@@ -1041,12 +695,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-run-action-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-run-action-form textarea {
|
.workflow-run-action-form textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -1106,11 +754,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1280px) {
|
||||||
.workflow-shell {
|
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-toolbar {
|
.workflow-workspace-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1127,10 +771,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.workflow-shell {
|
|
||||||
grid-template-columns: 210px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-editor {
|
.workflow-editor {
|
||||||
grid-template-columns: 140px minmax(0, 1fr);
|
grid-template-columns: 140px minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
|
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
|
||||||
@@ -1149,16 +789,6 @@
|
|||||||
height: calc(100vh - 94px);
|
height: calc(100vh - 94px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: minmax(150px, 24%) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-panel {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-identity-fields,
|
.workflow-identity-fields,
|
||||||
.workflow-command-bar {
|
.workflow-command-bar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1175,33 +805,6 @@
|
|||||||
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
|
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette .workflow-panel-heading {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group {
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group h3 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button {
|
|
||||||
min-width: 128px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-runs-layout {
|
.workflow-runs-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
|
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
|
||||||
@@ -1212,10 +815,6 @@
|
|||||||
border-bottom: var(--border-line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-run-action-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-run-history > div > span,
|
.workflow-run-history > div > span,
|
||||||
.workflow-run-events > div > span {
|
.workflow-run-events > div > span {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user