feat(forms): record picker for possible-value fields over large tables (thumbnails, paged dialog) #80

Merged
KofTwentyTwo merged 6 commits from feature/record-picker into main 2026-10-13 11:01:42 +00:00
KofTwentyTwo commented 2026-10-12 19:48:44 +00:00 (Migrated from github.com)

What

A record picker for possible-value fields whose source is a large table (100,000+ media and the like). It needs QRun-IO/qqq#1099, which adds QPossibleValuePicker, the paged picker search and the v1 contract. Fields without a picker are unchanged.

Inline field (PossibleValueSelect)

  • The typeahead asks the server for a limited page (20) of picker options and shows each option's thumbnail and subtitle.
  • The selection shows its thumbnail, with Change… (or Browse… when empty), Preview (the existing full-size lightbox) and View (the record in a new tab, so unsaved edits stay put).

Picker dialog (RecordPickerDialog, loaded by import() on Browse/Change)

  • Debounced (300 ms) server-side search: CONTAINS over the picker's search fields.
  • Filters generated from the picker table's field types: a possible-value field gets a select of its own values, booleans Yes/No, numbers and dates a range, strings "contains".
  • The declared sorts (Newest, Name, ...).
  • Grid and list views; thumbnails load lazily.
  • Infinite scroll (IntersectionObserver) with a Load more fallback.
    • The primary-key sort pages by keyset (afterId), others by offset.
    • No counts: the server returns hasMore.
  • Preview pane (desktop) with the large image, detail fields, Full size and Open; on phones a footer preview button.
  • Upload new: runs the picker's upload process embedded (ProcessRun gains onComplete) and selects the record it created.
  • Single or multiple selection (multiple prop, aria-multiselectable, Space toggles).
  • Filters, sort and view are remembered per field in local storage (try/catch; works without storage).
  • Accessibility:
    • Radix focus trap and Escape.
    • The results are a listbox of options; arrow keys move by row in the grid, Enter or a double click confirms.
    • axe-clean.
  • Full screen on phones, inset on larger screens, one scroll area per pane (no nested scrollbars). Theme tokens only, so light and dark both work.

Design: layers and API contract

The design note is docs/misc/RecordPicker.adoc in QRun-IO/qqq#1099.

Layer Responsibility
QQQ core picker metadata on the source; allow-listed filters and sorts; keyset/offset paging; limit+1 for hasMore
Javalin v1 picker body object (filter, afterId, skip, limit); options get values/displayValues; response gets hasMore; field metadata gets picker/pickerTableName
Next UI (this PR) inline thumbnails and actions; the dialog
App (kof22) declares the media picker; indexes (site_id, is_active, id DESC) and friends

