Skip to content

feat: collection table view and bulk editing (006) - #7

Merged
plainprogrammer merged 34 commits into
mainfrom
006-collection-list-view
Oct 1, 2026
Merged

plainprogrammer merged 34 commits into
mainfrom
006-collection-list-view

Conversation

@plainprogrammer

@plainprogrammer plainprogrammer commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR implements spec 006 Collection Table View and Bulk Editing (v5.0.0), following its plan and data model.

  • Table view. The collection can be shown as a table with one row per lot. Columns: name with thumbnail, set · number, language, finish, condition, quantity, price paid, and a "…" menu.
    • You can sort by name, set · number, condition (in scale order), quantity and price paid. Unspecified values sort last, and the grid's order breaks ties.
    • The filter, counts, empty states and 120-row pages are shared with the grid.
    • On phones, the optional columns fold into a line under the name.
    • Edit copy and Remove from a row return to the table, keeping its filter, sort and page.
  • View switch. Grid and Table appear in the filter bar. The choice is saved per user, and Grid stays the default.
    • The switch's options are buttons in a GET form, so nothing is ever prefetched.
    • The server also never saves a view from a prefetch-marked request (Sec-Purpose, X-Sec-Purpose, X-Moz, Purpose).
  • Bulk mode (Edit many).
    • The selection is stored on the server for each session. The database deletes it automatically when the session ends, including when a user's sessions are ended with delete_all.
    • You can tick rows across pages, or tick "Select all" to select every lot matching the filter. Unticking a row then keeps it as an exception.
    • The count is in copies ("12 of 4,812 selected").
    • Everything works without JavaScript: one PATCH form carries the ticks with whichever button you press (Filter, the sort headers, the pager, the actions, Done).
    • With JavaScript on, a Stimulus controller adds the live count, a mixed state on the "Select all" box, and Esc to leave bulk mode.
  • Set condition… has its own page. Lots that become identical merge, all or nothing at the 9,999-copy cap.
  • Remove asks for confirmation first, then offers a one-time Undo in the status message.
    • The Undo belongs to the session that removed. Any later removal in that session ends it.
    • Replaying an Undo answers 422; another session's or an unknown Undo answers 404.
  • Design system. Six new pattern docs (SortHeader, StatusAction, BulkConfirmPage, ChoicePage, BulkForm, ViewSwitchForm), with token-only CSS in collector/additions.css.

Migrations (additive, reversible): users.collection_view (default grid), bulk_selections + bulk_selection_marks, and bulk_removals (a partial unique index allows one undoable removal per session).

Also in this branch

  • Brakeman 8.1.0: bin/brakeman runs with --ensure-latest, so the upstream release had started failing CI.
  • Status line: it now shows an alert ahead of a notice, and never shows Undo beside an alert. This applies to every page.

Screenshots

These were captured from the dev server with real Scryfall data, in headless Firefox. The phone shots come from a 390px frame.

Bulk-flow walkthrough: grid → Table → sort by condition → row menu → Edit many → tick → Select all with one untick → Set condition → Remove → Undo → Esc.

Bulk-mode walkthrough

Grid with the view switch and Edit many Table, one row per lot
Grid with view switch Table view
Sorted by condition (scale order, unspecified last) Row actions menu
Sorted by condition Row actions
Bulk mode, with the count in copies Select all with one untick (mixed header; the bar stays pinned while scrolled)
Bulk mode ticked Select all with exception
Set condition page (51 items: the untick is honoured) After Set condition
Set condition page Condition set
Remove confirmation Removed, with a one-time Undo
Remove confirmation Removed with Undo
Restored by Undo Dark theme, table
Restored Dark table
Dark theme, bulk mode
Dark bulk mode
Phone: table Phone: bulk mode, actions menu
Phone table Phone bulk menu

What the walkthrough caught. These are all fixed test-first in this PR:

  • Selection mismatch (data-affecting). With scripting on, "Select all" then an untick showed 51 of 59 selected, but the action covered all 59. Spec v5.0.0 rewrote the FR-4 header rule so that rows changed against their rendered baseline are still applied after a header toggle. New AC-5.10 browser tests cover it (2746e20).
  • Bulk bar hidden while scrolling. The bulk bar slid under the sticky app header and filter bar. Both bars now sit in one sticky block below the header (cd95320).
  • Table card names in browser-default blue. They now use the design system's brand link colour (24ccf5b). Empty-state links from 004 get the same fix (7fcfb6f).

Process

  • The spec went through three Fable spec reviews, the plan through a Fable plan review, and the implementation through two Fable implementation reviews (Mode B). Both implementation reviews said SPEC-ALIGNED.
  • Every phase was test-first. Where implementers deviated from the plan, the commit bodies record it as Ruling: lines.

Performance (spec NFR, measured at 5,000 lots)

Measured on synthetic data in a rolled-back test-env transaction:

