Skip to content

Lists: typed, schema-driven row editor (replaces the freeform JSON box) - #163

Merged
Adron merged 2 commits into
mainfrom
issue-21-typed-row-editor
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-21-typed-row-editor

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stacked PR — 2 of 3

issue-21-typed-row-editor → base issue-18-column-builder (PR #161) → which bases on issue-17-list-schema-service (PR #143). Merge the stack bottom-up: #143, #161, then this, then #22.

What this adds

Rows on a list with columns are added and edited through a form generated from its live schema instead of a raw-JSON box:

Type Control
text / email / url / tel single-line box (email and URL validated locally)
textarea multi-line box
number numeric box, validation.step shown as a hint
boolean True/False dropdown — plus Neither (null) when the column is optional
date date picker → YYYY-MM-DD
datetime date picker + HH:MM → ISO
select / priority single select (priority over its four values)
multiselect checkbox set → a real JSON array

defaultValue pre-fills new rows; placeholder, helpText, visible and displayOrder are honoured; hidden columns fold behind a toggle so a required hidden column is still reachable. Conditional-visibility rules are evaluated client-side by ListFieldConditionEvaluator — the only place they are ever evaluated, since the server stores a condition and never applies it.

The grid no longer prints JSON: one labelled cell per visible column, a checkbox for boolean, chips for multiselect, an em dash where the row has no value. Row data is keyed by each column's key throughout.

The raw-JSON hatch stays — the only editor for a schema-less list, and one checkbox (“Edit rows as raw JSON”) away on a schema'd one.

Live evidence (probed today, 2026-09-16)

All throwaway lists were titled ZZ claude-probe … and deleted; a closing GET /api/lists shows only the account's pre-existing New list.

  1. A row write REPLACES rowData, it does not merge. A PUT carrying {"title":"replaced"} left the row holding only title — five other values, an unknown key included, gone. So the form re-sends every key it knows about, and any field the user didn't touch is echoed back as the exact JsonElement the server gave us, which keeps values the form can't fully model (a naive datetime, a number stored as a string, a nested object) byte-identical. A blank optional column the row never had is still left out rather than padding rowData with nulls; clearing a stored value sends an explicit null.
  2. Unknown keys are accepted and preserved on a schema'd list — including nested objects. They are what a destructive column rebuild leaves behind, so the grid names them (“No column for: …”) and the editor says it is keeping them rather than pretending they're gone.
  3. Types are enforced on write (each message captured live): "true" in a boolean column → 422 Done must be true or false; a comma string in a multiselect → Tags must be an array; 09/16/2026 → Due must be a valid date; a select value off-list → Status must be one of: open, closed. The exceptions: a number accepted as "7" — and then its own min/max check is silently skipped, which is exactly why the form sends a real number — and priority, which is not validated against its four values at all.
  4. 422 carries details:[{field,message}], up to eight at once, field being the column key and the message using its label. EnsureSuccessAsync flattens that to “Validation failed”, so the two row writes now throw ListRowValidationException and each message lands on its control. Required, email/url shape, number parse, min/max, minLength/maxLength and pattern are checked before the round trip.
  5. GET /api/lists/{id}/data omits each row's listId (and rowNumber), which ListDataRow.ListId's required turns into a JsonException right through GetListDataAsync — the crash Lists: viewing any list that has rows throws — ListDataRow.ListId is required but the API doesn't send it #144/PR fix(lists): viewing a list with rows threw — ListId was required but absent #146 fixes. ListDataRow.cs is that PR's, so this branch doesn't touch it: reads go through a new tolerant GetListRowsAsync that maps rows by hand and fills listId in from the request. Dependency: once fix(lists): viewing a list with rows threw — ListId was required but absent #146 lands, GetListDataAsync works again and ListDataRow.Version becomes available.
  6. version is not optimistic concurrency. Sending a deliberately stale version: 1 on a row PUT still answered 200 and bumped the row to 4 — the server ignores it. So there is nothing to hook up yet, and fix(lists): viewing a list with rows threw — ListId was required but absent #146's Version buys a display value rather than a conflict check.
  7. End-to-end with the real view-model: a throwaway net10.0 console project <Compile Include>-ing the model and row-field files by absolute path produced a twelve-type row payload — 201, stored exactly as sent — then an edit payload carrying an unknown key and a nested object — 200, both intact. It also confirmed the local pre-flight messages, the tri-state boolean options, the status=closed → “Why closed?” visibility flip, and the grid's cell kinds.

Left unverified / deliberately out

  • No WPF rendering check is possible from macOS. dotnet build passes in Debug and Release. The new controls use theme brushes and 3–4px corners, but two want eyes on Windows: the templated ComboBox drop-down (from Lists: schema column form builder (12 field types) #161) and the DatePicker, whose calendar popup keeps its platform look — only the field itself is painted from the theme. If that reads wrong in dark mode, the fix is a templated date box, not a colour override.
  • Datetimes are written as naive ISO (2026-09-16T14:30:00, accepted live) and never timezone-shifted, so a value entered is the value stored; a stored value with a Z that the user doesn't touch is re-sent verbatim.
  • Row pagination is still the first 50 (unchanged); #21 didn't ask for more.

Closes #21

🤖 Generated with Claude Code

…en one

Rows on a list with columns are now added and edited through a form generated
from its live schema — one control per type: single-line text, multi-line
textarea, numeric, True/False (plus **Neither** for a nullable optional
boolean), date picker, date + time, validated email/url, phone, single select,
multiselect checkboxes, and priority over its four values. `defaultValue`
pre-fills a new row; `placeholder`, `helpText`, `visible` and `displayOrder` are
honoured, and the conditional-visibility rules are evaluated client-side —
which is the only place they are ever evaluated, since the server stores a
condition and never applies it.

The grid stopped printing JSON: each row renders a labelled cell per visible
column, a checkbox for boolean, chips for multiselect, an em dash where a row
simply has no value. Row data is keyed by each column's key throughout, never
its label.

The raw-JSON escape hatch stays — it is the only editor for a schema-less list
(still fully supported) and remains one checkbox away on a schema'd one.

Live-probed behaviours this is built around (2026-09-16, throwaway lists since
deleted):

* **A row write REPLACES rowData.** A PUT carrying one key left the row holding
  only that key — five other values, orphans included, were gone. So the form
  re-sends every key it knows about, including keys with no column at all, and a
  field the user never touched is echoed back as the exact JSON the server gave
  us rather than a re-rendered one (which keeps a naive datetime, a number
  stored as a string, or a nested object intact).
* **Types are enforced on write.** `"true"` in a boolean column is 422, a
  multiselect must be a real array, a date must be YYYY-MM-DD — so the form
  emits real booleans, arrays and ISO strings. A number is the one the server is
  loose about: it accepts `"7"` and then silently skips its own min/max check,
  which is exactly why this sends a number.
* **422 carries `details:[{field,message}]`** — up to eight at once — where
  `field` is the column key and the message uses its label. The shared
  EnsureSuccessAsync flattens that to "Validation failed", so the two row writes
  now throw `ListRowValidationException` and each message lands on the control
  that caused it. Cheap checks (required, email/url shape, number parse,
  min/max, minLength/maxLength, pattern) run before the round trip.
* **GET /api/lists/{id}/data omits each row's `listId`**, which the strict
  `ListDataRow.ListId` still requires — so reads go through a new tolerant
  `GetListRowsAsync` until #144/PR #146 relaxes the model. `version` is
  therefore not surfaced, and loses nothing today: the server ignores a
  `version` sent on a row write (a deliberately stale one still answered 200),
  so there is no optimistic concurrency to hook up yet.

Verified end-to-end by feeding the payloads the real view-model produces to the
API: a twelve-type row was created and stored byte-for-byte as sent, and an edit
carrying an unknown key and a nested object came back with both intact.

Closes #21

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit 52197be into main Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema: replace the freeform JSON row editor with a typed, schema-driven one

1 participant