From f10476b6143c213bc9f7342507baa34fab5d1e7b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 11 Oct 2026 17:16:54 -0700 Subject: [PATCH 1/3] feat(jsonforms-renderers): add SearchSelect x-search.defaultFrom Prefill a search-select from a sibling field while it is empty, without overwriting a value the trader already set. Needed so NPQS consignee country can default from importing country without a JSON Forms COPY rule. Co-authored-by: Cursor --- .../docs/search-select-modes.md | 37 +++++ .../renderers/SearchSelectControl.test.tsx | 142 ++++++++++++++++++ .../src/renderers/SearchSelectControl.tsx | 56 +++++++ 3 files changed, 235 insertions(+) diff --git a/packages/jsonforms-renderers/docs/search-select-modes.md b/packages/jsonforms-renderers/docs/search-select-modes.md index 932391d..126c602 100644 --- a/packages/jsonforms-renderers/docs/search-select-modes.md +++ b/packages/jsonforms-renderers/docs/search-select-modes.md @@ -90,6 +90,43 @@ 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 prefill this field from another field on the same object while this field is still +empty, 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. The field stays +editable. A later change to the sibling does **not** overwrite a value that is already +set — including one that was copied earlier. Clearing this field while the sibling still +has a value copies 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 you only want to copy the sibling's +value while this field is empty. + ## 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..915611a 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -276,6 +276,148 @@ describe('SearchSelectControl dependsOn', () => { }) }) +describe('SearchSelectControl defaultFrom', () => { + const countryObject = { + type: 'object', + 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}', + }, + ...countryObject, + }, + consignee_country: { + type: 'object', + title: 'Consignee Country', + 'x-search': { + service: 'countries', + mode: 'small-list', + displayTemplate: '{name}', + defaultFrom: 'importing_country', + }, + ...countryObject, + }, + }, + } 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('does not overwrite a value already set on this field', async () => { + const { latest } = renderForm( + { consignee_country: { value: 'US', label: 'United States' } }, + async () => ({ + options: [ + { id: 'GB', name: 'United Kingdom' }, + { id: 'US', name: 'United States' }, + ], + }), + 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: 'US', label: 'United States' }) + }) + + 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..603cf0e 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 to copy into this field while this field is still empty. Unlike dependsOn, + // a later change to the sibling does not overwrite a value the user already has (or that was + // copied earlier). 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,37 @@ 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 +} + // 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 +223,27 @@ 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]) + + useEffect(() => { + if (!defaultFrom || !visible || !isEnabled || formReadonly) return + if (!isSearchSelectEmpty(data, isObjectMode)) return + const next = valueFromDefaultSibling(defaultFromSibling, isObjectMode) + if (next === undefined) return + handleChange(path, next) + // Keyed on the sibling snapshot and emptiness of this field — not on handleChange/path, + // which are stable for a mounted control. Writing here fills only while empty, so a + // second run after the copy sees a non-empty value and stops. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultFrom, defaultFromSiblingKey, data, isObjectMode, visible, isEnabled, formReadonly]) + useEffect(() => { if (configError) { setSelectedOption(undefined) From 48306055ba3e04077a738bb62e588daac85a0869 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 11 Oct 2026 17:26:25 -0700 Subject: [PATCH 2/3] fix(jsonforms-renderers): avoid duplicate type in defaultFrom tests tsc flagged 'type' being specified twice when spreading the shared country object shape into the SearchSelect defaultFrom schema fixtures. Co-authored-by: Cursor --- .../src/renderers/SearchSelectControl.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index 915611a..7584975 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -277,8 +277,7 @@ describe('SearchSelectControl dependsOn', () => { }) describe('SearchSelectControl defaultFrom', () => { - const countryObject = { - type: 'object', + const countryShape = { properties: { value: { type: 'string' }, label: { type: 'string' }, @@ -297,7 +296,7 @@ describe('SearchSelectControl defaultFrom', () => { mode: 'small-list', displayTemplate: '{name}', }, - ...countryObject, + ...countryShape, }, consignee_country: { type: 'object', @@ -308,7 +307,7 @@ describe('SearchSelectControl defaultFrom', () => { displayTemplate: '{name}', defaultFrom: 'importing_country', }, - ...countryObject, + ...countryShape, }, }, } as unknown as JsonSchema From 8c826c44f2bc7448db60279fa79093480d0c2de0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 13 Oct 2026 07:43:53 -0700 Subject: [PATCH 3/3] fix(jsonforms-renderers): sync defaultFrom when the sibling changes Match confirmed GeNS-style rules: Consignee Country stays editable, but follows Importing Country whenever that sibling changes. Co-authored-by: Cursor --- .../docs/search-select-modes.md | 22 +++++---- .../renderers/SearchSelectControl.test.tsx | 48 ++++++++++++++++++- .../src/renderers/SearchSelectControl.tsx | 42 ++++++++++++---- 3 files changed, 93 insertions(+), 19 deletions(-) diff --git a/packages/jsonforms-renderers/docs/search-select-modes.md b/packages/jsonforms-renderers/docs/search-select-modes.md index 126c602..cade94f 100644 --- a/packages/jsonforms-renderers/docs/search-select-modes.md +++ b/packages/jsonforms-renderers/docs/search-select-modes.md @@ -92,12 +92,18 @@ had a value clears this field. ## Default from a sibling (`defaultFrom`) -To prefill this field from another field on the same object while this field is still -empty, 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. The field stays -editable. A later change to the sibling does **not** overwrite a value that is already -set — including one that was copied earlier. Clearing this field while the sibling still -has a value copies again. +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 { @@ -124,8 +130,8 @@ has a value copies again. ``` `defaultFrom` is independent of `dependsOn`. Use `dependsOn` when this field's options -must filter by the sibling; use `defaultFrom` when you only want to copy the sibling's -value while this field is empty. +must filter by the sibling; use `defaultFrom` when this field should follow the sibling's +value. ## Display template diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index 7584975..39d563b 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -337,13 +337,14 @@ describe('SearchSelectControl defaultFrom', () => { }) }) - it('does not overwrite a value already set on this field', async () => { + 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, @@ -355,8 +356,51 @@ describe('SearchSelectControl defaultFrom', () => { 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' }) }) - expect(latest()?.consignee_country).toEqual({ value: 'US', label: 'United States' }) }) it('fills again after this field is cleared while the sibling still has a value', async () => { diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 603cf0e..674f702 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -19,9 +19,9 @@ 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 to copy into this field while this field is still empty. Unlike dependsOn, - // a later change to the sibling does not overwrite a value the user already has (or that was - // copied earlier). Same Resolve path as dependsOn: relative to this control's parent object. + // 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`. @@ -108,6 +108,18 @@ function valueFromDefaultSibling(sibling: unknown, isObjectMode: boolean): unkno 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 @@ -231,16 +243,28 @@ const SearchSelectControl = ({ 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 - if (!isSearchSelectEmpty(data, isObjectMode)) return const next = valueFromDefaultSibling(defaultFromSibling, isObjectMode) - if (next === undefined) return - handleChange(path, next) - // Keyed on the sibling snapshot and emptiness of this field — not on handleChange/path, - // which are stable for a mounted control. Writing here fills only while empty, so a - // second run after the copy sees a non-empty value and stops. + 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])