refactor: adopt shared split workspace

This commit is contained in:
2026-08-18 02:17:21 +02:00
parent b691a0c2d5
commit 4cf870f322
6 changed files with 42 additions and 14 deletions
+5 -3
View File
@@ -25,6 +25,8 @@ importing optional sibling modules.
- Signature references are evidence pointers and never a cryptographic claim. - Signature references are evidence pointers and never a cryptographic claim.
The module uses Core dialogs, controls, status, blockers, help, loading, empty, The module uses Core dialogs, controls, status, blockers, help, loading, empty,
error, and draft-guard contracts. Native selection buttons preserve keyboard error, draft-guard, and `WorkspaceLayout` contracts. The shared split-pane
order; bounded list/detail viewports remain responsive. English and German shell keeps the request collection and selected evidence in independently
catalogues cover module-owned copy and dates follow the active platform locale. 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.
+2 -1
View File
@@ -251,7 +251,8 @@ manifest = ModuleManifest(
body=( body=(
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. " "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. " "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", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
+2 -1
View File
@@ -25,6 +25,7 @@
} }
}, },
"scripts": { "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"
} }
} }
+18 -7
View File
@@ -1,6 +1,6 @@
import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react"; import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "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 { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
import ApprovalRequestDialog from "./ApprovalRequestDialog"; import ApprovalRequestDialog from "./ApprovalRequestDialog";
import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns"; 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); await load(undefined, id);
} }
return <main className="approvals-page"><div className="approvals-shell"> return <main className="approvals-page"><WorkspaceLayout
<aside className="approvals-list-panel"> variant="split"
primarySize="compact"
primaryScrollable={false}
contentScrollable={false}
className="approvals-shell"
primaryClassName="approvals-list-panel"
contentClassName="approvals-workspace"
primaryLabel="Approval requests"
contentLabel="Approval request details"
interfaceId="approvals.workspace"
helpContextId="approvals.workspace"
helpModuleId="approvals"
primary={<>
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? APPROVALS_I18N.loading : busy ? APPROVALS_I18N.busy : undefined} onClick={() => void load()} /><Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /></div> <div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? APPROVALS_I18N.loading : busy ? APPROVALS_I18N.busy : undefined} onClick={() => void load()} /><Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /></div>
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div>{!loading && items.length === 0 && <div className="approvals-empty">No approval requests</div>}</PageScrollViewport> <PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div>{!loading && items.length === 0 && <div className="approvals-empty">No approval requests</div>}</PageScrollViewport>
</aside> </>}
<section className="approvals-workspace"> >
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />} {!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />}
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail"> {selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
@@ -70,8 +82,7 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section> <section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section>
</div></PageScrollViewport>} </div></PageScrollViewport>}
{!selected && !loading && <div className="approvals-empty">Select or create an approval request.</div>} {!selected && !loading && <div className="approvals-empty">Select or create an approval request.</div>}
</section> </WorkspaceLayout>
</div>
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />} {creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => 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 && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => 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 && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => 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)); }} />} {selected && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => 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)); }} />}
+1 -2
View File
@@ -1,5 +1,4 @@
.approvals-page { height: 100%; min-height: 0; overflow: hidden; } .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-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-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; } .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-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-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; } .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; } }
@@ -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("<WorkspaceLayout"), "Approvals must use the shared workspace shell");
assert.ok(page.includes('variant="split"'), "Approvals must use the shared split-pane geometry");
assert.ok(page.includes('primarySize="compact"'), "The request list uses the bounded compact pane width");
assert.ok(page.includes("primaryScrollable={false}"), "The request list delegates scrolling to its contained viewport");
assert.ok(page.includes("contentScrollable={false}"), "The detail pane delegates scrolling to its contained viewport");
assert.ok(!styles.includes(".approvals-shell { display: grid"), "Approvals must not redefine the shared split-pane grid");
console.log("Approval workspace layout structural contract passed.");