From 133738c275d13e7e7847ecafeb2882e3e964f2da Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 13 Oct 2026 22:25:32 -0700 Subject: [PATCH 1/6] feat(jsonforms): multi-select SearchSelect for array x-search fields Array-of-object fields with x-search on items (e.g. NPQS Transit Countries) were falling through to ArrayControl Add Item cards. Match them at a higher rank and toggle selections in one searchable dropdown so single string/object SearchSelect behaviour stays unchanged. Co-authored-by: Cursor --- .../renderers/SearchSelectControl.test.tsx | 56 ++++++ .../src/renderers/SearchSelectControl.tsx | 173 ++++++++++++++---- .../renderers/SearchSelectControlTester.ts | 29 ++- 3 files changed, 218 insertions(+), 40 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index f39561f..46d0aff 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -437,3 +437,59 @@ describe('SearchSelectControl displayTemplate', () => { }) }) }) + +describe('SearchSelectControl multi (array of x-search objects)', () => { + const multiSchema = { + type: 'object', + properties: { + transit_countries: { + type: 'array', + title: 'Transit Countries', + 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 + + it('renders a searchable multi dropdown instead of Add Item cards', async () => { + const { latest } = renderForm( + {}, + async () => ({ + options: [ + { id: 'IN', name: 'India' }, + { id: 'SG', name: 'Singapore' }, + ], + }), + multiSchema, + multiUi, + ) + + expect(screen.queryByText('+ 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' }, + ]) + }) + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 3fa1140..5637cba 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,21 @@ 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 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 const configError = !serviceName ? 'Search not configured.' @@ -178,18 +196,35 @@ 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) return if (configError) { setSelectedOption(undefined) lastResolvedRef.current = undefined @@ -235,7 +270,7 @@ const SearchSelectControl = ({ return () => { cancelled = true } - }, [configError, currentValue, currentLabel, isObjectMode, service, searchParams, displayTemplate]) + }, [configError, currentValue, currentLabel, isObjectMode, isMulti, service, searchParams, displayTemplate]) const runSearch = useCallback( async (q: string, isLoadMore = false) => { @@ -366,7 +401,17 @@ 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) + const next = exists + ? selectedValues.filter((v) => v.value !== option.id) + : [...selectedValues, { value: option.id, label: option.name }] + 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 +427,23 @@ 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' + const closedDisplay = isMulti + ? '' + : (selectedOption?.name ?? '') + const multiSummary = + isMulti && selectedValues.length > 0 + ? selectedValues.map((v) => v.label || v.value).join(', ') + : '' return ( @@ -395,13 +452,49 @@ const SearchSelectControl = ({ {label} {showRequired && *} + {isMulti && selectedValues.length > 0 && ( + + {selectedValues.map((v) => ( + + {v.label || 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 +510,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) + return ( + e.preventDefault()} + onClick={() => onSelect(opt)} + style={{ + cursor: 'pointer', + borderRadius: 'var(--radius-2)', + backgroundColor: selected ? 'var(--accent-3)' : undefined, + }} + className="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' }), ) From 7415e55707d7569fc8ac1f2c0794aac7ab50e9c3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 13 Oct 2026 22:39:36 -0700 Subject: [PATCH 2/6] style(jsonforms): prettier-format SearchSelectControl Co-authored-by: Cursor --- .../src/renderers/SearchSelectControl.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 5637cba..05b8e66 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -437,13 +437,9 @@ const SearchSelectControl = ({ } const placeholder = (uischema?.options?.placeholder as string | undefined) ?? 'Select an option' - const closedDisplay = isMulti - ? '' - : (selectedOption?.name ?? '') + const closedDisplay = isMulti ? '' : (selectedOption?.name ?? '') const multiSummary = - isMulti && selectedValues.length > 0 - ? selectedValues.map((v) => v.label || v.value).join(', ') - : '' + isMulti && selectedValues.length > 0 ? selectedValues.map((v) => v.label || v.value).join(', ') : '' return ( From 09978b6ef0277d65551f0a4fdf9d845913cc9d5d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 14 Oct 2026 11:31:44 -0700 Subject: [PATCH 3/6] fix(jsonforms): show multi SearchSelect selection as chips only Drop the closed-field comma summary so Transit Countries does not duplicate chips and text when the dropdown is closed. Co-authored-by: Cursor --- .../src/renderers/SearchSelectControl.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 05b8e66..6aa508a 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -437,9 +437,8 @@ const SearchSelectControl = ({ } 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 ?? '') - const multiSummary = - isMulti && selectedValues.length > 0 ? selectedValues.map((v) => v.label || v.value).join(', ') : '' return ( @@ -487,7 +486,7 @@ const SearchSelectControl = ({
Date: Fri, 16 Oct 2026 02:48:07 -0700 Subject: [PATCH 4/6] fix(jsonforms-renderers): address multi SearchSelect review notes Resolve string-array chip labels on mount, enforce schema.maxItems in the UI, fix the Add Item assertion, and add a playground fixture plus docs for array shapes and tester rank. Co-authored-by: Cursor --- packages/jsonforms-renderers/dev/fixtures.ts | 60 ++++++++++++ .../docs/search-select-modes.md | 43 +++++++++ .../renderers/SearchSelectControl.test.tsx | 94 ++++++++++++++++--- .../src/renderers/SearchSelectControl.tsx | 72 ++++++++++++-- 4 files changed, 250 insertions(+), 19 deletions(-) diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 9c0bd46..5a6ba36 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","nz"]. ' + + '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: 'nz', label: 'New Zealand' }, + ], + transitCountryIds: ['au', 'nz'], + }, + }, { 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/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index 46d0aff..c1d9d81 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -445,6 +445,7 @@ describe('SearchSelectControl multi (array of x-search objects)', () => { transit_countries: { type: 'array', title: 'Transit Countries', + maxItems: 2, items: { type: 'object', 'x-search': { @@ -467,23 +468,35 @@ describe('SearchSelectControl multi (array of x-search objects)', () => { 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: [ - { id: 'IN', name: 'India' }, - { id: 'SG', name: 'Singapore' }, - ], - }), - multiSchema, - multiUi, - ) + 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) - expect(screen.queryByText('+ Add Item')).toBeNull() 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([ @@ -492,4 +505,61 @@ describe('SearchSelectControl multi (array of x-search objects)', () => { ]) }) }) + + 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'] }) + 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 6aa508a..c6c2693 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -151,6 +151,7 @@ const SearchSelectControl = ({ const service = useSearchService(serviceName) 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 [] @@ -165,6 +166,17 @@ const SearchSelectControl = ({ 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.' @@ -224,7 +236,33 @@ const SearchSelectControl = ({ }, [parentValuesKey, hasSelection, handleChange, path]) useEffect(() => { - if (isMulti) return + 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 @@ -270,7 +308,17 @@ const SearchSelectControl = ({ return () => { cancelled = true } - }, [configError, currentValue, currentLabel, isObjectMode, isMulti, service, searchParams, displayTemplate]) + }, [ + configError, + currentValue, + currentLabel, + isObjectMode, + isMulti, + selectedValues, + service, + searchParams, + displayTemplate, + ]) const runSearch = useCallback( async (q: string, isLoadMore = false) => { @@ -403,9 +451,14 @@ const SearchSelectControl = ({ const onSelect = (option: SearchOption) => { 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('') @@ -462,12 +515,12 @@ const SearchSelectControl = ({ fontSize: 'var(--font-size-1)', }} > - {v.label || v.value} + {chipLabels[v.value] ?? v.value} {isEnabled && ( e.preventDefault()} onClick={(e) => onRemoveChip(e, v.value)} onKeyDown={(e) => { @@ -577,19 +630,24 @@ const SearchSelectControl = ({ {options.map((opt) => { const selected = selectedIds.has(opt.id) + const capped = atMaxItems && !selected return ( e.preventDefault()} - onClick={() => onSelect(opt)} + onClick={() => { + if (!capped) onSelect(opt) + }} + aria-disabled={capped || undefined} style={{ - cursor: 'pointer', + cursor: capped ? 'not-allowed' : 'pointer', + opacity: capped ? 0.5 : 1, borderRadius: 'var(--radius-2)', backgroundColor: selected ? 'var(--accent-3)' : undefined, }} - className="hover:bg-(--gray-3)" + className={capped ? undefined : 'hover:bg-(--gray-3)'} > {opt.name} From 45021420ca149257752fc697bdb15c5978d3355a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 17 Oct 2026 03:56:35 -0700 Subject: [PATCH 5/6] fix(jsonforms-renderers): use countries dataset ids in multi fixture The playground countries service has no nz, so the string-items field showed a raw id chip. Preload au and lk instead. Co-authored-by: Cursor --- packages/jsonforms-renderers/dev/fixtures.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 5a6ba36..168b740 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -482,7 +482,7 @@ export const fixtures: Fixture[] = [ type: 'array', title: 'Transit Countries (string items)', description: - 'Same multi SearchSelect, but items are type string so the form stores ["au","nz"]. ' + + '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: { @@ -510,9 +510,9 @@ export const fixtures: Fixture[] = [ data: { transitCountries: [ { value: 'au', label: 'Australia' }, - { value: 'nz', label: 'New Zealand' }, + { value: 'lk', label: 'Sri Lanka' }, ], - transitCountryIds: ['au', 'nz'], + transitCountryIds: ['au', 'lk'], }, }, { From e7a96edfa3cc9f1e7a3fc33ee7526a440344ba05 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 17 Oct 2026 04:06:40 -0700 Subject: [PATCH 6/6] fix(jsonforms-renderers): flush JsonForms debounce before ArrayControl test cleanup Co-authored-by: Cursor --- .../src/renderers/ArrayControl.required.test.tsx | 8 ++++++-- .../src/renderers/SearchSelectControl.test.tsx | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) 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 c1d9d81..6de7b61 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -538,6 +538,10 @@ describe('SearchSelectControl multi (array of x-search objects)', () => { function Harness() { const [data, setData] = useState({ transit_countries: ['IN', 'SG'] }) + let live = true + finishers.push(() => { + live = false + }) return ( @@ -547,6 +551,7 @@ describe('SearchSelectControl multi (array of x-search objects)', () => { data={data} renderers={radixRenderers} onChange={({ data: next }) => { + if (!live) return writes.push(next as Data) setData(next as Data) }}