Cell edits now actually persist. Row-level batch save fires on
row-blur (selection moves to a different row); the request is one
PUT with the full merged row (server-side data + client drafts)
and If-Match: <etag> for optimistic concurrency. Conflict and
validation responses are surfaced inline; drafts are NEVER silently
discarded — when the server says no, the user's typing stays put
until they explicitly reload or replay.
Architecture (per the research synthesis from earlier in this
sequence):
- ETag tracking: context.js readRows captures the per-row ETag
from HttpFileHandle's response header on the initial GET.
Stashed at row.etag alongside row.data and row.yamlUrl. Phase 3
reads it; later phases (undo replay) inherit it.
- Row-blur trigger: editor.js setSelected calls a new
notifySelectionChanged() hook after selection lands. save.js's
onSelectionChanged tracks _previousSelectedRowId; when it
changes AND the previous row had drafts, fires saveRow(prevId).
Fire-and-forget — don't block the user's flow on the network.
- save.saveRow flow:
1. mergeRow(row.data, drafts) → full updated row.
2. js-yaml dump → wire body.
3. PUT row.yamlUrl, body, headers={Content-Type, If-Match}.
4. Branch on response status:
- 200/201 → success: clear drafts + invalid marks, capture
new ETag from response, replace row.data with merged.
- 202 → outbox queued (downstream client offline):
clear drafts (the outbox owns them now), mark row queued.
- 412 → stale: drafts STAY; mark row stale; show
status-bar prompt with [Use mine] / [Reload] buttons.
- 422 → server validation failed; body has
{errors: [{path, message}]}; mark each cell invalid via
a red-corner CSS marker + title-attribute tooltip.
- other → mark errored; drafts stay.
- Conflict resolution UX:
- "Use mine" replays the user's drafts onto fresh server
state. Re-GETs the row to learn the new ETag + new server
data, replaces row.data with the fresh server values, then
re-PUTs the merge of fresh + drafts. This is client-side
field-level last-writer-wins: fields the user did NOT
touch get the server's new values automatically; only
fields the user changed override server state. No JSON
Patch endpoint required — pure client logic on top of the
existing whole-row PUT path.
- "Reload" drops drafts entirely, re-GETs the row, repaints.
- Validation error display: per-cell red-corner triangle
(Excel-style) plus title-attribute tooltip on hover. Marker
keyed off data-col-idx + the column's field; survives until
the next edit on that cell or the next paint() cycle.
- beforeunload safety net: any rows with drafts at unload time
get one fire-and-forget save attempt. Modern browsers limit
what beforeunload can do; a follow-up could add fetch's
keepalive flag for a more reliable last-shot.
UI surfaces:
- Per-row state classes drive a left-border swatch in the first
cell:
--dirty subtle blue (uncommitted changes)
--saving muted grey (PUT in flight)
--queued warm yellow (outbox accepted)
--invalid orange (server 422)
--stale warning amber (server 412 — also tints row bg)
--errored red (other failure — also tints row bg)
These re-apply across re-paints via save.markAllDirtyRows()
called from main.js's paint() hook (innerHTML='' wipes them).
- #table-status doubles as the conflict prompt host. When a row
goes stale, the bar shows
"This row was changed by someone else. [Use mine] [Reload] [×]"
and the row-id it's bound to is stored on data-row-id so a
successful reload of that row dismisses the prompt.
Outbox (downstream client) interaction:
The cache layer's PUT-replay queue intercepts saves transparently.
On local network failure the cache returns 202 with
X-ZDDC-Cache: queued; we treat 202 as "succeeded for now" —
drafts clear (the outbox owns them and will replay), but the
row stays marked --queued so the user knows the write hasn't
reached upstream yet. When the cache replays and gets a
real 200/201/412/etc., the row state will reflect that on next
read (next paint cycle / page refresh).
Tests (4 new Phase 3 specs, total 31 in tests/tables.spec.js):
- row-blur fires PUT with merged drafts + If-Match. Edit a
cell in row 0, Enter (commits + moves to row 1). Verifies
PUT went out with the right URL, the merged YAML body
contains the new value AND the unchanged fields, and the
If-Match header carries the original ETag.
- 412 conflict marks row stale + shows status prompt. Verifies
the row gains the stale class, the status bar appears with
both [Use mine] and [Reload] buttons, AND the draft is
preserved (never silently dropped on conflict).
- 422 validation errors mark cells invalid. Verifies multiple
field errors → multiple red-corner cells.
- Reload button drops drafts and refreshes. Verifies the bar
hides and drafts clear after a successful reload GET.
Setup: a small page.route helper intercepts http://test.local/*
PUTs and GETs, lets each test queue the next response via
window.__nextResponse, and captures requests at
window.__capturedRequests for inspection. Test fixtures use
absolute http URLs in row.yamlUrl so the route catches them.
Bundle size: 127 KB → 134 KB.
Files:
- tables/js/save.js (new) — saveRow, useMine, reload, status
prompt, row-state markers, beforeunload flush.
- tables/js/editor.js — notifySelectionChanged hook.
- tables/js/context.js — etag + yamlUrl on each row.
- tables/js/main.js — paint() re-applies dirty markers via
save.markAllDirtyRows; exposes app.repaint for save callbacks.
- tables/build.sh — save.js in concat list.
- tables/css/table.css — row-state classes + invalid-cell corner
+ status-bar prompt styling.
- zddc/internal/handler/tables.html — regenerated bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
5.5 KiB
CSS
190 lines
5.5 KiB
CSS
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
|
|
|
.table-main {
|
|
padding: var(--spacing-md);
|
|
max-width: 100%;
|
|
}
|
|
|
|
.table-description {
|
|
margin: 0 0 var(--spacing-md);
|
|
color: var(--color-text-muted);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.table-status {
|
|
margin: 0 0 var(--spacing-md);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
background: var(--color-bg-warning, #fff8e6);
|
|
border: 1px solid var(--color-border, #d6cfa3);
|
|
border-radius: var(--radius-sm, 4px);
|
|
}
|
|
|
|
.table-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: var(--spacing-md);
|
|
margin: 0 0 var(--spacing-sm);
|
|
}
|
|
|
|
.table-toolbar__left,
|
|
.table-toolbar__right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
#table-add-row {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.table-rowcount {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.table-scroll {
|
|
overflow: auto;
|
|
max-height: calc(100vh - 200px);
|
|
border: 1px solid var(--color-border, #d8d8d8);
|
|
border-radius: var(--radius-sm, 4px);
|
|
}
|
|
|
|
.zddc-table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.zddc-table thead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
background: var(--color-bg-elevated, #f5f5f5);
|
|
}
|
|
|
|
.zddc-table__title-row .zddc-table__th {
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
text-align: left;
|
|
font-weight: 600;
|
|
border-bottom: 1px solid var(--color-border, #d8d8d8);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.zddc-table__title-row .zddc-table__th:hover {
|
|
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
|
|
}
|
|
|
|
.zddc-table__filter-row .zddc-table__filter-cell {
|
|
padding: 4px var(--spacing-sm);
|
|
border-bottom: 1px solid var(--color-border, #d8d8d8);
|
|
background: var(--color-bg-elevated, #f5f5f5);
|
|
}
|
|
|
|
.zddc-table__filter-text,
|
|
.zddc-table__filter-enum {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 2px 4px;
|
|
font-size: 0.85rem;
|
|
border: 1px solid var(--color-border, #d0d0d0);
|
|
border-radius: 3px;
|
|
background: var(--color-bg, #fff);
|
|
color: var(--color-text, #111);
|
|
}
|
|
|
|
.zddc-table__filter-enum {
|
|
min-height: 1.8em;
|
|
}
|
|
|
|
.zddc-table__row:nth-child(even) {
|
|
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
|
|
}
|
|
|
|
.zddc-table__row--readonly {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.zddc-table__cell {
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
|
|
vertical-align: top;
|
|
cursor: cell;
|
|
/* Hide the browser's default outline; the grid pattern renders
|
|
its own selection chrome via the --selected class. */
|
|
outline: none;
|
|
}
|
|
|
|
/* Currently-selected cell — Excel-style focus ring. The 2px outset
|
|
border doesn't push surrounding cells around because outline is
|
|
used instead of border. */
|
|
.zddc-table__cell--selected {
|
|
outline: 2px solid var(--color-accent, #2868c8);
|
|
outline-offset: -2px;
|
|
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08));
|
|
}
|
|
|
|
/* Inline cell-editor input: occupies the cell verbatim, no border so
|
|
it visually replaces the cell text. The selected outline on the
|
|
surrounding td still shows. */
|
|
.zddc-table__cell-input {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 0;
|
|
margin: 0;
|
|
border: none;
|
|
background: var(--color-bg, #fff);
|
|
color: var(--color-text, #111);
|
|
font: inherit;
|
|
outline: none;
|
|
}
|
|
|
|
/* Row-save state markers (Phase 3). The first cell of the row gets a
|
|
left-border swatch; the row tooltip on hover surfaces the state.
|
|
Colors track the state's urgency: dirty (subtle), saving (info),
|
|
queued (warm), invalid/stale (warning), errored (alert). */
|
|
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
|
|
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
|
|
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
|
|
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
|
|
.zddc-table__row--invalid td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); }
|
|
.zddc-table__row--errored td:first-child { box-shadow: inset 3px 0 0 var(--color-error, #c14242); background: var(--color-bg-error, rgba(193, 66, 66, 0.06)); }
|
|
|
|
/* Per-cell invalid marker — small red corner triangle, Excel-style.
|
|
The hover tooltip carries the validation message via title attr. */
|
|
.zddc-table__cell--invalid {
|
|
position: relative;
|
|
}
|
|
.zddc-table__cell--invalid::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 0;
|
|
height: 0;
|
|
border-style: solid;
|
|
border-width: 0 6px 6px 0;
|
|
border-color: transparent var(--color-error, #c14242) transparent transparent;
|
|
}
|
|
|
|
/* Status bar (table-status) when used as the stale-row prompt host. */
|
|
.table-status.table-status--prompt {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
background: var(--color-bg-warning, rgba(232, 163, 61, 0.08));
|
|
border: 1px solid var(--color-warning, #e8a33d);
|
|
border-radius: var(--radius-sm, 4px);
|
|
margin-bottom: var(--spacing-sm);
|
|
color: var(--color-text, #111);
|
|
}
|
|
|
|
.table-empty {
|
|
padding: var(--spacing-lg) var(--spacing-md);
|
|
text-align: center;
|
|
color: var(--color-text-muted);
|
|
font-style: italic;
|
|
}
|