diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..9a2eca1 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,32 @@ +# Datasources Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the governed +Datasource catalogue, staging area, connector-origin catalogue, governance +editor, previews, and immutable materialization history. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/datasources` catalogue | Governed directory | Select, register, refresh, freeze, govern, or retire | Shared loading/empty/error, permission, disabled-reason, contextual-help, and read-only states | +| Staging | Review/preflight queue | Upload, validate, inspect, and promote | Non-consumable bounded stage, explicit promotion confirmation, immutable resulting revision | +| Connector origins | Optional-provider directory | Register a live or cached source | Provider availability and supported modes without hard Connectors dependency | +| Governance editor | Effective authority/provenance editor | Change institutional data context | Guarded draft, authority/source/owner/purpose/quality/freshness semantics | +| Preview/materializations | Evidence register | Inspect current sample and immutable revisions | Row/schema bounds, freshness, provenance, fingerprints, hashes, and frozen labels | + +## Consequence And Availability Rules + +- Static uploads become usable only after staged review and explicit promotion. +- Cached refresh and stage promotion append immutable materializations; they do + not rewrite prior execution evidence. +- Freezing creates a labelled immutable state for reproducible consumers. +- Retirement blocks new definitions while retained materialization references + continue under their governing retention policy. +- Connector origin absence is an explained optional-capability state. Local + catalogue, staging, and static data remain usable. +- Governance metadata may be discoverable independently of protected rows and + never grants row access. + +Backend and WebUI manifests publish matching route/section/action surfaces. +English and German catalogues cover owned vocabulary, registration/governance/ +freeze drafts are guarded, and module integrations remain capability-based. diff --git a/src/govoplan_datasources/backend/manifest.py b/src/govoplan_datasources/backend/manifest.py index 1b4eb7d..9e9d626 100644 --- a/src/govoplan_datasources/backend/manifest.py +++ b/src/govoplan_datasources/backend/manifest.py @@ -34,6 +34,7 @@ from govoplan_core.core.provider_governance import ( ModuleArchitectureDocumentation, ModuleMaturityEvidence, ) +from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_datasources.backend.db import models as datasource_models from govoplan_datasources.backend.service import ( @@ -284,6 +285,14 @@ manifest = ModuleManifest( order=70, ), ), + view_surfaces=( + ViewSurface(id="datasources.page", module_id=MODULE_ID, kind="route", label="Datasources", order=70), + ViewSurface(id="datasources.catalogue", module_id=MODULE_ID, kind="section", label="Datasource catalogue", order=10), + ViewSurface(id="datasources.staging", module_id=MODULE_ID, kind="section", label="Datasource staging", order=20), + ViewSurface(id="datasources.origins", module_id=MODULE_ID, kind="section", label="Datasource origins", order=30), + ViewSurface(id="datasources.governance", module_id=MODULE_ID, kind="action", label="Datasource governance", order=40), + ViewSurface(id="datasources.preview", module_id=MODULE_ID, kind="section", label="Datasource preview and materializations", order=50), + ), ), route_factory=_router, capability_factories={ @@ -352,6 +361,83 @@ manifest = ModuleManifest( "risk_compliance", ), order=70, + metadata={ + "seed": True, + "help_contexts": [ + "datasources.page", + "datasources.catalogue", + "datasources.staging", + "datasources.origins", + "datasources.preview", + ], + }, + ), + DocumentationTopic( + id="datasources.governance", + title="Datasource authority and governance", + summary="Explain who owns data meaning, authority, correction, privacy, quality, freshness, retention, and dependent uses.", + body=( + "Authority mode states whether GovOPlaN, an external system, a synchronized projection, an overlay, or a linked reference " + "controls the data. The authoritative source, owner, steward, responsible organization/function, schema owner, privacy " + "profile, retention policy, transfer agreement, legal basis, holds, correction procedure, purposes, official keys, and " + "known limits provide discoverable institutional context. Freshness and quality policies are typed JSON contracts retained " + "with materialization evidence; enforcement remains with the provider or consuming control that declares support. Metadata " + "visibility never grants row access." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "data_steward", "product_owner"), + related_modules=("policy", "organizations", "idm", "dataflow", "reporting", "risk_compliance"), + order=71, + metadata={ + "seed": True, + "help_contexts": [ + "datasources.governance", + "datasources.field.authority-mode", + "datasources.field.authoritative-source", + "datasources.field.classification", + "datasources.field.publication-state", + "datasources.field.freshness-policy", + "datasources.field.quality-policy", + ], + }, + ), + DocumentationTopic( + id="datasources.reference.fields-and-consequences", + title="Datasource fields and lifecycle consequences", + summary="Live, cached, static, staging, promotion, refresh, freeze, and retirement semantics.", + body=( + "A Datasource key is the stable catalogue identity used by consumers. Live mode reads through an available origin; cached " + "mode refreshes an origin into immutable revisions; static mode promotes uploaded content from staging. Stages are bounded, " + "inspectable, and non-consumable until promoted. Promotion creates or updates a governed Datasource and appends an immutable " + "materialization. Refresh appends a new cached revision without rewriting older evidence. Freeze labels an immutable, " + "addressable state for reproducible execution. Retirement removes the Datasource from new definitions while retained " + "materialization references remain governed. Connector absence disables origin registration but leaves local catalogue and " + "staging behavior available." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "product_owner"), + related_modules=("connectors", "dataflow", "workflow_engine", "reporting", "audit"), + order=72, + metadata={ + "seed": True, + "help_contexts": [ + "datasources.field.origin", + "datasources.field.key", + "datasources.field.mode", + "datasources.action.promote", + "datasources.action.refresh", + "datasources.action.freeze", + "datasources.action.retire", + ], + "consequence_classes": { + "promote": "Creates or updates a governed Datasource and appends an immutable materialization revision.", + "refresh": "Reads the cached origin and appends a new immutable materialization revision.", + "freeze": "Creates a labelled immutable state for reproducible consumers and evidence.", + "retire": "Prevents new selection while retained materialization references remain governed.", + }, + }, ), ), ) diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..0c9b3f8 --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import unittest + +from govoplan_datasources.backend.manifest import manifest + + +class DatasourcesInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual({"/datasources"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] + self.assertEqual( + { + "datasources.page", + "datasources.catalogue", + "datasources.staging", + "datasources.origins", + "datasources.governance", + "datasources.preview", + }, + {item.id for item in frontend.view_surfaces}, # type: ignore[union-attr] + ) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + lifecycle = topics["datasources.lifecycle"] + governance = topics["datasources.governance"] + reference = topics["datasources.reference.fields-and-consequences"] + + self.assertIn("datasources.staging", lifecycle.metadata["help_contexts"]) + self.assertIn("datasources.field.authority-mode", governance.metadata["help_contexts"]) + self.assertIn("datasources.action.promote", reference.metadata["help_contexts"]) + self.assertIn("freeze", reference.metadata["consequence_classes"]) + self.assertIn("retire", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/datasources/DatasourcesPage.tsx b/webui/src/features/datasources/DatasourcesPage.tsx index 3eb3bc7..2b329a7 100644 --- a/webui/src/features/datasources/DatasourcesPage.tsx +++ b/webui/src/features/datasources/DatasourcesPage.tsx @@ -20,9 +20,11 @@ import { Upload } from "lucide-react"; import { + ActionBlockerHint, Button, ConfirmDialog, Dialog, + DocumentationHelpLink, DismissibleAlert, FormField, IconButton, @@ -31,6 +33,8 @@ import { StatusBadge, hasScope, isApiError, + useUnsavedChanges, + useUnsavedDraftGuard, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; @@ -54,6 +58,12 @@ import { type DatasourcePreview, type DatasourceStage } from "../../api/datasources"; +import { + DATASOURCE_FIELDS_DOCUMENTATION, + DATASOURCE_GOVERNANCE_DOCUMENTATION, + DATASOURCES_DOCUMENTATION, + DATASOURCES_I18N +} from "./interfacePatterns"; type CatalogueView = "catalogue" | "staging" | "origins"; type AddKind = "upload" | "origin"; @@ -86,7 +96,9 @@ export default function DatasourcesPage({ const [freezeOpen, setFreezeOpen] = useState(false); const [freezeLabel, setFreezeLabel] = useState(""); const [retireOpen, setRetireOpen] = useState(false); + const [promoteOpen, setPromoteOpen] = useState(false); const [governanceOpen, setGovernanceOpen] = useState(false); + const { requestDiscard } = useUnsavedChanges(); const canManage = hasScope(auth, "datasources:source:write") || hasScope(auth, "datasources:source:admin"); @@ -211,8 +223,8 @@ export default function DatasourcesPage({ } }; - const freezeSelected = async () => { - if (!selectedDatasource) return; + const freezeSelected = async (): Promise => { + if (!selectedDatasource) return false; setWorking(true); setError(""); try { @@ -225,8 +237,10 @@ export default function DatasourcesPage({ setFreezeOpen(false); setFreezeLabel(""); await reload(selectedDatasource.ref); + return true; } catch (operationError) { setError(apiErrorMessage(operationError)); + return false; } finally { setWorking(false); } @@ -248,6 +262,23 @@ export default function DatasourcesPage({ } }; + useUnsavedDraftGuard({ + dirty: Boolean(freezeOpen && freezeLabel.trim()), + onSave: freezeSelected, + onDiscard: () => { + setFreezeOpen(false); + setFreezeLabel(""); + }, + title: "i18n:govoplan-datasources.unsaved_freeze_title", + message: "i18n:govoplan-datasources.unsaved_freeze_message" + }); + + const closeFreeze = () => { + if (working) return; + if (freezeLabel.trim()) requestDiscard(() => setFreezeOpen(false)); + else setFreezeOpen(false); + }; + return (
@@ -261,6 +292,7 @@ export default function DatasourcesPage({ variant="ghost" onClick={() => void reload(selectedDatasourceRef)} disabled={loading || working} + disabledReason={loading ? DATASOURCES_I18N.loading : working ? DATASOURCES_I18N.working : undefined} /> setAddOpen(true)} disabled={!canStage && !canManage} + disabledReason={!canStage && !canManage ? DATASOURCES_I18N.manageReason : undefined} />
@@ -384,8 +417,9 @@ export default function DatasourcesPage({ + {view === "catalogue" && selectedDatasource?.mode === "cached" ? ( - ) : null} @@ -396,8 +430,9 @@ export default function DatasourcesPage({ icon={} onClick={() => setGovernanceOpen(true)} disabled={!canManage || working} + disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined} /> - setRetireOpen(true)} disabled={!canManage || working} + disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined} /> ) : null} {view === "staging" && selectedStage?.state === "ready" ? ( @@ -423,6 +460,7 @@ export default function DatasourcesPage({ variant="primary" onClick={() => setAddOpen(true)} disabled={!canManage || working} + disabledReason={working ? DATASOURCES_I18N.working : !canManage ? DATASOURCES_I18N.manageReason : undefined} > Register @@ -441,6 +479,18 @@ export default function DatasourcesPage({ {success} ) : null} + {!canManage && !canStage ? : null}
@@ -510,12 +560,10 @@ export default function DatasourcesPage({ { - if (!working) setFreezeOpen(false); - }} + onClose={closeFreeze} footer={( <> - + @@ -525,7 +573,7 @@ export default function DatasourcesPage({

Create an immutable, addressable state for reproducible runs and evidence.

- + setFreezeLabel(event.target.value)} @@ -533,6 +581,18 @@ export default function DatasourcesPage({ />
+ setPromoteOpen(false)} + onConfirm={() => { + setPromoteOpen(false); + void promoteStage(); + }} + /> (null); const [freshness, setFreshness] = useState("{}"); const [quality, setQuality] = useState("{}"); + const [baselineKey, setBaselineKey] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const { requestDiscard } = useUnsavedChanges(); useEffect(() => { if (!open || !datasource) return; - setDraft(structuredClone(datasource.governance)); - setFreshness(JSON.stringify(datasource.governance.freshness_policy, null, 2)); - setQuality(JSON.stringify(datasource.governance.quality_policy, null, 2)); + const nextDraft = structuredClone(datasource.governance); + const nextFreshness = JSON.stringify(datasource.governance.freshness_policy, null, 2); + const nextQuality = JSON.stringify(datasource.governance.quality_policy, null, 2); + setDraft(nextDraft); + setFreshness(nextFreshness); + setQuality(nextQuality); + setBaselineKey(JSON.stringify({ draft: nextDraft, freshness: nextFreshness, quality: nextQuality })); setError(""); }, [datasource, open]); - const save = async () => { - if (!datasource || !draft) return; + const dirty = Boolean(open && draft && JSON.stringify({ draft, freshness, quality }) !== baselineKey); + + const save = async (): Promise => { + if (!datasource || !draft) return false; setBusy(true); setError(""); try { @@ -764,13 +832,29 @@ function GovernanceDialog({ quality_policy: parseObject(quality, "Quality policy") }); await onSaved(updated); + return true; } catch (saveError) { setError(apiErrorMessage(saveError)); + return false; } finally { setBusy(false); } }; + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: onClose, + title: "i18n:govoplan-datasources.unsaved_governance_title", + message: "i18n:govoplan-datasources.unsaved_governance_message" + }); + + const close = () => { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + }; + const setValue = ( key: K, value: DatasourceGovernance[K] @@ -781,11 +865,11 @@ function GovernanceDialog({ open={open} title="Datasource governance" className="datasources-governance-dialog" - onClose={() => { if (!busy) onClose(); }} + onClose={close} footer={( <> - - + @@ -795,7 +879,7 @@ function GovernanceDialog({ {draft ? (
- + - + setValue("authoritative_source_ref", event.target.value || null)} /> - + setValue("classification", event.target.value)} /> - + setValue("publication_state", event.target.value)} /> @@ -847,7 +931,7 @@ function GovernanceDialog({ setValue("correction_procedure_ref", event.target.value || null)} />
- +