mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
fix(ara-projects): align the add-document composer with the table it feeds (#412)
Three complaints, one cause: the composer was a separate block trying to look like part of the table. It now *is* part of it — a `<tfoot>`, so its fields sit in the same columns as the rows by construction, and the table's own headings label them. No second caption set to drift out of line, and no unlabelled fields either. Its controls belong to a form declared below the table and are wired to it by `form=`, because a `<form>` cannot sit between a `<table>` and its rows; Enter still submits. It stays distinct: a 4px rule above it, a tinted band, and its own "Add a document" caption spanning the width. Also: selects get `pr-8` so the native chevron stops overlapping the option text, the Stage column widens to `w-52`, and the last column widens to fit the Add button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c94a538ed
commit
df8e0315cb
1 changed files with 151 additions and 107 deletions
|
|
@ -44,8 +44,11 @@ const EMPTY_DRAFT: Draft = {
|
|||
namingRule: "",
|
||||
};
|
||||
|
||||
// `pr-8` keeps the option text clear of the native chevron, which is drawn
|
||||
// inside the control's right padding — at `px-2` a long stage name runs
|
||||
// straight under it.
|
||||
const selectClass =
|
||||
"w-full rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400";
|
||||
"w-full rounded-md border border-gray-200 bg-white px-2 py-1.5 pr-8 text-sm text-gray-700 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-gray-200 bg-white px-2 py-1.5 font-mono text-xs text-gray-700 placeholder:font-sans placeholder:text-gray-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:cursor-not-allowed disabled:bg-gray-50";
|
||||
|
|
@ -67,6 +70,13 @@ export function RequirementsTable({
|
|||
|
||||
const collectionUrl = `/api/projects/${projectId}/workstreams/${workstream.workstreamId}/evidence-requirements`;
|
||||
|
||||
/**
|
||||
* Ties the composer's controls to the form element below the table. Derived
|
||||
* from the workstream rather than generated, so it is the same string on the
|
||||
* server and in the browser — and distinct per workstream.
|
||||
*/
|
||||
const addFormId = `evidence-add-${workstream.workstreamId}`;
|
||||
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({ queryKey: evidenceQueryKey(projectId) });
|
||||
|
||||
|
|
@ -197,12 +207,14 @@ export function RequirementsTable({
|
|||
<thead>
|
||||
<tr className="bg-white text-xs uppercase tracking-wide text-gray-400">
|
||||
<th className="px-5 py-2 font-semibold">Document</th>
|
||||
<th className="w-48 px-3 py-2 font-semibold">Stage</th>
|
||||
{/* Wide enough for "Whole workstream" and a stage name, chevron included. */}
|
||||
<th className="w-52 px-3 py-2 font-semibold">Stage</th>
|
||||
<th className="px-3 py-2 font-semibold">Naming rule</th>
|
||||
<th className="w-24 px-3 py-2 text-center font-semibold">
|
||||
Required
|
||||
</th>
|
||||
<th className="w-14 px-3 py-2" />
|
||||
{/* Wide enough for the composer's Add button, not just the bin. */}
|
||||
<th className="w-24 px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -312,22 +324,148 @@ export function RequirementsTable({
|
|||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
{/*
|
||||
The composer lives in the table's own `<tfoot>`, so its fields sit
|
||||
in the same columns as the rows above by construction — no second
|
||||
set of column captions to go subtly out of line, and the table's
|
||||
headings label the composer too.
|
||||
It stays obviously separate: a heavy rule above it, a tinted band,
|
||||
and its own "Add a document" caption spanning the width.
|
||||
The controls are associated with the `<form>` below by id rather
|
||||
than being wrapped in one, because a `<form>` cannot sit between a
|
||||
`<table>` and its rows. Enter still submits.
|
||||
*/}
|
||||
{canManage && (
|
||||
<tfoot className="bg-gray-50/70" data-testid="evidence-add-form">
|
||||
<tr>
|
||||
{/*
|
||||
The dividing rule hangs off this cell rather than off
|
||||
`<tfoot>`: in the collapsed border model a row-group border
|
||||
is the least reliably drawn of the two, and this one spans
|
||||
the full width anyway.
|
||||
*/}
|
||||
<td
|
||||
colSpan={5}
|
||||
className="border-t-4 border-gray-100 px-5 pb-1 pt-3"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add a document
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-5 py-2">
|
||||
<select
|
||||
className={selectClass}
|
||||
form={addFormId}
|
||||
value={draft.fileType}
|
||||
disabled={busy}
|
||||
aria-label="Document"
|
||||
data-testid="evidence-add-file-type"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, fileType: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose a document…</option>
|
||||
{FILE_TYPE_GROUPS.map((group) => (
|
||||
<optgroup key={group.group} label={group.group}>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
className={selectClass}
|
||||
form={addFormId}
|
||||
value={draft.projectWorkstreamStageId}
|
||||
disabled={busy || !hasStages}
|
||||
aria-label="Stage"
|
||||
data-testid="evidence-add-stage"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
projectWorkstreamStageId: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value={NO_STAGE}>
|
||||
{hasStages ? "Whole workstream" : "No stages yet"}
|
||||
</option>
|
||||
{workstream.stages.map((stage) => (
|
||||
<option key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
form={addFormId}
|
||||
value={draft.namingRule}
|
||||
disabled={busy}
|
||||
placeholder="e.g. [AssetRef]_[WORef]_Cert"
|
||||
aria-label="Naming rule"
|
||||
data-testid="evidence-add-naming-rule"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, namingRule: event.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
form={addFormId}
|
||||
checked={draft.required}
|
||||
disabled={busy}
|
||||
aria-label="Required"
|
||||
data-testid="evidence-add-required"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, required: event.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
form={addFormId}
|
||||
disabled={busy}
|
||||
data-testid="evidence-add-submit"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 pb-3 text-xs text-gray-400">
|
||||
{hasStages
|
||||
? "Leave the stage as “Whole workstream” to expect the document at any point."
|
||||
: "This workstream has no stages yet, so a requirement applies to the workstream as a whole. Tag it with a stage once the ladder exists."}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The composer is deliberately *not* a twelfth column-aligned row.
|
||||
Labelled cells sitting under the table's own headings read as a second
|
||||
header row — two near-identical sets of words, never quite in line,
|
||||
which is at its worst when the table is empty. So it announces itself
|
||||
with its own heading and lays its controls out as a form: the fields
|
||||
carry placeholders and `aria-label`s rather than column captions, and
|
||||
they are free to size themselves.
|
||||
The form the composer's controls belong to. It carries the submit
|
||||
handler and nothing else — the fields themselves are up in the table's
|
||||
footer, pointed here by their `form` attribute.
|
||||
*/}
|
||||
{canManage && (
|
||||
<form
|
||||
className="border-t-4 border-gray-100 bg-gray-50/70 px-5 py-4"
|
||||
data-testid="evidence-add-form"
|
||||
id={addFormId}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!draft.fileType) {
|
||||
|
|
@ -336,101 +474,7 @@ export function RequirementsTable({
|
|||
}
|
||||
add.mutate(draft);
|
||||
}}
|
||||
>
|
||||
<p className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add a document
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
className={`${selectClass} min-w-[11rem] flex-[2]`}
|
||||
value={draft.fileType}
|
||||
disabled={busy}
|
||||
aria-label="Document"
|
||||
data-testid="evidence-add-file-type"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, fileType: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose a document…</option>
|
||||
{FILE_TYPE_GROUPS.map((group) => (
|
||||
<optgroup key={group.group} label={group.group}>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className={`${selectClass} min-w-[10rem] flex-1`}
|
||||
value={draft.projectWorkstreamStageId}
|
||||
disabled={busy || !hasStages}
|
||||
aria-label="Stage"
|
||||
data-testid="evidence-add-stage"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
projectWorkstreamStageId: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value={NO_STAGE}>
|
||||
{hasStages ? "No stage — whole workstream" : "No stages yet"}
|
||||
</option>
|
||||
{workstream.stages.map((stage) => (
|
||||
<option key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className={`${inputClass} min-w-[10rem] flex-1`}
|
||||
value={draft.namingRule}
|
||||
disabled={busy}
|
||||
placeholder="Naming rule, e.g. [WORef]_Cert"
|
||||
aria-label="Naming rule"
|
||||
data-testid="evidence-add-naming-rule"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, namingRule: event.target.value })
|
||||
}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 whitespace-nowrap text-xs text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
checked={draft.required}
|
||||
disabled={busy}
|
||||
data-testid="evidence-add-required"
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, required: event.target.checked })
|
||||
}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
data-testid="evidence-add-submit"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
{hasStages
|
||||
? "Leave the stage unset to expect the document at any point in the workstream."
|
||||
: "This workstream has no stages yet, so a requirement applies to the workstream as a whole. Tag it with a stage once the ladder exists."}
|
||||
</p>
|
||||
</form>
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue