Skip to content

feat(lists): saved views — shared/personal, default, fork-to-personal (#51) - #107

Merged
Adron merged 1 commit into
parity/queuefrom
issue/51-list-saved-views
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/51-list-saved-views

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #51. Part of epic #49.

The contract was captured live, because it is documented nowhere

Saved views are absent from the help centre and a bare object in the OpenAPI spec. I created a
view on the test account, probed it, forked it, and deleted both (account verified back to
{"views":[]}). What that established:

  • GET /api/lists/{id}/views{ "views": [ … ] }; a view is
    { id, listId, userId, name, scope, config, isDefault, position }.
  • POST requires name and scope; scope is exactly personal or shared (server's own
    400: scope must be "personal" or "shared"). Returns 201 { "view": { … } }.
  • config defaults to { "mode": "records", "density": "comfortable", "filters": [] }.
  • POST …/views/{viewId} forks → a personal copy named "<name> (copy)", position incremented.
  • DELETE …/views/{viewId}{ "revoked"… } — actually { "message": "View deleted" }.
  • Unknown config values are silently dropped, not rejected.

Design

  • config is a raw JsonObject wrapper (ListViewConfig) with typed projections (mode,
    storedMode, density, filters). Keys the client does not model survive a round-trip
    untouched
    — the server already drops what it does not know; the client must not compound that.
  • Because the server silently rewrites config, every write returns the server's copy and the
    ViewModel replaces state with it
    , never with an optimistic copy. "Set as default" additionally
    re-reads the list so a moved default flag is reflected on siblings.
  • scope is an enum; fromApi returns null for anything the API would 400 on, and the repository
    refuses a missing/unknown scope or a blank name before opening a connection.
  • Ownership: rename/delete are offered only for views the user owns. An unrecognised scope
    from the server reads as SHARED — the conservative choice, so no destructive action is offered
    on a view that may not be the user's. A 403 is surfaced as a message with the view left in place.
  • The switcher is a Composable slot on the stateless ListDetailScreen driven by its own
    ListViewsViewModel, so the detail screen and its existing tests stay independent of views.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 868 tests, 0 failures
(29 new). Coverage includes: view list parse with unknown config keys; create/update/delete
round-trips asserting real paths and bodies (including "only the changed field is sent"); create
rejecting both a missing and an unrecognised scope with server.requestCount == 0; fork from
POST …/views/{viewId} with an empty body; and a write whose config the server silently altered
ending up showing the server's value
at both repository and ViewModel level. 6 Compose cases
compile, not executed (no emulator).

No :app or settings.gradle.kts change was needed.

Blocking finding for #52 — please read before that issue is attempted

config.mode cannot currently carry a view mode other than records. Live probing showed
cards, card, grid, table, erd, diagram, board, gallery, kanban and list all
round-tripping back as records, with no error — the server silently rewrites them.

So either (a) the web's card/grid/ERD modes are client-side-only and not persisted in a saved view,
(b) they live under a different config key I did not guess, or (c) they are gated on something the
test account lacks. #52 assumes view modes are configured through config.mode, and that assumption
is not supported by the live API.

The client is ready either way: ListViewConfig preserves unknown keys and exposes storedMode, so
whatever the real mechanism is, nothing is lost in transit and only the ListViewMode enum needs
extending.

Other notes

  • Selecting a view currently changes only which view is marked in use; wiring selection into row
    rendering is Lists: card, grid and ERD view modes #52's job by design.
  • Whether a non-owner may re-default someone else's shared view is unknown, so "Set as default" is
    offered on any view and a refusal is surfaced gracefully rather than pre-emptively hidden.

Lists could only ever render one way; they now carry saved views. Adds the
DTOs, domain model, repository calls and a switcher on the list detail screen.

- GET/POST/PUT/DELETE /api/lists/{id}/views, plus POST …/views/{viewId} to fork
  a view into a personal copy — the escape hatch when somebody else's shared
  view does not suit.
- `scope` is an enum; a missing or unrecognised scope fails locally rather than
  spending a request the API answers with a 400.
- `config` is kept as its raw JSON object so keys this client does not model
  survive a round-trip; mode/density/filters are projected from it.
- Every write adopts the view the server returned, because the API silently
  drops config values it does not recognise instead of rejecting them.
- Renaming and deleting are offered only for views the user owns; somebody
  else's shared view is forked instead, and a server refusal is surfaced with
  the view left in place.

The switcher is a slot on the stateless detail screen with its own ViewModel on
the same nav entry, so the detail screen and its tests stay independent of it.

Tests: 12 MockWebServer round-trips over the real paths and bodies, 5 mapper
cases, 12 ViewModel cases, and 6 Compose cases for the switcher.

Closes #51
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