diff --git a/packages/jsonforms-renderers/docs/search-select-modes.md b/packages/jsonforms-renderers/docs/search-select-modes.md index 932391d..cade94f 100644 --- a/packages/jsonforms-renderers/docs/search-select-modes.md +++ b/packages/jsonforms-renderers/docs/search-select-modes.md @@ -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 diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index f39561f..39d563b 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -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 ( + + + writes.push(data as Data)} + /> + + + ) + } + + render() + + 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' } diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 389cbc6..674f702 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -19,6 +19,10 @@ interface XSearchOptions { params?: Record // sibling property name (sent as params.parent), or param-key → sibling property for several live filters dependsOn?: string | Record + // 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 @@ -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 @@ -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(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)