diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 9ecd67d..702ecb2 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -25,6 +25,8 @@ importing optional sibling modules. - Signature references are evidence pointers and never a cryptographic claim. The module uses Core dialogs, controls, status, blockers, help, loading, empty, -error, and draft-guard contracts. Native selection buttons preserve keyboard -order; bounded list/detail viewports remain responsive. English and German -catalogues cover module-owned copy and dates follow the active platform locale. +error, draft-guard, and `WorkspaceLayout` contracts. The shared split-pane +shell keeps the request collection and selected evidence in independently +scrollable panes, then stacks them at the platform narrow-layout breakpoint. +Native selection buttons preserve keyboard order. English and German catalogues +cover module-owned copy and dates follow the active platform locale. diff --git a/src/govoplan_approvals/backend/manifest.py b/src/govoplan_approvals/backend/manifest.py index bcde983..7e57858 100644 --- a/src/govoplan_approvals/backend/manifest.py +++ b/src/govoplan_approvals/backend/manifest.py @@ -251,7 +251,8 @@ manifest = ModuleManifest( body=( "An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. " "Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables. " - "When Tasks is enabled, a pending step appears in the common work inbox only for a principal who currently passes the exact decision eligibility checks." + "When Tasks is enabled, a pending step appears in the common work inbox only for a principal who currently passes the exact decision eligibility checks. " + "The workspace keeps the permission-filtered request collection and selected evidence in separately scrollable panes and stacks them at narrow widths without losing selection." ), layer="configured", documentation_types=("admin", "user"), diff --git a/webui/package.json b/webui/package.json index 6c8e0f4..62b49ab 100644 --- a/webui/package.json +++ b/webui/package.json @@ -25,6 +25,7 @@ } }, "scripts": { - "test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs" + "test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs", + "test:workspace-layout": "node tests/workspace-layout-ui-structure.test.mjs" } } diff --git a/webui/src/features/approvals/ApprovalsPage.tsx b/webui/src/features/approvals/ApprovalsPage.tsx index 1edebd8..6cc92f5 100644 --- a/webui/src/features/approvals/ApprovalsPage.tsx +++ b/webui/src/features/approvals/ApprovalsPage.tsx @@ -1,6 +1,6 @@ import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui"; +import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, WorkspaceLayout, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui"; import { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals"; import ApprovalRequestDialog from "./ApprovalRequestDialog"; import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns"; @@ -54,12 +54,24 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext) await load(undefined, id); } - return
- -
+ } + > {error && {error}} {!canWrite && } {selected &&
@@ -70,8 +82,7 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)

History

{history.map((event) =>
{event.sequence}{humanize(event.event_type)}
)}
} {!selected && !loading &&
Select or create an approval request.
} -
-
+ {creating && setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />} {selected && decision && setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); return true; } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); return false; } finally { setBusy(false); } }} />} {selected && setEscalating(false)} onConfirm={() => { setBusy(true); setError(""); void escalateApproval(settings, selected).then(() => { setEscalating(false); return reload(selected.id); }).catch((failure) => setError(text(failure, "The Approval step could not be escalated."))).finally(() => setBusy(false)); }} />} diff --git a/webui/src/styles/approvals.css b/webui/src/styles/approvals.css index 66104e6..6651dbc 100644 --- a/webui/src/styles/approvals.css +++ b/webui/src/styles/approvals.css @@ -1,5 +1,4 @@ .approvals-page { height: 100%; min-height: 0; overflow: hidden; } -.approvals-shell { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); height: 100%; min-height: 0; } .approvals-list-panel { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--border-color, #d8dde3); } .approvals-toolbar { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #d8dde3); } .approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; } @@ -45,4 +44,4 @@ .approval-compare-toolbar { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 8px; } .approval-diff-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; } -@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } } +@media (max-width: 900px) { .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } } diff --git a/webui/tests/workspace-layout-ui-structure.test.mjs b/webui/tests/workspace-layout-ui-structure.test.mjs new file mode 100644 index 0000000..08ecfdf --- /dev/null +++ b/webui/tests/workspace-layout-ui-structure.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const page = fs.readFileSync("src/features/approvals/ApprovalsPage.tsx", "utf8"); +const styles = fs.readFileSync("src/styles/approvals.css", "utf8"); + +assert.ok(page.includes("