Skip to content

feat(lists): per-column validation and help text, an Add Row form, and the GitHub repo tag - #101

Merged
Adron merged 4 commits into
devfrom
feat/lists-authoring-g50
Sep 17, 2026
Merged

Adron merged 4 commits into
devfrom
feat/lists-authoring-g50

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

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.md P2-G listed 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:

validationRules { min, max, minLength, maxLength, pattern }
helpText · placeholder · isRequired · isVisible · displayOrder

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 year under 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.

AddRowSheetView 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 — bulk entry never needs the mouse between rows.
  • 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. There is a test asserting the two interact correctly: staying open must not be confused with clearing.

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.

pattern is 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

githubRepo and githubRepoPrivate have been on the wire since the list routes shipped, and OwnedListMappers 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 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:

/api/documents/templates        GET
/api/documents/from-template    POST
/api/documents/templates/seed-defaults  POST

There is no list equivalent, and no ListTemplate schema in components.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 failures
  • swift test InterlinedKit --skip ContractTests → Executed 485 tests, with 0 failures
  • swift test InterlinedPersistence → Executed 140 tests, with 0 failures
  • Decision 0003 (anchored) → zero hits

New tests: AddRowViewModelTests (11), RowValueValidatorTests (14).

Stacking

Branches from #87 (fix/lists-detail-envelope), which is where the schema object, the key/label split and the per-column metadata come from — this is unbuildable without it. Also merges #99 (Xcode 27 Document disambiguation), without which the App target does not compile at all.

Review order: #99 → #87 → this.

🤖 Generated with Claude Code

Adron and others added 4 commits September 15, 2026 04:22
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>
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.

1 participant