diff --git a/README.md b/README.md index d82d31d..0d97daa 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,9 @@ The `@govoplan/tenancy-webui` package contributes `system-tenants` and module owns the `/admin` shell but does not import these panels. Historical `access.admin.*` surface identifiers remain stable so existing saved Views keep working after the ownership move. + +The tenant registry and active-tenant settings follow the shared interface +pattern contract documented in +[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). +Manifest-provided documentation topics back contextual help for tenant fields, +governance limits, permission blockers, and lifecycle consequences. diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..29c826e --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,43 @@ +# Tenancy Interface Pattern Migration + +This document records the bounded migration of Tenancy-owned WebUI surfaces to +the GovOPlaN interface pattern language. Core owns the shared components and the +Admin host; Tenancy owns the behavior and documentation described here. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `tenancy.admin.system-tenants` | Administration directory and list-detail | Create, configure, suspend | Shared admin layout, DataGrid, stable row actions, adaptive create/edit dialog, lifecycle confirmation, contextual help | +| Tenant details dialog | Read-only evidence/detail | None | Stable labels, effective governance provenance, retained object counts | +| `tenancy.admin.tenant-settings` | Effective configuration | Configure | Shared admin layout, language selection, dirty-state guard, permission blocker, contextual help | + +## Consequence And Availability Rules + +- Creating a tenant establishes a new data and administration boundary and + provisions a protected initial owner. +- Tenant slugs are immutable after creation. +- System policy caps tenant governance overrides. A tenant can narrow an + allowance but cannot loosen a system denial. +- Suspension retains tenant-owned records and audit evidence. The active + tenant cannot be suspended until the operator changes context. +- Unavailable actions remain visible when they belong to the surface and state + the missing permission, inapplicable state, responsible actor, and + destination where applicable. +- Dirty dialogs and settings use the shared unsaved-change guard. Consequential + suspension continues to use the shared destructive confirmation dialog. + +## State And Accessibility Evidence + +The panels use Core loading, error, success, empty, disabled-action, blocker, +dialog, and status components. Row actions reserve a stable three-action area, +retain translated accessible labels, and do not disappear for row-specific +permission or lifecycle states. Dialog order follows identity, ownership, +locale/status, description, and governed capabilities. Shared dialogs own focus +containment and restoration, and the existing Admin shell provides responsive +composition. + +Stable help references are contributed through the module manifest for the +tenant registry, tenant settings, lifecycle actions, and individual fields. +The WebUI structural test and backend documentation-contract test prevent those +references and state explanations from silently regressing. diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index c350cfd..7ab3261 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -6,7 +6,7 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_TENANCY_TENANT_RESOLVER, ) -from govoplan_core.core.modules import DocumentationTopic, FrontendModule, ModuleContext, ModuleManifest +from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, FrontendModule, ModuleContext, ModuleManifest from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.views import ViewSurface @@ -51,7 +51,10 @@ manifest = ModuleManifest( documentation_types=("user",), audience=("user", "tenant_admin"), related_modules=("access",), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": ["tenancy.current-context", "tenancy.selector"], + }, ), DocumentationTopic( id="tenancy.lifecycle-and-settings", @@ -61,7 +64,49 @@ manifest = ModuleManifest( documentation_types=("admin",), audience=("system_admin", "tenant_admin", "operator"), related_modules=("access", "admin", "audit"), - metadata={"kind": "reference"}, + links=( + DocumentationLink(label="Tenant administration", href="/admin", kind="runtime"), + DocumentationLink(label="Tenant registry API", href="/api/v1/admin/tenants", kind="api"), + DocumentationLink(label="Tenant settings API", href="/api/v1/admin/tenant/settings", kind="api"), + ), + metadata={ + "kind": "reference", + "help_contexts": [ + "tenancy.admin.system-tenants", + "tenancy.admin.tenant-settings", + "tenancy.admin.lifecycle", + "tenancy.admin.blocked", + ], + }, + ), + DocumentationTopic( + id="tenancy.reference.admin-fields", + title="Tenant administration fields and consequences", + summary="Tenant identity, ownership, locale, governance overrides, and lifecycle state have different mutation and recovery consequences.", + body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. Locale and enabled languages are bounded by system language packages. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it.", + documentation_types=("admin",), + audience=("system_admin", "tenant_admin", "operator"), + related_modules=("access", "admin", "audit"), + links=( + DocumentationLink(label="Tenant administration", href="/admin", kind="runtime"), + DocumentationLink(label="Tenant registry API", href="/api/v1/admin/tenants", kind="api"), + ), + metadata={ + "kind": "reference", + "help_contexts": [ + "tenancy.field.slug", + "tenancy.field.initial-owner", + "tenancy.field.locale", + "tenancy.field.languages", + "tenancy.field.governance", + "tenancy.action.suspend", + ], + "consequence_classes": { + "create": "Creates a new tenant boundary and provisions its protected initial owner.", + "update": "Changes tenant-local identity, locale, or governance configuration.", + "suspend": "Blocks normal tenant use while retaining data and audit evidence.", + }, + }, ), ), frontend=FrontendModule( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..caa961b --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import unittest + +from govoplan_tenancy.backend.manifest import manifest + + +class TenancyInterfaceDocumentationContractTests(unittest.TestCase): + def test_tenancy_admin_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + surfaces = {surface.id for surface in frontend.view_surfaces} # type: ignore[union-attr] + self.assertEqual( + { + "tenancy.admin.system-tenants", + "tenancy.admin.tenant-settings", + }, + surfaces, + ) + + def test_tenancy_topics_publish_stable_help_contexts(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + self.assertIn("tenancy.current-context", topics) + self.assertIn("tenancy.lifecycle-and-settings", topics) + self.assertIn("tenancy.reference.admin-fields", topics) + + lifecycle_contexts = set(topics["tenancy.lifecycle-and-settings"].metadata["help_contexts"]) + self.assertIn("tenancy.admin.system-tenants", lifecycle_contexts) + self.assertIn("tenancy.admin.tenant-settings", lifecycle_contexts) + self.assertIn("tenancy.action.suspend", topics["tenancy.reference.admin-fields"].metadata["help_contexts"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index f072bde..985ab1f 100644 --- a/webui/package.json +++ b/webui/package.json @@ -7,7 +7,8 @@ "module": "src/index.ts", "types": "src/index.ts", "scripts": { - "test:tenancy-admin": "node scripts/test-tenancy-admin-structure.mjs" + "test:tenancy-admin": "node scripts/test-tenancy-admin-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..27761ea --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -0,0 +1,30 @@ +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 tenants = source("../src/features/admin/TenantsPanel.tsx"); +const settings = source("../src/features/admin/TenantSettingsPanel.tsx"); +const patterns = source("../src/features/admin/interfacePatterns.ts"); +const moduleSource = source("../src/module.ts"); +const translations = source("../src/i18n/generatedTranslations.ts"); + +assert(tenants.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Both Tenancy admin surfaces expose contextual documentation"); +assert(tenants.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only Tenancy states identify the actor, action, and destination"); +assert(tenants.includes("disabledReason") && settings.includes("disabledReason"), "Disabled Tenancy actions explain their state"); +assert(tenants.includes("requestDiscard(closeEditor)"), "The tenant editor uses the shared unsaved-change guard when closing"); +assert(settings.includes("requestDiscard(() => void load())"), "Tenant settings protect dirty state when reloading"); +assert(tenants.includes("ConfirmDialog") && tenants.includes("confirmSuspend"), "Tenant suspension remains explicitly confirmed"); +assert(tenants.includes("minimumSlots={3}"), "Tenant row actions reserve stable keyboard and visual positions"); +assert(!tenants.includes("applicable:"), "Row-specific unavailable actions stay visible with an explanation"); +assert(patterns.includes('topicId: "tenancy.lifecycle-and-settings"') && patterns.includes('topicId: "tenancy.reference.admin-fields"'), "Tenancy uses stable manifest-backed help references"); +assert(moduleSource.includes('version: "0.1.8"'), "The WebUI contribution reports the module release version"); +assert(moduleSource.includes('label: "i18n:govoplan-tenancy.tenants.1f7ae776"') && moduleSource.includes('label: "i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8"'), "View-surface labels are localized"); +assert(translations.includes('"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011"'), "Availability explanations are present in the translation catalog"); + +console.log("Tenancy surfaces satisfy the recorded interface pattern-language contract."); diff --git a/webui/src/features/admin/TenantSettingsPanel.tsx b/webui/src/features/admin/TenantSettingsPanel.tsx index 910ac09..50d2e04 100644 --- a/webui/src/features/admin/TenantSettingsPanel.tsx +++ b/webui/src/features/admin/TenantSettingsPanel.tsx @@ -1,10 +1,25 @@ import { useEffect, useState } from "react"; -import type { ApiSettings } from "@govoplan/core-webui"; -import { Button } from "@govoplan/core-webui"; -import { Card } from "@govoplan/core-webui"; -import { FormField } from "@govoplan/core-webui"; +import { + ActionBlockerHint, + AdminPageLayout, + AdminSelectionList, + Button, + Card, + DocumentationHelpLink, + FormField, + adminErrorMessage, + useDeltaWatermarks, + useUnsavedChanges, + useUnsavedDraftGuard, + type ApiSettings +} from "@govoplan/core-webui"; import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/tenancy"; -import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { + TENANCY_ADMIN_DOCUMENTATION, + TENANCY_FIELD_DOCUMENTATION, + TENANCY_INTERFACE_I18N, + tenantMutationDisabledReason +} from "./interfacePatterns"; const DELTA_KEY = "tenancy:tenant-settings"; @@ -31,6 +46,7 @@ export default function TenantSettingsPanel({ }: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise;}) { + const { requestDiscard } = useUnsavedChanges(); const [draft, setDraft] = useState(fallback); const [savedDraft, setSavedDraft] = useState(fallback); const [loading, setLoading] = useState(true); @@ -40,6 +56,13 @@ export default function TenantSettingsPanel({ const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes); const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft); + const saveDisabledReason = tenantMutationDisabledReason({ + busy, + permitted: canWrite, + complete: Boolean(draft.default_locale.trim() && draft.enabled_language_codes.length), + changed: dirty, + permissionReason: TENANCY_INTERFACE_I18N.settingsWriteRequired + }); useUnsavedDraftGuard({ dirty, @@ -108,11 +131,26 @@ export default function TenantSettingsPanel({ loading={loading} error={error} success={success} - actions={<>}> + actions={<>}> + + {!canWrite && }
- + setDraft({ ...draft, name: event.target.value })} /> - setDraft({ ...draft, slug: event.target.value })} /> - {editing === "new" && } - setDraft({ ...draft, defaultLocale: event.target.value })} /> - {editing !== "new" && } -