From d8ae506ff85cb5b2d942c2c5c7d87136de545092 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 09:35:52 +0200 Subject: [PATCH] Migrate Files surfaces to interface patterns --- README.md | 3 + docs/FILES_HANDBOOK.md | 12 ++ docs/INTERFACE_PATTERN_MIGRATION.md | 55 +++++++++ src/govoplan_files/backend/manifest.py | 5 + tests/test_manifest_documentation.py | 8 ++ webui/package.json | 3 +- .../test-interface-pattern-language.mjs | 46 ++++++++ .../files/FileConnectorSettingsPanel.tsx | 108 +++++++++++------- webui/src/features/files/FilesPage.tsx | 43 +++++-- 9 files changed, 229 insertions(+), 54 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 webui/scripts/test-interface-pattern-language.mjs diff --git a/README.md b/README.md index 7e8ab1d..60b55eb 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ Connector and collaboration ownership boundaries are documented in `docs/CONNECTOR_BOUNDARY.md` and `docs/DOCUMENT_COLLABORATION_BOUNDARY.md`. The role-adaptive user, administration, integration, and operator guide is the [Files handbook](docs/FILES_HANDBOOK.md). +The Files route, connector surfaces, consequence classes, and pattern-language +verification are recorded in +[Files interface pattern migration](docs/INTERFACE_PATTERN_MIGRATION.md). Archive imports use a two-phase preview and confirmation flow for ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ. Requests are spooled to bounded temporary files; diff --git a/docs/FILES_HANDBOOK.md b/docs/FILES_HANDBOOK.md index fcdca45..b25bece 100644 --- a/docs/FILES_HANDBOOK.md +++ b/docs/FILES_HANDBOOK.md @@ -24,6 +24,10 @@ a document collaboration engine, or a records-management system. | Verify a release or scenario | [Acceptance scenarios](#acceptance-scenarios) | | Check whether an idea exists today | [Implemented and planned boundary](#implemented-and-planned-boundary) | +The Files-owned interface archetypes, consequence classes, disabled-state +wording, and verification evidence are recorded in +[Files Interface Pattern Migration](INTERFACE_PATTERN_MIGRATION.md). + ## The service contract A managed file is a tenant-scoped logical asset with exactly one user or group @@ -185,6 +189,14 @@ inside the chosen owner space: Browse, import, and sync never write, rename, or delete the remote source. +Connector administration separates endpoint profiles, reusable credentials, +and inherited policy. Ordinary setup uses typed fields and provider discovery; +provider metadata JSON is available only under advanced compatibility options. +Read-only deployment entries explain where they must be changed, and disabled +actions identify the missing permission, target, input, or running operation. +The contextual help icon opens the configured Help Center topic when Docs is +enabled and the hosted GovOPlaN documentation otherwise. + ## Process perspective ### Managed ingestion diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..7656b44 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,55 @@ +# Files Interface Pattern Migration + +This inventory records the Files-owned part of the GovOPlaN interface pattern +language. Core owns the shell and shared components; Files owns the composition +and consequences described here. + +## Surface inventory + +| Surface | Primary task | Archetype | Consequence | Pattern evidence | +| --- | --- | --- | --- | --- | +| `/files` space and folder panes | Browse managed and connected content without losing location | Directory/explorer | Low for navigation; medium for exposing filenames and provenance | Full-height two-pane workspace, bounded panes, stable selection and contextual Help Center link | +| `/files` toolbar and property filters | Find and act on the current selection | Explorer actions and local filtering | Medium for upload, move, copy, share and synchronization; high for delete | Actions remain beside the affected list, disabled controls explain permission/state/selection blockers, destructive work uses `ConfirmDialog` | +| Upload/archive, transfer, rename and connector-import dialogs | Supply and review one bounded change | Adaptive create/edit or guided import | Medium to high because files, paths and external bytes change | Shared `Dialog`, `FileDropZone`, validation, conflict review, unsaved inputs and explicit confirmation | +| File share dialog | Inspect and change access | Review/decision | High because another actor gains access | Shared dialog, access explanation, stable row actions and destructive confirmation | +| System/tenant/group/user connector surfaces | Compare connections, credentials and effective policy | Administration/configuration | High because endpoints, secrets and inherited policy control external access | Shared `ConnectionTree`, adaptive forms, `ActionBlockerHint`, policy provenance and contextual admin help | +| Connection and credential editors | Create or edit one governed endpoint or secret | Adaptive create/edit | High because a saved change may enable remote access | Relevant fields only, typed credential controls, discovery/test, advanced compatibility section, unsaved-change guard and disabled-save reasons | +| Connector policy card | Narrow inherited connector access | Effective-policy editor | High because deny/allow changes affect lower scopes | Typed reference selectors, deny precedence warning, effective source evidence and permission blocker | +| `files.widget.spaces` | See available managed/connected spaces and open Files | Dashboard widget | Low; names and provider state may still be sensitive | Shared loading/alert/status components, bounded item count, permission-filtered contribution | +| Files chooser capability used by another module | Select a managed snapshot without importing Files internals | Directory chooser | Medium because the exact selected version becomes another module's input | Shared dialog/confirmation, capability boundary and exact file/version evidence | + +## State and consequence contract + +- Loading, errors, success, empty results, access explanations and confirmation + use Core components. Files does not reproduce the application shell. +- A connector that comes from deployment settings remains visible but read-only; + its action explains that bootstrap configuration and a restart are required. +- Missing permission, target, selection, endpoint, or compatible provider is an + explained disabled state. It is not represented only by color or absence. +- Provider metadata JSON is an expert compatibility escape hatch inside the + collapsed shared advanced-options component. Ordinary connector setup uses + typed provider, endpoint, credential, capability and policy controls. +- Endpoint discovery and credential tests are explicit and report their result; + they do not save the draft. Save remains the only committing action. +- Connector/profile disable and managed-file delete remain confirmed actions and + state their immediate effect. Soft deletion must not be described as purge. +- External connector data, paths and credential references are rendered only in + already-authorized administration or explorer contexts. Secret values are + never returned for rendering. + +## Accessibility and responsive evidence + +Shared `Dialog` owns focus entry, Escape handling and focus return. Form and +toolbar DOM order is the keyboard order; disabled-action tooltips are themselves +focusable and expose the reason. The connector form uses semantic sections and +labels, status is textual as well as colored, and result alerts are announced by +the shared alert component. The explorer collapses to one column below 1050 px; +connector forms and action rows collapse below 760 px while preserving source +order. Long provider choices scroll inside the segmented control rather than +expanding the page. + +The focused structural test guards these contracts, optional-module boundaries, +confirmation, contextual help, advanced-only JSON and responsive rules. Core's +TypeScript build, structural localization audit, module-permutation suite and +full-product bundle check provide the integration gates. + diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 1d2139e..88a2f55 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -566,6 +566,11 @@ manifest = ModuleManifest( "route": "/admin?section=tenant-file-connectors", "screen": "File connections", "section": "Profiles, credentials, and effective connector policy", + "help_contexts": [ + "files.connectors", + "files.connector.credentials", + "files.connector.policy", + ], "security_invariants": [ "New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.", "Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.", diff --git a/tests/test_manifest_documentation.py b/tests/test_manifest_documentation.py index bba696d..ef228bb 100644 --- a/tests/test_manifest_documentation.py +++ b/tests/test_manifest_documentation.py @@ -127,6 +127,14 @@ class FilesManifestDocumentationTests(unittest.TestCase): self.assertEqual("reference", topic.metadata["kind"]) self.assertEqual("File connections", topic.metadata["screen"]) self.assertTrue(topic.metadata["section"]) + self.assertEqual( + { + "files.connectors", + "files.connector.credentials", + "files.connector.policy", + }, + set(topic.metadata["help_contexts"]), + ) self.assertIn("deny rules win", topic.body) self.assertIn("redact secret values", topic.body) self.assertIn("same transaction", topic.body) diff --git a/webui/package.json b/webui/package.json index cf733ea..891da4c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -15,7 +15,8 @@ }, "scripts": { "test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs", - "test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs" + "test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs", + "test:interface-pattern-language": "node scripts/test-interface-pattern-language.mjs" }, "peerDependencies": { "@vitejs/plugin-react": "^5.2.0", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..aa5d839 --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +function read(relativePath) { + return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +const connector = read("../src/features/files/FileConnectorSettingsPanel.tsx"); +const filesPage = read("../src/features/files/FilesPage.tsx"); +const moduleSource = read("../src/module.ts"); +const styles = read("../src/styles/file-manager.css"); +const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md"); + +assert.match(connector, /ActionBlockerHint,[\s\S]*AdvancedOptionsPanel,[\s\S]*DocumentationHelpLink/); +assert.match(connector, /ConnectionTree,[\s\S]*ConfirmDialog,[\s\S]*Dialog,[\s\S]*LoadingFrame/); +assert.match(connector, /topicId: "files\.governed-connectors-and-provenance"/); +assert.match(connector, /documentation=\{CONNECTOR_DOCUMENTATION\}/); +assert.match(connector, /disabledReason=\{profileSaveDisabledReason\}/); +assert.match(connector, /disabledReason=\{credentialSaveDisabledReason\}/); +assert.match(connector, /disabledReason: mutationBlocker/); +assert.match(connector, / = { generic: "https://files.example.test" }; +const CONNECTOR_DOCUMENTATION = { + topicId: "files.governed-connectors-and-provenance", + documentationType: "admin" +} as const; + +const CONNECTOR_RECOVERY_DOCUMENTATION = { + topicId: "files.reference.integrity-recovery-and-fail-closed-transports", + documentationType: "admin" +} as const; + export default function FileConnectorSettingsPanel({ settings, scopeType, @@ -796,10 +806,14 @@ export default function FileConnectorSettingsPanel({ const credentialSaveDisabled = !credentialDraft || saving || credentialMissingRequired; const profileSaveTooltip = requiredFieldsTooltip(profileMissingFields); const credentialSaveTooltip = requiredFieldsTooltip(credentialMissingFields); + const profileSaveDisabledReason = saving ? "Wait for the current connector change to finish." : profileSaveTooltip; + const credentialSaveDisabledReason = saving ? "Wait for the current credential change to finish." : credentialSaveTooltip; const canDiscoverProfileEndpoint = Boolean(draft && (draft.provider === "webdav" || draft.provider === "nextcloud")); const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud"); const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported; - const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported); + const credentialTestTooltip = saving ? "Wait for the current connector change to finish." : + testingCredentialLogin ? "The credential test is already running." : + credentialTestHelpText(credentialTestProfile, credentialTestUnsupported); const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : []; const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : ""; const connectorColumns: ConnectionTreeColumn[] = [ @@ -846,18 +860,23 @@ export default function FileConnectorSettingsPanel({ function renderConnectorActions(row: ConnectorTreeRow) { if (row.kind === "credential") { const readOnly = row.credential.source_kind !== "database"; + const mutationBlocker = connectorMutationBlocker(canWrite, readOnly, saving); return , disabled: !canWrite || readOnly || saving, onClick: () => startCredentialEdit(row.credential) }, - { id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label }), icon: , variant: "danger", applicable: row.credential.enabled, disabled: !canWrite || readOnly || saving, onClick: () => setPendingCredentialDeactivate(row.credential) } + { id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.credential.label }), icon: , disabled: Boolean(mutationBlocker), disabledReason: mutationBlocker, onClick: () => startCredentialEdit(row.credential) }, + { id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label }), icon: , variant: "danger", disabled: Boolean(mutationBlocker) || !row.credential.enabled, disabledReason: mutationBlocker || (!row.credential.enabled ? "This credential is already disabled." : ""), onClick: () => setPendingCredentialDeactivate(row.credential) } ]} />; } const readOnly = row.profile.source_kind !== "database"; + const mutationBlocker = connectorMutationBlocker(canWrite, readOnly, saving); + const testBlocker = !row.profile.enabled ? "Enable this connection before testing it." : + saving ? "Wait for the current connector change to finish." : + testingProfileId === row.profile.id ? "This connection test is already running." : ""; return , applicable: row.profile.enabled, disabled: saving || testingProfileId === row.profile.id, onClick: () => void testBrowse(row.profile) }, - { id: "add-credential", label: i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label }), icon: , variant: "primary", disabled: !canWrite || saving, onClick: () => startCredentialCreate(row.profile) }, - { id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label }), icon: , disabled: !canWrite || readOnly || saving, onClick: () => startEdit(row.profile) }, - { id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label }), icon: , variant: "danger", applicable: row.profile.enabled, disabled: !canWrite || readOnly || saving, onClick: () => setPendingDeactivate(row.profile) } + { id: "test", label: i18nMessage("i18n:govoplan-files.test_value.6a5d10a5", { value0: row.profile.label }), icon: , disabled: Boolean(testBlocker), disabledReason: testBlocker, onClick: () => void testBrowse(row.profile) }, + { id: "add-credential", label: i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label }), icon: , variant: "primary", disabled: Boolean(mutationBlocker), disabledReason: mutationBlocker, onClick: () => startCredentialCreate(row.profile) }, + { id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label }), icon: , disabled: Boolean(mutationBlocker), disabledReason: mutationBlocker, onClick: () => startEdit(row.profile) }, + { id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label }), icon: , variant: "danger", disabled: Boolean(mutationBlocker) || !row.profile.enabled, disabledReason: mutationBlocker || (!row.profile.enabled ? "This connection is already disabled." : ""), onClick: () => setPendingDeactivate(row.profile) } ]} />; } @@ -876,7 +895,8 @@ export default function FileConnectorSettingsPanel({ requiredAction: "Ask an administrator to grant the required permission or make the change at a higher scope.", actor: "System or tenant administrator", target: "Administration > GLOBAL or TENANT > File Connectors" - }} /> + }} + documentation={CONNECTOR_DOCUMENTATION} /> } {targetSelectionRequired && !hasSelectableTarget && @@ -888,7 +908,8 @@ export default function FileConnectorSettingsPanel({ requiredAction: "Create or select a user or group first.", actor: "Access administrator", target: targetPluralLabel(scopeType, targetLabel) - }} /> + }} + documentation={CONNECTOR_DOCUMENTATION} /> } {targetSelectionRequired && @@ -911,10 +932,11 @@ export default function FileConnectorSettingsPanel({ actions={ canWrite ?
- - + + +
: - null + }> @@ -931,10 +953,13 @@ export default function FileConnectorSettingsPanel({ void savePolicyDraft()} disabled={saving || loading}> -