diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index cea3a78..6dab88a 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -203,6 +203,44 @@ export const fixtures: Fixture[] = [ } as UISchemaElement, data: { country: { value: 'au', label: 'Australia' } }, }, + { + id: 'auto-fill-group', + name: 'Auto-Fill Group', + schema: { + type: 'object', + properties: { + employeeId: { + type: 'object', + description: 'Pick an employee — firstName/lastName/department below fill automatically', + 'x-search': { service: 'employees', mode: 'large-searchable-list' }, + properties: { + value: { type: 'string', minLength: 1 }, + label: { type: 'string' }, + }, + required: ['value'], + }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + department: { type: 'string' }, + }, + } as unknown as JsonSchema, + uischema: { + type: 'AutoFillGroup', + label: 'Employee lookup', + options: { + autoFill: { + trigger: 'employeeId', + fill: [{ path: 'department', from: 'department.name' }], + }, + }, + elements: [ + { type: 'Control', scope: '#/properties/employeeId', options: { placeholder: 'Search employee…' } }, + { type: 'Control', scope: '#/properties/firstName' }, + { type: 'Control', scope: '#/properties/lastName' }, + { type: 'Control', scope: '#/properties/department' }, + ], + } as UISchemaElement, + }, { id: 'date', name: 'Date / Time', diff --git a/packages/jsonforms-renderers/dev/searchServices.ts b/packages/jsonforms-renderers/dev/searchServices.ts index 17e3d52..5cc847d 100644 --- a/packages/jsonforms-renderers/dev/searchServices.ts +++ b/packages/jsonforms-renderers/dev/searchServices.ts @@ -1,5 +1,13 @@ import type { SearchOption, SearchServiceRegistry } from '../src' +// Richer records than a plain {id, name} — the extra fields are what AutoFillGroup matches +// against sibling scopes (firstName/lastName/department) once an employee is selected. +const EMPLOYEES: SearchOption[] = [ + { id: 'e1', name: 'Amara Perera', firstName: 'Amara', lastName: 'Perera', department: { name: 'Engineering' } }, + { id: 'e2', name: 'Nadeesha Silva', firstName: 'Nadeesha', lastName: 'Silva', department: { name: 'Design' } }, + { id: 'e3', name: 'Ruwan Fernando', firstName: 'Ruwan', lastName: 'Fernando', department: { name: 'Finance' } }, +] + // Small fixed list so pagination (5/page) and search filtering are both easy to exercise by hand. const COUNTRIES: SearchOption[] = [ { id: 'au', name: 'Australia' }, @@ -37,4 +45,13 @@ export const searchServices: SearchServiceRegistry = { return COUNTRIES.find((c) => c.id === value) }, }, + employees: { + async search({ query }) { + const matches = query ? EMPLOYEES.filter((e) => e.name.toLowerCase().includes(query.toLowerCase())) : EMPLOYEES + return { options: matches, nextCursor: undefined } + }, + async resolve(value) { + return EMPLOYEES.find((e) => e.id === value) + }, + }, } diff --git a/packages/jsonforms-renderers/src/contexts/SearchServiceContext.tsx b/packages/jsonforms-renderers/src/contexts/SearchServiceContext.tsx index 7c1c604..c919a68 100644 --- a/packages/jsonforms-renderers/src/contexts/SearchServiceContext.tsx +++ b/packages/jsonforms-renderers/src/contexts/SearchServiceContext.tsx @@ -3,6 +3,9 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react' export interface SearchOption { id: string name: string + // any other fields the service returns for this record — lets a consumer like AutoFillGroup + // fill sibling fields from the same payload the dropdown already fetched, no second lookup + [key: string]: unknown } export interface SearchResult { diff --git a/packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx b/packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx new file mode 100644 index 0000000..d97794e --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx @@ -0,0 +1,215 @@ +import { + type Layout, + type ControlElement, + type UISchemaElement, + type JsonSchema, + type OwnPropsOfLayout, + type CoreActions, + Resolve, + Paths, + composePaths, + update, +} from '@jsonforms/core' +import { ctxToLayoutProps, withJsonFormsContext, JsonFormsDispatch, type JsonFormsStateContext } from '@jsonforms/react' +import { Box, Flex, Heading, Text } from '@radix-ui/themes' +import { MagicWandIcon } from '@radix-ui/react-icons' +import { useEffect, useMemo, useRef, type ComponentType, type Dispatch } from 'react' +import type { SearchOption } from '../contexts/SearchServiceContext' +import { readByPath } from '../utils/objectPath' + +interface FillEntry { + // where to write, relative to this group's own path — or an absolute form path when `absolute: true` + path: string + // dot-path into the trigger's selected payload; '' means "the whole payload" + from: string + absolute?: boolean +} + +interface AutoFillOptions { + // relative dot-path (from this group's own path) to the trigger Control declared in `elements` + trigger: string + // explicit overrides — for response keys that don't match a sibling's own path, reshaping, or wholesale drops. + // everything else is auto-matched: a descendant Control whose own relative path matches a key in the payload, + // and whose schema type is compatible with that value, is filled automatically. + fill?: FillEntry[] +} + +type AutoFillGroupLayout = Layout & { + label?: string + options?: { autoFill?: AutoFillOptions } +} + +interface AutoFillGroupProps extends OwnPropsOfLayout { + uischema: AutoFillGroupLayout + schema: JsonSchema + path: string + data?: unknown + dispatch: Dispatch +} + +interface TriggerValue { + value?: string + label?: string + payload?: SearchOption +} + +// container element types this walks into to find fillable Control leaves — a nested AutoFillGroup +// is its own independent trigger/fill boundary and is deliberately left opaque, not recursed into +const CONTAINER_TYPES = new Set(['VerticalLayout', 'HorizontalLayout', 'Group', 'Categorization', 'Category']) + +function collectControlElements(elements: UISchemaElement[], acc: ControlElement[] = []): ControlElement[] { + for (const el of elements) { + if (el.type === 'Control' && (el as ControlElement).scope) { + acc.push(el as ControlElement) + } else if (CONTAINER_TYPES.has(el.type) && 'elements' in el) { + collectControlElements((el as Layout).elements, acc) + } + } + return acc +} + +function isTypeCompatible(schemaType: string | string[] | undefined, value: unknown): boolean { + if (!schemaType) return true // unknown schema type — don't block; AJV will catch a real mismatch on submit + const types = Array.isArray(schemaType) ? schemaType : [schemaType] + return types.some((t) => { + switch (t) { + case 'string': + return typeof value === 'string' + case 'number': + case 'integer': + return typeof value === 'number' + case 'boolean': + return typeof value === 'boolean' + case 'object': + return typeof value === 'object' && value !== null && !Array.isArray(value) + case 'array': + return Array.isArray(value) + case 'null': + return value === null + default: + return true + } + }) +} + +const AutoFillGroupControl = ({ uischema, schema, path, data, dispatch, renderers, cells, enabled, visible = true }: AutoFillGroupProps) => { + const layout = uischema + const elements = layout.elements + const autoFill = layout.options?.autoFill + + const triggerValue = autoFill ? (Resolve.data(data, autoFill.trigger) as TriggerValue | undefined) : undefined + const payload = triggerValue?.payload + + const autoTargets = useMemo(() => { + if (!autoFill) return [] + return collectControlElements(elements) + .map((control) => ({ control, relativePath: Paths.fromScoped(control) })) + .filter(({ relativePath }) => relativePath !== autoFill.trigger) + }, [elements, autoFill?.trigger]) + + const lastPayloadRef = useRef(undefined) + + useEffect(() => { + if (!autoFill) return + if (payload === lastPayloadRef.current) return + lastPayloadRef.current = payload + + const explicit = autoFill.fill ?? [] + const explicitRelPaths = new Set(explicit.map((f) => f.path)) + + if (!payload) { + // trigger cleared — clear whatever this group previously filled + autoTargets + .filter(({ relativePath }) => !explicitRelPaths.has(relativePath)) + .forEach(({ relativePath }) => dispatch(update(composePaths(path, relativePath), () => undefined))) + explicit.forEach((entry) => { + const targetPath = entry.absolute ? entry.path : composePaths(path, entry.path) + dispatch(update(targetPath, () => undefined)) + }) + return + } + + explicit.forEach((entry) => { + const { found, value } = readByPath(payload, entry.from) + if (!found) { + console.warn(`[AutoFillGroup] "${entry.from}" not found in the trigger's payload for fill target "${entry.path}"`) + return + } + const targetPath = entry.absolute ? entry.path : composePaths(path, entry.path) + dispatch(update(targetPath, () => value)) + }) + + autoTargets.forEach(({ control, relativePath }) => { + if (explicitRelPaths.has(relativePath)) return // explicit fill wins + const { found, value } = readByPath(payload, relativePath) + if (!found) return // not every sibling needs to come from this payload + + const targetSchema = Resolve.schema(schema, control.scope, schema) + if (!isTypeCompatible(targetSchema?.type as string | string[] | undefined, value)) { + console.warn( + `[AutoFillGroup] payload field "${relativePath}" (${typeof value}) does not match schema type "${targetSchema?.type}" — skipped`, + ) + return + } + dispatch(update(composePaths(path, relativePath), () => value)) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run only when the trigger's resolved payload changes + }, [payload]) + + if (visible === false) return null + + if (!autoFill) { + return ( + + + AutoFillGroup requires an `options.autoFill.trigger` configuration. + + + ) + } + + return ( + + {layout.label && ( + + + + {layout.label} + + + )} + + {elements.map((element, index) => ( + + ))} + + + ) +} + +const withAutoFillGroupContext = (Component: typeof AutoFillGroupControl): ComponentType => + // bypasses the plain layout wiring — we additionally need raw `dispatch` to write to sibling paths, + // not just the group's own path, the same way DependentSelectControl reaches into ctx for cross-field reads + withJsonFormsContext(function AutoFillGroupWithContext({ + ctx, + props: ownProps, + }: { + ctx: JsonFormsStateContext + props: OwnPropsOfLayout + }) { + const layoutProps = ctxToLayoutProps(ctx, ownProps) + // dispatch is always set once JsonForms has initialized — this control never renders before that + return + }) + +const AutoFillGroupRenderer = withAutoFillGroupContext(AutoFillGroupControl) + +export default AutoFillGroupRenderer \ No newline at end of file diff --git a/packages/jsonforms-renderers/src/renderers/AutoFillGroupControlTester.ts b/packages/jsonforms-renderers/src/renderers/AutoFillGroupControlTester.ts new file mode 100644 index 0000000..b703a87 --- /dev/null +++ b/packages/jsonforms-renderers/src/renderers/AutoFillGroupControlTester.ts @@ -0,0 +1,3 @@ +import { rankWith, uiTypeIs, type RankedTester } from '@jsonforms/core' + +export const AutoFillGroupControlTester: RankedTester = rankWith(1, uiTypeIs('AutoFillGroup')) \ No newline at end of file diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index af922ad..60c07a7 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -15,10 +15,13 @@ interface XSearchOptions { mode?: SearchSelectMode } -// shape of `data` for an object-typed `x-search` field (`type: "object"`); string-typed fields keep `data` as the raw id +// shape of `data` for an object-typed `x-search` field (`type: "object"`); string-typed fields keep `data` as the raw id. +// `payload` carries the full selected SearchOption (beyond just id/name) — consumed by AutoFillGroup to +// fill sibling fields, without a second lookup against the search service. interface SearchSelectValue { value: string label?: string + payload?: SearchOption } type SearchSelectProps = ControlProps & { @@ -232,7 +235,7 @@ const SearchSelectControl = ({ } const onSelect = (option: SearchOption) => { - handleChange(path, isObjectMode ? { value: option.id, label: option.name } : option.id) + handleChange(path, isObjectMode ? { value: option.id, label: option.name, payload: option } : option.id) setSelectedOption(option) // prevent resolve effect from re-running for the just-selected value lastResolvedRef.current = { value: option.id, label: isObjectMode ? option.name : undefined } diff --git a/packages/jsonforms-renderers/src/renderers/index.ts b/packages/jsonforms-renderers/src/renderers/index.ts index a991663..5cff952 100644 --- a/packages/jsonforms-renderers/src/renderers/index.ts +++ b/packages/jsonforms-renderers/src/renderers/index.ts @@ -5,6 +5,8 @@ import RadioControl, { RadioControlTester } from './RadioControl' import SelectControl, { SelectControlTester } from './SelectControl' import SearchSelectControl from './SearchSelectControl' import { SearchSelectControlTester } from './SearchSelectControlTester' +import AutoFillGroupControl from './AutoFillGroupControl' +import { AutoFillGroupControlTester } from './AutoFillGroupControlTester' import DateControl, { DateControlTester } from './DateControl' import { VerticalLayoutRenderer, @@ -40,6 +42,7 @@ export const radixRenderers = [ { tester: HorizontalLayoutTester, renderer: HorizontalLayoutRenderer }, { tester: GroupLayoutTester, renderer: GroupLayoutRenderer }, { tester: CategorizationLayoutTester, renderer: CategorizationLayoutRenderer }, + { tester: AutoFillGroupControlTester, renderer: AutoFillGroupControl }, { tester: FileControlTester, renderer: FileControl }, { tester: SpreadsheetControlTester, renderer: SpreadsheetControl }, { tester: ArrayControlTester, renderer: ArrayControl }, @@ -61,6 +64,8 @@ export { default as SpreadsheetControl } from './SpreadsheetControl' export * from './SpreadsheetControlTester' export { default as SearchSelectControl } from './SearchSelectControl' export * from './SearchSelectControlTester' +export { default as AutoFillGroupControl } from './AutoFillGroupControl' +export * from './AutoFillGroupControlTester' export { default as ArrayControl } from './ArrayControl' export * from './ArrayControlTester' export * from './LabelRenderer' diff --git a/packages/jsonforms-renderers/src/utils/objectPath.ts b/packages/jsonforms-renderers/src/utils/objectPath.ts new file mode 100644 index 0000000..1bf5632 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/objectPath.ts @@ -0,0 +1,12 @@ +// Reads a dot-path off a plain object, distinguishing "not present" from a legitimately falsy/undefined value. +export function readByPath(obj: unknown, path: string): { found: boolean; value: unknown } { + if (path === '') return { found: true, value: obj } + + let current: unknown = obj + for (const key of path.split('.')) { + if (current === null || typeof current !== 'object') return { found: false, value: undefined } + if (!(key in (current as Record))) return { found: false, value: undefined } + current = (current as Record)[key] + } + return { found: true, value: current } +} \ No newline at end of file