Adopt the shared interface pattern language
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user