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
38 changes: 38 additions & 0 deletions packages/jsonforms-renderers/dev/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions packages/jsonforms-renderers/dev/searchServices.ts
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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)
},
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
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 {
Expand Down Expand Up @@ -32,7 +35,7 @@
return <SearchServiceContext.Provider value={value}>{children}</SearchServiceContext.Provider>
}

export function useSearchService(name: string | undefined): SearchService | null {

Check failure on line 38 in packages/jsonforms-renderers/src/contexts/SearchServiceContext.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const registry = useContext(SearchServiceContext)
if (!registry || !name) return null
return registry[name] ?? null
Expand Down
215 changes: 215 additions & 0 deletions packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx
Original file line number Diff line number Diff line change
@@ -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<CoreActions>
}

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)

Check failure on line 65 in packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

This assertion is unnecessary since it does not change the type of the expression
}
}
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])

Check warning on line 108 in packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

React Hook useMemo has a missing dependency: 'autoFill'. Either include it or remove the dependency array

const lastPayloadRef = useRef<unknown>(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)) {

Check failure on line 148 in packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

This assertion is unnecessary since it does not change the type of the expression
console.warn(
`[AutoFillGroup] payload field "${relativePath}" (${typeof value}) does not match schema type "${targetSchema?.type}" — skipped`,

Check failure on line 150 in packages/jsonforms-renderers/src/renderers/AutoFillGroupControl.tsx

View workflow job for this annotation

GitHub Actions / Quality Check & Build

Invalid type "string | string[] | undefined" of template literal expression
)
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 (
<Box mb="6">
<Text size="2" color="red">
AutoFillGroup requires an `options.autoFill.trigger` configuration.
</Text>
</Box>
)
}

return (
<Box mb="6" p="4" style={{ borderLeft: '3px solid var(--accent-9)', background: 'var(--accent-2)', borderRadius: 'var(--radius-3)' }}>
{layout.label && (
<Flex align="center" gap="2" mb="3">
<MagicWandIcon />
<Heading size="3" className="text-gray-500 uppercase tracking-wide font-semibold">
{layout.label}
</Heading>
</Flex>
)}
<Flex direction="column" gap="4">
{elements.map((element, index) => (
<JsonFormsDispatch
key={`${path}-${index}`}
uischema={element}
schema={schema}
path={path}
renderers={renderers}
cells={cells}
enabled={enabled}
/>
))}
</Flex>
</Box>
)
}

const withAutoFillGroupContext = (Component: typeof AutoFillGroupControl): ComponentType<OwnPropsOfLayout> =>
// 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 <Component {...(layoutProps as unknown as AutoFillGroupProps)} dispatch={ctx.dispatch!} />
})

const AutoFillGroupRenderer = withAutoFillGroupContext(AutoFillGroupControl)

export default AutoFillGroupRenderer
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { rankWith, uiTypeIs, type RankedTester } from '@jsonforms/core'

export const AutoFillGroupControlTester: RankedTester = rankWith(1, uiTypeIs('AutoFillGroup'))
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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 }
Expand Down
5 changes: 5 additions & 0 deletions packages/jsonforms-renderers/src/renderers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand All @@ -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'
12 changes: 12 additions & 0 deletions packages/jsonforms-renderers/src/utils/objectPath.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>))) return { found: false, value: undefined }
current = (current as Record<string, unknown>)[key]
}
return { found: true, value: current }
}
Loading