Step Time Budget
Table, any sort 12–35 ms < 500 ms
Set condition on all 5,000 377–496 ms (was 8.5 s before 6e553c3) < 2 s
Remove all 5,000 ~1.8 s < 2 s
Undo of 5,000 ~0.85 s (was 7.4 s) —

The queries for the table and the bulk table don't grow with the number of rows (spec/requests/query_counts_spec.rb).

Test plan

  • bin/ci passes: RuboCop, Brakeman, bundler-audit, importmap audit and RSpec (480 examples, 0 failures)
  • The three migrations roll back and re-apply cleanly, with db/schema.rb unchanged
  • Automated screenshot walkthrough at 1280px and 390px, in light and dark themes (see Screenshots; it found and fixed three issues)
  • Manual walkthrough at 1280px and 390px, in light and dark themes: switch views, sort, Edit many, tick across pages, Select all then untick one, Set condition, Remove then Undo, Esc with and without an open menu
  • Repeat the bulk flow with JavaScript disabled, including Enter in the filter and paging with ticks kept
  • Check focus rings, touch targets and status announcements on the view switch, sort headers, checkboxes, bulk bar and Undo

🤖 Generated with Claude Code

plainprogrammer and others added 30 commits October 1, 2026 10:46
…(v2.0.0)

Approved spec with the Fable spec-review revisions applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
View switch buttons and prefetch guard, redirects after Set condition and Remove, the tick-submission protocol, and Undo after sign-out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit many and Done submit instead of linking, the header checkbox acts on changes, the bulk form's default submit is the filter, and the Undo URL follows the local-path rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k editing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix Sort.from_key for the default key, really hide the Edit many form, harden id and page params, check the Undo cap in one query, run the 360px checks in the narrow frame, split long examples and Phase 6's commit, and add tests for AC-2.3, AC-6.5, AC-7.6 and AC-7.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 1: The table's model: shared filter, rows and sort

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Plan: Phase 2: The table view and its sortable headers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 3: The view switch and the saved view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 4: Row actions return to the table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 5: The bulk selection model

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 6: Bulk mode: Edit many, the bulk form, selecting and Done

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hing

Plan: Phase 6: Bulk mode: Edit many, the bulk form, selecting and Done
Ruling: SelectionsController#ids keeps only digit strings (grep(/\A\d+\z/)) instead of grep(String) — an empty ticked_ids[] list arrives as [""] and counted as a tick of lot 0, giving NONE_LEFT instead of NOTHING_SELECTED — cost if wrong: none, non-numeric ids never matched a lot anyway
Ruling: bulk_mode_spec scopes the aria-selected row lookup to "tbody tr:has(...)" — with SQLite reusing id 1, "tr:has(input[value='1'])" matched the header row's Select all box (value "1") — cost if wrong: none, the assertion is unchanged for body rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e table

Plan: Phase 6: Bulk mode: Edit many, the bulk form, selecting and Done

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 7: Live count, mixed header and Esc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 8: Set condition on the selection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 9: Bulk Remove with Undo
Ruling: the layout's status line prefers flash[:alert] over flash[:notice] and offers no Undo beside an alert — a refusal rendered with flash.now[:alert] otherwise showed a notice still in the flash from an unfollowed redirect — a stale notice is now hidden whenever an alert is present

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 10: Design-system docs, phone sweep and query counts
Ruling: BulkForm doc's markup follows the rendered partial (c-collection wrapper, rendered_q/page/all_rendered hidden fields, the c-table__select checkbox column, pager position) rather than the plan's abbreviated example — the doc must match what the app renders — none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 10: Design-system docs, phone sweep and query counts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 10: Design-system docs, phone sweep and query counts
Ruling: the Set condition and removal examples are tagged :aggregate_failures — RuboCop's RSpec/MultipleExpectations rejects two expectations otherwise, and the project rule asks for the tag — none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bin/brakeman runs with --ensure-latest, so the 8.1.0 release made the pre-commit hook and bin/ci fail on 8.0.6. No new warnings.

Ruling: bump the gem rather than drop --ensure-latest — keeps the project's CI policy — cost if wrong: one revertible lockfile line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in one statement

Plan: Phase 11: Integration verification

Set condition updates lots that don't merge with one tenant-scoped update_all
(lot_key rebuilt in SQL from bound parameters), and Undo inserts restored lots
that don't merge with one insert_all. Merging lots keep the validated path.

Timings at 5,000 lots (none merge), before -> after:
- Set condition: 8,547 ms -> 377-496 ms (budget < 2 s)
- Undo: 7,407 ms -> 800-893 ms (plan threshold 5 s)
- Remove all: unchanged at ~1.8 s (budget < 2 s)

