Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
43 changes: 43 additions & 0 deletions packages/jsonforms-renderers/docs/search-select-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
// @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'
import { radixRenderers } from './index'

type Data = Record<string, unknown>

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()
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { 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<Data>({ transit_countries: ['IN', 'SG'] })
let live = true
finishers.push(() => {
live = false
})
return (
<Theme>
<SearchServiceProvider services={services}>
<JsonForms
schema={stringMultiSchema}
uischema={multiUi}
data={data}
renderers={radixRenderers}
onChange={({ data: next }) => {
if (!live) return
writes.push(next as Data)
setData(next as Data)
Comment thread
ginaxu1 marked this conversation as resolved.
Resolved
}}
/>
</SearchServiceProvider>
</Theme>
)
}

render(<Harness />)

expect(await screen.findByText('India')).toBeTruthy()
expect(screen.getByText('Singapore')).toBeTruthy()
expect(screen.queryByText('IN')).toBeNull()
})
})
Loading
Loading