feat(lists): per-column validation and help text, an Add Row form, and the GitHub repo tag - #101
Merged
Merged
Conversation
The client modelled a list's schema as a DSL string. The API models it as an
object, and always has — `POST /api/lists` with a string schema answers a flat
`400 {"error":"Invalid schema: DSL must be an object"}`. Every route in the
family was therefore wrong, in both directions, and the tests that said otherwise
were asserting against fixtures they had invented.
What was broken, confirmed by live probe against the .env test account:
- `GET /api/lists/{id}` answers `{data}` and was decoded as a bare ListDTO, so
`detail(listId:)` could never decode a real response. That is #75, and it was
the least consequential member of the family — no shipped path called it.
- `GET /api/lists/{id}/schema` answers `{data:{name, description?, fields[]}}`
and was decoded as `{schema: String}`. This one is not dead code:
ListRowsViewModel and SchemaEditorView both call it, so the row table and the
schema editor could not load a schema at all.
- `PUT /api/lists/{id}/schema` wants an object body and answers `{message, data}`
— note that contradicts the OpenAPI 200 example, which shows a bare
`{properties}`; the capture wins.
- `POST /api/lists` and `PUT /api/lists/{id}` answer `{message, data}`.
- `GET /api/users/{u}/lists/{id}` answers `{list, ancestors}` — a third envelope
convention on the same resource — where the client decoded a bare ListDTO.
The deeper fix is the key/label split. Row data is keyed by the column's `key`;
`label` is only what the header renders. `SchemaField` had one `name` doing both
jobs, which was harmless only because the client could not create a schema at
all. The moment that was fixed, a column labelled "Publication Year" over a key
of `year` would have rendered every cell empty. `key` and `label` are now
separate, `ListColumn` carries both through the table, and the DSL initialiser
sets them equal — which is exactly what the DSL means.
`ListDTO.schema` was documented as "present on detail and create responses" and
is present on no captured payload, so `OwnedList.schemaDescription` has always
been empty wherever it was shown. It is now derived from the real columns.
Also lands the destructive-change guard the client knew nothing about: dropping a
column that still holds row data is refused with a 400 until `?force=true`. That
is a question, not a malfunction, so it surfaces as its own error and the editor
asks it. `force` is never set on a first attempt — there is no path to a forced
write the server did not ask for.
The per-column metadata this exposes — helpText, placeholder, isRequired,
isVisible, displayOrder, defaultValue and validationRules {min, max, minLength,
maxLength, pattern, options} — confirms work-consolidation.md P2-G, which is what
blocked item 1 of #50. The row inspector renders help text and placeholders now;
the editor UI for the rules is #50's.
Every fixture in this change is a captured response body, recorded in
docs/spikes/list-schema-wire-shapes.md, and the two regressions are pinned
directly: decoding the real payload as the old shape must throw. StubAPIClient
records the encoded request body so "sends a string where an object is required"
is testable at the domain layer at all.
One limitation is flagged rather than worked around: the server's
`propertiesWithData` column list cannot be surfaced, because APIError.badRequest
keeps only the decoded `{error}` string. ListSchemaConflictDTO models the full
shape so that becomes a one-line change once the kit keeps the body.
Refs #75, #85
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
…Xcode 27
`dev` stopped building when Xcode was updated on this machine mid-session. The
macOS 27 SDK adds a `Document` protocol to SwiftUI —
public protocol Document: ReadableDocument, WritableDocument
— which collides with the domain's `Document` struct in any file importing both.
That is every documents-feature view, and the same unchanged source went from
compiling to eleven `'Document' is ambiguous for type lookup` errors across eight
files.
Only the SwiftUI-importing files are affected, which is what makes the diagnosis
unambiguous: the view models import Foundation, Observation and InterlinedDomain
but not SwiftUI, and they compile untouched.
The fix is to qualify the type at the use sites. Three alternatives were
considered and rejected. Renaming the domain model is the tail wagging the dog —
`Document` is the right name, and it is correct across Kit, Domain, Persistence
and their tests. A module-level typealias would shorten the use sites at the cost
of giving one concept two names, so the next reader has to learn they are the
same thing. Dropping `import SwiftUI` is not available; these are views.
Every edit is a type position. No user-facing string, accessibility label or
other identifier containing the word Document is touched — the diff is eleven
lines, each one a `Document` that the compiler itself pointed at.
Worth knowing rather than fixing: this is a standing hazard. Any domain type
sharing a name with a SwiftUI symbol is one SDK update away from the same break,
and the diagnosis is written down at the top of DocumentsListView so the next
occurrence takes minutes.
Refs #98
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d the GitHub repo tag
Item 1 of this issue was blocked on `P2-G` — the schema DSL's validation
encoding was API-unconfirmed. It is confirmed now, and it turned out not to be
DSL syntax at all: `validationRules {min, max, minLength, maxLength, pattern}`,
`helpText`, `placeholder`, `isRequired` and `isVisible` are first-class fields
the server has stored since the schema routes shipped and no client ever wrote.
The schema editor gains them behind a per-column Details disclosure, so naming a
column and picking its type stays one line. The controls are type-dependent:
length rules only for the text family, range rules only for number. Offering a
"Min length" on a checkbox would be a control that cannot do anything, and
sending the rule would put a constraint on the server that nothing enforces.
The editor also stops re-deriving a column's key from its label. The two are
separate on the server, and a web-authored column can be keyed `year` under a
label of "Publication Year" — rewriting the key on a macOS save would orphan
every stored cell in that column.
Validation needed somewhere to be used, and there was nowhere: macOS had no
add-row form. `addRow()` created an empty row and left the user to fill it in
through the inspector, which is why help text and placeholders had no entry
point. AddRowSheetView is that form, and it carries the two behaviours the help
page is explicit about. "Add another after saving" persists across lists and
visits, empties the form, counts up and returns focus to the first field, so
bulk entry never needs the mouse between rows. And a rejected save clears
nothing — a session that loses a typed row to a validation error is worse than
one that never offered the form, so the failure path deliberately does not touch
the values.
Local validation refuses a row before the service call rather than after. The
rules are the server's and it would reject the same row, so spending a
round-trip to be told what we already know costs the user time and tells them
less: a 400 cannot say which column was wrong. `pattern` is the one rule
deliberately not checked — it is a server-side regular expression, and compiling
it locally would let this client refuse a value the server accepts whenever the
two engines disagree on syntax.
`githubRepo` and `githubRepoPrivate` have been on the wire since the list routes
shipped, and the owned-list mapper hard-coded `gitHubSource: nil` past them — so
a GitHub-backed list looked like a plain one and the private-repository warning
had nothing to render from. The repository is linked now, with the Private repo
tag, which is not decoration: a link to a private repository sends a visitor to a
GitHub sign-in or a "not found" page, and the list has to say so first.
Item 3, the From Template tab, is NOT built. There is no list-templates route:
the live spec has `/api/documents/templates` and `/api/documents/from-template`
and no list equivalent, so the web feature has no API this client can call.
Recorded rather than half-built.
Refs #50
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #50 — three of its four items. Item 3 is upstream-blocked and is not built; evidence below.
Item 1 — validation and help text (was blocked on
P2-G)work-consolidation.mdP2-Glisted the schema DSL's validation encoding as API-unconfirmed, which is what blocked this. It is confirmed, and it turned out not to be DSL syntax at all — these are first-class fields the server has stored since the schema routes shipped and no client ever wrote:The schema editor gains them behind a per-column Details disclosure, so the common case — name a column, pick its type — stays one line.
The controls are type-dependent. Length rules only for the text family, range rules only for
number. A "Min length" on a checkbox would be a control that cannot do anything, and sending the rule would put a constraint on the server that nothing enforces.The editor also stops re-deriving a column's key from its label. They are separate on the server, and a web-authored column can be keyed
yearunder a label of "Publication Year" — rewriting the key on a macOS save would orphan every stored cell in that column.Item 2 — the Add Row form, which had to exist first
Validation and help text needed somewhere to be used, and there was nowhere: macOS had no add-row form.
addRow()created an empty row and left the user to fill it in through the inspector.AddRowSheetViewcarries the two behaviours the help page is explicit about:Local validation refuses a row before the service call. The rules are the server's and it would reject the same row, so spending a round-trip to be told what we already know costs the user time and tells them less — a 400 cannot say which column was wrong. Every failure is reported at once, against the field that caused it.
patternis deliberately never evaluated locally. It is a server-side regular expression; compiling it here would let this client refuse a value the server accepts whenever the two engines disagree on syntax. A round-trip is the correct cost for the one rule we cannot faithfully reproduce. There is a test pinning that as a decision rather than an omission.Item 4 — the repository link and Private-repo tag
githubRepoandgithubRepoPrivatehave been on the wire since the list routes shipped, andOwnedListMappershard-codedgitHubSource: nilpast them — so a GitHub-backed list looked like a plain one and the private-repository warning had nothing to render from.The tag is not decoration: a link to a private repository sends a visitor to a GitHub sign-in or a "not found" page, and the help page is explicit that the list should say so before they follow it. Absent — rather than shown as "public" — when the server did not say.
Item 3 — From Template: no such route exists
Searched all 233 paths in the live spec:
There is no list equivalent, and no
ListTemplateschema incomponents.schemas. The web documents a From Template tab; the API this client can reach serves nothing behind it.Not half-built. It needs a backend ask — the shape of a pre-defined list schema catalogue — and that belongs on #58.
Verification
xcodebuild build→** BUILD SUCCEEDED **xcodebuild test(App) →Executed 1002 tests, with 0 failures·** TEST SUCCEEDED **swift test InterlinedDomain→Executed 1017 tests, with 0 failuresswift test InterlinedKit --skip ContractTests→Executed 485 tests, with 0 failuresswift test InterlinedPersistence→Executed 140 tests, with 0 failuresNew tests:
AddRowViewModelTests(11),RowValueValidatorTests(14).Stacking
Branches from #87 (
fix/lists-detail-envelope), which is where the schema object, thekey/labelsplit and the per-column metadata come from — this is unbuildable without it. Also merges #99 (Xcode 27Documentdisambiguation), without which the App target does not compile at all.Review order: #99 → #87 → this.
🤖 Generated with Claude Code