Ruling: existing_targets filters by catalog_entry_id in SQL and drops changed lots and non-matching keys in Ruby — the lot_key IN plus id NOT IN query over 5,000 ids took 2.9 s alone — loads every lot of the changed printings (one query, ~100 ms at 5,000).
Ruling: the update_all justification is a same-line comment, not a rubocop:disable — Rails/SkipsModelValidations is off in this config — none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan: Phase 11: Integration verification

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Plan: Phase 11: Integration verification

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…m the implementation review

Plan: Phase 11: Integration verification

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…4.0.1)

From the implementation review: every other sortable column states none; columns that can't be sorted state nothing. Wording only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Memories: migrate schema noise and :primary rollback; Brakeman --ensure-latest. Lessons from spec 006: Turbo prefetch vs GET side effects, bulk writes and perf data, repeated reviews and implementer rulings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The filter bar and the bulk bar now share one sticky c-bulkhead block
below the app header, so scrolling no longer slides the count, actions
and Done under the header. The bulk bar sits outside form#bulk, so its
buttons join the form with form="bulk"; Filter stays the default submit.

Plan: Phase 11: Integration verification

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Card-name links in the collection table rendered browser-default blue
and underlined. They now use brand, underline on hover and show the
focus ring, documented as the TableItemLink addition.

Plan: Phase 11: Integration verification

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR screenshot walkthrough showed Select all then an untick counting 51 of 59 while the action covered 59. FR-4 now resets on a header toggle and then applies rows that differ from their rendered baseline; new AC-5.10. Plan gains Phase 12 for the fix and the empty-state link colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With scripting, ticking "Select all" then unticking a row showed
"3 of 5 selected" but the action covered all 5: the header always changed
on the client, so the server ignored every row. Each row now carries its
rendered baseline (baseline_ids[]) and scripting reports a header toggle
(header_toggled); after a toggle only rows that differ from their baseline
apply (spec v5.0.0 FR-4, AC-5.10).

Plan: Phase 12: Selection baselines (spec v5.0.0) and empty-state link colour
Ruling: model spec "selects every matching lot from the header…" now clears with ticked [b], baseline [b] instead of ticked [a, b] — a was rendered unticked (an exception), so a real browser can't send a ticked against a ticked baseline; leaving rows as rendered keeps the "header untick clears" intent — cost if wrong: one less case where a re-ticked exception is discarded, which v5.0.0 now selects by design
Ruling: model spec "selects lots added later" ticks a with baseline [a] when entering Select all (a was rendered ticked after include!), and sends baseline [a, b] on the next page — keeps the example's intent (reach Select all with no exceptions, then make both exceptions) — cost if wrong: none expected
Ruling: request spec "keeps an untick as an exception across pages" and "clears everything when the header is unticked…" send baseline [first] (the row rendered ticked under Select all); submit_bulk gains baseline: and always sends header_toggled "0" — cost if wrong: none expected
Ruling: added a request spec for the rendered baseline_ids[]/header_toggled fields, not listed in the phase — cost if wrong: one extra example

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
plainprogrammer and others added 2 commits October 1, 2026 10:47
The empty collection's "Search cards" link rendered in the browser's default
blue with an underline; 004's design rule makes links brand.

Plan: Phase 12: Selection baselines (spec v5.0.0) and empty-state link colour

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Desktop, phone (390px narrow frame) and dark-theme captures of the table, view switch, sorting, bulk mode, Set condition, Remove and Undo, plus the bulk-flow walkthrough GIF, from real Scryfall data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plainprogrammer
plainprogrammer force-pushed the 006-collection-list-view branch from 80b64a3 to d977550 Compare October 1, 2026 14:52
plainprogrammer and others added 2 commits October 1, 2026 11:08
Root cause: a frame navigation with data-turbo-action="advance" pushes the
URL, but its follow-up visit is willRender: false, so Turbo's
lastRenderedLocation stays at the old URL. On Back, the restoration visit's
turbo:before-cache caches the current, filtered page under that old key.
Normally Back has already read the cached snapshot; on a slow runner the
write lands first and Back restores the filtered results at the unfiltered
URL (CI failures in collection_spec and catalog_search_spec).

Reproduction: after filtering, call Turbo.session.view.cacheSnapshot() once
(the same write, landing early), then go_back. The new
spec/system/back_navigation_spec.rb does this for the collection and search
pages and failed with "3 of 5 items" / stale results before the fix.

Fix: <meta name="turbo-cache-control" content="no-cache"> on the collection
and search pages, so Turbo never caches them and Back fetches fresh HTML.
Cost: Back to those pages refetches instead of restoring instantly. It is
still a Turbo visit, so window state survives.

Memory notes updated with the root cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
catalog_search_spec.rb:4 times out opening a card page on GitHub CI only (runs 36793202581, 36882235200); cold template compilation and the Back race are ruled out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plainprogrammer
plainprogrammer merged commit 3060e5c into main Oct 1, 2026
1 check passed
@plainprogrammer
plainprogrammer deleted the 006-collection-list-view branch October 1, 2026 15:57
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