Bundle (per the coordinator's budget note)

main this branch
Route first load (/app/_/_/edit) 335.4 KB 335.4 KB
Shared CSS 21.5 KB 21.5 KB (only existing utilities and controls; no new CSS)
Total JS 1005.4 KB 1007.9 KB (+2.5 KB)
recordPicker lazy chunk – 17.0 KB across 3 Turbopack variants (one per parent context); a session loads one of about 5.9 KB; budget 18 KB

Tests

  • Unit
    • record-picker.test.ts: criteria per field kind; keyset vs offset requests; safe thumbnail URLs; subtitles; preferences and corrupt or blocked storage.
    • RecordPickerDialog.test.tsx (11 tests): grid with preview and axe; keyset Load more; debounced search; filters and sort remembered per field; click, Select, double click, keyboard Enter; current selection shown as chosen; multiple selection; upload-and-select; no upload without permission; Escape; error with retry.
    • PossibleValueSelect.test.tsx (+4): typeahead thumbnails from a limited page; selection thumbnail with Change/Preview/View; Browse opens the dialog and sets the value.
    • ProcessRun.test.tsx: onComplete fires once.
  • E2E (tests/e2e/record-picker.spec.ts, mocked v1 routes that behave like the backend with 150 records):
    • open, keyset scroll, search, filter, keyboard select, save, and the saved image persists on reload
    • filters and view remembered per field, Escape returns focus
  • Gates:
    • tsc clean
    • eslint src: 5 warnings, the same as main
    • license headers OK
    • vitest: 2336 passed. The 2 failures (tests/standalone/acceptance-stage, tests/acceptance/support/standalone-variant) are Windows path-separator tests that fail identically on main.
    • Playwright: 134 passed
    • perf-budget: OK

Screenshots (light and dark at 390, 1024 and 1440)

Light Dark
Field, 1024
Picker, 1440
Picker, 1024
Picker, 390
List view, 390

All 18 shots are on the pr-assets/record-picker branch. To regenerate: QQQ_PICKER_SCREENSHOTS=<dir> pnpm test:e2e record-picker.

Release order

  1. QRun-IO/qqq#1099 in a 4.1.x RC
  2. This PR in a Next RC
  3. Website-Backend bump and configuration

🤖 Generated with Claude Code

## What A **record picker** for possible-value fields whose source is a large table (100,000+ media and the like). It needs QRun-IO/qqq#1099, which adds `QPossibleValuePicker`, the paged picker search and the v1 contract. Fields without a picker are unchanged. ### Inline field (`PossibleValueSelect`) - The typeahead asks the server for a limited page (20) of picker options and shows each option's thumbnail and subtitle. - The selection shows its thumbnail, with **Change…** (or **Browse…** when empty), **Preview** (the existing full-size lightbox) and **View** (the record in a new tab, so unsaved edits stay put). ### Picker dialog (`RecordPickerDialog`, loaded by `import()` on Browse/Change) - Debounced (300 ms) server-side search: CONTAINS over the picker's search fields. - Filters generated from the picker table's field types: a possible-value field gets a select of its own values, booleans Yes/No, numbers and dates a range, strings "contains". - The declared sorts (Newest, Name, ...). - Grid and list views; thumbnails load lazily. - Infinite scroll (IntersectionObserver) with a **Load more** fallback. - The primary-key sort pages by **keyset** (`afterId`), others by offset. - No counts: the server returns `hasMore`. - Preview pane (desktop) with the large image, detail fields, Full size and Open; on phones a footer preview button. - **Upload new**: runs the picker's upload process embedded (`ProcessRun` gains `onComplete`) and selects the record it created. - Single or **multiple** selection (`multiple` prop, `aria-multiselectable`, Space toggles). - Filters, sort and view are remembered **per field** in local storage (try/catch; works without storage). - Accessibility: - Radix focus trap and Escape. - The results are a `listbox` of `option`s; arrow keys move by row in the grid, Enter or a double click confirms. - axe-clean. - Full screen on phones, inset on larger screens, one scroll area per pane (no nested scrollbars). Theme tokens only, so light and dark both work. ### Design: layers and API contract The design note is `docs/misc/RecordPicker.adoc` in QRun-IO/qqq#1099. | Layer | Responsibility | |---|---| | QQQ core | picker metadata on the source; allow-listed filters and sorts; keyset/offset paging; `limit+1` for `hasMore` | | Javalin v1 | `picker` body object (`filter`, `afterId`, `skip`, `limit`); options get `values`/`displayValues`; response gets `hasMore`; field metadata gets `picker`/`pickerTableName` | | Next UI (this PR) | inline thumbnails and actions; the dialog | | App (kof22) | declares the media picker; indexes `(site_id, is_active, id DESC)` and friends | ## Bundle (per the coordinator's budget note) | | main | this branch | |---|---|---| | Route first load (`/app/_/_/edit`) | 335.4 KB | 335.4 KB | | Shared CSS | 21.5 KB | 21.5 KB (only existing utilities and controls; no new CSS) | | Total JS | 1005.4 KB | 1007.9 KB (+2.5 KB) | | `recordPicker` lazy chunk | – | 17.0 KB across 3 Turbopack variants (one per parent context); a session loads one of about 5.9 KB; budget 18 KB | ## Tests - **Unit** - `record-picker.test.ts`: criteria per field kind; keyset vs offset requests; safe thumbnail URLs; subtitles; preferences and corrupt or blocked storage. - `RecordPickerDialog.test.tsx` (11 tests): grid with preview and axe; keyset Load more; debounced search; filters and sort remembered per field; click, Select, double click, keyboard Enter; current selection shown as chosen; multiple selection; upload-and-select; no upload without permission; Escape; error with retry. - `PossibleValueSelect.test.tsx` (+4): typeahead thumbnails from a limited page; selection thumbnail with Change/Preview/View; Browse opens the dialog and sets the value. - `ProcessRun.test.tsx`: `onComplete` fires once. - **E2E** (`tests/e2e/record-picker.spec.ts`, mocked v1 routes that behave like the backend with 150 records): - open, keyset scroll, search, filter, keyboard select, save, and the saved image persists on reload - filters and view remembered per field, Escape returns focus - Gates: - `tsc` clean - `eslint src`: 5 warnings, the same as main - license headers OK - `vitest`: 2336 passed. The 2 failures (`tests/standalone/acceptance-stage`, `tests/acceptance/support/standalone-variant`) are Windows path-separator tests that fail identically on main. - Playwright: 134 passed - `perf-budget`: OK ## Screenshots (light and dark at 390, 1024 and 1440) | | Light | Dark | |---|---|---| | Field, 1024 | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/field-light-1024.png?raw=true) | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/field-dark-1024.png?raw=true) | | Picker, 1440 | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-light-1440.png?raw=true) | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-dark-1440.png?raw=true) | | Picker, 1024 | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-light-1024.png?raw=true) | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-dark-1024.png?raw=true) | | Picker, 390 | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-light-390.png?raw=true) | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-dark-390.png?raw=true) | | List view, 390 | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-list-light-390.png?raw=true) | ![](https://github.com/QRun-IO/qqq-frontend-next/blob/pr-assets/record-picker/record-picker/picker-list-dark-390.png?raw=true) | All 18 shots are on the `pr-assets/record-picker` branch. To regenerate: `QQQ_PICKER_SCREENSHOTS=<dir> pnpm test:e2e record-picker`. ## Release order 1. QRun-IO/qqq#1099 in a 4.1.x RC 2. This PR in a Next RC 3. Website-Backend bump and configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.