diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 9c0bd46..168b740 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -455,6 +455,66 @@ export const fixtures: Fixture[] = [ } as UISchemaElement, data: { country: { value: 'au', label: 'Australia' } }, }, + { + id: 'search-select-multi', + name: 'Search Select (Multi)', + schema: { + type: 'object', + properties: { + transitCountries: { + type: 'array', + title: 'Transit Countries (object items)', + description: + 'Array whose items carry x-search. Renders as one multi SearchSelect with chips (not Add Item cards). ' + + 'Object items store [{ value, label }, …]. maxItems: 3 stops further picks in the UI.', + maxItems: 3, + items: { + type: 'object', + 'x-search': { service: 'countries', mode: 'large-searchable-list', displayTemplate: '{name}' }, + properties: { + value: { type: 'string', minLength: 1 }, + label: { type: 'string' }, + }, + required: ['value'], + }, + }, + transitCountryIds: { + type: 'array', + title: 'Transit Countries (string items)', + description: + 'Same multi SearchSelect, but items are type string so the form stores ["au","lk"]. ' + + 'Chips resolve labels via the service on mount.', + maxItems: 3, + items: { + type: 'string', + 'x-search': { service: 'countries', mode: 'large-searchable-list', displayTemplate: '{name}' }, + }, + }, + }, + } as unknown as JsonSchema, + uischema: { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/transitCountries', + options: { placeholder: 'Search for countries…' }, + }, + { + type: 'Control', + scope: '#/properties/transitCountryIds', + options: { placeholder: 'Search for countries…' }, + }, + ], + } as UISchemaElement, + data: { + transitCountries: [ + { value: 'au', label: 'Australia' }, + { value: 'lk', label: 'Sri Lanka' }, + ], + transitCountryIds: ['au', 'lk'], + }, + }, { id: 'search-select-templates', name: 'Search Select (Display template)', diff --git a/packages/jsonforms-renderers/docs/search-select-modes.md b/packages/jsonforms-renderers/docs/search-select-modes.md index 932391d..209fad0 100644 --- a/packages/jsonforms-renderers/docs/search-select-modes.md +++ b/packages/jsonforms-renderers/docs/search-select-modes.md @@ -6,6 +6,9 @@ exposing `loadOnOpen` / `searchable` / `paginated` as three independent flags (m the 8 combinations don't make sense), the control only accepts one of the three modes below. +Tester rank is 4 (above ArrayControl at 3 and Select at 2), so array fields whose +`items` carry `x-search` render as this control instead of Add Item cards. + ```jsonc { "type": "string", @@ -125,6 +128,46 @@ instead of fetching. Object-typed fields store `{ value: id, label: templated name }`. A later open uses the saved label and does not re-resolve. +## Multi-select (`type: "array"`) + +Multi mode is inferred from `schema.type === "array"` with `x-search` on `items`. +There is no separate `multiple` flag. Single string/object fields stay single-select. + +Stored shapes: + +- `items.type: "object"` (with `value` / `label`): `[{ value, label }, …]` +- `items.type: "string"`: `["id", …]`. Chips keep labels from the pick, and on mount + each id is resolved through the service so preloaded forms show names, not raw ids. + +`schema.maxItems` is enforced in the UI: once the cap is reached, further picks are +ignored (unselected options are disabled). Removal and de-selection still work. AJV +still validates preloaded data that already exceeds the cap. + +`dependsOn` behaves like single-select: changing a previously set sibling clears the +whole multi selection. + +```jsonc +{ + "transitCountries": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "x-search": { + "service": "countries", + "mode": "large-searchable-list", + "displayTemplate": "{name}", + }, + "properties": { + "value": { "type": "string" }, + "label": { "type": "string" }, + }, + "required": ["value"], + }, + }, +} +``` + ## The three modes | mode | fetch on open | typing | pagination | diff --git a/packages/jsonforms-renderers/src/renderers/ArrayControl.required.test.tsx b/packages/jsonforms-renderers/src/renderers/ArrayControl.required.test.tsx index cd63beb..fdb0510 100644 --- a/packages/jsonforms-renderers/src/renderers/ArrayControl.required.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/ArrayControl.required.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' import { useState } from 'react' -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { JsonForms } from '@jsonforms/react' import { Theme } from '@radix-ui/themes' import type { JsonSchema, UISchemaElement } from '@jsonforms/core' @@ -9,7 +9,11 @@ import { radixRenderers } from './index' type Data = Record -afterEach(() => { +afterEach(async () => { + // JsonForms debounces onChange by 10ms. Flush it before jsdom is torn down. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) cleanup() }) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index f39561f..6de7b61 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -437,3 +437,134 @@ describe('SearchSelectControl displayTemplate', () => { }) }) }) + +describe('SearchSelectControl multi (array of x-search objects)', () => { + const multiSchema = { + type: 'object', + properties: { + transit_countries: { + type: 'array', + title: 'Transit Countries', + maxItems: 2, + items: { + type: 'object', + 'x-search': { + service: 'countries', + mode: 'small-list', + displayTemplate: '{name}', + }, + properties: { + value: { type: 'string', minLength: 1 }, + label: { type: 'string' }, + }, + required: ['value'], + }, + }, + }, + } as unknown as JsonSchema + + const multiUi = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/transit_countries' }], + } as UISchemaElement + + const multiOptions = [ + { id: 'IN', name: 'India' }, + { id: 'SG', name: 'Singapore' }, + { id: 'LK', name: 'Sri Lanka' }, + ] + + it('renders a searchable multi dropdown instead of Add Item cards', async () => { + const { latest } = renderForm({}, async () => ({ options: multiOptions }), multiSchema, multiUi) + + expect(screen.queryByRole('button', { name: /Add Item/ })).toBeNull() + fireEvent.focus(screen.getByRole('textbox')) + fireEvent.click(await screen.findByText('India')) + fireEvent.click(await screen.findByText('Singapore')) + + await waitFor(() => { + expect(latest()?.transit_countries).toEqual([ + { value: 'IN', label: 'India' }, + { value: 'SG', label: 'Singapore' }, + ]) + }) + }) + + it('does not add another option once schema.maxItems is reached', async () => { + const { latest } = renderForm({}, async () => ({ options: multiOptions }), multiSchema, multiUi) + + fireEvent.focus(screen.getByRole('textbox')) + fireEvent.click(await screen.findByText('India')) + fireEvent.click(await screen.findByText('Singapore')) + fireEvent.click(await screen.findByText('Sri Lanka')) + + await waitFor(() => { + expect(latest()?.transit_countries).toEqual([ + { value: 'IN', label: 'India' }, + { value: 'SG', label: 'Singapore' }, + ]) + }) + }) + + it('resolves string-array ids to chip labels on mount', async () => { + const stringMultiSchema = { + type: 'object', + properties: { + transit_countries: { + type: 'array', + title: 'Transit Countries', + items: { + type: 'string', + 'x-search': { + service: 'countries', + mode: 'small-list', + displayTemplate: '{name}', + }, + }, + }, + }, + } as unknown as JsonSchema + + const names: Record = { IN: 'India', SG: 'Singapore' } + const writes: Data[] = [] + const services = { + countries: { + search: async () => ({ options: multiOptions }), + async resolve(value: string) { + return { id: value, name: names[value] ?? value } + }, + }, + } + + function Harness() { + const [data, setData] = useState({ transit_countries: ['IN', 'SG'] }) + let live = true + finishers.push(() => { + live = false + }) + return ( + + + { + if (!live) return + writes.push(next as Data) + setData(next as Data) + }} + /> + + + ) + } + + render() + + expect(await screen.findByText('India')).toBeTruthy() + expect(screen.getByText('Singapore')).toBeTruthy() + expect(screen.queryByText('IN')).toBeNull() + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 3fa1140..c6c2693 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -8,6 +8,7 @@ import { useClearWhenHidden } from '../hooks/useClearWhenHidden' import { useIsRequired } from '../hooks/useIsRequired' import { getErrorMessage } from '../utils/error' import { renderTemplate } from '../utils/template' +import { searchSelectTargetSchema } from './SearchSelectControlTester' import * as React from 'react' export type SearchSelectMode = 'small-list' | 'large-searchable-list' | 'large-paginated-list' @@ -80,6 +81,15 @@ interface SearchSelectValue { label?: string } +function asSearchSelectValue(raw: unknown): SearchSelectValue | undefined { + if (typeof raw === 'string' && raw.length > 0) return { value: raw } + if (raw && typeof raw === 'object' && typeof (raw as SearchSelectValue).value === 'string') { + const v = raw as SearchSelectValue + return { value: v.value, label: v.label } + } + return undefined +} + type SearchSelectProps = ControlProps & { schema: JsonSchema & { 'x-search'?: XSearchOptions } } @@ -110,7 +120,9 @@ const SearchSelectControl = ({ schema, uischema, }: SearchSelectProps) => { - const xSearch = ((schema as Record)?.['x-search'] as XSearchOptions) ?? { + const isMulti = schema.type === 'array' + const itemSchema = (searchSelectTargetSchema(schema) ?? schema) as JsonSchema & { 'x-search'?: XSearchOptions } + const xSearch = (itemSchema['x-search'] as XSearchOptions | undefined) ?? { service: '', displayTemplate: '', } @@ -138,15 +150,33 @@ const SearchSelectControl = ({ }, [parentValues, parentValuesKey, xSearch.params]) const service = useSearchService(serviceName) - const isObjectMode = schema.type === 'object' - // legacy records may have a bare string in `data` if the field was migrated from `type: "string"` after being saved - const normalizedData: SearchSelectValue | undefined = isObjectMode - ? typeof data === 'string' - ? { value: data } - : ((data as SearchSelectValue | undefined) ?? undefined) - : undefined - const currentValue = isObjectMode ? normalizedData?.value : (data as string | undefined) - const currentLabel = normalizedData?.label + const isObjectMode = itemSchema.type === 'object' + const maxItems = typeof schema.maxItems === 'number' ? schema.maxItems : undefined + const selectedValues: SearchSelectValue[] = useMemo(() => { + if (isMulti) { + if (!Array.isArray(data)) return [] + return data.map(asSearchSelectValue).filter((v): v is SearchSelectValue => !!v) + } + const one = asSearchSelectValue(isObjectMode ? data : typeof data === 'string' ? data : undefined) + return one ? [one] : [] + }, [data, isMulti, isObjectMode]) + + // Single-select helpers (unchanged behaviour for string/object fields). + const currentValue = !isMulti ? selectedValues[0]?.value : undefined + const currentLabel = !isMulti ? selectedValues[0]?.label : undefined + const selectedIds = useMemo(() => new Set(selectedValues.map((v) => v.value)), [selectedValues]) + const hasSelection = selectedValues.length > 0 + // Multi string arrays only store ids; keep labels from onSelect / resolve for chips. + const multiLabelCacheRef = useRef>({}) + const [multiLabelVersion, setMultiLabelVersion] = useState(0) + const chipLabels = useMemo(() => { + const labels: Record = {} + for (const v of selectedValues) { + labels[v.value] = v.label || multiLabelCacheRef.current[v.value] || v.value + } + return labels + }, [selectedValues, multiLabelVersion]) + const atMaxItems = isMulti && maxItems !== undefined && selectedValues.length >= maxItems const configError = !serviceName ? 'Search not configured.' @@ -178,18 +208,61 @@ const SearchSelectControl = ({ const lastResolvedRef = useRef<{ value: string; label?: string } | undefined>(undefined) const lastParentRef = useRef | undefined>(undefined) + const commitSelection = useCallback( + (next: SearchSelectValue[]) => { + if (isMulti) { + handleChange(path, next.length > 0 ? next.map((v) => (isObjectMode ? v : v.value)) : undefined) + return + } + const one = next[0] + if (!one) { + handleChange(path, undefined) + return + } + handleChange(path, isObjectMode ? { value: one.value, label: one.label } : one.value) + }, + [handleChange, path, isMulti, isObjectMode], + ) + useEffect(() => { if (!parentValues) return const prev = lastParentRef.current lastParentRef.current = parentValues - if (!prev || !currentValue) return + if (!prev || !hasSelection) return // same rule as the original single-sibling check: only a previously-set value // changing (including to unset) clears; going from unset → set does not const changed = Object.keys(parentValues).some((k) => prev[k] !== undefined && prev[k] !== parentValues[k]) - if (changed) handleChange(path, isObjectMode ? undefined : null) - }, [parentValuesKey, currentValue, handleChange, path, isObjectMode]) + if (changed) handleChange(path, undefined) + }, [parentValuesKey, hasSelection, handleChange, path]) useEffect(() => { + if (isMulti) { + // String arrays store only ids; object items already carry labels. Resolve + // any id that still lacks a chip label (preload / remount). + if (configError || isObjectMode) return + const missing = selectedValues.filter((v) => !v.label && !multiLabelCacheRef.current[v.value]) + if (missing.length === 0) return + if (!service?.resolve) return + + let cancelled = false + void Promise.all( + missing.map(async (v) => { + try { + const opt = await service.resolve!(v.value, searchParams) + if (cancelled || !opt) return + multiLabelCacheRef.current[v.value] = presentOption(opt, displayTemplate).name + } catch { + // keep the raw id on the chip + } + }), + ).then(() => { + if (!cancelled) setMultiLabelVersion((n) => n + 1) + }) + return () => { + cancelled = true + } + } + if (configError) { setSelectedOption(undefined) lastResolvedRef.current = undefined @@ -235,7 +308,17 @@ const SearchSelectControl = ({ return () => { cancelled = true } - }, [configError, currentValue, currentLabel, isObjectMode, service, searchParams, displayTemplate]) + }, [ + configError, + currentValue, + currentLabel, + isObjectMode, + isMulti, + selectedValues, + service, + searchParams, + displayTemplate, + ]) const runSearch = useCallback( async (q: string, isLoadMore = false) => { @@ -366,7 +449,22 @@ const SearchSelectControl = ({ } const onSelect = (option: SearchOption) => { - handleChange(path, isObjectMode ? { value: option.id, label: option.name } : option.id) + if (isMulti) { + const exists = selectedIds.has(option.id) + if (!exists && atMaxItems) return + const next = exists + ? selectedValues.filter((v) => v.value !== option.id) + : [...selectedValues, { value: option.id, label: option.name }] + if (!exists) { + multiLabelCacheRef.current[option.id] = option.name + setMultiLabelVersion((n) => n + 1) + } + commitSelection(next) + // keep the dropdown open so more countries can be added + setInputValue('') + return + } + commitSelection([{ value: option.id, label: option.name }]) setSelectedOption(option) // prevent resolve effect from re-running for the just-selected value lastResolvedRef.current = { value: option.id, label: isObjectMode ? option.name : undefined } @@ -382,11 +480,18 @@ const SearchSelectControl = ({ setSelectedOption(undefined) } + const onRemoveChip = (e: React.SyntheticEvent, value: string) => { + e.stopPropagation() + commitSelection(selectedValues.filter((v) => v.value !== value)) + } + const onClearKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') onClear(e) } const placeholder = (uischema?.options?.placeholder as string | undefined) ?? 'Select an option' + // Multi: chips above the field are the selection UI; keep the closed input empty. + const closedDisplay = isMulti ? '' : (selectedOption?.name ?? '') return ( @@ -395,13 +500,49 @@ const SearchSelectControl = ({ {label} {showRequired && *} + {isMulti && selectedValues.length > 0 && ( + + {selectedValues.map((v) => ( + + {chipLabels[v.value] ?? v.value} + {isEnabled && ( + e.preventDefault()} + onClick={(e) => onRemoveChip(e, v.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onRemoveChip(e, v.value) + }} + style={{ lineHeight: 1, color: 'var(--gray-11)', cursor: 'pointer' }} + > + × + + )} + + ))} + + )} +
{ if (!modeConfig?.searchable) return @@ -417,7 +558,7 @@ const SearchSelectControl = ({ > - {currentValue && isEnabled && ( + {hasSelection && isEnabled && ( )} - {options.map((opt) => ( - e.preventDefault()} - onClick={() => onSelect(opt)} - style={{ - cursor: 'pointer', - borderRadius: 'var(--radius-2)', - backgroundColor: opt.id === currentValue ? 'var(--accent-3)' : undefined, - }} - className="hover:bg-(--gray-3)" - > - {opt.name} - - ))} + {options.map((opt) => { + const selected = selectedIds.has(opt.id) + const capped = atMaxItems && !selected + return ( + e.preventDefault()} + onClick={() => { + if (!capped) onSelect(opt) + }} + aria-disabled={capped || undefined} + style={{ + cursor: capped ? 'not-allowed' : 'pointer', + opacity: capped ? 0.5 : 1, + borderRadius: 'var(--radius-2)', + backgroundColor: selected ? 'var(--accent-3)' : undefined, + }} + className={capped ? undefined : 'hover:bg-(--gray-3)'} + > + + {opt.name} + {isMulti && selected && ( + + ✓ + + )} + + + ) + })} {modeConfig?.paginated && hasMore && !loadingMore && ( diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControlTester.ts b/packages/jsonforms-renderers/src/renderers/SearchSelectControlTester.ts index 3b7336f..d4f6934 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControlTester.ts +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControlTester.ts @@ -1,12 +1,31 @@ import { rankWith, schemaMatches } from '@jsonforms/core' import type { JsonSchema } from '@jsonforms/core' +function xSearchService(schema: JsonSchema | undefined): string | undefined { + if (!schema || typeof schema !== 'object') return undefined + const xs = (schema as Record)['x-search'] as { service?: unknown } | undefined + return typeof xs?.service === 'string' && xs.service !== '' ? xs.service : undefined +} + +/** Schema node that carries `x-search` — the field itself, or array `items`. */ +export function searchSelectTargetSchema(schema: JsonSchema): JsonSchema | undefined { + if (xSearchService(schema)) return schema + if (schema.type === 'array') { + const items = (schema as { items?: JsonSchema | JsonSchema[] }).items + if (items && !Array.isArray(items) && xSearchService(items)) return items + } + return undefined +} + +// Rank 4 beats ArrayControlTester (3) so array-of-x-search fields render as a +// multi SearchSelect instead of Add Item cards. Single string/object fields +// keep the same behaviour as before (still match; rank was already ≥ Text). export const SearchSelectControlTester = rankWith( - 3, + 4, schemaMatches((schema: JsonSchema) => { - const xs = (schema as Record)['x-search'] as { service?: unknown } | undefined - return ( - (schema.type === 'string' || schema.type === 'object') && typeof xs?.service === 'string' && xs.service !== '' - ) + const target = searchSelectTargetSchema(schema) + if (!target) return false + const t = target.type + return t === 'string' || t === 'object' }), )