diff --git a/README.md b/README.md index 0f8660a..7a7b5d0 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ organizations module is active. Those controls are limited to governance and policy settings such as tenant model customization, change-request requirements, audit detail, and retention behavior. +The reviewed surface inventory, consequence classes, availability rules, and +accessibility evidence are recorded in +[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). + ## Module Contract The module registers two capabilities from diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..39584db --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,51 @@ +# Organizations Interface Pattern Migration + +This document records the bounded migration of Organizations-owned WebUI +surfaces to the GovOPlaN interface pattern language. Core owns shared controls +and host shells. Organizations owns tenant-local model definitions, concrete +units, relations, functions, settings, and their mutation consequences. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/organizations` model section | Repeated administration and definition library | Change model vocabulary | Shared DataGrid/cards/dialogs, stable row actions, governed change reference, contextual help | +| `/organizations` units section | Hierarchy explorer and directory | Change hierarchy and routing context | Shared ExplorerTree/DataGrid, parent selection, explicit write blockers, guarded drafts | +| `/organizations` relations section | Repeated administration | Change structure traversal | Typed source/target editor, lifecycle state, contextual field help | +| `/organizations` functions section | Repeated administration | Change institutional responsibility vocabulary | Stable function rows, optional capability actions, explicit Access boundary | +| `organizations.admin.tenant` | Effective tenant configuration | Change governance, audit, and retention behavior | Shared admin layout, permission blocker, dirty-state guard, contextual help | +| `organizations.functionPicker` | Governed reference selector | Select an active function | Tenant-safe labels, bounded loading/error state, no sibling-private import | + +## Consequence And Availability Rules + +- Organization hierarchies and settings are tenant-owned. A tenant may start + from a versioned system template, but it does not inherit one mutable global + hierarchy. +- Unit types, structures, relation types, and function types define the model; + units, relations, and functions are concrete facts within it. +- Parent and relation changes can affect downstream routing, Postbox + resolution, reporting, and policy context. Slugs are stable integration + references. +- Deactivation preserves the record and audit evidence while removing it from + active selection. The UI does not represent deactivation as deletion. +- Delegation and act-in-place flags describe organizational semantics only. + Access and governed workflows still decide effective authority. +- When tenant governance requires it, model mutations need an approved + change-request reference. Dirty dialogs, section changes, and reloads use the + shared unsaved-change guard. +- Unavailable actions remain visible and identify the missing permission, + responsible administrator, and administration destination. + +## State And Accessibility Evidence + +The module uses Core page, subnavigation, grid, tree, card, dialog, loading, +alert, status, blocker, field-help, and disabled-action controls. Stable table +action slots remain keyboard reachable; dialogs retain shared focus containment +and return. Responsive behavior stays owned by the existing workspace and admin +shell CSS. Labels and accessible properties use the English/German module +catalogue, and API errors are bounded to the current tenant operation. + +The manifest contributes stable help contexts for the workspace, sections, +settings, fields, lifecycle changes, and governed change references. Backend +and WebUI contract tests prevent those boundaries and explanations from +silently regressing. diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py index 0259c86..e38bf90 100644 --- a/src/govoplan_organizations/backend/manifest.py +++ b/src/govoplan_organizations/backend/manifest.py @@ -8,6 +8,7 @@ from govoplan_core.core.access import ( ) from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.modules import ( + DocumentationLink, DocumentationTopic, FrontendModule, FrontendRoute, @@ -155,7 +156,7 @@ manifest = ModuleManifest( NavItem( path="/organizations", label="Organizations", - icon="users", + icon="building-2", required_any=ORGANIZATIONS_READ_SCOPES, order=70, ), @@ -175,7 +176,7 @@ manifest = ModuleManifest( NavItem( path="/organizations", label="Organizations", - icon="users", + icon="building-2", required_any=ORGANIZATIONS_READ_SCOPES, order=70, ), @@ -229,8 +230,89 @@ manifest = ModuleManifest( layer="configured", documentation_types=("admin", "user"), audience=("tenant_admin", "access_admin", "operator"), + related_modules=("tenancy", "access", "idm", "policy", "audit"), + links=( + DocumentationLink( + label="Organizations workspace", + href="/organizations", + kind="runtime", + ), + DocumentationLink( + label="Organization model API", + href="/api/v1/organizations/model", + kind="api", + ), + ), + metadata={ + "kind": "guide", + "help_contexts": [ + "organizations.workspace", + "organizations.model", + "organizations.units", + "organizations.relations", + "organizations.functions", + "organizations.admin.tenant", + "organizations.blocked", + ], + }, order=25, ), + DocumentationTopic( + id="organizations.reference.fields-and-consequences", + title="Organization model fields and consequences", + summary=( + "Distinguish tenant-owned model definitions, concrete units, " + "relations, functions, governance references, and lifecycle state." + ), + body=( + "Unit types, structures, relation types, and function types define " + "the tenant-owned model. Units, relations, and functions are concrete " + "institutional facts within that model. Slugs are stable references for " + "integrations; parent and relation changes affect hierarchy traversal and " + "downstream routing. Deactivation retains the record and evidence but " + "removes it from active selection. Delegation and act-in-place flags only " + "describe permitted organizational semantics; Access and governed workflows " + "still decide effective authority. When configured, a recorded change-request " + "ID is required before model mutations. Organization settings are tenant-owned " + "and do not inherit a live global hierarchy." + ), + layer="configured", + documentation_types=("admin",), + audience=("tenant_admin", "access_admin", "operator"), + related_modules=("tenancy", "access", "idm", "policy", "audit"), + links=( + DocumentationLink( + label="Organizations workspace", + href="/organizations", + kind="runtime", + ), + DocumentationLink( + label="Organization settings API", + href="/api/v1/organizations/settings", + kind="api", + ), + ), + metadata={ + "kind": "reference", + "help_contexts": [ + "organizations.field.name", + "organizations.field.slug", + "organizations.field.parent", + "organizations.field.relation", + "organizations.field.function", + "organizations.field.change-request", + "organizations.field.audit-retention", + "organizations.action.deactivate", + ], + "consequence_classes": { + "model_change": "Changes the valid vocabulary and constraints for tenant-owned organization facts.", + "hierarchy_change": "Changes traversal, routing, and inherited institutional context for downstream modules.", + "deactivate": "Retains the fact and evidence while removing it from active selection.", + "settings": "Changes tenant-owned governance, audit detail, and retention behavior.", + }, + }, + order=26, + ), ), architecture=declared_module_architecture( layer="institutional_foundation", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..7b42934 --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import unittest + +from govoplan_organizations.backend.manifest import manifest + + +class OrganizationsInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_admin_surface_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual( + {"/organizations"}, + {route.path for route in frontend.routes}, # type: ignore[union-attr] + ) + self.assertEqual( + {"organizations.admin.tenant"}, + {surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr] + ) + self.assertTrue( + all(item.icon == "building-2" for item in manifest.nav_items) + ) + + def test_topics_publish_stable_help_and_consequence_metadata(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + self.assertIn("organizations.model", topics) + self.assertIn("organizations.reference.fields-and-consequences", topics) + self.assertIn( + "organizations.admin.tenant", + topics["organizations.model"].metadata["help_contexts"], + ) + reference = topics["organizations.reference.fields-and-consequences"] + self.assertIn( + "organizations.field.change-request", + reference.metadata["help_contexts"], + ) + self.assertIn("hierarchy_change", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index ba5dd02..c25872c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -7,7 +7,8 @@ "module": "src/index.ts", "types": "src/index.ts", "scripts": { - "test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs" + "test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs", + "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs" }, "exports": { ".": { diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..c9720ca --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; + +function source(path) { + return readFileSync(new URL(path, import.meta.url), "utf8"); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const page = source("../src/features/organizations/OrganizationsPage.tsx"); +const settings = source("../src/features/organizations/OrganizationsAdminPanel.tsx"); +const patterns = source("../src/features/organizations/interfacePatterns.ts"); +const moduleSource = source("../src/module.ts"); +const translations = source("../src/i18n/generatedTranslations.ts"); + +assert(page.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Workspace and tenant settings expose contextual documentation"); +assert(page.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only states identify action, actor, and destination"); +assert(page.includes("disabledReason") && settings.includes("disabledReason"), "Unavailable organization actions explain their state"); +assert(page.includes("requestDiscard(() => void loadModel())") && settings.includes("requestDiscard(() => void load())"), "Reload preserves dirty organization drafts and settings"); +assert(page.includes("hasDirtyDraft ? requestDiscard(discardDrafts)"), "Editor close uses the shared unsaved-change guard"); +assert(page.includes("ORGANIZATIONS_FIELD_DOCUMENTATION"), "Model fields link to stable consequence documentation"); +assert(patterns.includes('topicId: "organizations.model"') && patterns.includes('topicId: "organizations.reference.fields-and-consequences"'), "Organizations uses manifest-backed help references"); +assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-organizations.organizations_administration"'), "WebUI metadata matches the module release and localizes its composed surface"); +assert(translations.includes('"i18n:govoplan-organizations.read_only_summary"'), "Blocker explanations are present in the translation catalogue"); +assert(!page.includes("window.confirm"), "Organizations does not use browser-native consequential confirmation"); + +console.log("Organizations surfaces satisfy the recorded interface pattern-language contract."); diff --git a/webui/src/features/organizations/OrganizationsAdminPanel.tsx b/webui/src/features/organizations/OrganizationsAdminPanel.tsx index 1821f1c..0193439 100644 --- a/webui/src/features/organizations/OrganizationsAdminPanel.tsx +++ b/webui/src/features/organizations/OrganizationsAdminPanel.tsx @@ -1,12 +1,15 @@ import { useEffect, useState } from "react"; import { + ActionBlockerHint, AdminPageLayout, Button, Card, + DocumentationHelpLink, FormField, ToggleSwitch, adminErrorMessage, hasAnyScope, + useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings, type AuthInfo @@ -17,6 +20,12 @@ import { type OrganizationAuditDetailLevel, type OrganizationSettingsItem } from "../../api/organizations"; +import { + ORGANIZATIONS_DOCUMENTATION, + ORGANIZATIONS_FIELD_DOCUMENTATION, + ORGANIZATIONS_INTERFACE_I18N, + organizationWriteReason +} from "./interfacePatterns"; const FALLBACK_SETTINGS: OrganizationSettingsItem = { tenant_id: "", @@ -40,8 +49,16 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings: const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const { requestDiscard } = useUnsavedChanges(); const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]); const dirty = settingsKey(draft) !== settingsKey(savedDraft); + const reloadDisabledReason = loading + ? ORGANIZATIONS_INTERFACE_I18N.loading + : busy + ? ORGANIZATIONS_INTERFACE_I18N.busy + : undefined; + const saveDisabledReason = organizationWriteReason(canWrite, busy, "settings") + ?? (!dirty ? ORGANIZATIONS_INTERFACE_I18N.noChanges : undefined); useUnsavedDraftGuard({ dirty, @@ -99,20 +116,57 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings: loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8" error={error} success={success} - actions={<>} + actions={( + <> + + + + + )} > + {!canWrite && ( + + )}
setDraft({ ...draft, allow_tenant_model_customization: checked })} label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751" /> setDraft({ ...draft, require_model_change_requests: checked })} label="i18n:govoplan-organizations.require_model_change_requests.83454cad" /> @@ -122,12 +176,20 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
- + - + void; disabled: boolean; + disabledReason?: string; }) { return ( <> - + onChange({ ...draft, name: event.target.value })} /> - + onChange({ ...draft, slug: event.target.value })} />
- +