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)