UNPKG

@json-layout/core

Version:

Compilation and state management utilities for JSON Layout.

955 lines (895 loc) 43 kB
import { isSwitchStruct, childIsCompositeCompObject, childIsSlotCompObject, isCompositeLayout, isFocusableLayout, isItemsLayout, isGetItemsExpression, isGetItemsFetch, isListLayout } from '@json-layout/vocabulary' import { produce } from 'immer' import debug from 'debug' import { getChildDisplay } from './utils/display.js' import { shallowEqualArray, shallowProduceArray, shallowProduceObject } from './utils/immutable.js' import { getRegexp } from './utils/regexps.js' import { pathURL } from './utils/urls.js' const logStateNode = debug('jl:state-node') const logValidation = debug('jl:validation') const logGetItems = debug('jl:get-items') /** * @param {unknown} data * @returns {boolean} */ const isDataEmpty = (data) => { if (data === '' || data === undefined) return true if (Array.isArray(data) && !data.length) return true if (typeof data === 'object' && !Array.isArray(data) && !!data && Object.values(data).findIndex(prop => prop !== undefined) === -1) return true return false } /** * @param {unknown} data * @param {import('@json-layout/vocabulary').BaseCompObject} layout * @param {import('./types.js').StateNodeOptions} options * @returns {boolean} */ export const useDefaultData = (data, layout, options) => { if (options.defaultOn === 'missing' && data === undefined) return true if (options.defaultOn === 'empty' && isDataEmpty(data)) return true return false } // use Immer for efficient updating with immutability and no-op detection /** @type {(draft: import('./types.js').StateNode, key: string | number, fullKey: string, parentFullKey: string | null, dataPath: string, parentDataPath: string | null, skeleton: import('../index.js').SkeletonNode, layout: import('@json-layout/vocabulary').BaseCompObject, width: number, cols: number, data: unknown, error: string | undefined, validated: boolean, options: import('./types.js').StateNodeOptions, autofocus: boolean, shouldLoadData: boolean, props: import('@json-layout/vocabulary').StateNodePropsLib, slots: import('@json-layout/vocabulary').Slots | undefined, itemsCacheKey: any, children: import('../index.js').StateNode[] | undefined) => import('../index.js').StateNode} */ const produceStateNode = produce((draft, key, fullKey, parentFullKey, dataPath, parentDataPath, skeleton, layout, width, cols, data, error, validated, options, autofocus, shouldLoadData, props, slots, itemsCacheKey, children) => { draft.messages = layout.messages ? produceStateNodeMessages(draft.messages || {}, layout.messages, options) : options.messages draft.key = key draft.fullKey = fullKey draft.parentFullKey = parentFullKey draft.dataPath = dataPath draft.parentDataPath = parentDataPath draft.skeleton = skeleton draft.layout = layout draft.width = width draft.options = options draft.cols = cols if (shouldLoadData || (draft.loading && draft.data === data)) draft.loading = true else delete draft.loading draft.data = data draft.error = error draft.itemsCacheKey = itemsCacheKey draft.childError = children && (children.findIndex(c => c.error || c.childError) !== -1) draft.validated = validated if (autofocus) { draft.autofocus = true delete draft.autofocusChild } else { delete draft.autofocus const autofocusChild = children?.find(c => c.autofocus) if (autofocusChild) draft.autofocusChild = autofocusChild.key else delete draft.autofocusChild } draft.props = props draft.slots = slots draft.children = children }) /** @type {(draft: import('../i18n/types.js').LocaleMessages, layoutMessages: Partial<import('../i18n/types.js').LocaleMessages>, options: import('./types.js').StateNodeOptions) => import('../i18n/types.js').LocaleMessages} */ const produceStateNodeMessages = produce((draft, layoutMessages, options) => { Object.assign(draft, options.messages, layoutMessages) }) /** @type {(draft: unknown[], children: import('../index.js').StateNode[]) => unknown[]} */ const produceStateNodeDataChildrenArray = produce((draft, children) => { for (const child of children) { const key = /** @type {number} */(child.key) if (child.data === undefined) delete draft[key] else draft[key] = child.data } // remove trailing undefined values from tuples while (draft.length && draft[draft.length - 1] === undefined) { draft.pop() } }) /** @type {(draft: Record<string, unknown>[], parentDataPath: string, additionalPropertiesErrors?: import('ajv').ErrorObject[], propertyKeys?: string[], removePropertyKeys?: string[]) => Record<string, unknown>[]} */ const produceStateNodeDataArray = produce((draft, parentDataPath, additionalPropertiesErrors, propertyKeys, removePropertyKeys) => { for (let i = 0; i < draft.length; i++) { if (typeof draft[i] === 'object' && draft[i] !== null && !(draft[i] instanceof File)) { draft[i] = cleanNodeData(draft[i], parentDataPath + '/' + i, additionalPropertiesErrors, propertyKeys, removePropertyKeys) } } }) /** @type {(draft: Record<string, unknown>, parentDataPath: string, additionalPropertiesErrors?: import('ajv').ErrorObject[], propertyKeys?: string[], removePropertyKeys?: string[]) => Record<string, unknown>} */ const cleanNodeData = produce((draft, parentDataPath, additionalPropertiesErrors, propertyKeys, removePropertyKeys) => { // if (propertyKeys && (propertyKeys.length || children?.length)) { if (propertyKeys) { for (const key of Object.keys(draft)) { if (!propertyKeys.includes(key)) delete draft[key] } } if (removePropertyKeys) { for (const key of removePropertyKeys) { delete draft[key] } } if (additionalPropertiesErrors?.length) { for (const error of additionalPropertiesErrors) { if (error.instancePath !== parentDataPath) continue if (error.keyword === 'additionalProperties') { delete draft[error.params.additionalProperty] } if (error.keyword === 'unevaluatedProperties') { delete draft[error.params.unevaluatedProperty] } } } for (const key of Object.keys(draft)) { if (draft[key] === undefined) delete draft[key] } }) /** @type {(draft: Record<string, unknown>, parentDataPath: string, hydratedChild: import('../index.js').StateNode, childrenSkeletons: import('../index.js').SkeletonNode[] | undefined) => Record<string, unknown>} */ const produceHydratedStateNodeData = produce((draft, parentDataPath, hydratedChild, childrenSkeletons) => { if (hydratedChild.dataPath === parentDataPath) { if (hydratedChild.data === undefined || hydratedChild.data === null) return if (childrenSkeletons && childrenSkeletons.length > 1) { for (const key of Object.keys(hydratedChild.data)) { if (!hydratedChild.skeleton.propertyKeys.includes(key)) continue if (!hydratedChild.skeleton.propertyKeys.includes(key) && childrenSkeletons.some(s => s !== hydratedChild.skeleton && s.propertyKeys.includes(key))) continue draft[key] = /** @type {any} */(hydratedChild.data)[key] } } else { Object.assign(draft, hydratedChild.data) } for (const key of hydratedChild.skeleton.propertyKeys) { if (/** @type {any} */(hydratedChild.data)[key] === undefined) { delete draft[key] } } } else { if (hydratedChild.data === undefined) delete draft[hydratedChild.key] else draft[hydratedChild.key] = hydratedChild.data } }) /** @type {(draft: Record<string, unknown>, parentData: Record<string, unknown>, propertyKeys: string[], patterns: string[]) => Record<string, unknown>} */ const producePatternPropertiesData = produce((draft, parentData, propertyKeys, patterns) => { for (const key of Object.keys(parentData)) { if (propertyKeys.includes(key)) continue if (!patterns.some(p => !!key.match(getRegexp(p)))) continue draft[key] = parentData[key] } for (const key of Object.keys(draft)) { if (!(key in parentData)) delete draft[key] } }) /** @type {(draft: import('./types.js').StateNodeOptions, parentNodeOptions: import('./types.js').StateNodeOptions, nodeOptions: Partial<import('./types.js').StateNodeOptions> | undefined) => import('./types.js').StateNodeOptions} */ const produceNodeOptions = produce((draft, parentNodeOptions, nodeOptions = {}) => { for (const key in parentNodeOptions) { draft[key] = nodeOptions[key] ?? parentNodeOptions[key] } for (const key in nodeOptions) { draft[key] = nodeOptions[key] } for (const key in draft) { if (!(key in parentNodeOptions) && !(key in nodeOptions)) { delete draft[key] } } }) /** @type {(draft: import('./types.js').StateNodeOptions) => import('./types.js').StateNodeOptions} */ const produceReadonlyArrayItemOptions = produce((draft) => { draft.readOnly = true draft.summary = true }) /** @type {(draft: import('./types.js').StateNodeOptions, section: import('@json-layout/vocabulary').CompositeCompObject) => import('./types.js').StateNodeOptions} */ const produceCompositeChildrenOptions = produce((draft, section) => { if (section.title && draft.titleDepth < 6) draft.titleDepth += 1 }) /** * should match if an error belongs to this exact node * @param {import('ajv').ErrorObject} error * @param {import('../index.js').SkeletonNode} skeleton * @param {string} dataPath * @param {string | null} parentDataPath * @returns {boolean} */ const matchLocalError = (error, skeleton, dataPath, parentDataPath) => { const originalError = error.params?.errors?.[0] ?? error if (parentDataPath === originalError.instancePath && originalError.params?.missingProperty === skeleton.key) { return true } if ( originalError.instancePath === dataPath && (originalError.schemaPath === skeleton.pointer || originalError.schemaPath === skeleton.refPointer) && !originalError.params?.missingProperty ) { return true } return false } /** * should match if an error belongs to a child of the current node but the child was not displayed * @param {import('../index.js').CompiledLayout} compiledLayout * @param {import('ajv').ErrorObject} error * @param {import('../index.js').SkeletonNode} skeleton * @param {string} dataPath * @param {string | null} parentDataPath * @returns {boolean} */ const matchChildError = (compiledLayout, error, skeleton, dataPath, parentDataPath) => { const originalError = error.params?.errors?.[0] ?? error if (!originalError.instancePath.startsWith(dataPath)) return false if (originalError.schemaPath === skeleton.pointer || originalError.schemaPath.startsWith(skeleton.pointer + '/') || originalError.schemaPath === skeleton.refPointer || originalError.schemaPath.startsWith(skeleton.refPointer + '/')) return true if (skeleton.children) { for (const c of skeleton.children) { const childSkeleton = compiledLayout.skeletonNodes[c] if (!childSkeleton) continue if (matchPointerError(error, childSkeleton.pointer, childSkeleton.refPointer, dataPath, parentDataPath)) return true } } if (skeleton.childrenTrees) { for (const c of skeleton.childrenTrees) { const childTree = compiledLayout.skeletonTrees[c] if (!childTree) continue if (matchPointerError(error, childTree.root, childTree.refPointer, dataPath, parentDataPath)) return true } } return false } /** * should match any error related to the data node no matter the origin in the schema * @param {import('ajv').ErrorObject} error * @param {string} dataPath * @returns {boolean} */ const matchDataPathError = (error, dataPath) => { const originalError = error.params?.errors?.[0] ?? error return originalError.instancePath.startsWith(dataPath) } /** * should match if an error belongs to a child of the current node but the child was not displayed * @param {import('ajv').ErrorObject} error * @param {string} pointer1 * @param {string} pointer2 * @param {string} dataPath * @param {string | null} parentDataPath * @returns {boolean} */ const matchPointerError = (error, pointer1, pointer2, dataPath, parentDataPath) => { const originalError = error.params?.errors?.[0] ?? error if (!originalError.instancePath.startsWith(dataPath)) return false if (originalError.schemaPath === pointer1 || originalError.schemaPath.startsWith(pointer1 + '/') || originalError.schemaPath === pointer2 || originalError.schemaPath.startsWith(pointer2 + '/') ) return true return false } /** * @param {import('../index.js').CompiledExpression[]} expressions * @param {import('@json-layout/vocabulary').Expression} expression * @param {any} data * @param {import('./types.js').StateNodeOptions} options * @param {import('./utils/display.js').Display} display * @param {import('@json-layout/vocabulary').BaseCompObject} layout * @param {Record<string, import('ajv').ValidateFunction>} validates * @param {unknown} rootData * @param {import('../compile/types.js').ParentContextExpression | null} parentContext * @returns {any} */ export function evalExpression (expressions, expression, data, options, display, layout, validates, rootData, parentContext) { if (expression.ref === undefined) throw new Error('expression was not compiled : ' + JSON.stringify(expression)) const compiledExpression = expressions[expression.ref] try { if (expression.pure) { return compiledExpression(data, data, options, options.context, display, layout, options.readOnly, options.summary, validates) } else { return compiledExpression(data, data, options, options.context, display, layout, options.readOnly, options.summary, validates, rootData, parentContext) } } catch (err) { /** @type {any} */ const info = { expression, data, context: options.context, display } info[expression.dataAlias] = data if (!expression.pure) { info.rootData = rootData info.parent = parentContext } console.warn('json-layout: failed to evaluate expression', err, info) throw new Error('json-layout: failed to evaluate expression') } } const noneComp = { comp: 'none' } /** * @param {import('@json-layout/vocabulary').NormalizedLayout} normalizedLayout * @param {import('@json-layout/vocabulary').Child | null} childDefinition * @param {import('./types.js').StateNodeOptions} options * @param {import('../index.js').CompiledLayout} compiledLayout * @param {import('./utils/display.js').Display} display * @param {unknown} data * @param {unknown} rootData * @param {import('../compile/types.js').ParentContextExpression | null} parentContext * @returns {import('@json-layout/vocabulary').BaseCompObject} */ const getCompObject = (normalizedLayout, childDefinition, options, compiledLayout, display, data, rootData, parentContext) => { if (isSwitchStruct(normalizedLayout)) { for (const compObject of normalizedLayout.switch) { if (!compObject.if || !!evalExpression(compiledLayout.expressions, compObject.if, data, options, display, compObject, compiledLayout.validates, rootData, parentContext)) { return compObject } } } else { if (childDefinition?.if && !evalExpression(compiledLayout.expressions, childDefinition.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)) { return noneComp } if (normalizedLayout.if && !evalExpression(compiledLayout.expressions, normalizedLayout.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)) { return noneComp } return normalizedLayout } return noneComp } /** * * @param {import('./types.js').CreateStateTreeContext} context * @param {import('./types.js').StateNodeOptions} parentOptions * @param {import('../index.js').CompiledLayout} compiledLayout * @param {string | number} key * @param {string} fullKey * @param {string | null} parentFullKey * @param {string} dataPath * @param {string | null} parentDataPath * @param {import('../index.js').SkeletonNode} skeleton * @param {import('@json-layout/vocabulary').Child | null} childDefinition * @param {import('./utils/display.js').Display} parentDisplay * @param {any} data * @param {import('../compile/types.js').ParentContextExpression | null} parentContext * @param {import('./types.js').ValidationState} validationState * @param {import('./types.js').StateNode} [reusedNode] * @returns {import('./types.js').StateNode} */ export function createStateNode ( context, parentOptions, compiledLayout, key, fullKey, parentFullKey, dataPath, parentDataPath, skeleton, childDefinition, parentDisplay, data, parentContext, validationState, reusedNode ) { logStateNode('createStateNode', fullKey) /** @type {import('./types.js').StateNodeCacheKey | null} */ let cacheKey = null // NOTE we have to exclude nodes with errors from the cache, because context.errors is unpurely modified // TODO: implement a cleaner way to filter context.errors while being able to reuse nodes with errors if (skeleton.pure && !reusedNode?.error && !reusedNode?.childError && !parentOptions.noStateCache) { const validatedCacheKey = validationState.validatedForm || validationState.validatedChildren.includes(fullKey) cacheKey = [ reusedNode, parentOptions, compiledLayout, fullKey, context.currentInput !== null && context.currentInput.startsWith(fullKey), skeleton, childDefinition, parentDisplay.width, validatedCacheKey, context.activatedItems, context.initial, context.rehydrateErrors?.length ?? 0, data ] if (reusedNode && context.cacheKeys[fullKey] && shallowEqualArray(context.cacheKeys[fullKey], cacheKey)) { logStateNode('createStateNode cache hit', fullKey) // @ts-ignore if (context._debugCache) context._debugCache[fullKey] = (context._debugCache[fullKey] ?? []).concat(['hit']) if (reusedNode.layout.comp === 'list' && reusedNode.layout.getItems) { logGetItems(fullKey, 'list component node is fully reused from cache, no fetch will be triggered') } return reusedNode } else { logStateNode('createStateNode cache miss', fullKey) // @ts-ignore if (context._debugCache) context._debugCache[fullKey] = (context._debugCache[fullKey] ?? []).concat(['miss']) } } else { logStateNode('createStateNode cache skip', fullKey) // @ts-ignore if (context._debugCache) context._debugCache[fullKey] = (context._debugCache[fullKey] ?? []).concat(['skip']) } const normalizedLayout = childDefinition && (childIsCompositeCompObject(childDefinition) || childIsSlotCompObject(childDefinition)) ? childDefinition : compiledLayout.normalizedLayouts[skeleton.pointer] const layout = getCompObject(normalizedLayout, childDefinition, parentOptions, compiledLayout, parentDisplay, data, context.rootData, parentContext) const [display, cols] = getChildDisplay(parentDisplay, childDefinition?.cols ?? layout.cols) const slots = childDefinition?.slots ?? layout.slots const options = layout.getOptions ? produceNodeOptions( reusedNode?.options ?? /** @type {import('./types.js').StateNodeOptions} */({}), parentOptions, evalExpression(compiledLayout.expressions, layout.getOptions, data, parentOptions, display, layout, compiledLayout.validates, context.rootData, parentContext) ) : parentOptions if (context.initial && parentOptions.autofocus && layout.autofocus && layout.comp !== 'none') { context.autofocusTarget = fullKey } let nodeData = data if (nodeData === null && !layout.nullable) nodeData = undefined const shouldUseDefaultData = layout.getDefaultData && useDefaultData(nodeData, layout, options) && context.currentInput !== fullKey if (layout.getConstData) { if (!context.rehydrate) { nodeData = evalExpression(compiledLayout.expressions, layout.getConstData, nodeData, options, display, layout, compiledLayout.validates, context.rootData, parentContext) } } else { if (shouldUseDefaultData && layout.getDefaultData && !context.rehydrate) { const defaultData = evalExpression(compiledLayout.expressions, layout.getDefaultData, nodeData, options, display, layout, compiledLayout.validates, context.rootData, parentContext) if (nodeData === undefined || !isDataEmpty(defaultData)) { nodeData = defaultData } } } /** @type {import('./types.js').StateNode[] | undefined} */ let children if (isCompositeLayout(layout, compiledLayout.components)) { // TODO: make this type casting safe using prior validation const objectData = /** @type {Record<string, unknown>} */(nodeData ?? {}) const childrenOptions = produceCompositeChildrenOptions(options, layout) let actualPropertyKeys = skeleton.propertyKeys const removePropertyKeys = options.readOnlyPropertiesMode === 'remove' ? skeleton.roPropertyKeys : [] children = [] let focusChild = context.autofocusTarget === fullKey for (let i = 0; i < layout.children.length; i++) { const childLayout = layout.children[i] if ( ['remove', 'hide'].includes(options.readOnlyPropertiesMode) && skeleton.roPropertyKeys?.includes(/** @type {string} */(childLayout.key)) ) continue let childSkeleton = skeleton const childSkeletonKey = skeleton.children?.find(c => compiledLayout.skeletonNodes[c].key === childLayout.key) if (childSkeletonKey !== undefined) childSkeleton = compiledLayout.skeletonNodes[childSkeletonKey] if (childSkeleton.condition) { if (!evalExpression(compiledLayout.expressions, childSkeleton.condition, objectData, parentOptions, display, layout, compiledLayout.validates, context.rootData, parentContext)) { if (childLayout.key === '$then' || childLayout.key === '$else') { const reverseChildSkeletonKey = skeleton.children?.find(c => compiledLayout.skeletonNodes[c].key === (childLayout.key === '$then' ? '$else' : '$then')) const reverseChildSkeleton = reverseChildSkeletonKey !== undefined && compiledLayout.skeletonNodes[reverseChildSkeletonKey] if (reverseChildSkeleton) { for (const key of childSkeleton.propertyKeys) { if (!reverseChildSkeleton.propertyKeys.includes(key)) { removePropertyKeys.push(key) } } } } continue } } const isSameDataPath = typeof childLayout.key === 'string' && childLayout.key.startsWith('$') const childFullKey = `${fullKey}/${childLayout.key}` if (focusChild) context.autofocusTarget = childFullKey const ogChildData = isSameDataPath ? objectData : objectData[childLayout.key] let childData = ogChildData if (childLayout.key === '$patternProperties') { const childNormalizedLayout = /** @type {import('@json-layout/vocabulary').List} */(compiledLayout.normalizedLayouts[childSkeleton.pointer]) childData = producePatternPropertiesData( /** @type {Record<string, unknown>} */(reusedNode?.children?.find(c => c.key === '$patternProperties')?.data ?? {}), /** @type {Record<string, unknown>} */(objectData), skeleton.propertyKeys, childNormalizedLayout.indexed ?? [] ) actualPropertyKeys = actualPropertyKeys.concat(Object.keys(/** @type {any} */(childData))) } const child = createStateNode( context, childrenOptions, compiledLayout, childLayout.key, childFullKey, fullKey, isSameDataPath ? dataPath : `${dataPath}/${childLayout.key}`, dataPath, childSkeleton, childLayout, display, childData, { parent: parentContext, data: objectData }, validationState, reusedNode?.children?.find(c => c.fullKey === childFullKey) ) if (child.autofocus || child.autofocusChild !== undefined) focusChild = false // the child data was hydrated if (child.data !== ogChildData) { // WARN: this manner of creating empty object or array based on the type of the child key does not seem very safe // can we find a better way to discriminate ? maybe store an extra info on the the skeleton, like "emptyData" ? nodeData = produceHydratedStateNodeData( nodeData ?? ((typeof child.key === 'number' && key !== '$oneOf') ? [] : {}), dataPath, child, skeleton.children?.map(childSkeletonKey => compiledLayout.skeletonNodes[childSkeletonKey]) ) } children.push(child) } const removeAdditional = [true, 'unknown'].includes(options.removeAdditional) || children?.some(c => c.key === '$patternProperties') nodeData = cleanNodeData( /** @type {Record<string, unknown>} */(nodeData ?? {}), dataPath, context.additionalPropertiesErrors, removeAdditional ? actualPropertyKeys : undefined, removePropertyKeys ) } if (skeleton.nullable && context.errors) { // remove errors related to the management of nullable context.errors = context.errors.filter(error => { if (!matchDataPathError(error, dataPath)) return true if (error.keyword === 'anyOf') return false if (error.keyword === 'type' && error.params?.type === 'null') return false return true }) } if (key === '$oneOf' && skeleton.childrenTrees) { // find the oneOf child that was either previously selected // or the one matching the specified discriminator // or the one that is valid with current data let activeChildTreeIndex = /** @type {number} */context.activatedItems[fullKey] const validChildTreeIndex = skeleton.childrenTrees?.findIndex((childTree) => compiledLayout.validates[compiledLayout.skeletonTrees[childTree].refPointer](data)) if (activeChildTreeIndex === undefined) { if (skeleton.discriminator !== undefined && validChildTreeIndex === -1) { activeChildTreeIndex = skeleton.childrenTrees?.findIndex((childTree) => skeleton.discriminator !== undefined && data?.[skeleton.discriminator] !== undefined && data[skeleton.discriminator] === compiledLayout.skeletonTrees[childTree].discriminatorValue) } else { activeChildTreeIndex = validChildTreeIndex } } if (activeChildTreeIndex !== -1) { const activeChildTree = compiledLayout.skeletonTrees[skeleton.childrenTrees[activeChildTreeIndex]] const activeChildNode = compiledLayout.skeletonNodes[activeChildTree.root] if (!(fullKey in context.activatedItems)) context.autoActivatedItems[fullKey] = activeChildTreeIndex context.errors = context.errors?.filter(error => { // if an item was selected, remove the oneOf error if (matchLocalError(error, skeleton, dataPath, parentDataPath)) { return false } // also remove the errors from other children of the oneOf if (matchChildError(compiledLayout, error, skeleton, dataPath, parentDataPath) && !matchPointerError(error, activeChildNode.pointer, activeChildNode.refPointer, dataPath, parentDataPath)) { return false } return true }) if (context.additionalPropertiesErrors?.length) { // exclude the additional properties errors from the other children context.additionalPropertiesErrors = context.additionalPropertiesErrors?.filter(error => { // keep errors from other parts of the data if (!matchDataPathError(error, dataPath)) return true // keep errors from the active child's schema if (matchPointerError(error, activeChildNode.pointer, activeChildNode.refPointer, dataPath, parentDataPath)) return true // remove errors from other children if (matchChildError(compiledLayout, error, skeleton, dataPath, parentDataPath) && !matchPointerError(error, activeChildNode.pointer, activeChildNode.refPointer, dataPath, parentDataPath)) return false // ignore unevaluatedProperties errors from higher level that can be triggered because the active element is not yet valid // TODO: should the last check include comparing with activeChildNode.propertyKeys ? if (validChildTreeIndex === -1) return false return true }) } const activeChildKey = `${fullKey}/${activeChildTreeIndex}` if (context.autofocusTarget === fullKey) context.autofocusTarget = activeChildKey const child = createStateNode( context, options, compiledLayout, activeChildTreeIndex, activeChildKey, fullKey, dataPath, dataPath, activeChildNode, null, display, nodeData, { parent: parentContext, data: nodeData }, validationState, reusedNode?.children?.[0] ) // the oneOf was hydrated if (child.data !== nodeData) { // nodeData = produceHydratedStateNodeData(nodeData, dataPath, child, skeleton.children?.map(childSkeletonKey => compiledLayout.skeletonNodes[childSkeletonKey])) nodeData = child.data } children = [child] } } if (isListLayout(layout)) { if (layout.indexed) { const objectData = /** @type {Record<string, unknown>} */(nodeData ?? []) const listItemOptions = layout.listEditMode === 'inline' ? options : produceReadonlyArrayItemOptions(options) children = [] let focusChild = context.autofocusTarget === fullKey const childrenKeys = Object.keys(objectData) for (let i = 0; i < childrenKeys.length; i++) { const childKey = childrenKeys[i] let valueChildSkeleton = /** @type {import('../index.js').SkeletonNode | null} */ null if (skeleton?.childrenTrees?.length === 1) { valueChildSkeleton = compiledLayout.skeletonNodes[compiledLayout.skeletonTrees[skeleton?.childrenTrees[0]]?.root] } else { for (let p = 0; p < layout.indexed.length; p++) { const pattern = layout.indexed[p] const childTreeKey = skeleton?.childrenTrees?.[p] if (!childTreeKey) throw new Error(`missing skeleton tree for pattern ${pattern}`) if (childKey.match(getRegexp(pattern))) { valueChildSkeleton = compiledLayout.skeletonNodes[compiledLayout.skeletonTrees[childTreeKey]?.root] } } } if (valueChildSkeleton) { const childFullKey = `${fullKey}/${childKey}` if (focusChild) context.autofocusTarget = childFullKey const valueChildData = objectData[childKey] const valueChild = createStateNode( context, (layout.listEditMode === 'inline-single' && context.activatedItems[fullKey] === i) ? options : listItemOptions, compiledLayout, childKey, childFullKey, fullKey, `${dataPath}/${childKey}`, dataPath, valueChildSkeleton, null, display, valueChildData, { parent: parentContext, data: objectData }, validationState, reusedNode?.children?.find(c => c.key === childKey) ) if (valueChild.autofocus || valueChild.autofocusChild !== undefined) focusChild = false children.push(valueChild) if (valueChild.data !== valueChildData) { // WARN: this manner of creating empty object or array based on the type of the child key does not seem very safe // can we find a better way to discriminate ? maybe store an extra info on the the skeleton, like "emptyData" ? nodeData = produceHydratedStateNodeData( nodeData ?? {}, dataPath, valueChild, skeleton.children?.map(childSkeletonKey => compiledLayout.skeletonNodes[childSkeletonKey]) ) } } } } else { const arrayData = /** @type {unknown[]} */(nodeData ?? []) const childSkeleton = /** @type {import('../index.js').SkeletonNode} */(skeleton?.childrenTrees?.[0] && compiledLayout.skeletonNodes[compiledLayout.skeletonTrees[skeleton?.childrenTrees?.[0]]?.root]) const listItemOptions = layout.listEditMode === 'inline' ? options : produceReadonlyArrayItemOptions(options) children = [] let focusChild = context.autofocusTarget === fullKey for (let i = 0; i < arrayData.length; i++) { const itemData = arrayData[i] const childFullKey = `${fullKey}/${i}` if (focusChild) context.autofocusTarget = childFullKey const child = createStateNode( context, (layout.listEditMode === 'inline-single' && context.activatedItems[fullKey] === i) ? options : listItemOptions, compiledLayout, i, childFullKey, fullKey, `${dataPath}/${i}`, dataPath, childSkeleton, null, display, itemData, { parent: parentContext, data: arrayData }, validationState, reusedNode?.children?.[i] ) if (child.autofocus || child.autofocusChild !== undefined) focusChild = false children.push(child) } // duplicate active child at the end of the list in case of dialog/menu edition if (context.activatedItems[fullKey] !== undefined && (layout.listEditMode === 'menu' || layout.listEditMode === 'dialog')) { const i = context.activatedItems[fullKey] const activeChild = createStateNode( context, options, compiledLayout, i, `${fullKey}/${i}`, fullKey, `${dataPath}/${i}`, dataPath, childSkeleton, null, display, arrayData[i], { parent: parentContext, data: arrayData }, validationState, reusedNode?.children?.[i] ) children.push(activeChild) } } } let error = context.errors?.find(e => matchLocalError(e, skeleton, dataPath, parentDataPath)) // findLast in following lines is important because we want to keep the error of the highest child (deepest errors are listed first) if (!error && !isCompositeLayout(layout, compiledLayout.components) && layout.comp !== 'slot') { error = context.errors?.findLast(e => matchChildError(compiledLayout, e, skeleton, dataPath, parentDataPath)) } if (!error && context.rehydrate && context.rehydrateErrors) { error = context.rehydrateErrors?.findLast(e => matchChildError(compiledLayout, e, skeleton, dataPath, parentDataPath)) } // capture errors so that they are not repeated in parent nodes if (layout.comp !== 'none') { if (error) { logValidation(`${fullKey} capture validation error on node`, error) context.errors = context.errors?.filter(e => error !== e) if (!isCompositeLayout(layout, compiledLayout.components) && layout.comp !== 'slot') { context.errors = context.errors?.filter(e => !matchChildError(compiledLayout, e, skeleton, dataPath, parentDataPath)) } if (context.rehydrate && context.rehydrateErrors) { context.rehydrateErrors = context.rehydrateErrors?.filter(e => !matchChildError(compiledLayout, e, skeleton, dataPath, parentDataPath)) } } } if (!layout.getConstData) { if (typeof children?.[0]?.key === 'number' && layout.comp !== 'one-of-select' && !layout.indexed) { // case of an array of children nodes nodeData = produceStateNodeDataChildrenArray( /** @type {unknown[]} */(nodeData ?? []), children ) } else if (Array.isArray(nodeData) && isItemsLayout(layout, compiledLayout.components)) { // case of a multi-valued select of objects const itemsSkeletonTree = skeleton.childrenTrees?.[0] && compiledLayout.skeletonTrees[skeleton.childrenTrees?.[0]] const itemsSkeletonNode = (itemsSkeletonTree && compiledLayout.skeletonNodes[itemsSkeletonTree.root]) || null nodeData = produceStateNodeDataArray( /** @type {Record<string, unknown>[]} */(nodeData ?? []), dataPath, context.additionalPropertiesErrors, itemsSkeletonNode?.propertyKeys.length ? itemsSkeletonNode?.propertyKeys : undefined, options.readOnlyPropertiesMode === 'remove' ? itemsSkeletonNode?.roPropertyKeys : undefined ) } else if (typeof nodeData === 'object' && !(nodeData instanceof File) && isItemsLayout(layout, compiledLayout.components)) { // case of a select of objects nodeData = cleanNodeData( /** @type {Record<string, unknown>} */(nodeData ?? {}), dataPath, context.additionalPropertiesErrors, skeleton?.propertyKeys.length ? skeleton?.propertyKeys : undefined, options.readOnlyPropertiesMode === 'remove' ? skeleton?.roPropertyKeys : undefined ) } // the producer is not perfect, sometimes data is considered mutated but it is actually the same // we double check here to avoid unnecessary re-renders if (nodeData !== data) { if (Array.isArray(data) && Array.isArray(nodeData)) nodeData = shallowProduceArray(data, nodeData) // @ts-ignore else if (typeof data === 'object' && typeof nodeData === 'object') nodeData = shallowProduceObject(data, nodeData) } if (!(shouldUseDefaultData) && isDataEmpty(nodeData)) { if (layout.nullable) { // if a property is nullable empty values are converted to null // except for undefined if we need to distinguish between empty and missing data if (options.defaultOn !== 'missing' || nodeData !== undefined) { nodeData = null } } else if (options.defaultOn !== 'missing') { // remove empty data, except if we need to distinguish between empty and missing data nodeData = undefined } } } const validated = validationState.validatedForm || validationState.validatedChildren.includes(fullKey) || (validationState.initialized === false && options.initialValidation === 'always') || (validationState.initialized === false && options.initialValidation === 'withData' && !isDataEmpty(nodeData)) let props if (layout.getProps) { props = evalExpression(compiledLayout.expressions, layout.getProps, nodeData, options, display, layout, compiledLayout.validates, context.rootData, parentContext) } /** @type {any} */ let itemsCacheKey if (isItemsLayout(layout, compiledLayout.components)) { // prefetch items or preresolve url and store a key whose changes can be monitored to re-trigger actual fetch if (layout.items) itemsCacheKey = layout.items else if (layout.getItems?.immutable && reusedNode?.itemsCacheKey) itemsCacheKey = reusedNode.itemsCacheKey else if (layout.getItems && isGetItemsExpression(layout.getItems)) { if (layout.getItems.immutable && reusedNode?.itemsCacheKey) { itemsCacheKey = reusedNode.itemsCacheKey } else { try { itemsCacheKey = evalExpression(compiledLayout.expressions, layout.getItems, nodeData, options, display, layout, compiledLayout.validates, context.rootData, parentContext) } catch (err) { itemsCacheKey = null } } } else if (layout.getItems && isGetItemsFetch(layout.getItems)) { try { const urlExprResult = evalExpression(compiledLayout.expressions, layout.getItems.url, null, options, display, layout, compiledLayout.validates, context.rootData, parentContext) const url = pathURL(urlExprResult, options.fetchBaseURL) if (layout.getItems.searchParams) { for (const [key, expr] of Object.entries(layout.getItems.searchParams)) { let val try { val = evalExpression(compiledLayout.expressions, expr, null, options, display, layout, compiledLayout.validates, context.rootData, parentContext) if (val) url.searchParams.set(key, val) } catch (err) { // nothing to o } } } if (layout.getItems.headers) { for (const [key, expr] of Object.entries(layout.getItems.headers)) { let val try { val = evalExpression(compiledLayout.expressions, expr, null, options, display, layout, compiledLayout.validates, context.rootData, parentContext) if (val) url.searchParams.set('__jl__header__' + key, val) } catch (err) { // nothing to o } } } itemsCacheKey = url.href } catch (err) { console.warn('failed to process URL for getItems', err) itemsCacheKey = null } } } const autofocus = isFocusableLayout(layout, compiledLayout.components) && !options.readOnly && !options.summary && context.autofocusTarget === fullKey const shouldLoadData = layout.comp === 'list' && itemsCacheKey && reusedNode?.itemsCacheKey !== itemsCacheKey if (shouldLoadData) { logGetItems(fullKey, 'list component with getItems expression registered for fetch', itemsCacheKey) } else if (layout.comp === 'list' && itemsCacheKey) { logGetItems(fullKey, 'list component with unchanged getItems cache key, no fetch will be triggered', itemsCacheKey) } const node = produceStateNode( reusedNode ?? /** @type {import('./types.js').StateNode} */({}), key, fullKey, parentFullKey, dataPath, parentDataPath, skeleton, layout, display.width, cols, nodeData, error?.message, validated, options, autofocus, shouldLoadData, props, slots, itemsCacheKey, children && shallowProduceArray(reusedNode?.children, children) ) if (cacheKey) { cacheKey[0] = node context.cacheKeys[fullKey] = cacheKey } if (shouldLoadData) context.getItemsDataRequests.push(node) return node } /** @type {(draft: any, node: import('./types.js').StateNode, data: unknown) => any} */ export const producePatchedData = produce((draft, node, data) => { if (node.dataPath === node.parentDataPath) { Object.assign(draft, data) // remove properties that previously came from this merged child and were removed if (node.data && typeof data === 'object' && data !== null) { for (const key of Object.keys(node.data)) { if (!(key in data)) { delete draft[key] } } } } else { draft[node.key] = data } }) /** @type {(existingData: any[], existingItems: import('@json-layout/vocabulary').SelectItems, newItems: import('@json-layout/vocabulary').SelectItems) => any} */ export const produceListData = (existingData, existingItems, newItems) => { const data = [] for (const item of newItems) { const existingItem = existingItems.find(i => i.key === item.key) if (existingItem) { data.push(existingData[existingItems.indexOf(existingItem)]) } else { data.push(item.value) } } return shallowProduceArray(existingData, data) }