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:
20 files changed
+2013
-16
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 //
|
||||
|
||||
@@ -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 })))
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))}`
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export type {
|
||||
QThemeMetaData,
|
||||
QTableMetaData,
|
||||
QFieldMetaData,
|
||||
QPossibleValuePicker,
|
||||
QVirtualFieldMetaData,
|
||||
QProcessMetaData,
|
||||
QFrontendStepMetaData,
|
||||
|
||||
@@ -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`).
|
||||
*/
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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` })
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user