diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..b47cf66 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,38 @@ +# Dataflow Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the pipeline +library, graphical and constrained-SQL editors, node inspector, intermediate +preview, automation triggers, and durable run/deployment surfaces. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/dataflow` library | Governed directory | Select, create, reuse, or retire a pipeline | Stable loading, empty, permission, read-only, disabled-reason, and help states | +| Graph/SQL workspace | Consequential definition editor | Append immutable revision | One canonical graph, guarded draft, constrained SQL compilation, diagnostics, and explicit save/discard | +| Node inspector | Typed configuration editor | Change transform semantics | Contextual node/expression help, typed validation, bounded intermediate preview, and read-only state | +| Trigger editor | Governed automation editor | Create, enable, change, or delete trigger | Guarded nested draft, pinned revision/grant, authorization recheck, and destructive confirmation | +| Preview/results | Bounded evidence preview | Read transient intermediate rows | Selected-node context, diagnostics, privacy boundary, row limit, and no retained preview contents | +| Runs/deployments | Asynchronous command register | Queue, publish, cancel, reconcile, or promote | Explicit consequence confirmation, durable command identity, progress, recovery state, and retained evidence | + +## Consequence And Availability Rules + +- Saving appends an immutable definition revision. Derivation creates a + separately governed copy pinned to the source revision and content hash. +- SQL is parsed and compiled into the canonical graph. It is never passed + directly to a database or execution provider. +- Preview is bounded and transient. A saved run is revision-pinned and records + actor, authority, idempotency, progress, output, and recovery evidence. +- Publishing appends a governed Datasource materialization. Unknown external + outcomes require reconciliation before retry. +- Staging and production promotion require confirmation and never rewrite a + revision. Cancellation cannot promise reversal of acknowledged external + effects. +- Missing optional Datasources or automation capabilities disable only their + associated actions; local graph, SQL, validation, and preview behavior stays + available where authorized. + +Backend and WebUI manifests publish matching surface identifiers. English and +German catalogues cover module-owned navigation, states, and core commands. +Contextual help resolves from manifest documentation, main and nested drafts +are guarded, and unavailable actions carry stable reasons. diff --git a/src/govoplan_dataflow/backend/manifest.py b/src/govoplan_dataflow/backend/manifest.py index c2ab4ca..81938d0 100644 --- a/src/govoplan_dataflow/backend/manifest.py +++ b/src/govoplan_dataflow/backend/manifest.py @@ -181,6 +181,113 @@ DOCUMENTATION = ( "Output publication uses forward recovery and blocks blind retry " "when provider acknowledgement is uncertain." ), + "help_contexts": [ + "dataflow.page", + "dataflow.library", + "dataflow.graph", + "dataflow.sql", + "dataflow.inspector", + "dataflow.results", + "dataflow.state.read-only", + ], + }, + ), + DocumentationTopic( + id="dataflow.reference.nodes-and-expressions", + title="Dataflow nodes and expressions", + summary="Typed node inputs, expressions, schema propagation, and bounded intermediate previews.", + body=( + "Every graph node declares typed inputs, configuration, output schema, and validation rules. " + "Source nodes pin inline content or governed Datasource references; combine, filter, transform, " + "quality, reconciliation, reusable-subflow, and output nodes remain explicit in the canonical graph. " + "Expressions use the typed Dataflow expression language and never execute arbitrary host or database " + "code. Selecting a node may request a bounded intermediate preview; preview rows are transient, " + "privacy-filtered for the actor, and are not retained as run output. SQL editing compiles into the same " + "canonical graph, so unsupported statements are diagnostics rather than pass-through SQL." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "data_steward"), + order=76, + related_modules=("datasources", "connectors", "policy", "audit"), + metadata={ + "help_contexts": [ + "dataflow.field.node-name", + "dataflow.field.source", + "dataflow.field.expression", + "dataflow.field.schema", + "dataflow.action.preview-node", + ], + }, + ), + DocumentationTopic( + id="dataflow.reference.fields-and-consequences", + title="Dataflow fields and lifecycle consequences", + summary="Definition scope, revision, reuse, automation, execution, publication, promotion, and deletion semantics.", + body=( + "Scope determines ownership and Policy inheritance. Templates can be derived but not run; complete " + "flows may be previewed, revisioned, automated, and executed when effective Policy allows it. Saving " + "appends an immutable revision. A scoped copy pins its source revision and content hash. Triggers pin " + "the revision and authorization grant, then re-evaluate authority for every delivery. Runs create " + "durable command and recovery evidence. Publishing creates a governed Datasource materialization, and " + "environment promotion changes which immutable revision is eligible for staging or production runs. " + "Deletion prevents future use while retained run, deployment, lineage, audit, and recovery evidence " + "continues under its retention policy." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "product_owner"), + order=77, + related_modules=("datasources", "workflow_engine", "notifications", "policy", "audit"), + metadata={ + "help_contexts": [ + "dataflow.field.scope", + "dataflow.field.definition-kind", + "dataflow.action.save", + "dataflow.action.derive", + "dataflow.action.trigger", + "dataflow.action.delete", + ], + "consequence_classes": { + "save_revision": "Appends an immutable pipeline definition revision.", + "derive_copy": "Creates a separately governed copy pinned to the source revision and hash.", + "configure_trigger": "Creates or changes an automation command with revision and authorization evidence.", + "delete_pipeline": "Prevents future use while retained evidence remains governed.", + }, + }, + ), + DocumentationTopic( + id="dataflow.execution-and-recovery", + title="Dataflow execution, publication, and recovery", + summary="Pinned runs, environment promotion, output publication, cancellation, reconciliation, and retained evidence.", + body=( + "Every run is pinned to an immutable revision and idempotency key. The queue records actor, authority, " + "environment, progress, cancellation, output, and recovery state. Database-only runs commit atomically. " + "Publication to a governed Datasource uses forward recovery: an unknown provider outcome is reconciled " + "before retry so output is not duplicated. Staging and production promotion is explicit and does not " + "rewrite a revision. Cancellation is best effort once external work has started; the final evidence " + "states whether work stopped, completed, failed, or requires operator reconciliation." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "security_admin"), + order=78, + related_modules=("datasources", "notifications", "policy", "audit", "ops"), + metadata={ + "help_contexts": [ + "dataflow.runs", + "dataflow.action.queue-run", + "dataflow.action.publish", + "dataflow.action.promote-staging", + "dataflow.action.promote-production", + "dataflow.state.recovery-attention", + ], + "consequence_classes": { + "queue_run": "Creates a durable asynchronous command and authorization evidence.", + "publish_output": "Creates or updates a governed Datasource and appends a materialization.", + "promote_revision": "Makes an immutable revision eligible in a higher execution environment.", + "cancel_run": "Requests cancellation; already acknowledged external effects may remain.", + }, }, ), ) @@ -374,6 +481,69 @@ manifest = ModuleManifest( ), ), view_surfaces=( + ViewSurface( + id="dataflow.page", + module_id=MODULE_ID, + kind="route", + label="Dataflow", + order=72, + ), + ViewSurface( + id="dataflow.library", + module_id=MODULE_ID, + kind="section", + label="Pipeline library", + parent_id="dataflow.page", + order=10, + ), + ViewSurface( + id="dataflow.graph", + module_id=MODULE_ID, + kind="section", + label="Graph editor", + parent_id="dataflow.page", + order=20, + ), + ViewSurface( + id="dataflow.sql", + module_id=MODULE_ID, + kind="section", + label="Constrained SQL editor", + parent_id="dataflow.page", + order=30, + ), + ViewSurface( + id="dataflow.inspector", + module_id=MODULE_ID, + kind="section", + label="Node inspector", + parent_id="dataflow.page", + order=40, + ), + ViewSurface( + id="dataflow.results", + module_id=MODULE_ID, + kind="section", + label="Preview and diagnostics", + parent_id="dataflow.page", + order=50, + ), + ViewSurface( + id="dataflow.triggers", + module_id=MODULE_ID, + kind="action", + label="Automation triggers", + parent_id="dataflow.page", + order=60, + ), + ViewSurface( + id="dataflow.runs", + module_id=MODULE_ID, + kind="action", + label="Runs and deployments", + parent_id="dataflow.page", + order=70, + ), ViewSurface( id="dataflow.widget.pipelines", module_id=MODULE_ID, diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..b429e3b --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_dataflow.backend.manifest import get_manifest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class DataflowInterfaceDocumentationContractTests(unittest.TestCase): + def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None: + frontend = get_manifest().frontend + self.assertIsNotNone(frontend) + surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr] + self.assertEqual( + { + "dataflow.page", + "dataflow.library", + "dataflow.graph", + "dataflow.sql", + "dataflow.inspector", + "dataflow.results", + "dataflow.triggers", + "dataflow.runs", + "dataflow.widget.pipelines", + }, + set(surfaces), + ) + for surface_id in ( + "dataflow.library", + "dataflow.graph", + "dataflow.sql", + "dataflow.inspector", + "dataflow.results", + "dataflow.triggers", + "dataflow.runs", + ): + self.assertEqual("dataflow.page", surfaces[surface_id].parent_id) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in get_manifest().documentation} + boundary = topics["dataflow.module-boundary"] + fields = topics["dataflow.reference.fields-and-consequences"] + nodes = topics["dataflow.reference.nodes-and-expressions"] + execution = topics["dataflow.execution-and-recovery"] + + self.assertIn("dataflow.state.read-only", boundary.metadata["help_contexts"]) + self.assertIn("dataflow.field.expression", nodes.metadata["help_contexts"]) + self.assertIn("save_revision", fields.metadata["consequence_classes"]) + self.assertIn("delete_pipeline", fields.metadata["consequence_classes"]) + self.assertIn("publish_output", execution.metadata["consequence_classes"]) + self.assertIn("promote_revision", execution.metadata["consequence_classes"]) + + def test_webui_uses_shared_help_guard_and_consequence_components(self) -> None: + page = (REPO_ROOT / "webui/src/features/dataflow/DataflowPage.tsx").read_text( + encoding="utf-8" + ) + inspector = ( + REPO_ROOT / "webui/src/features/dataflow/NodeInspector.tsx" + ).read_text(encoding="utf-8") + + for component in ( + "ActionBlockerHint", + "DocumentationHelpLink", + "useUnsavedDraftGuard", + "ConfirmDialog", + ): + self.assertIn(component, page) + self.assertIn("DATAFLOW_NODE_DOCUMENTATION", inspector) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/dataflow/DataflowPage.tsx b/webui/src/features/dataflow/DataflowPage.tsx index 365409b..aac1f1d 100644 --- a/webui/src/features/dataflow/DataflowPage.tsx +++ b/webui/src/features/dataflow/DataflowPage.tsx @@ -26,10 +26,12 @@ import { Upload } from "lucide-react"; import { + ActionBlockerHint, Button, ConfirmDialog, Dialog, DismissibleAlert, + DocumentationHelpLink, FormField, IconButton, LoadingFrame, @@ -97,6 +99,12 @@ import { type PipelineDraft } from "./model"; import { dataflowNodeIcon } from "./nodeIcons"; +import { + DATAFLOW_DOCUMENTATION, + DATAFLOW_FIELDS_DOCUMENTATION, + DATAFLOW_I18N, + DATAFLOW_RUN_DOCUMENTATION +} from "./interfacePatterns"; type ResultTab = "preview" | "diagnostics"; type SnapshotFormat = "json" | "csv"; @@ -145,6 +153,9 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings const canEdit = canWrite && ( !draft?.id || draft.governance?.actions.edit?.allowed !== false ); + const editBlockedReason = !canWrite + ? DATAFLOW_I18N.writeReason + : draft?.governance?.actions.edit?.reason ?? DATAFLOW_I18N.writeReason; const canReuse = Boolean( draft?.id && canWrite @@ -313,8 +324,8 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings useUnsavedDraftGuard({ dirty, - title: "Unsaved pipeline", - message: "Save or discard the pipeline changes before leaving this workspace.", + title: "i18n:govoplan-dataflow.unsaved_title", + message: "i18n:govoplan-dataflow.unsaved_message", onSave: saveDraft, onDiscard: discardDraft }); @@ -577,6 +588,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings variant="ghost" onClick={() => requestNavigation(() => void loadPipelines(draft?.id))} disabled={loading} + disabledReason={loading ? DATAFLOW_I18N.loading : undefined} /> @@ -658,6 +671,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
+ ariaLabel="Pipeline editor mode" options={[ @@ -676,7 +690,11 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings onClick={() => void runPreview(selectedNodeId ?? undefined)} disabled={working || !canPreview} disabledReason={ - draft.governance?.actions.run?.reason ?? undefined + working + ? DATAFLOW_I18N.working + : !canRun + ? DATAFLOW_I18N.runReason + : draft.governance?.actions.run?.reason ?? undefined } > {preview ? : } @@ -695,8 +713,12 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings disabled={working || !canRun || dirty || !canStartSavedRun || !draft.currentRevision} disabledReason={ dirty - ? "Save the pipeline before starting a pinned run." - : draft.governance?.actions.run?.reason ?? undefined + ? DATAFLOW_I18N.saveFirst + : working + ? DATAFLOW_I18N.working + : !canRun + ? DATAFLOW_I18N.runReason + : draft.governance?.actions.run?.reason ?? undefined } > Run @@ -707,6 +729,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings variant="ghost" onClick={() => setDefinitionSettingsOpen(true)} disabled={!draft} + disabledReason={!draft ? DATAFLOW_I18N.noSelection : undefined} /> {draft.id ? ( setDeriveOpen(true)} disabled={!canReuse} + disabledReason={ + !canReuse + ? draft.governance?.actions.derive?.reason ?? editBlockedReason + : undefined + } /> ) : null} {draft.id ? ( @@ -724,6 +752,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings variant="ghost" onClick={() => setTriggersOpen(true)} disabled={!canViewTriggers} + disabledReason={!canViewTriggers ? DATAFLOW_I18N.writeReason : undefined} /> ) : null} requestDiscard(() => undefined)} disabled={!dirty || saving} + disabledReason={ + saving + ? DATAFLOW_I18N.working + : !dirty + ? DATAFLOW_I18N.noChanges + : undefined + } /> {draft.id ? ( setDeleteOpen(true)} disabled={!canEdit || saving} + disabledReason={ + saving ? DATAFLOW_I18N.working : !canEdit ? editBlockedReason : undefined + } /> ) : null} @@ -757,6 +805,26 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings {success ? ( {success} ) : null} + {!canEdit ? ( +
+ +
+ ) : null}
{draft.editorMode === "graph" ? (