Adopt the shared interface pattern language

This commit is contained in:
2026-08-04 08:21:50 +02:00
parent 2f53dc44dc
commit 4e7e972415
6 changed files with 84 additions and 13 deletions
+2
View File
@@ -22,6 +22,8 @@ Connectors; Projects stores only the native planning object and canonical
external reference.
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md).
The list-detail, editor, state, consequence, and accessibility mapping is in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
## Development
+27
View File
@@ -0,0 +1,27 @@
# Projects Interface Pattern Migration
Projects uses a full-height list-detail workspace. It owns native portfolio,
project, and milestone context; Tasks and Tickets own actionable work,
Connectors owns OpenProject transport, and optional integrations remain
capability-based.
| Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- |
| `/projects` catalogue | Search/filter and select a planning object | Loading, empty, failed, filtered, and selected states retain the list context. Restricted records are removed by backend authorization rather than cosmetically hidden. |
| Object detail | Inspect status, dates, ownership/membership evidence, outcomes, benefits, dependencies, and links | The selected identity, key, state, and revision remain visible while detail changes. |
| Create/edit dialog | Adaptive create/edit | Core Dialog supplies focus containment and return. Stable key, state, visibility, parent, dates, and change reason have labelled controls; save errors remain attached to the dialog. |
| Revisioned save | Consequential corrective action | Every successful save creates immutable revision and lifecycle evidence under optimistic concurrency. The required change reason explains the new record. |
Creating and editing require the Projects write permission. Restricted
visibility changes who may read an object, so its consequence is explained at
the field and enforced by the backend ACL. The workspace reflows to list then
detail at narrow widths, preserves semantic button/list behavior, and uses Core
buttons, icon buttons, dialog, alerts, loading, status, field labels, scrolling,
and documentation help.
Verification:
- `npm run test:interface-pattern`
- Projects service, migration, and manifest tests
- the Core TypeScript graph, structural localization audit, theme check, module
permutations, and full-product bundle budget
@@ -222,6 +222,11 @@ DOCUMENTATION = (
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
kind="repository",
),
DocumentationLink(
label="Projects interface pattern audit",
href="govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
),
metadata={
"seed": True,
+3
View File
@@ -13,6 +13,9 @@
},
"./styles/projects.css": "./src/styles/projects.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/projects/ProjectsPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/projects.css", "utf8");
assert.ok(page.includes("DocumentationHelpLink"), "Projects exposes configured-system help");
assert.ok(page.includes("PageScrollViewport"), "Projects owns bounded list and detail scrolling");
assert.ok(page.includes("<Dialog"), "Projects uses the shared focus-contained editor dialog");
assert.ok(page.includes("FieldLabel"), "Project editor fields use the shared label/help contract");
assert.ok(page.includes('DismissibleAlert tone="danger" resetKey={error}'), "Save failures remain attached to the editor");
assert.ok(page.includes("StatusBadge"), "Project lifecycle state is not conveyed by color alone");
assert.ok(!page.includes("window.alert("), "Projects must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Projects uses semantic interactive elements");
assert.ok(styles.includes("@media (max-width: 560px)"), "Projects retains a narrow-viewport editor layout");
assert.ok(styles.includes(":focus-visible"), "Projects retains visible keyboard focus");
console.log("Projects interface pattern contract passed.");
+29 -13
View File
@@ -16,7 +16,9 @@ import {
import {
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
IconButton,
LoadingIndicator,
PageScrollViewport,
@@ -69,6 +71,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editorError, setEditorError] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<ProjectRecord | null>(null);
const [saving, setSaving] = useState(false);
@@ -123,12 +126,14 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
function openCreate() {
setEditing(null);
setEditorError("");
setEditorOpen(true);
}
function openEdit() {
if (!selected) return;
setEditing(selected);
setEditorError("");
setEditorOpen(true);
}
@@ -188,10 +193,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
}
setEditorOpen(false);
setEditing(null);
setEditorError("");
await reload();
setSelectedKey(objectKey(saved));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The project object could not be saved.");
setEditorError(reason instanceof Error ? reason.message : "The project object could not be saved.");
} finally {
setSaving(false);
}
@@ -221,6 +227,10 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
</select>
</label>
<span className="projects-count">{total} objects</span>
<DocumentationHelpLink
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
label="Open Projects documentation"
/>
{canWrite &&
<Button type="button" variant="primary" onClick={openCreate}>
<Plus size={16} aria-hidden="true" /> New
@@ -270,7 +280,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
record={editing}
objects={parentOptions}
saving={saving}
onClose={() => setEditorOpen(false)}
error={editorError}
onClose={() => {
setEditorOpen(false);
setEditorError("");
}}
onSave={save}
/>
</main>
@@ -337,11 +351,12 @@ function PlanningStat({ label, value }: { label: string; value: number }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: {
function ProjectEditorDialog({ open, record, objects, saving, error, onClose, onSave }: {
open: boolean;
record: ProjectRecord | null;
objects: ProjectRecord[];
saving: boolean;
error: string;
onClose: () => void;
onSave: (values: EditorValues) => Promise<void>;
}) {
@@ -381,9 +396,10 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</Button>
</>
}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
<label>
<span>Type</span>
<FieldLabel>Type</FieldLabel>
<select
value={values.kind}
disabled={Boolean(record)}
@@ -398,7 +414,7 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</label>
{values.kind !== "portfolio" &&
<label className="project-editor-wide">
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span>
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
<select
value={values.parentRef}
required={values.kind === "milestone"}
@@ -418,40 +434,40 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</label>
}
<label>
<span>Key</span>
<FieldLabel help="Stable identifier used in links and external mappings.">Key</FieldLabel>
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
</label>
<label className="project-editor-wide">
<span>Title</span>
<FieldLabel>Title</FieldLabel>
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
</label>
<label>
<span>State</span>
<FieldLabel>State</FieldLabel>
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select>
</label>
<label>
<span>Visibility</span>
<FieldLabel help="Restricted objects require an owner or explicit membership grant.">Visibility</FieldLabel>
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
<option value="tenant">Tenant</option>
<option value="restricted">Restricted</option>
</select>
</label>
<label>
<span>Starts</span>
<FieldLabel>Starts</FieldLabel>
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
</label>
<label>
<span>Due</span>
<FieldLabel>Due</FieldLabel>
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
</label>
<label className="project-editor-wide">
<span>Description</span>
<FieldLabel>Description</FieldLabel>
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
</label>
<label className="project-editor-wide">
<span>Change reason</span>
<FieldLabel help="Recorded with the immutable project revision and lifecycle evidence.">Change reason</FieldLabel>
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
</label>
</form>