Merge pull request #80 from QRun-IO/feature/record-picker

feat(forms): record picker for possible-value fields over large tables (thumbnails, paged dialog)
This commit was merged in pull request #80.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-13 06:01:41 -05:00
commit 6dea1c3a86
20 files changed
+2013 -16

No files matched your search

+5
View File
@@ -32,6 +32,11 @@
"marker": "qqq-sql-workbench-editor",
"gzipKB": 135
},
"recordPicker": {
"$comment": "The record picker dialog (RecordPickerDialog: search, filters, thumbnail grid and list, preview, upload) for fields whose possible value source declares a picker, loaded when Browse/Change is clicked: import() in PossibleValueSelect. Turbopack emits one variant of the async chunk per parent context that bundles PossibleValueSelect (record forms, process forms, bulk-load mapping), so the budget covers all variants; a session loads one (about 5.9 KB gzip, with the dialog-only record-picker-query helpers). Measured 17.0 KB gzip across the three variants at introduction; budget about 6% above. The inline field (thumbnails, Browse/Preview/View) adds 2.5 KB to total JS (main 1005.4 KB, this branch 1007.9 KB on 2026-10-12) and nothing to any route first load or the shared CSS.",
"marker": "qqq-record-picker-grid",
"gzipKB": 18
},
"sqlFormatter": {
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
"marker": "logicalOperatorNewline",
+1 -1
View File
@@ -46,7 +46,7 @@ import { NotFoundState } from '@/components/layout/NotFoundState'
// Metadata selects one screen. Keep the other three screens out of this route's first load.
const RecordQuery = lazy(() => import('@/components/query/RecordQuery').then((module) => ({ default: module.RecordQuery })))
const ProcessRun = lazy(() => import('@/components/process/ProcessRun').then((module) => ({ default: module.ProcessRun })))
import { ProcessRun } from '@/components/process/ProcessRunLazy'
const AppHome = lazy(() => import('@/components/widgets/AppHome').then((module) => ({ default: module.AppHome })))
const ReportRun = lazy(() => import('@/components/reports/ReportRun').then((module) => ({ default: module.ReportRun })))
@@ -335,6 +335,8 @@ export function DynamicFormField({
initialLabel={displayValueOverrides?.[field.name] ?? record?.displayValues?.[field.name]}
inlineOptions={inlineOptions}
chipField={field}
picker={field.picker}
pickerTableName={field.pickerTableName}
context={pvContext}
error={fieldError}
disabled={isDisabled}
@@ -30,12 +30,24 @@ vi.mock('@/lib/api/possible-values', () => ({
fetchTablePossibleValues: vi.fn(),
fetchProcessPossibleValues: vi.fn(),
fetchPossibleValues: vi.fn(),
fetchPossibleValuesPage: vi.fn(),
}))
// the lazily loaded picker dialog: confirms option 7 when its button is pressed
vi.mock('./RecordPickerDialog', () => ({
default: ({ onConfirm, selectedIds, label }: { onConfirm: (options: QPossibleValue[]) => void; selectedIds: Array<string | number>; label: string }) => (
<div role="dialog" aria-label={`Choose ${label}`}>
<span data-testid="picker-selected">{selectedIds.join(',')}</span>
<button type="button" onClick={() => onConfirm([{ id: 7, label: 'Lighthouse', values: { thumbnailUrl: '/t/7.jpg' } }])}>Pick Lighthouse</button>
</div>
),
}))
import {
fetchTablePossibleValues,
fetchProcessPossibleValues,
fetchPossibleValues,
fetchPossibleValuesPage,
} from '@/lib/api/possible-values'
const mockFetchTable = vi.mocked(fetchTablePossibleValues)
@@ -663,3 +675,79 @@ describe('PossibleValueSelect inline possible values and chips (QRun-IO/qqq#721)
expect(screen.getByRole('option', { name: 'Low' }).querySelector('[data-chip-color]')).toBeNull()
})
})
describe('PossibleValueSelect with a record picker', () => {
const PICKER = { thumbnailField: 'thumbnailUrl', previewField: 'galleryUrl', subtitleFields: ['mimeType'] }
const HARBOR: QPossibleValue = { id: 3, label: 'Harbor', values: { thumbnailUrl: '/t/3.jpg', galleryUrl: '/g/3.jpg', mimeType: 'image/jpeg' } }
const mockPage = vi.mocked(fetchPossibleValuesPage)
/**
* A form with an image field whose source declares a picker.
* @param props - The held value, and its display label.
* @returns The field and a readout of the form value.
*/
function PickerWrapper({ defaultValue, initialLabel }: { defaultValue?: unknown; initialLabel?: string }) {
const { control, watch } = useForm<Record<string, unknown>>({ defaultValues: { featuredMediaId: defaultValue ?? null } })
return (
<>
<PossibleValueSelect
id="image"
label="Featured Image"
name="featuredMediaId"
control={control}
fieldName="featuredMediaId"
context={{ type: 'table', tableName: 'blog_post' }}
initialLabel={initialLabel}
picker={PICKER}
pickerTableName="media"
/>
<output data-testid="form-value">{String(watch('featuredMediaId'))}</output>
</>
)
}
beforeEach(() => {
mockPage.mockResolvedValue({ options: [HARBOR], hasMore: false })
})
it('shows thumbnails and subtitles in the typeahead, from a limited server-side page', async () => {
const user = userEvent.setup()
render(<PickerWrapper />)
await user.click(screen.getByRole('combobox'))
const option = await screen.findByRole('option', { name: /Harbor/ })
expect(option.querySelector('img')).toHaveAttribute('src', '/t/3.jpg')
expect(option).toHaveTextContent('image/jpeg')
expect(mockPage).toHaveBeenCalledWith({ type: 'table', tableName: 'blog_post' }, 'featuredMediaId', undefined, expect.objectContaining({ picker: { limit: 20 } }))
expect(mockFetchTable).not.toHaveBeenCalled()
})
it('shows the selected thumbnail with Change, Preview and View, even when the record gave its label', async () => {
render(<PickerWrapper defaultValue={3} initialLabel="Harbor" />)
await waitFor(() => expect(screen.getByRole('combobox').querySelector('img')).toHaveAttribute('src', '/t/3.jpg'))
expect(mockPage).toHaveBeenCalledWith(expect.anything(), 'featuredMediaId', undefined, expect.objectContaining({ ids: '3', picker: {} }))
expect(screen.getByRole('button', { name: 'Change…' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Preview' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View' })).toHaveAttribute('href', '/app/media/3')
})
it('opens the picker dialog and sets the field to the chosen record', async () => {
const user = userEvent.setup()
render(<PickerWrapper defaultValue={3} initialLabel="Harbor" />)
await user.click(screen.getByRole('button', { name: 'Change…' }))
expect(await screen.findByRole('dialog', { name: 'Choose Featured Image' })).toBeInTheDocument()
expect(screen.getByTestId('picker-selected')).toHaveTextContent('3')
await user.click(screen.getByRole('button', { name: 'Pick Lighthouse' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByTestId('form-value')).toHaveTextContent('7')
expect(screen.getByRole('combobox')).toHaveTextContent('Lighthouse')
expect(screen.getByRole('combobox').querySelector('img')).toHaveAttribute('src', '/t/7.jpg')
})
it('offers Browse (and no Preview or View) when nothing is selected', async () => {
render(<PickerWrapper />)
expect(screen.getByRole('button', { name: 'Browse…' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'View' })).not.toBeInTheDocument()
})
})
+104 -6
View File
@@ -20,22 +20,32 @@
'use client'
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import React, { lazy, Suspense, useState, useRef, useEffect, useCallback, useMemo } from 'react'
import type { Control, FieldError } from 'react-hook-form'
import { Controller, useWatch } from 'react-hook-form'
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
import type { QFieldMetaData, QPossibleValue } from '@/types'
import type { QFieldMetaData, QPossibleValue, QPossibleValuePicker } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import {
fetchTablePossibleValues,
fetchProcessPossibleValues,
fetchPossibleValues,
fetchPossibleValuesPage,
} from '@/lib/api/possible-values'
import { chipStyle, CHIP_COLOR_CLASSES, findAdornment } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { pickerPreviewUrl, pickerRecordPath, pickerSubtitle, pickerThumbnailUrl } from '@/lib/utils/record-picker'
import { buttonClass } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
// the record picker dialog and the full-size preview load only when opened
const RecordPickerDialog = lazy(() => import('./RecordPickerDialog'))
import { FilePreviewLightbox } from '@/components/preview/FilePreviewLightboxLazy'
/** How many options the inline typeahead of a picker field asks for (the dialog pages the rest). */
const PICKER_INLINE_LIMIT = 20
/**
* Props for the {@link PossibleValueSelect} component.
*/
@@ -75,6 +85,13 @@ interface PossibleValueSelectProps {
chipField?: QFieldMetaData
/** Keep the label for assistive technology only (the host shows its own caption). */
hideLabel?: boolean
/**
* The record picker of the field's source (`field.picker`): options show thumbnails, the
* selection shows its thumbnail with Browse, Preview and Open, and Browse opens the picker dialog.
*/
picker?: QPossibleValuePicker
/** The table the picker reads (`field.pickerTableName`). */
pickerTableName?: string
}
/**
@@ -136,6 +153,8 @@ export function PossibleValueSelect({
required = false,
placeholder,
hideLabel = false,
picker,
pickerTableName,
'data-qqq-id': dataQqqId,
}: PossibleValueSelectProps) {
const [isOpen, setIsOpen] = useState(false)
@@ -150,6 +169,8 @@ export function PossibleValueSelect({
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSequence = useRef(0)
const [browsing, setBrowsing] = useState(false)
const [previewing, setPreviewing] = useState(false)
const formValues = useWatch({ control }) as Record<string, unknown> | undefined
// What every request carries for the field's filter: the form's current values.
const scope = useMemo(() => ({ formValues: formValues ?? {} }), [formValues])
@@ -175,7 +196,9 @@ export function PossibleValueSelect({
try {
const request = { searchTerm: term || undefined, ...scope }
let results: QPossibleValue[]
if (context.type === 'table') {
if (picker) {
results = (await fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ...request, picker: { limit: PICKER_INLINE_LIMIT } })).options
} else if (context.type === 'table') {
results = await fetchTablePossibleValues(context.tableName, fieldName, request)
} else if (context.type === 'process') {
results = await fetchProcessPossibleValues(context.processName, fieldName, request)
@@ -192,7 +215,7 @@ export function PossibleValueSelect({
if (sequence === requestSequence.current) setIsLoading(false)
}
},
[context, fieldName, possibleValueSourceName, scope, inlineOptions]
[context, fieldName, possibleValueSourceName, scope, inlineOptions, picker]
)
/**
@@ -236,10 +259,12 @@ export function PossibleValueSelect({
if (inline) setSelectedOption(inline)
return
}
if (initialLabel) return
if (initialLabel && !picker) return
let cancelled = false
const request = { ids: held, ...scope }
const lookup = context.type === 'table'
const lookup = picker
? fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ...request, picker: {} }).then((page) => page.options)
: context.type === 'table'
? fetchTablePossibleValues(context.tableName, fieldName, request)
: context.type === 'process'
? fetchProcessPossibleValues(context.processName, fieldName, request)
@@ -278,7 +303,24 @@ export function PossibleValueSelect({
// A CHIP adornment styles each option by its value (color and icon), as Material's renderOption does.
const hasChips = Boolean(chipField && findAdornment(chipField, 'CHIP'))
const pickerThumbnail = (option: QPossibleValue | null, size: string) => {
const url = pickerThumbnailUrl(option, picker)
// eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer
return url ? <img src={url} alt="" loading="lazy" decoding="async" className={cn(size, 'shrink-0 rounded bg-muted object-cover')} data-qqq-id="picker-thumbnail" /> : null
}
const optionContent = (option: QPossibleValue) => {
if (picker) {
const subtitle = pickerSubtitle(option, picker)
return (
<span className="flex min-w-0 items-center gap-2">
{pickerThumbnail(option, 'h-8 w-8')}
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{subtitle && <span className="block truncate text-xs text-muted-foreground">{subtitle}</span>}
</span>
</span>
)
}
if (!hasChips || !chipField) return <span>{option.label}</span>
const { color, icon } = chipStyle(chipField, option.id)
return (
@@ -367,6 +409,7 @@ export function PossibleValueSelect({
: heldValue ? (initialLabel ?? heldValue) : ''
return (
<>
<div ref={containerRef} className="relative">
<div
ref={triggerRef}
@@ -398,6 +441,7 @@ export function PossibleValueSelect({
error ? 'border-destructive' : 'border-input'
)}
>
{picker && heldValue && selectedOption && String(selectedOption.id) === heldValue && pickerThumbnail(selectedOption, 'mr-2 h-8 w-8')}
<span
className={cn(
'flex-1 truncate text-sm',
@@ -507,6 +551,60 @@ export function PossibleValueSelect({
</div>
)}
</div>
{picker && !disabled && (
<div className="mt-1 flex flex-wrap gap-1" data-qqq-id={dataQqqId ? `picker-actions-${dataQqqId}` : 'picker-actions'}>
<button type="button" onClick={() => { setIsOpen(false); setBrowsing(true) }} className={buttonClass('secondary', 'sm')} data-qqq-id="picker-browse">
{heldValue ? 'Change…' : 'Browse…'}
</button>
{heldValue && pickerPreviewUrl(selectedOption, picker) && (
<button type="button" onClick={() => setPreviewing(true)} className={buttonClass('ghost', 'sm')} data-qqq-id="picker-preview">Preview</button>
)}
{heldValue && pickerTableName && (
<a href={pickerRecordPath(pickerTableName, heldValue)} target="_blank" rel="noopener noreferrer" className={buttonClass('ghost', 'sm')} data-qqq-id="picker-view">View</a>
)}
</div>
)}
{picker && browsing && (
<Suspense fallback={null}>
<RecordPickerDialog
label={label}
fieldName={fieldName}
context={context}
possibleValueSourceName={possibleValueSourceName}
picker={picker}
pickerTableName={pickerTableName}
formValues={scope.formValues}
selectedIds={heldValue ? [heldValue] : []}
onConfirm={(chosen) => {
setBrowsing(false)
if (chosen[0]) {
field.onChange(chosen[0].id)
setSelectedOption(chosen[0])
}
}}
onClose={() => setBrowsing(false)}
/>
</Suspense>
)}
{picker && previewing && selectedOption && (
<Suspense fallback={null}>
<FilePreviewLightbox
items={[{
url: pickerPreviewUrl(selectedOption, picker) ?? '',
thumbnailUrl: pickerThumbnailUrl(selectedOption, picker),
fileName: selectedOption.label,
kind: 'image',
alt: selectedOption.label,
previewable: true,
thumbnailSize: 40,
}]}
index={0}
onIndexChange={() => { /* a single image */ }}
onClose={() => setPreviewing(false)}
/>
</Suspense>
)}
</>
)
}}
/>
@@ -0,0 +1,244 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for RecordPickerDialog
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { axe } from 'jest-axe'
import type { QPossibleValue, QPossibleValuePicker, QTableMetaData } from '@/types'
import { RecordPickerDialog } from './RecordPickerDialog'
vi.mock('@/lib/api/possible-values', () => ({
fetchPossibleValuesPage: vi.fn(),
fetchTablePossibleValues: vi.fn(),
}))
vi.mock('@/lib/hooks/use-metadata', () => ({
useTableMetaData: vi.fn(),
useProcessMetaData: vi.fn(),
}))
vi.mock('@/components/process/ProcessRun', () => ({
ProcessRun: ({ onComplete }: { onComplete?: (values: Record<string, unknown>) => void }) => (
<button type="button" onClick={() => onComplete?.({ mediaId: 99 })}>Finish upload</button>
),
}))
import { fetchPossibleValuesPage, fetchTablePossibleValues } from '@/lib/api/possible-values'
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
const mockPage = vi.mocked(fetchPossibleValuesPage)
const mockChoices = vi.mocked(fetchTablePossibleValues)
const field = (name: string, label: string, type: string, extra: object = {}) =>
({ name, label, type, isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...extra })
const MEDIA = {
name: 'media',
label: 'Media',
primaryKeyField: 'id',
fields: {
id: field('id', 'ID', 'LONG'),
mediaTypeId: field('mediaTypeId', 'Media Type', 'LONG', { possibleValueSourceName: 'mediaType' }),
width: field('width', 'Width', 'INTEGER'),
altText: field('altText', 'Alt Text', 'STRING'),
},
} as unknown as QTableMetaData
const PICKER: QPossibleValuePicker = {
thumbnailField: 'thumbnailUrl',
previewField: 'galleryUrl',
subtitleFields: ['mediaTypeId'],
detailFields: ['width', 'altText'],
filterFields: ['mediaTypeId', 'width'],
sorts: [{ label: 'Newest', fieldName: 'id', isAscending: false }, { label: 'Name', fieldName: 'name', isAscending: true }],
pageSize: 2,
uploadProcessName: 'uploadImage',
uploadResultIdField: 'mediaId',
}
const media = (id: number, label: string): QPossibleValue => ({
id,
label,
values: { thumbnailUrl: `/t/${id}.jpg`, galleryUrl: `/g/${id}.jpg`, mediaTypeId: 1, width: 1200, altText: `${label} alt` },
displayValues: { mediaTypeId: 'Photo', width: '1,200' },
})
/** The result options (not the native options of the sort and filter selects). */
const resultOptions = () => within(screen.getByRole('listbox', { name: /results/ })).getAllByRole('option')
const onConfirm = vi.fn()
const onClose = vi.fn()
/**
* Render the dialog for the blog post's image field.
* @param props - Overrides.
* @returns The render result.
*/
function renderDialog(props: Partial<React.ComponentProps<typeof RecordPickerDialog>> = {}) {
return render(
<RecordPickerDialog
label="Featured Image"
fieldName="featuredMediaId"
context={{ type: 'table', tableName: 'blog_post' }}
picker={PICKER}
pickerTableName="media"
onConfirm={onConfirm}
onClose={onClose}
{...props}
/>
)
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.mocked(useTableMetaData).mockReturnValue({ data: MEDIA } as ReturnType<typeof useTableMetaData>)
vi.mocked(useProcessMetaData).mockReturnValue({ data: { name: 'uploadImage', label: 'Upload image', hasPermission: true } } as ReturnType<typeof useProcessMetaData>)
mockChoices.mockResolvedValue([{ id: 1, label: 'Photo' }, { id: 2, label: 'Logo' }])
mockPage.mockImplementation(async (_context, _field, _source, request) => {
if (request.ids) return { options: request.ids.split(',').map((id) => media(Number(id), `Media ${id}`)), hasMore: false }
if (request.picker.afterId === 8) return { options: [media(7, 'Seven')], hasMore: false }
return { options: [media(9, 'Nine'), media(8, 'Eight')], hasMore: true }
})
})
afterEach(() => {
vi.useRealTimers()
})
describe('RecordPickerDialog', () => {
it('shows a thumbnail grid of the first page and a preview of the focused option', async () => {
const { baseElement } = renderDialog()
expect(await screen.findByRole('option', { name: /^Nine/ })).toBeInTheDocument()
const options = resultOptions()
expect(options).toHaveLength(2)
expect(options[0].querySelector('img')).toHaveAttribute('src', '/t/9.jpg')
expect(within(options[0]).getByText('Photo')).toBeInTheDocument()
const preview = screen.getByLabelText('Preview', { selector: 'aside' })
expect(within(preview).getByRole('img')).toHaveAttribute('src', '/g/9.jpg')
expect(within(preview).getByText('Nine alt')).toBeInTheDocument()
expect(within(preview).getByRole('link', { name: /open/i })).toHaveAttribute('href', '/app/media/9')
expect(mockPage.mock.calls[0][3].picker).toEqual({ filter: { criteria: [], booleanOperator: 'AND', orderBys: [{ fieldName: 'id', isAscending: false }] } })
expect(await axe(baseElement)).toHaveNoViolations()
})
it('loads the next page by keyset, without counting', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
await userEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByRole('option', { name: /^Seven/ })).toBeInTheDocument()
expect(mockPage.mock.calls.at(-1)![3].picker.afterId).toBe(8)
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
it('debounces the search box into a server-side search', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
vi.useFakeTimers({ shouldAdvanceTime: true })
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'har' } })
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'harbor' } })
await act(async () => { vi.advanceTimersByTime(350) })
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].searchTerm).toBe('harbor'))
expect(mockPage.mock.calls.filter((call) => call[3].searchTerm === 'har')).toHaveLength(0)
})
it('filters by the picker table fields and remembers the filters per field', async () => {
const { unmount } = renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
await userEvent.click(screen.getByRole('button', { name: 'Filters' }))
await waitFor(() => expect(screen.getByRole('option', { name: 'Logo' })).toBeInTheDocument())
await userEvent.selectOptions(screen.getByLabelText('Media Type'), '2')
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].picker.filter?.criteria).toEqual([{ fieldName: 'mediaTypeId', operator: 'EQUALS', values: ['2'] }]))
await userEvent.selectOptions(screen.getByLabelText('Sort'), '1')
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].picker.filter?.orderBys).toEqual([{ fieldName: 'name', isAscending: true }]))
unmount()
renderDialog()
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].picker.filter?.criteria).toEqual([{ fieldName: 'mediaTypeId', operator: 'EQUALS', values: ['2'] }]))
expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeInTheDocument()
})
it('selects with a click and confirms with Select; a double click confirms at once', async () => {
renderDialog()
await userEvent.click(await screen.findByRole('option', { name: /^Eight/ }))
expect(resultOptions()[1]).toHaveAttribute('aria-selected', 'true')
await userEvent.click(screen.getByRole('button', { name: 'Select' }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 8 })])
onConfirm.mockClear()
await userEvent.dblClick(screen.getByRole('option', { name: /^Nine/ }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 9 })])
})
it('moves with the arrow keys and confirms with Enter', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
const options = resultOptions()
options[0].focus()
fireEvent.keyDown(options[0], { key: 'ArrowRight' })
expect(options[1]).toHaveFocus()
fireEvent.keyDown(options[1], { key: 'Enter' })
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 8 })])
})
it('shows the current selection as chosen', async () => {
renderDialog({ selectedIds: [8] })
await waitFor(() => expect(resultOptions()[1]).toHaveAttribute('aria-selected', 'true'))
expect(screen.getByText('Media 8', { selector: '[data-qqq-id="record-picker-selection"]' })).toBeInTheDocument()
})
it('toggles several options in multiple selection', async () => {
renderDialog({ multiple: true })
await userEvent.click(await screen.findByRole('option', { name: /^Nine/ }))
await userEvent.click(screen.getByRole('option', { name: /^Eight/ }))
expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true')
expect(screen.getByText('2 selected')).toBeInTheDocument()
await userEvent.click(screen.getByRole('option', { name: /^Nine/ }))
await userEvent.click(screen.getByRole('button', { name: 'Select' }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 8 })])
})
it('uploads a new record and selects it', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
await userEvent.click(screen.getByRole('button', { name: /upload image/i }))
await userEvent.click(await screen.findByRole('button', { name: 'Finish upload' }))
expect(await screen.findByText('Media 99', { selector: '[data-qqq-id="record-picker-selection"]' })).toBeInTheDocument()
expect(resultOptions()[0]).toHaveAttribute('data-value', '99')
await userEvent.click(screen.getByRole('button', { name: 'Select' }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 99 })])
})
it('offers no upload without permission, and closes with Escape', async () => {
vi.mocked(useProcessMetaData).mockReturnValue({ data: { name: 'uploadImage', label: 'Upload image', hasPermission: false } } as ReturnType<typeof useProcessMetaData>)
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
expect(screen.queryByRole('button', { name: /upload image/i })).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('reports a failed load with a retry', async () => {
mockPage.mockRejectedValueOnce(new Error('down'))
renderDialog()
expect(await screen.findByRole('alert')).toHaveTextContent('Results could not be loaded.')
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByRole('option', { name: /^Nine/ })).toBeInTheDocument()
})
})
+606
View File
@@ -0,0 +1,606 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file RecordPickerDialog — the record picker for a field whose possible value source declares a
* `picker` (tables that grow large, e.g. 100,000+ media): server-side search, filters from the
* picker's table, orderings, a thumbnail grid or a list, keyset or offset paging without counts,
* a preview pane, uploading a new record, and single or multiple selection. Loaded on demand by
* {@link PossibleValueSelect}. The API contract and scale approach are in QQQ's
* docs/misc/RecordPicker.adoc.
*/
'use client'
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { Check, ExternalLink, Grid2x2, List, Loader2, Maximize2, Search, Upload, X } from 'lucide-react'
import type { QFieldMetaData, QPossibleValue, QPossibleValuePicker } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { fetchPossibleValuesPage, fetchTablePossibleValues } from '@/lib/api/possible-values'
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
import { cn } from '@/lib/utils/cn'
import {
type PickerFilterValue,
type PickerPreferences,
pickerFilterKind,
pickerPageRequest,
pickerStorageKey,
readPickerPreferences,
writePickerPreferences,
} from '@/lib/utils/record-picker-query'
import { pickerPreviewUrl, pickerRecordPath, pickerSubtitle, pickerThumbnailUrl, pickerValueText } from '@/lib/utils/record-picker'
import { buttonClass, iconButtonClass, INPUT_CLASS, SELECT_CLASS, ALERT_CLASS, LABEL_CLASS } from '@/components/ui/controls'
import { FilePreviewLightbox } from '@/components/preview/FilePreviewLightboxLazy'
import { ProcessRun } from '@/components/process/ProcessRunLazy'
/** Search debounce, in milliseconds. */
const SEARCH_DEBOUNCE_MS = 300
/** Props for {@link RecordPickerDialog}. */
export interface RecordPickerDialogProps {
/** The field's label (the dialog title is "Choose {label}"). */
label: string
/** The field the picker chooses for. */
fieldName: string
/** Where the field is (its possible-values route). */
context: PossibleValueContext
/** Source name, for a standalone lookup. */
possibleValueSourceName?: string
/** The field's picker. */
picker: QPossibleValuePicker
/** The table the picker reads. */
pickerTableName?: string
/** The form's current values (the field's own filter may use them). */
formValues?: Record<string, unknown>
/** Choose several records (an association or gallery) instead of one. */
multiple?: boolean
/** The ids already chosen. */
selectedIds?: Array<string | number>
/** Called with the chosen options when the user confirms. */
onConfirm: (options: QPossibleValue[]) => void
/** Called when the dialog closes without a choice. */
onClose: () => void
}
/**
* One filter control: a choice (the field's own possible values), yes/no, a text, or a range.
*
* @param props - The field, its value, the picker table and the change handler.
* @returns The labelled control.
*/
function PickerFilter({ field, value, tableName, onChange }: {
field: QFieldMetaData
value: PickerFilterValue | undefined
tableName: string
onChange: (value: PickerFilterValue | undefined) => void
}) {
const kind = pickerFilterKind(field)
const [choices, setChoices] = useState<QPossibleValue[]>([])
const id = `record-picker-filter-${field.name}`
useEffect(() => {
if (kind !== 'choice') return
let cancelled = false
fetchTablePossibleValues(tableName, field.name, { useCase: 'filter' })
.then((options) => { if (!cancelled) setChoices(options) })
.catch(() => { if (!cancelled) setChoices([]) })
return () => { cancelled = true }
}, [kind, tableName, field.name])
const set = (patch: PickerFilterValue) => {
const next = { ...(value ?? {}), ...patch }
onChange(next.value || next.from || next.to ? next : undefined)
}
if (kind === 'choice' || kind === 'boolean') {
return (
<label htmlFor={id} className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
{field.label}
<select id={id} value={value?.value ?? ''} onChange={(event) => set({ value: event.target.value })} className={SELECT_CLASS} data-qqq-id={`record-picker-filter-${field.name}`}>
<option value="">Any</option>
{kind === 'boolean'
? (<><option value="true">Yes</option><option value="false">No</option></>)
: choices.map((choice) => <option key={String(choice.id)} value={String(choice.id)}>{choice.label}</option>)}
</select>
</label>
)
}
if (kind === 'text') {
return (
<label htmlFor={id} className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
{field.label}
<input id={id} type="text" value={value?.value ?? ''} onChange={(event) => set({ value: event.target.value })} className={INPUT_CLASS} placeholder="Contains" data-qqq-id={`record-picker-filter-${field.name}`} />
</label>
)
}
const type = kind === 'number' ? 'number' : 'date'
return (
<fieldset className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground" data-qqq-id={`record-picker-filter-${field.name}`}>
<legend className="mb-1">{field.label}</legend>
<div className="flex gap-1">
<input type={type} aria-label={`${field.label} from`} value={value?.from ?? ''} onChange={(event) => set({ from: event.target.value })} className={INPUT_CLASS} placeholder="From" />
<input type={type} aria-label={`${field.label} to`} value={value?.to ?? ''} onChange={(event) => set({ to: event.target.value })} className={INPUT_CLASS} placeholder="To" />
</div>
</fieldset>
)
}
/**
* The record picker dialog. Escape closes it and focus is trapped while it is open (Radix);
* arrow keys move through the results (by row in the grid), Enter or a double click confirms,
* Space toggles in multiple selection.
*
* @param props - See {@link RecordPickerDialogProps}.
* @returns The dialog.
*/
export function RecordPickerDialog({
label,
fieldName,
context,
possibleValueSourceName,
picker,
pickerTableName,
formValues,
multiple = false,
selectedIds = [],
onConfirm,
onClose,
}: RecordPickerDialogProps) {
const restoreFocus = useRestoreFocus(true)
const { data: table } = useTableMetaData(pickerTableName)
const { data: uploadProcess } = useProcessMetaData(picker.uploadProcessName)
const storageKey = pickerStorageKey(pickerTableName, fieldName)
const [preferences, setPreferences] = useState<PickerPreferences>(() => readPickerPreferences(storageKey))
const [search, setSearch] = useState('')
const [term, setTerm] = useState('')
const [options, setOptions] = useState<QPossibleValue[]>([])
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const [active, setActive] = useState(0)
// the option the user last focused or clicked (the preview follows it)
const [previewId, setPreviewId] = useState<string | null>(null)
const [chosen, setChosen] = useState<QPossibleValue[]>([])
const [showFilters, setShowFilters] = useState(false)
const [uploading, setUploading] = useState(false)
const [previewing, setPreviewing] = useState<QPossibleValue | null>(null)
const sequence = useRef(0)
const resultsRef = useRef<HTMLDivElement>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const selectedKey = selectedIds.map(String).join(',')
// the remembered filters/ordering/view of this field
useEffect(() => { writePickerPreferences(storageKey, preferences) }, [storageKey, preferences])
// debounce the search box
useEffect(() => {
const timer = setTimeout(() => setTerm(search.trim()), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [search])
const filterFields = useMemo(
() => (picker.filterFields ?? []).map((name) => table?.fields?.[name]).filter((field): field is QFieldMetaData => Boolean(field)),
[picker.filterFields, table]
)
const activeFilterCount = Object.values(preferences.filters).filter(Boolean).length
/**
* Load a page: the first page replaces the results, later pages append to them.
*
* @param loaded - The options already shown ([] for the first page).
*/
const load = useCallback(async (loaded: QPossibleValue[]) => {
const current = ++sequence.current
setLoading(true)
setFailed(false)
try {
const page = await fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, {
searchTerm: term || undefined,
formValues,
picker: pickerPageRequest(picker, preferences, table?.fields, table?.primaryKeyField, loaded),
})
if (current !== sequence.current) return
const seen = new Set(loaded.map((option) => String(option.id)))
setOptions([...loaded, ...page.options.filter((option) => !seen.has(String(option.id)))])
setHasMore(page.hasMore)
} catch {
if (current === sequence.current) setFailed(true)
} finally {
if (current === sequence.current) setLoading(false)
}
}, [context, fieldName, possibleValueSourceName, term, formValues, picker, preferences, table])
// a new search, filter or ordering starts over (after the table's fields are known)
useEffect(() => {
if (pickerTableName && !table) return
setActive(0)
void load([])
}, [term, preferences.filters, preferences.sortIndex, table]) // eslint-disable-line react-hooks/exhaustive-deps
// the current selection, looked up once so it shows as chosen with its thumbnail
useEffect(() => {
if (!selectedKey) return
let cancelled = false
fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ids: selectedKey, formValues, picker: {} })
.then((page) => { if (!cancelled) setChosen(page.options) })
.catch(() => { /* the results still show the selection */ })
return () => { cancelled = true }
}, [selectedKey]) // eslint-disable-line react-hooks/exhaustive-deps
// infinite scroll: load the next page when the end of the results comes into view
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || !hasMore || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && !loading) void load(options)
}, { root: resultsRef.current, rootMargin: '200px' })
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMore, loading, options, load])
const isChosen = (option: QPossibleValue) => chosen.some((item) => String(item.id) === String(option.id))
const focused = options[active] ?? null
const previewOption = options.find((option) => String(option.id) === previewId) ?? chosen[0] ?? options[0] ?? null
/**
* Choose an option: replace the choice, or toggle it in multiple selection.
*
* @param option - The option.
*/
const choose = (option: QPossibleValue) => {
if (!multiple) setChosen([option])
else setChosen((current) => (current.some((item) => String(item.id) === String(option.id))
? current.filter((item) => String(item.id) !== String(option.id))
: [...current, option]))
}
const confirm = (option?: QPossibleValue) => {
const result = option && !multiple ? [option] : chosen
if (!result.length) return
onConfirm(result)
}
/**
* How many results one grid row holds (from the laid-out options), for Up/Down.
*
* @returns The column count (1 in the list).
*/
const columns = () => {
if (preferences.view === 'list') return 1
const items = resultsRef.current?.querySelectorAll<HTMLElement>('[role="option"]')
if (!items || items.length < 2) return 1
const top = items[0].offsetTop
let count = 0
for (const item of Array.from(items)) {
if (item.offsetTop !== top) break
count += 1
}
return Math.max(1, count)
}
const moveTo = (index: number) => {
const next = Math.max(0, Math.min(options.length - 1, index))
setActive(next)
resultsRef.current?.querySelector<HTMLElement>(`[data-index="${next}"]`)?.focus()
if (next >= options.length - 1 && hasMore && !loading) void load(options)
}
const handleResultsKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
if (!options.length) return
let handled = true
if (event.key === 'ArrowRight') moveTo(active + 1)
else if (event.key === 'ArrowLeft') moveTo(active - 1)
else if (event.key === 'ArrowDown') moveTo(active + columns())
else if (event.key === 'ArrowUp') moveTo(active - columns())
else if (event.key === 'Home') moveTo(0)
else if (event.key === 'End') moveTo(options.length - 1)
else if (event.key === ' ' && focused) choose(focused)
else if (event.key === 'Enter' && focused) {
if (multiple) choose(focused)
else confirm(focused)
} else handled = false
if (handled) event.preventDefault()
}
const setFilter = (name: string, value: PickerFilterValue | undefined) => {
setPreferences((current) => {
const filters = { ...current.filters }
if (value) filters[name] = value
else delete filters[name]
return { ...current, filters }
})
}
/**
* Select the record an upload created, read from the upload process's result value.
*
* @param values - The completed process's values.
*/
const handleUploaded = async (values: Record<string, unknown>) => {
const id = picker.uploadResultIdField ? values[picker.uploadResultIdField] : undefined
setUploading(false)
if (id === undefined || id === null || id === '') return
try {
const page = await fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ids: String(id), formValues, picker: {} })
const created = page.options[0]
if (!created) return
setOptions((current) => [created, ...current.filter((option) => String(option.id) !== String(created.id))])
setActive(0)
choose(created)
} catch {
/* the upload succeeded; it is found by searching */
}
}
const thumbnail = (option: QPossibleValue, size: string) => {
const url = pickerThumbnailUrl(option, picker)
return url
// eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer
? <img src={url} alt="" loading="lazy" decoding="async" className={cn(size, 'rounded object-cover bg-muted')} />
: <span aria-hidden="true" className={cn(size, 'rounded bg-muted')} />
}
const previewUrl = pickerPreviewUrl(previewOption, picker)
return (
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose() }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[2000] bg-black/80" />
<Dialog.Content
onCloseAutoFocus={restoreFocus}
aria-describedby={undefined}
className="fixed inset-0 z-[2001] flex flex-col overflow-hidden border border-border bg-card text-foreground shadow-xl focus:outline-none sm:inset-6 sm:rounded-lg"
data-qqq-id="record-picker-dialog"
data-multiple={multiple || undefined}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
<Dialog.Title className="min-w-0 flex-1 truncate text-sm font-semibold">{`Choose ${label}`}</Dialog.Title>
{uploadProcess?.hasPermission && !uploading && (
<button type="button" onClick={() => setUploading(true)} className={buttonClass('secondary', 'sm')} data-qqq-id="record-picker-upload">
<Upload className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{uploadProcess.label || 'Upload new'}</span>
</button>
)}
<Dialog.Close className={iconButtonClass()} aria-label="Close" data-qqq-id="record-picker-close">
<X className="h-4 w-4" aria-hidden="true" />
</Dialog.Close>
</div>
{uploading && picker.uploadProcessName && uploadProcess ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3 sm:p-4" data-qqq-id="record-picker-upload-panel">
<button type="button" onClick={() => setUploading(false)} className={cn(buttonClass('ghost', 'sm'), 'mb-2')}>Back to the list</button>
<Suspense fallback={<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label="Loading" />}>
<ProcessRun processName={picker.uploadProcessName} processMetaData={uploadProcess} isEmbedded onComplete={(values) => { void handleUploaded(values) }} />
</Suspense>
</div>
) : (
<>
<div className="flex flex-wrap items-end gap-2 border-b border-border px-3 py-2 sm:px-4">
<label className="relative min-w-0 flex-1 basis-48">
<span className="sr-only">{`Search ${label}`}</span>
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search"
className={cn(INPUT_CLASS, 'pl-8')}
data-qqq-id="record-picker-search"
autoFocus
/>
</label>
{(picker.sorts?.length ?? 0) > 1 && (
<label className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="sr-only sm:not-sr-only">Sort</span>
<select
value={preferences.sortIndex}
onChange={(event) => setPreferences((current) => ({ ...current, sortIndex: Number(event.target.value) }))}
className={SELECT_CLASS}
aria-label="Sort"
data-qqq-id="record-picker-sort"
>
{picker.sorts!.map((sort, index) => <option key={sort.label} value={index}>{sort.label}</option>)}
</select>
</label>
)}
{filterFields.length > 0 && (
<button
type="button"
onClick={() => setShowFilters((shown) => !shown)}
aria-expanded={showFilters}
className={buttonClass('secondary', 'sm')}
data-qqq-id="record-picker-filters-toggle"
>
{activeFilterCount ? `Filters (${activeFilterCount})` : 'Filters'}
</button>
)}
<div className="flex" role="group" aria-label="View">
<button type="button" onClick={() => setPreferences((current) => ({ ...current, view: 'grid' }))} aria-pressed={preferences.view === 'grid'} aria-label="Grid view" className={cn(iconButtonClass(), preferences.view === 'grid' && 'bg-accent text-foreground')} data-qqq-id="record-picker-view-grid">
<Grid2x2 className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" onClick={() => setPreferences((current) => ({ ...current, view: 'list' }))} aria-pressed={preferences.view === 'list'} aria-label="List view" className={cn(iconButtonClass(), preferences.view === 'list' && 'bg-accent text-foreground')} data-qqq-id="record-picker-view-list">
<List className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
{showFilters && filterFields.length > 0 && pickerTableName && (
<div className="grid grid-cols-1 gap-2 border-b border-border px-3 py-2 sm:grid-cols-2 sm:px-4 lg:grid-cols-4" data-qqq-id="record-picker-filters">
{filterFields.map((field) => (
<PickerFilter key={field.name} field={field} value={preferences.filters[field.name]} tableName={pickerTableName} onChange={(value) => setFilter(field.name, value)} />
))}
{activeFilterCount > 0 && (
<button type="button" onClick={() => setPreferences((current) => ({ ...current, filters: {} }))} className={cn(buttonClass('ghost', 'sm'), 'self-end')}>Clear filters</button>
)}
</div>
)}
<div className="flex min-h-0 flex-1">
<div
ref={resultsRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto p-3 sm:p-4"
aria-busy={loading || undefined}
data-qqq-id="record-picker-results"
data-view={preferences.view}
>
{failed ? (
<p role="alert" className={ALERT_CLASS}>
Results could not be loaded.{' '}
<button type="button" className="underline" onClick={() => void load([])}>Try again</button>
</p>
) : !loading && options.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground" data-qqq-id="record-picker-empty">No matches.</p>
) : (
<ul
role="listbox"
aria-label={`${label} results`}
aria-multiselectable={multiple || undefined}
onKeyDown={handleResultsKeyDown}
className={preferences.view === 'grid' ? 'qqq-record-picker-grid grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'flex flex-col gap-1'}>
{options.map((option, index) => {
const selected = isChosen(option)
const subtitle = pickerSubtitle(option, picker)
return (
<li
key={String(option.id)}
role="option"
aria-selected={selected}
tabIndex={index === active ? 0 : -1}
data-index={index}
data-qqq-id="record-picker-option"
data-value={String(option.id)}
onClick={() => { setActive(index); setPreviewId(String(option.id)); choose(option) }}
onDoubleClick={() => confirm(option)}
onFocus={() => { setActive(index); setPreviewId(String(option.id)) }}
className={cn(
'relative cursor-pointer rounded-md border p-1.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
preferences.view === 'list' && 'flex items-center gap-3',
selected ? 'border-primary bg-accent' : 'border-border'
)}
>
{preferences.view === 'grid' ? thumbnail(option, 'block aspect-square w-full') : thumbnail(option, 'h-10 w-10 shrink-0')}
<span className={cn('block min-w-0', preferences.view === 'grid' && 'mt-1')}>
<span className="block truncate font-medium" title={option.label}>{option.label}</span>
{subtitle && <span className="block truncate text-xs text-muted-foreground">{subtitle}</span>}
</span>
{selected && (
<Check className="absolute right-2 top-2 h-4 w-4 rounded-full bg-primary text-primary-foreground" aria-hidden="true" />
)}
</li>
)
})}
</ul>
)}
<div ref={sentinelRef} aria-hidden="true" />
{loading && (
<p role="status" className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading...
</p>
)}
{hasMore && !loading && !failed && (
<div className="flex justify-center py-3">
<button type="button" onClick={() => void load(options)} className={buttonClass('secondary', 'sm')} data-qqq-id="record-picker-more">Load more</button>
</div>
)}
</div>
{previewOption && (
<aside className="hidden min-h-0 w-80 shrink-0 overflow-y-auto border-l border-border p-4 lg:block" aria-label="Preview" data-qqq-id="record-picker-preview">
{previewUrl && (
<button type="button" onClick={() => setPreviewing(previewOption)} className="block w-full rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`Preview ${previewOption.label} full size`}>
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
<img src={previewUrl} alt={previewOption.label} className="max-h-80 w-full rounded-md bg-muted object-contain" />
</button>
)}
<h3 className="mt-3 break-words text-sm font-semibold">{previewOption.label}</h3>
{(picker.detailFields ?? []).length > 0 && (
<dl className="mt-2 flex flex-col gap-1 text-xs">
{picker.detailFields!.map((name) => {
const text = pickerValueText(previewOption, name)
if (!text) return null
return (
<div key={name} className="flex gap-2">
<dt className="shrink-0 text-muted-foreground">{table?.fields?.[name]?.label ?? name}</dt>
<dd className="min-w-0 break-words">{text}</dd>
</div>
)
})}
</dl>
)}
<div className="mt-3 flex flex-wrap gap-2">
{previewUrl && (
<button type="button" onClick={() => setPreviewing(previewOption)} className={buttonClass('secondary', 'sm')}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
Full size
</button>
)}
{pickerTableName && (
<a href={pickerRecordPath(pickerTableName, previewOption.id)} target="_blank" rel="noopener noreferrer" className={buttonClass('secondary', 'sm')}>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
Open
</a>
)}
</div>
</aside>
)}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 sm:px-4">
<p className={cn(LABEL_CLASS, 'min-w-0 flex-1 truncate')} aria-live="polite" data-qqq-id="record-picker-selection">
{chosen.length === 0 ? 'Nothing selected' : multiple ? `${chosen.length} selected` : chosen[0].label}
</p>
{previewOption && previewUrl && (
<button type="button" onClick={() => setPreviewing(previewOption)} className={cn(buttonClass('secondary'), 'lg:hidden')} aria-label={`Preview ${previewOption.label} full size`}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
<Dialog.Close className={buttonClass('secondary')} data-qqq-id="record-picker-cancel">Cancel</Dialog.Close>
<button type="button" onClick={() => confirm()} disabled={chosen.length === 0} className={buttonClass('primary')} data-qqq-id="record-picker-select">
Select
</button>
</div>
</>
)}
{previewing && (
<Suspense fallback={null}>
<FilePreviewLightbox
items={[{
url: pickerPreviewUrl(previewing, picker) ?? '',
thumbnailUrl: pickerThumbnailUrl(previewing, picker),
fileName: previewing.label,
kind: 'image',
alt: previewing.label,
previewable: true,
thumbnailSize: 40,
}]}
index={0}
onIndexChange={() => { /* a single image */ }}
onClose={() => setPreviewing(null)}
/>
</Suspense>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
export default RecordPickerDialog
@@ -28,7 +28,7 @@ import type { QFieldMetaData, QRecord } from '@/types'
import type { FilePreviewItem } from '@/lib/utils/file-preview'
import { filePreviewItems } from '@/lib/utils/file-preview'
const FilePreviewLightbox = lazy(() => import('./FilePreviewLightbox'))
import { FilePreviewLightbox } from './FilePreviewLightboxLazy'
/** What a thumbnail asks of the gallery around it. */
interface FilePreviewGalleryContextValue {
@@ -0,0 +1,26 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file FilePreviewLightboxLazy — the one lazy import of the file preview lightbox. Every screen that
* can open it shares this single import site, so the lightbox is emitted once instead of once per
* screen that bundles it.
*/
import { lazy } from 'react'
/** The lightbox, loaded when a preview first opens. */
export const FilePreviewLightbox = lazy(() => import('./FilePreviewLightbox'))
@@ -0,0 +1,72 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for ProcessRun's completion callback (the record picker's upload-and-select)
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { QProcessMetaData } from '@/types'
const processState = { current: { phase: 'step', values: {} as Record<string, unknown>, steps: [], screenInstance: 1 } }
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn().mockResolvedValue({ apps: {}, appTree: [] }) }))
vi.mock('@/lib/hooks/use-metadata', () => ({ useTableMetaData: () => ({ data: undefined }) }))
vi.mock('@/lib/hooks/use-process', () => ({
useProcess: () => ({ state: processState.current, start: vi.fn(), submit: vi.fn(), back: vi.fn(), cancel: vi.fn() }),
}))
vi.mock('./ProcessStepScreen', () => ({ ProcessStepScreen: () => <p>step</p> }))
vi.mock('./ProcessResultStep', () => ({ ProcessResultStep: () => <p>done</p> }))
import { ProcessRun } from './ProcessRun'
const PROCESS = { name: 'uploadImage', label: 'Upload image', tableName: 'media', stepFlow: 'LINEAR', frontendSteps: [] } as unknown as QProcessMetaData
/**
* Render an embedded run with a completion callback.
* @param onComplete - The callback.
* @returns The rerender function.
*/
function renderRun(onComplete: (values: Record<string, unknown>) => void) {
const client = new QueryClient()
const view = () => (
<QueryClientProvider client={client}>
<ProcessRun processName="uploadImage" processMetaData={PROCESS} isEmbedded onComplete={onComplete} />
</QueryClientProvider>
)
const result = render(view())
return () => result.rerender(view())
}
describe('ProcessRun onComplete', () => {
it('reports the final values once, when the run completes', () => {
const onComplete = vi.fn()
processState.current = { phase: 'step', values: {}, steps: [], screenInstance: 1 }
const rerender = renderRun(onComplete)
expect(onComplete).not.toHaveBeenCalled()
processState.current = { phase: 'complete', values: { mediaId: 42 }, steps: [], screenInstance: 2 }
rerender()
expect(onComplete).toHaveBeenCalledTimes(1)
expect(onComplete).toHaveBeenCalledWith({ mediaId: 42 })
rerender()
expect(onComplete).toHaveBeenCalledTimes(1)
})
})
+10
View File
@@ -63,6 +63,8 @@ export interface ProcessRunProps {
* stepper, no Cancel or Close, and Return starts a fresh run in place instead of leaving the page.
*/
isEmbedded?: boolean
/** Called once, with the process's final values, when the run completes (e.g. a picker selects an uploaded record). */
onComplete?: (values: Record<string, unknown>) => void
/** Additional CSS class names applied to the root container. */
className?: string
}
@@ -149,6 +151,7 @@ export function ProcessRun({
initialRequest,
returnTo,
isEmbedded = false,
onComplete,
className,
}: ProcessRunProps) {
const router = useRouter()
@@ -178,6 +181,13 @@ export function ProcessRun({
start()
}, [boundsMessage, start])
const completedRef = useRef(false)
useEffect(() => {
if (state.phase !== 'complete' || completedRef.current) return
completedRef.current = true
onComplete?.(state.values)
}, [state.phase, state.values, onComplete])
//////////////////////////////////////////////////////////////////
// move focus to the new screen's heading (WCAG 2.4.3), not on //
// the first screen //
+25
View File
@@ -0,0 +1,25 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file ProcessRunLazy — the one lazy import of the process runner. The process route and the record
* picker's upload share this single import site, so the runner is emitted once.
*/
import { lazy } from 'react'
/** The process runner, loaded when a process first runs. */
export const ProcessRun = lazy(() => import('./ProcessRun').then((module) => ({ default: module.ProcessRun })))
+89 -8
View File
@@ -20,9 +20,34 @@
import { z } from 'zod'
import type { QPossibleValue } from '@/types'
import type { QPossibleValue, QQueryFilter } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import apiClient from './client'
/**
* A record picker page request (the v1 body's `picker` object), for a field whose possible value
* source declares a picker. The server allows criteria only on the picker's filter fields and
* orderings only on its sort fields (or the primary key).
*/
export interface PickerPageRequest {
/** Criteria (`AND`ed with the field's own filter) and the ordering; `skip`/`limit` are ignored. */
filter?: Pick<QQueryFilter, 'criteria' | 'orderBys'> & { booleanOperator?: 'AND' | 'OR' }
/** Keyset paging: the last option id of the previous page (ordering by the primary key alone). */
afterId?: string | number
/** Offset paging, for other orderings. */
skip?: number
/** Options per page (default: the picker's page size). */
limit?: number
}
/** A page of picker options and whether another follows (the picker never counts). */
export interface PossibleValuesPage {
/** The options, each with its picker display values. */
options: QPossibleValue[]
/** Whether another page follows. */
hasMore: boolean
}
/**
* Common request parameters accepted by all possible-values endpoints.
*
@@ -64,20 +89,34 @@ export interface PossibleValuesRequest {
* Distinct from `values`/`ids`, which look choices up by identity.
*/
formValues?: Record<string, unknown>
/** A record picker request: options carry display values and the response says whether more follow. */
picker?: PickerPageRequest
}
/** Validate the native envelope before exposing options to a picker. */
const possibleValuesResponse = z.union([z.object({
options: z.array(z.object({ id: z.union([z.string(), z.number().finite()]), label: z.string() }).passthrough()),
}), z.object({}).strict().transform(() => ({ options: [] }))])
/**
* Validate the native envelope before exposing options to a picker. Jackson omits an empty
* `options` list, so `{}` (or `{ hasMore: false }` for a picker request) means no options.
*/
const possibleValuesResponse = z.union([
z.object({
options: z.array(z.object({
id: z.union([z.string(), z.number().finite()]),
label: z.string(),
values: z.record(z.unknown()).optional(),
displayValues: z.record(z.string()).optional(),
}).passthrough()),
hasMore: z.boolean().optional(),
}),
z.object({ hasMore: z.boolean().optional() }).strict().transform((empty) => ({ options: [], hasMore: empty.hasMore })),
])
/**
* Search a possible-value source through its v1 route (`POST`, JSON body).
* @param url - v1 route relative to the API base URL.
* @param request - Search text, identifiers, form values and field context.
* @returns Validated native options; rejects HTTP and response-format failures.
* @returns Validated native options and whether more follow; rejects HTTP and response-format failures.
*/
async function loadPossibleValues(url: string, request: PossibleValuesRequest): Promise<QPossibleValue[]> {
async function loadPossibleValuesPage(url: string, request: PossibleValuesRequest): Promise<PossibleValuesPage> {
const { searchTerm, useCase } = request
const ids = request.ids ?? request.values
const body: Record<string, unknown> = {}
@@ -87,8 +126,50 @@ async function loadPossibleValues(url: string, request: PossibleValuesRequest):
else if (request.labels) body.labels = request.labels.split(',')
if (useCase) body.useCase = useCase
if (request.formValues) body.values = request.formValues
if (request.picker) body.picker = request.picker
const result = await apiClient.post<unknown>(url, body)
return possibleValuesResponse.parse(result).options
const page = possibleValuesResponse.parse(result)
return { options: page.options as QPossibleValue[], hasMore: page.hasMore ?? false }
}
/**
* Search a possible-value source through its v1 route, returning the options only.
* @param url - v1 route relative to the API base URL.
* @param request - Search text, identifiers, form values and field context.
* @returns Validated native options.
*/
async function loadPossibleValues(url: string, request: PossibleValuesRequest): Promise<QPossibleValue[]> {
return (await loadPossibleValuesPage(url, request)).options
}
/**
* The v1 possible-values route of a field: a table or process field, or a standalone source.
* @param context - Where the field is (table, process or standalone).
* @param fieldName - Exact field identifier.
* @param possibleValueSourceName - Source name for a standalone lookup (defaults to the field name).
* @returns The route relative to the API base URL.
*/
export function possibleValuesUrl(context: PossibleValueContext, fieldName: string, possibleValueSourceName?: string): string {
if (context.type === 'table') return `/table/${encodeURIComponent(context.tableName)}/possibleValues/${encodeURIComponent(fieldName)}`
if (context.type === 'process') return `/processes/${encodeURIComponent(context.processName)}/possibleValues/${encodeURIComponent(fieldName)}`
return `/possibleValues/${encodeURIComponent(possibleValueSourceName ?? fieldName)}`
}
/**
* Load a page of a field's choices with the record picker (options with display values).
* @param context - Where the field is (table, process or standalone).
* @param fieldName - Exact field identifier.
* @param possibleValueSourceName - Source name for a standalone lookup.
* @param request - Search, ids, form values and the picker page request.
* @returns The options and whether another page follows.
*/
export async function fetchPossibleValuesPage(
context: PossibleValueContext,
fieldName: string,
possibleValueSourceName: string | undefined,
request: PossibleValuesRequest & { picker: PickerPageRequest }
): Promise<PossibleValuesPage> {
return loadPossibleValuesPage(possibleValuesUrl(context, fieldName, possibleValueSourceName), request)
}
/**
+166
View File
@@ -0,0 +1,166 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file record-picker-query — the record picker dialog's filter controls, the filter and ordering
* a picker page sends (keyset or offset paging), and the per-field preferences remembered in local
* storage. Loaded with the dialog only.
*/
import type { QFieldMetaData, QFilterCriteria, QPossibleValuePicker, QPossibleValue } from '@/types'
import type { PickerPageRequest } from '@/lib/api/possible-values'
/** One filter control's value: an exact value, a text, or a from/to range. */
export interface PickerFilterValue {
/** Exact value (a possible value id, `true`/`false`), or the text a string field contains. */
value?: string
/** Range start (a number, or a `yyyy-mm-dd` date). */
from?: string
/** Range end (a number, or a `yyyy-mm-dd` date). */
to?: string
}
/** What the picker remembers per field: filters, ordering and view. */
export interface PickerPreferences {
/** Filter values by field name. */
filters: Record<string, PickerFilterValue>
/** Index into the picker's sorts. */
sortIndex: number
/** Thumbnail grid or list. */
view: 'grid' | 'list'
}
/** The preferences of a field the picker has not seen. */
export const DEFAULT_PICKER_PREFERENCES: PickerPreferences = { filters: {}, sortIndex: 0, view: 'grid' }
/** How a filter field is edited: a choice, a text, or a range. */
export type PickerFilterKind = 'choice' | 'boolean' | 'text' | 'number' | 'date'
/**
* The control kind of a picker filter field.
* @param field - The field (of the picker's table).
* @returns `choice` for a possible-value field, else by type.
*/
export function pickerFilterKind(field: Pick<QFieldMetaData, 'type' | 'possibleValueSourceName'>): PickerFilterKind {
if (field.possibleValueSourceName) return 'choice'
if (field.type === 'BOOLEAN') return 'boolean'
if (field.type === 'INTEGER' || field.type === 'LONG' || field.type === 'DECIMAL') return 'number'
if (field.type === 'DATE' || field.type === 'DATE_TIME') return 'date'
return 'text'
}
/**
* The criteria of the filter values, by each field's control kind.
* @param filters - Filter values by field name.
* @param fields - The picker table's fields, by name.
* @returns The criteria (`AND`ed).
*/
export function pickerCriteria(filters: Record<string, PickerFilterValue>, fields: Record<string, QFieldMetaData> | undefined): QFilterCriteria[] {
const criteria: QFilterCriteria[] = []
for (const [fieldName, filter] of Object.entries(filters)) {
const field = fields?.[fieldName]
if (!field || !filter) continue
const kind = pickerFilterKind(field)
if (kind === 'choice' || kind === 'boolean') {
if (filter.value === undefined || filter.value === '') continue
criteria.push({ fieldName, operator: 'EQUALS', values: [kind === 'boolean' ? filter.value === 'true' : filter.value] })
} else if (kind === 'text') {
if (filter.value?.trim()) criteria.push({ fieldName, operator: 'CONTAINS', values: [filter.value.trim()] })
} else {
const bound = (value: string | undefined, end: boolean) => {
if (!value) return undefined
if (kind === 'number') return Number(value)
return field.type === 'DATE_TIME' ? `${value}T${end ? '23:59:59' : '00:00:00'}Z` : value
}
const from = bound(filter.from, false)
const to = bound(filter.to, true)
if (from !== undefined && !(typeof from === 'number' && Number.isNaN(from))) criteria.push({ fieldName, operator: 'GREATER_THAN_OR_EQUALS', values: [from] })
if (to !== undefined && !(typeof to === 'number' && Number.isNaN(to))) criteria.push({ fieldName, operator: 'LESS_THAN_OR_EQUALS', values: [to] })
}
}
return criteria
}
/**
* The picker page request for the filters and ordering, continuing after the options loaded so
* far: by keyset (the last id) when ordering by the primary key alone, else by offset.
* @param picker - The picker.
* @param preferences - Filters and the chosen ordering.
* @param fields - The picker table's fields.
* @param primaryKey - The picker table's primary key field.
* @param loaded - The options already loaded (empty for the first page).
* @returns The request.
*/
export function pickerPageRequest(
picker: QPossibleValuePicker,
preferences: PickerPreferences,
fields: Record<string, QFieldMetaData> | undefined,
primaryKey: string | undefined,
loaded: QPossibleValue[]
): PickerPageRequest {
const sort = picker.sorts?.[preferences.sortIndex] ?? picker.sorts?.[0]
const orderBys = sort ? [{ fieldName: sort.fieldName, isAscending: sort.isAscending }] : undefined
const request: PickerPageRequest = {
filter: { criteria: pickerCriteria(preferences.filters, fields), booleanOperator: 'AND', ...(orderBys ? { orderBys } : {}) },
}
if (loaded.length) {
if (sort && primaryKey && sort.fieldName === primaryKey) request.afterId = loaded[loaded.length - 1].id
else request.skip = loaded.length
}
return request
}
/**
* The local storage key of a field's picker preferences.
* @param tableName - The picker's table.
* @param fieldName - The field.
* @returns The key.
*/
export function pickerStorageKey(tableName: string | undefined, fieldName: string): string {
return `qqq-picker-${tableName ?? ''}-${fieldName}`
}
/**
* The remembered preferences of a field (defaults when none, or storage is unavailable).
* @param key - {@link pickerStorageKey}.
* @returns The preferences.
*/
export function readPickerPreferences(key: string): PickerPreferences {
try {
const stored = JSON.parse(localStorage.getItem(key) ?? 'null') as Partial<PickerPreferences> | null
if (!stored || typeof stored !== 'object') return DEFAULT_PICKER_PREFERENCES
return {
filters: stored.filters && typeof stored.filters === 'object' ? stored.filters : {},
sortIndex: Number.isInteger(stored.sortIndex) ? stored.sortIndex! : 0,
view: stored.view === 'list' ? 'list' : 'grid',
}
} catch {
return DEFAULT_PICKER_PREFERENCES
}
}
/**
* Remember a field's preferences (ignored when storage is unavailable).
* @param key - {@link pickerStorageKey}.
* @param preferences - The preferences.
*/
export function writePickerPreferences(key: string, preferences: PickerPreferences): void {
try {
localStorage.setItem(key, JSON.stringify(preferences))
} catch {
/* private mode or blocked storage: the picker still works */
}
}
+155
View File
@@ -0,0 +1,155 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the record picker helpers
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { QFieldMetaData, QPossibleValuePicker } from '@/types'
import {
DEFAULT_PICKER_PREFERENCES,
pickerCriteria,
pickerFilterKind,
pickerPageRequest,
pickerStorageKey,
readPickerPreferences,
writePickerPreferences,
} from './record-picker-query'
import { pickerPreviewUrl, pickerRecordPath, pickerSubtitle, pickerThumbnailUrl } from './record-picker'
const field = (name: string, type: QFieldMetaData['type'], extra: Partial<QFieldMetaData> = {}): QFieldMetaData =>
({ name, label: name, type, isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...extra })
const FIELDS: Record<string, QFieldMetaData> = {
id: field('id', 'LONG'),
mediaTypeId: field('mediaTypeId', 'LONG', { possibleValueSourceName: 'mediaType' }),
isActive: field('isActive', 'BOOLEAN'),
mimeType: field('mimeType', 'STRING'),
width: field('width', 'INTEGER'),
createDate: field('createDate', 'DATE_TIME'),
takenOn: field('takenOn', 'DATE'),
}
const PICKER: QPossibleValuePicker = {
thumbnailField: 'thumbnailUrl',
previewField: 'galleryUrl',
subtitleFields: ['mediaTypeId', 'width'],
sorts: [{ label: 'Newest', fieldName: 'id', isAscending: false }, { label: 'Name', fieldName: 'name', isAscending: true }],
}
afterEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('pickerFilterKind', () => {
it('chooses a control by possible values, then type', () => {
expect(pickerFilterKind(FIELDS.mediaTypeId)).toBe('choice')
expect(pickerFilterKind(FIELDS.isActive)).toBe('boolean')
expect(pickerFilterKind(FIELDS.mimeType)).toBe('text')
expect(pickerFilterKind(FIELDS.width)).toBe('number')
expect(pickerFilterKind(FIELDS.createDate)).toBe('date')
})
})
describe('pickerCriteria', () => {
it('builds equality, contains and inclusive range criteria, skipping empty filters', () => {
const criteria = pickerCriteria({
mediaTypeId: { value: '3' },
isActive: { value: 'false' },
mimeType: { value: ' png ' },
width: { from: '800', to: 'x' },
createDate: { from: '2026-01-01', to: '2026-01-31' },
takenOn: { to: '2026-02-01' },
unknown: { value: 'ignored' },
}, FIELDS)
expect(criteria).toEqual([
{ fieldName: 'mediaTypeId', operator: 'EQUALS', values: ['3'] },
{ fieldName: 'isActive', operator: 'EQUALS', values: [false] },
{ fieldName: 'mimeType', operator: 'CONTAINS', values: ['png'] },
{ fieldName: 'width', operator: 'GREATER_THAN_OR_EQUALS', values: [800] },
{ fieldName: 'createDate', operator: 'GREATER_THAN_OR_EQUALS', values: ['2026-01-01T00:00:00Z'] },
{ fieldName: 'createDate', operator: 'LESS_THAN_OR_EQUALS', values: ['2026-01-31T23:59:59Z'] },
{ fieldName: 'takenOn', operator: 'LESS_THAN_OR_EQUALS', values: ['2026-02-01'] },
])
expect(pickerCriteria({ mimeType: { value: ' ' }, mediaTypeId: { value: '' } }, FIELDS)).toEqual([])
})
})
describe('pickerPageRequest', () => {
const options = [{ id: 9, label: 'a' }, { id: 7, label: 'b' }]
it('pages a primary-key ordering by keyset, after the last id', () => {
const first = pickerPageRequest(PICKER, DEFAULT_PICKER_PREFERENCES, FIELDS, 'id', [])
expect(first).toEqual({ filter: { criteria: [], booleanOperator: 'AND', orderBys: [{ fieldName: 'id', isAscending: false }] } })
expect(pickerPageRequest(PICKER, DEFAULT_PICKER_PREFERENCES, FIELDS, 'id', options).afterId).toBe(7)
})
it('pages other orderings by offset', () => {
const request = pickerPageRequest(PICKER, { ...DEFAULT_PICKER_PREFERENCES, sortIndex: 1 }, FIELDS, 'id', options)
expect(request.filter?.orderBys).toEqual([{ fieldName: 'name', isAscending: true }])
expect(request.skip).toBe(2)
expect(request.afterId).toBeUndefined()
})
it('falls back to the first ordering for a stale remembered index', () => {
expect(pickerPageRequest(PICKER, { ...DEFAULT_PICKER_PREFERENCES, sortIndex: 7 }, FIELDS, 'id', []).filter?.orderBys?.[0].fieldName).toBe('id')
})
})
describe('option display', () => {
const option = {
id: 1,
label: 'Harbor',
values: { thumbnailUrl: '/t/harbor.jpg', galleryUrl: 'javascript:alert(1)', mediaTypeId: 3, width: 1200 },
displayValues: { mediaTypeId: 'Photo', width: '1,200' },
}
it('uses safe thumbnail URLs and falls back from an unsafe preview URL', () => {
expect(pickerThumbnailUrl(option, PICKER)).toBe('/t/harbor.jpg')
expect(pickerPreviewUrl(option, PICKER)).toBe('/t/harbor.jpg')
expect(pickerThumbnailUrl({ id: 2, label: 'x', values: { thumbnailUrl: 'javascript:alert(1)' } }, PICKER)).toBeUndefined()
expect(pickerThumbnailUrl(option, undefined)).toBeUndefined()
})
it('joins formatted subtitle values', () => {
expect(pickerSubtitle(option, PICKER)).toBe('Photo · 1,200')
expect(pickerSubtitle({ id: 3, label: 'y' }, PICKER)).toBe('')
})
it('links to the record view', () => {
expect(pickerRecordPath('media', 'a/b')).toBe('/app/media/a%2Fb')
})
})
describe('preferences', () => {
it('round-trips through local storage per table and field', () => {
const key = pickerStorageKey('media', 'featuredMediaId')
expect(readPickerPreferences(key)).toEqual(DEFAULT_PICKER_PREFERENCES)
writePickerPreferences(key, { filters: { isActive: { value: 'true' } }, sortIndex: 1, view: 'list' })
expect(readPickerPreferences(key)).toEqual({ filters: { isActive: { value: 'true' } }, sortIndex: 1, view: 'list' })
expect(readPickerPreferences(pickerStorageKey('media', 'otherField'))).toEqual(DEFAULT_PICKER_PREFERENCES)
})
it('survives corrupt entries and unavailable storage', () => {
localStorage.setItem('k', '{not json')
expect(readPickerPreferences('k')).toEqual(DEFAULT_PICKER_PREFERENCES)
localStorage.setItem('k', JSON.stringify({ filters: 'x', sortIndex: 'y', view: 'z' }))
expect(readPickerPreferences('k')).toEqual(DEFAULT_PICKER_PREFERENCES)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked') })
expect(() => writePickerPreferences('k', DEFAULT_PICKER_PREFERENCES)).not.toThrow()
})
})
+79
View File
@@ -0,0 +1,79 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file record-picker — display helpers of the record picker (a possible value source that
* declares a `picker`): option thumbnails, subtitles and record links. The dialog's query and
* preference helpers are in record-picker-query (loaded with the dialog).
*/
import type { QPossibleValue, QPossibleValuePicker } from '@/types'
import { safePreviewUrl } from './file-preview'
/**
* The thumbnail URL of an option, when its thumbnail field holds a safe URL.
* @param option - A picker option.
* @param picker - The picker.
* @returns The URL, or `undefined`.
*/
export function pickerThumbnailUrl(option: QPossibleValue | null | undefined, picker: QPossibleValuePicker | undefined): string | undefined {
if (!option || !picker?.thumbnailField) return undefined
return safePreviewUrl(option.values?.[picker.thumbnailField])
}
/**
* The large preview URL of an option (its preview field, else its thumbnail).
* @param option - A picker option.
* @param picker - The picker.
* @returns The URL, or `undefined`.
*/
export function pickerPreviewUrl(option: QPossibleValue | null | undefined, picker: QPossibleValuePicker | undefined): string | undefined {
if (!option || !picker) return undefined
return (picker.previewField ? safePreviewUrl(option.values?.[picker.previewField]) : undefined) ?? pickerThumbnailUrl(option, picker)
}
/**
* The display text of one picker value of an option (formatted where the server formatted it).
* @param option - A picker option.
* @param fieldName - One of the picker's display fields.
* @returns The text, or an empty string.
*/
export function pickerValueText(option: QPossibleValue, fieldName: string): string {
const display = option.displayValues?.[fieldName]
if (display !== undefined && display !== null) return display
const raw = option.values?.[fieldName]
return raw === null || raw === undefined ? '' : String(raw)
}
/**
* The subtitle of an option: its subtitle fields' texts, joined.
* @param option - A picker option.
* @param picker - The picker.
* @returns The subtitle, or an empty string.
*/
export function pickerSubtitle(option: QPossibleValue, picker: QPossibleValuePicker | undefined): string {
return (picker?.subtitleFields ?? []).map((field) => pickerValueText(option, field)).filter(Boolean).join(' · ')
}
/**
* The record view path of an option, opened from the picker.
* @param tableName - The picker's table.
* @param id - The option id.
* @returns `/app/{tableName}/{id}`.
*/
export function pickerRecordPath(tableName: string, id: string | number): string {
return `/app/${encodeURIComponent(tableName)}/${encodeURIComponent(String(id))}`
}
+1
View File
@@ -42,6 +42,7 @@ export type {
QThemeMetaData,
QTableMetaData,
QFieldMetaData,
QPossibleValuePicker,
QVirtualFieldMetaData,
QProcessMetaData,
QFrontendStepMetaData,
+35
View File
@@ -405,6 +405,13 @@ export interface QFieldMetaData {
* combined with `possibleValueSourceName`). Searched client-side by label prefix, as in Material.
*/
inlinePossibleValueSource?: { enumValues?: Array<{ id: string | number; label: string }> }
/**
* The record picker declared by the field's possible value source (sources over large tables,
* e.g. 100,000+ media): forms offer a searchable, filterable, paged picker dialog.
*/
picker?: QPossibleValuePicker
/** The table the picker reads (its field labels and types describe the picker's fields). */
pickerTableName?: string
/** A printf-style or date-format string used when rendering the field value. */
displayFormat?: string
/** Maximum character length enforced during validation (for STRING/TEXT fields). */
@@ -430,6 +437,34 @@ export interface QFieldMetaData {
supplementalFieldMetaData?: Record<string, unknown>
}
/**
* A record picker declared on a table possible value source (backend `QPossibleValuePicker`).
* Every field named here belongs to {@link QFieldMetaData.pickerTableName}; the possible-values API
* filters, sorts and returns values only for these fields.
*/
export interface QPossibleValuePicker {
/** Image URL field shown for each option and the selection. */
thumbnailField?: string
/** Larger image URL field for the preview pane (else the thumbnail). */
previewField?: string
/** Short secondary text under each option label. */
subtitleFields?: string[]
/** Fields listed in the preview pane (e.g. dimensions, size, alt text). */
detailFields?: string[]
/** Fields offered as filters. */
filterFields?: string[]
/** Fields matched by the search box (CONTAINS). */
searchFields?: string[]
/** Offered orderings; the first is the default. A primary-key sort pages by keyset. */
sorts?: Array<{ label: string; fieldName: string; isAscending: boolean }>
/** Options per page. */
pageSize?: number
/** A process the picker can run to add a record (e.g. upload an image). */
uploadProcessName?: string
/** The output value of that process holding the new record's id, which is then selected. */
uploadResultIdField?: string
}
/**
* A virtual (computed, not stored) field of a table (v1 `VirtualFieldMetaData`).
*/
+4
View File
@@ -56,6 +56,10 @@ export interface QPossibleValue {
id: number | string
/** Human-readable text displayed to the user in autocomplete and select controls. */
label: string
/** For a record picker request: the option's picker display values (thumbnail, subtitle, details), raw. */
values?: Record<string, unknown>
/** For a record picker request: formatted or translated values, where they differ from the raw ones. */
displayValues?: Record<string, string>
}
/** A single field change within an audit entry */
+300
View File
@@ -0,0 +1,300 @@
// E2E: the record picker for a possible-value field whose source declares a picker (large
// tables such as media): open, search, filter, page, select, save, and the saved value persists.
// Against mocked v1 routes that behave like the backend (keyset pages, allow-listed filters).
import { test, expect, type Page, type Route } from '@playwright/test'
import { METADATA, setupApiMocks } from './api-mocks'
import { checkA11y } from './a11y-helpers'
// a 2x2 PNG, served for every /media-fixtures/*.png request
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP4z8DwnwEMGBgYGBgYAAAg6AHvOnTvPAAAAABJRU5ErkJggg==', 'base64')
const field = (name: string, label: string, type: string, extra: object = {}) =>
({ name, label, type, isEditable: true, isRequired: false, isHeavy: false, isHidden: false, adornments: [], ...extra })
const PICKER = {
thumbnailField: 'thumbnailUrl',
previewField: 'galleryUrl',
subtitleFields: ['mediaTypeId', 'dimensions'],
detailFields: ['dimensions', 'fileSize', 'altText'],
filterFields: ['mediaTypeId', 'width'],
searchFields: ['name'],
sorts: [{ label: 'Newest', fieldName: 'id', isAscending: false }, { label: 'Name', fieldName: 'name', isAscending: true }],
pageSize: 24,
}
const MEDIA_TABLE = {
name: 'media',
label: 'Media',
isHidden: false,
primaryKeyField: 'id',
fields: {
id: field('id', 'ID', 'LONG', { isEditable: false }),
name: field('name', 'Name', 'STRING'),
mediaTypeId: field('mediaTypeId', 'Media Type', 'LONG', { possibleValueSourceName: 'mediaType' }),
width: field('width', 'Width', 'INTEGER'),
dimensions: field('dimensions', 'Dimensions', 'STRING'),
fileSize: field('fileSize', 'File Size', 'LONG'),
altText: field('altText', 'Alt Text', 'STRING'),
thumbnailUrl: field('thumbnailUrl', 'Thumbnail URL', 'STRING'),
galleryUrl: field('galleryUrl', 'Gallery URL', 'STRING'),
},
sections: [{ name: 'media', label: 'Media', tier: 'T1', fieldNames: ['id', 'name', 'mediaTypeId', 'width'], isHidden: false }],
exposedJoins: [],
capabilities: ['TABLE_QUERY', 'TABLE_GET'],
readPermission: true, insertPermission: false, editPermission: false, deletePermission: false,
usesVariants: false, variantTableLabel: '',
}
const BLOG_POST_TABLE = {
name: 'blog_post',
label: 'Blog Post',
isHidden: false,
primaryKeyField: 'id',
fields: {
id: field('id', 'ID', 'LONG', { isEditable: false }),
title: field('title', 'Title', 'STRING', { maxLength: 100 }),
featuredMediaId: field('featuredMediaId', 'Featured Image', 'LONG', { possibleValueSourceName: 'media', picker: PICKER, pickerTableName: 'media' }),
},
sections: [{ name: 'content', label: 'Content', tier: 'T1', fieldNames: ['title', 'featuredMediaId'], isHidden: false }],
exposedJoins: [],
capabilities: ['TABLE_QUERY', 'TABLE_GET', 'TABLE_COUNT', 'TABLE_UPDATE'],
readPermission: true, insertPermission: true, editPermission: true, deletePermission: false,
usesVariants: false, variantTableLabel: '',
}
const TYPES = [{ id: 1, label: 'Photo' }, { id: 2, label: 'Logo' }]
const WORDS = ['Harbor', 'Sunset', 'Lighthouse', 'Forest', 'Desert', 'Canyon', 'Glacier', 'Meadow']
// 150 media records: enough for several keyset pages
const MEDIA = Array.from({ length: 150 }, (_, index) => {
const id = index + 1
const name = `${WORDS[index % WORDS.length]} ${id}`
const type = id % 5 === 0 ? 2 : 1
const width = 800 + (id % 4) * 400
return {
id, label: name,
values: { thumbnailUrl: `/media-fixtures/t-${id}.png`, galleryUrl: `/media-fixtures/g-${id}.png`, mediaTypeId: type, dimensions: `${width} × 800`, fileSize: 1000 * id, altText: `${name} alt`, width },
displayValues: { mediaTypeId: TYPES[type - 1].label, fileSize: `${id} KB` },
}
})
const INSTANCE = {
...METADATA,
tables: { ...METADATA.tables, media: MEDIA_TABLE, blog_post: BLOG_POST_TABLE },
apps: {
crm: {
...METADATA.apps.crm,
children: [...METADATA.apps.crm.children, { name: 'blog_post', label: 'Blog Posts', type: 'TABLE', iconName: 'article' }],
sections: [{ ...METADATA.apps.crm.sections[0], tables: ['person', 'blog_post'] }],
},
},
}
interface Criterion { fieldName: string; operator: string; values: unknown[] }
interface PickerBody { searchTerm?: string; ids?: string[]; picker?: { filter?: { criteria?: Criterion[]; orderBys?: Array<{ fieldName: string; isAscending: boolean }> }; afterId?: string | number; skip?: number; limit?: number } }
/**
* Answer a possible-values request for the image field as the backend does for a picker request.
* @param body - The request body.
* @returns The response body.
*/
function pickerResponse(body: PickerBody) {
const strip = (option: typeof MEDIA[number]) => body.picker ? option : { id: option.id, label: option.label }
if (body.ids?.length) return { options: MEDIA.filter((option) => body.ids!.includes(String(option.id))).map(strip) }
let rows = [...MEDIA]
const term = body.searchTerm?.toLowerCase()
if (term) rows = rows.filter((option) => option.label.toLowerCase().includes(term))
for (const criterion of body.picker?.filter?.criteria ?? []) {
if (!PICKER.filterFields.includes(criterion.fieldName)) throw new Error(`not a filter field: ${criterion.fieldName}`)
const value = (option: typeof MEDIA[number]) => Number(option.values[criterion.fieldName as 'width'])
if (criterion.operator === 'EQUALS') rows = rows.filter((option) => String(value(option)) === String(criterion.values[0]))
if (criterion.operator === 'GREATER_THAN_OR_EQUALS') rows = rows.filter((option) => value(option) >= Number(criterion.values[0]))
}
const order = body.picker?.filter?.orderBys?.[0] ?? { fieldName: 'id', isAscending: false }
rows.sort((a, b) => order.fieldName === 'name' ? a.label.localeCompare(b.label) : b.id - a.id)
if (body.picker?.afterId !== undefined) rows = rows.filter((option) => option.id < Number(body.picker!.afterId))
else rows = rows.slice(body.picker?.skip ?? 0)
const limit = body.picker?.limit ?? PICKER.pageSize
return { options: rows.slice(0, limit).map(strip), hasMore: body.picker ? rows.length > limit : undefined }
}
/**
* The values of a record update: a JSON body, or multipart parts (a `values` JSON part, or one
* part per field).
* @param body - The raw request body.
* @returns The values sent.
*/
function updateValues(body: string): Record<string, unknown> {
try {
const json = JSON.parse(body) as Record<string, unknown>
return (json.values ?? json) as Record<string, unknown>
} catch {
const parts = Object.fromEntries([...body.matchAll(/name="([^"]+)"\r\n(?:[^\r\n]+\r\n)*\r\n([\s\S]*?)\r\n--/g)].map((m) => [m[1], m[2]]))
if (parts.values) return updateValues(parts.values)
return parts
}
}
/**
* Mock the blog post (stateful: an update is what a later read returns), media and the picker.
* @param page - The page.
* @returns The bodies of the updates sent.
*/
async function setupPickerMocks(page: Page): Promise<Array<Record<string, unknown>>> {
await setupApiMocks(page)
const updates: Array<Record<string, unknown>> = []
const post = { id: 1, title: 'Harbor walk', featuredMediaId: 2 }
const postRecord = () => ({
tableName: 'blog_post', recordLabel: post.title,
values: { ...post },
displayValues: { id: '1', title: post.title, featuredMediaId: MEDIA.find((option) => option.id === post.featuredMediaId)?.label ?? '' },
})
const json = (route: Route, body: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => json(route, INSTANCE))
await page.route('**/qqq/v1/metaData/table/media**', (route) => json(route, MEDIA_TABLE))
await page.route('**/qqq/v1/metaData/table/blog_post**', (route) => json(route, BLOG_POST_TABLE))
await page.route(/\/qqq\/v1\/table\/blog_post\/1(\?|$)/, async (route) => {
const method = route.request().method()
if (method === 'PATCH' || method === 'PUT') {
const values = updateValues(route.request().postData() ?? '')
updates.push(values)
if (values.featuredMediaId !== undefined) post.featuredMediaId = Number(values.featuredMediaId)
if (typeof values.title === 'string') post.title = values.title
}
return json(route, { record: postRecord() })
})
await page.route('**/qqq/v1/table/blog_post/possibleValues/featuredMediaId**', (route) => json(route, pickerResponse(route.request().postDataJSON() as PickerBody)))
await page.route('**/qqq/v1/table/media/possibleValues/mediaTypeId**', (route) => json(route, { options: TYPES }))
await page.route('**/media-fixtures/**', (route) => route.fulfill({ contentType: 'image/png', body: PNG }))
return updates
}
/**
* Open the edit form of blog post 1 and wait for the image field.
* @param page - The page.
*/
async function openEditForm(page: Page) {
await page.goto('/app/blog_post/1/edit')
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
await expect(page.getByRole('combobox', { name: 'Featured Image' })).toContainText('Sunset 2', { timeout: 30000 })
}
test.describe('record picker', () => {
test('open, search, filter, page, select and save; the new image persists', async ({ page }) => {
const updates = await setupPickerMocks(page)
await openEditForm(page)
// the selection shows its thumbnail with Change, Preview and View
const combobox = page.getByRole('combobox', { name: 'Featured Image' })
await expect(combobox.locator('img')).toHaveAttribute('src', '/media-fixtures/t-2.png')
await expect(page.getByRole('link', { name: 'View' })).toHaveAttribute('href', '/app/media/2')
await page.getByRole('button', { name: 'Change…' }).click()
const dialog = page.getByRole('dialog', { name: 'Choose Featured Image' })
await expect(dialog).toBeVisible()
const results = dialog.getByRole('listbox', { name: 'Featured Image results' })
const options = results.getByRole('option')
// the first keyset page, newest first; the current image is not on it
await expect(options).toHaveCount(24)
await expect(options.first()).toContainText('Canyon 150')
await checkA11y(page)
// more pages by keyset as the list scrolls
await options.last().scrollIntoViewIfNeeded()
await expect(options).toHaveCount(48)
await expect(options.nth(24)).toContainText('126')
// search on the server (debounced)
await dialog.getByPlaceholder('Search').fill('lighthouse')
await expect(options.first()).toContainText('Lighthouse 147')
await expect(options).toHaveCount(19)
// filter by media type from the media table's own possible values
await dialog.getByRole('button', { name: 'Filters' }).click()
await dialog.getByLabel('Media Type').selectOption({ label: 'Logo' })
await expect(dialog.getByRole('button', { name: 'Filters (1)' })).toBeVisible()
await expect(options).toHaveCount(3)
await expect(options.first()).toContainText('Lighthouse 115')
// choose with the keyboard: focus the results, move right, confirm with Enter
await options.first().focus()
await page.keyboard.press('ArrowRight')
await expect(options.nth(1)).toBeFocused()
await expect(dialog.locator('[data-qqq-id="record-picker-preview"]')).toContainText('Lighthouse 75')
await page.keyboard.press('Enter')
await expect(dialog).toBeHidden()
await expect(combobox).toContainText('Lighthouse 75')
await expect(combobox.locator('img')).toHaveAttribute('src', '/media-fixtures/t-75.png')
await page.locator('[data-qqq-id="button-save"], button:has-text("Save")').first().click()
await expect.poll(() => updates.length).toBe(1)
expect(String(updates[0].featuredMediaId)).toBe('75')
// a fresh load of the edit form shows the saved image
await openEditFormAfterSave(page)
})
test('remembers the filters of the field and closes with Escape', async ({ page }) => {
await setupPickerMocks(page)
await openEditForm(page)
await page.getByRole('button', { name: 'Change…' }).click()
const dialog = page.getByRole('dialog', { name: 'Choose Featured Image' })
await dialog.getByRole('button', { name: 'Filters' }).click()
await dialog.getByLabel('Media Type').selectOption({ label: 'Logo' })
await expect(dialog.getByRole('listbox').getByRole('option')).toHaveCount(24)
await dialog.getByRole('button', { name: 'List view' }).click()
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(page.getByRole('button', { name: 'Change…' })).toBeFocused()
await page.getByRole('button', { name: 'Change…' }).click()
await expect(dialog.getByRole('button', { name: 'Filters (1)' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'List view' })).toHaveAttribute('aria-pressed', 'true')
await expect(dialog.getByRole('listbox').getByRole('option').first()).toContainText('Canyon 150')
})
})
/**
* After saving, the record is read back from the (stateful) mock: the edit form shows the new image.
* @param page - The page.
*/
async function openEditFormAfterSave(page: Page) {
await page.goto('/app/blog_post/1/edit')
const combobox = page.getByRole('combobox', { name: 'Featured Image' })
await expect(combobox).toContainText('Lighthouse 75', { timeout: 30000 })
await expect(combobox.locator('img')).toHaveAttribute('src', '/media-fixtures/t-75.png')
}
// Screenshots for the PR (light and dark at phone, tablet and desktop widths), only on request:
// QQQ_PICKER_SCREENSHOTS=<dir> pnpm test:e2e record-picker
const SCREENSHOT_DIR = process.env.QQQ_PICKER_SCREENSHOTS
test.describe('record picker screenshots', () => {
test.skip(!SCREENSHOT_DIR, 'set QQQ_PICKER_SCREENSHOTS to capture')
for (const width of [390, 1024, 1440]) {
for (const mode of ['light', 'dark'] as const) {
test(`${mode} at ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 })
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await setupPickerMocks(page)
await openEditForm(page)
await page.screenshot({ path: `${SCREENSHOT_DIR}/field-${mode}-${width}.png` })
await page.getByRole('button', { name: 'Change…' }).click()
const dialog = page.getByRole('dialog', { name: 'Choose Featured Image' })
await expect(dialog.getByRole('listbox').getByRole('option').first()).toBeVisible()
await dialog.getByRole('listbox').getByRole('option').nth(2).click()
await dialog.getByRole('button', { name: 'Filters' }).click()
await expect(dialog.getByLabel('Media Type')).toBeVisible()
await page.waitForLoadState('networkidle')
// no horizontal scroll inside the dialog at any width
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: `${SCREENSHOT_DIR}/picker-${mode}-${width}.png` })
await dialog.getByRole('button', { name: 'List view' }).click()
await page.screenshot({ path: `${SCREENSHOT_DIR}/picker-list-${mode}-${width}.png` })
})
}
}
})