Skip to content
Open
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
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 @@ -90,6 +90,49 @@ query-param name → sibling property (`dependsOn: { "commodity": "commodity_com
Fetching waits until every listed sibling has a value; changing any sibling that already
had a value clears this field.

## Default from a sibling (`defaultFrom`)

To mirror another field on the same object, set `x-search.defaultFrom` to that sibling's
property name. The control copies the sibling's current value (a string, or
`{ value, label }`) into this field:

- while this field is empty (first fill, or after the trader clears it)
- whenever the sibling **changes** — that overwrite includes a value the trader set by
hand

The field stays editable between sibling changes. Used for NPQS "Country of Consignee"
following "Name of Importing Country" (GeNS-style): change importing country, consignee
country follows; the trader can still pick a different consignee country until importing
country changes again.

```jsonc
{
"importing_country": {
"type": "object",
"x-search": {
"service": "static-data",
"mode": "large-searchable-list",
"displayTemplate": "{name}",
"params": { "id": "countries", "version": "1" },
},
},
"consignee_country": {
"type": "object",
"x-search": {
"service": "static-data",
"mode": "large-searchable-list",
"displayTemplate": "{name}",
"params": { "id": "countries", "version": "1" },
"defaultFrom": "importing_country",
},
},
}
```

`defaultFrom` is independent of `dependsOn`. Use `dependsOn` when this field's options
must filter by the sibling; use `defaultFrom` when this field should follow the sibling's
value.

## Display template

`displayTemplate` is required on every `x-search` field. `{id}` and `{name}` are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,191 @@ describe('SearchSelectControl dependsOn', () => {
})
})

describe('SearchSelectControl defaultFrom', () => {
const countryShape = {
properties: {
value: { type: 'string' },
label: { type: 'string' },
},
required: ['value'],
}

const defaultFromSchema = {
type: 'object',
properties: {
importing_country: {
type: 'object',
title: 'Importing Country',
'x-search': {
service: 'countries',
mode: 'small-list',
displayTemplate: '{name}',
},
...countryShape,
},
consignee_country: {
type: 'object',
title: 'Consignee Country',
'x-search': {
service: 'countries',
mode: 'small-list',
displayTemplate: '{name}',
defaultFrom: 'importing_country',
},
...countryShape,
},
},
} as unknown as JsonSchema

const defaultFromUi = {
type: 'VerticalLayout',
elements: [
{ type: 'Control', scope: '#/properties/importing_country' },
{ type: 'Control', scope: '#/properties/consignee_country' },
],
} as UISchemaElement

it('copies the sibling into this field while it is empty', async () => {
const { latest } = renderForm(
{},
async () => ({ options: [{ id: 'GB', name: 'United Kingdom' }] }),
defaultFromSchema,
defaultFromUi,
)

fireEvent.focus(screen.getAllByRole('textbox')[0])
fireEvent.click(await screen.findByText('United Kingdom'))

await waitFor(() => {
expect(latest()?.importing_country).toEqual({ value: 'GB', label: 'United Kingdom' })
expect(latest()?.consignee_country).toEqual({ value: 'GB', label: 'United Kingdom' })
})
})

it('overwrites this field when the sibling changes', async () => {
const { latest } = renderForm(
{ consignee_country: { value: 'US', label: 'United States' } },
async () => ({
options: [
{ id: 'GB', name: 'United Kingdom' },
{ id: 'US', name: 'United States' },
{ id: 'AU', name: 'Australia' },
],
}),
defaultFromSchema,
defaultFromUi,
)

fireEvent.focus(screen.getAllByRole('textbox')[0])
fireEvent.click(await screen.findByText('United Kingdom'))

await waitFor(() => {
expect(latest()?.importing_country).toEqual({ value: 'GB', label: 'United Kingdom' })
expect(latest()?.consignee_country).toEqual({ value: 'GB', label: 'United Kingdom' })
})

fireEvent.focus(screen.getAllByRole('textbox')[0])
fireEvent.click(await screen.findByText('Australia'))

await waitFor(() => {
expect(latest()?.importing_country).toEqual({ value: 'AU', label: 'Australia' })
expect(latest()?.consignee_country).toEqual({ value: 'AU', label: 'Australia' })
})
})

it('keeps a user edit until the sibling changes', async () => {
const { latest } = renderForm(
{
importing_country: { value: 'GB', label: 'United Kingdom' },
consignee_country: { value: 'GB', label: 'United Kingdom' },
},
async () => ({
options: [
{ id: 'GB', name: 'United Kingdom' },
{ id: 'US', name: 'United States' },
{ id: 'AU', name: 'Australia' },
],
}),
defaultFromSchema,
defaultFromUi,
)

// Consignee is the second textbox; open it and pick a different country.
fireEvent.focus(screen.getAllByRole('textbox')[1])
fireEvent.click(await screen.findByText('United States'))

await waitFor(() => {
expect(latest()?.consignee_country).toEqual({ value: 'US', label: 'United States' })
expect(latest()?.importing_country).toEqual({ value: 'GB', label: 'United Kingdom' })
})

fireEvent.focus(screen.getAllByRole('textbox')[0])
fireEvent.click(await screen.findByText('Australia'))

await waitFor(() => {
expect(latest()?.importing_country).toEqual({ value: 'AU', label: 'Australia' })
expect(latest()?.consignee_country).toEqual({ value: 'AU', label: 'Australia' })
})
})

it('fills again after this field is cleared while the sibling still has a value', async () => {
const { latest } = renderForm(
{
importing_country: { value: 'GB', label: 'United Kingdom' },
consignee_country: { value: 'US', label: 'United States' },
},
async () => ({
options: [
{ id: 'GB', name: 'United Kingdom' },
{ id: 'US', name: 'United States' },
],
}),
defaultFromSchema,
defaultFromUi,
)

// Consignee is the second field; its clear control is the second "Clear selection".
fireEvent.click(screen.getAllByRole('button', { name: 'Clear selection' })[1])

await waitFor(() => {
expect(latest()?.consignee_country).toEqual({ value: 'GB', label: 'United Kingdom' })
})
})

it('does not copy when the form is readonly', async () => {
const writes: Data[] = []
const services = {
countries: {
search: async () => ({ options: [{ id: 'GB', name: 'United Kingdom' }] }),
},
}

function Harness() {
return (
<Theme>
<SearchServiceProvider services={services}>
<JsonForms
schema={defaultFromSchema}
uischema={defaultFromUi}
data={{ importing_country: { value: 'GB', label: 'United Kingdom' } }}
renderers={radixRenderers}
readonly
onChange={({ data }) => writes.push(data as Data)}
/>
</SearchServiceProvider>
</Theme>
)
}

render(<Harness />)

await waitFor(() => {
expect(screen.getAllByRole('textbox')[0]).toBeTruthy()
})
expect(writes.every((w) => w.consignee_country == null)).toBe(true)
})
})

describe('SearchSelectControl displayTemplate', () => {
const portOption = { id: 'USTMR', name: 'ALTHEIMER' }

Expand Down
80 changes: 80 additions & 0 deletions packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ interface XSearchOptions {
params?: Record<string, unknown>
// sibling property name (sent as params.parent), or param-key → sibling property for several live filters
dependsOn?: string | Record<string, string>
// Sibling property this field mirrors. Copies while empty, and whenever the sibling
// changes (overwriting a prior value — including a user edit). The field stays editable.
// Same Resolve path as dependsOn: relative to this control's parent object.
defaultFrom?: string
// `{id}` / `{name}` string used as the dropdown / selected label. Required —
// a missing or empty value shows a config error instead of falling back to `name`.
displayTemplate: string
Expand Down Expand Up @@ -73,6 +77,49 @@ function resolveDependsOnValues(
return values
}

function isSearchSelectEmpty(data: unknown, isObjectMode: boolean): boolean {
if (data == null || data === '') return true
if (isObjectMode) {
if (typeof data === 'string') return data.length === 0
if (typeof data === 'object') {
const value = (data as SearchSelectValue).value
return typeof value !== 'string' || value.length === 0
}
}
return false
}

// Shape this field's stored value from a sibling's current data. Object-mode fields keep
// { value, label }; string-mode fields store the id only.
function valueFromDefaultSibling(sibling: unknown, isObjectMode: boolean): unknown {
if (sibling == null || sibling === '') return undefined
if (typeof sibling === 'string') {
return sibling.length > 0 ? (isObjectMode ? { value: sibling } : sibling) : undefined
}
if (typeof sibling === 'object') {
const value = (sibling as SearchSelectValue).value
if (typeof value !== 'string' || value.length === 0) return undefined
if (isObjectMode) {
const label = (sibling as SearchSelectValue).label
return typeof label === 'string' && label.length > 0 ? { value, label } : { value }
}
return value
}
return undefined
}

function searchSelectValuesEqual(a: unknown, b: unknown, isObjectMode: boolean): boolean {
if (isSearchSelectEmpty(a, isObjectMode) && (b === undefined || isSearchSelectEmpty(b, isObjectMode))) {
return true
}
if (isObjectMode) {
const av = typeof a === 'object' && a !== null ? (a as SearchSelectValue) : undefined
const bv = typeof b === 'object' && b !== null ? (b as SearchSelectValue) : undefined
return av?.value === bv?.value && (av?.label ?? undefined) === (bv?.label ?? undefined)
}
return a === b
}

// shape of `data` for an object-typed `x-search` field (`type: "object"`); string-typed fields keep `data` as the raw id
interface SearchSelectValue {
value: string
Expand Down Expand Up @@ -188,6 +235,39 @@ const SearchSelectControl = ({
if (changed) handleChange(path, isObjectMode ? undefined : null)
}, [parentValuesKey, currentValue, handleChange, path, isObjectMode])

const defaultFrom =
typeof xSearch.defaultFrom === 'string' && xSearch.defaultFrom.length > 0 ? xSearch.defaultFrom : undefined
const formReadonly = ctx.readonly === true
const defaultFromSibling = useMemo(() => {
if (!defaultFrom) return undefined
return Resolve.data(ctx.core?.data, parentPath ? `${parentPath}.${defaultFrom}` : defaultFrom)
}, [defaultFrom, ctx.core?.data, parentPath])
const defaultFromSiblingKey = useMemo(() => JSON.stringify(defaultFromSibling ?? null), [defaultFromSibling])
// Tracks the last sibling snapshot we reacted to so a sibling change always syncs,
// while a user edit of this field alone does not.
const lastDefaultFromSiblingKey = useRef<string | undefined>(undefined)

useEffect(() => {
if (!defaultFrom || !visible || !isEnabled || formReadonly) return
const next = valueFromDefaultSibling(defaultFromSibling, isObjectMode)
const prevKey = lastDefaultFromSiblingKey.current
const siblingChanged = prevKey !== undefined && prevKey !== defaultFromSiblingKey
lastDefaultFromSiblingKey.current = defaultFromSiblingKey

if (siblingChanged) {
// Importing country (etc.) changed: mirror it, including clearing this field.
if (searchSelectValuesEqual(data, next ?? undefined, isObjectMode)) return
handleChange(path, next === undefined ? undefined : next)
return
}

// Sibling unchanged: only fill while empty (initial select, or after a clear).
if (isSearchSelectEmpty(data, isObjectMode) && next !== undefined) {
handleChange(path, next)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFrom, defaultFromSiblingKey, data, isObjectMode, visible, isEnabled, formReadonly])

useEffect(() => {
if (configError) {
setSelectedOption(undefined)
Expand Down
Loading