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.
## 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 |  |  |
| 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](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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)Picker dialog (
RecordPickerDialog, loaded byimport()on Browse/Change)afterId), others by offset.hasMore.ProcessRungainsonComplete) and selects the record it created.multipleprop,aria-multiselectable, Space toggles).listboxofoptions; arrow keys move by row in the grid, Enter or a double click confirms.Design: layers and API contract
The design note is
docs/misc/RecordPicker.adocin QRun-IO/qqq#1099.limit+1forhasMorepickerbody object (filter,afterId,skip,limit); options getvalues/displayValues; response getshasMore; field metadata getspicker/pickerTableName(site_id, is_active, id DESC)and friendsBundle (per the coordinator's budget note)
/app/_/_/edit)recordPickerlazy chunkTests
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:onCompletefires once.tests/e2e/record-picker.spec.ts, mocked v1 routes that behave like the backend with 150 records):tsccleaneslint src: 5 warnings, the same as mainvitest: 2336 passed. The 2 failures (tests/standalone/acceptance-stage,tests/acceptance/support/standalone-variant) are Windows path-separator tests that fail identically on main.perf-budget: OKScreenshots (light and dark at 390, 1024 and 1440)
All 18 shots are on the
pr-assets/record-pickerbranch. To regenerate:QQQ_PICKER_SCREENSHOTS=<dir> pnpm test:e2e record-picker.Release order
🤖 Generated with Claude Code