From 7c8f7debeb372c083f9a64e856aa839811aed745 Mon Sep 17 00:00:00 2001 From: "S. M. Mir-Ismaili" Date: Thu, 23 Dec 2021 13:19:50 +0330 Subject: [PATCH] Fix "dot-separated" paths by making paths type `string[]` (instead of `string`) for react (in these packages: "core", "react" and "material-renderers") eclipsesource/jsonforms#1849 eclipsesource/jsonforms#1831 --- packages/core/src/actions/actions.ts | 4 +- packages/core/src/i18n/i18nUtil.ts | 9 +-- packages/core/src/reducers/core.ts | 55 ++++++------- packages/core/src/reducers/reducers.ts | 6 +- packages/core/src/reducers/uischemas.ts | 4 +- packages/core/src/util/combinators.ts | 2 +- packages/core/src/util/path.ts | 49 ++++++------ packages/core/src/util/renderer.ts | 36 ++++----- packages/core/src/util/resolvers.ts | 19 +++-- packages/core/src/util/runtime.ts | 14 ++-- packages/core/src/util/util.ts | 10 +-- packages/core/test/util/renderer.test.ts | 4 +- .../MaterialListWithDetailRenderer.tsx | 4 +- .../src/complex/CombinatorProperties.tsx | 2 +- .../material/src/complex/DeleteDialog.tsx | 2 +- .../complex/MaterialArrayControlRenderer.tsx | 9 ++- .../src/complex/MaterialTableControl.tsx | 79 ++++++++++--------- .../material/src/complex/TableToolbar.tsx | 4 +- .../material/src/layouts/ArrayToolbar.tsx | 4 +- .../src/layouts/ExpandPanelRenderer.tsx | 32 ++++---- .../src/layouts/MaterialArrayLayout.tsx | 17 ++-- .../layouts/MaterialArrayLayoutRenderer.tsx | 2 +- packages/material/src/util/datejs.ts | 4 +- packages/material/src/util/debounce.ts | 5 +- packages/material/src/util/layout.tsx | 2 +- packages/react/src/JsonForms.tsx | 8 +- .../react/test/renderers/JsonForms.test.tsx | 7 +- 27 files changed, 204 insertions(+), 189 deletions(-) diff --git a/packages/core/src/actions/actions.ts b/packages/core/src/actions/actions.ts index aed400b0ae..f3a8149d37 100644 --- a/packages/core/src/actions/actions.ts +++ b/packages/core/src/actions/actions.ts @@ -72,7 +72,7 @@ export type CoreActions = export interface UpdateAction { type: 'jsonforms/UPDATE'; - path: string; + path: string[]; updater(existingData?: any): any; } @@ -167,7 +167,7 @@ export const setAjv = (ajv: AJV) => ({ }); export const update = ( - path: string, + path: string[], updater: (existingData: any) => any ): UpdateAction => ({ type: UPDATE_DATA, diff --git a/packages/core/src/i18n/i18nUtil.ts b/packages/core/src/i18n/i18nUtil.ts index 75f6ffd6b2..4ee280344e 100644 --- a/packages/core/src/i18n/i18nUtil.ts +++ b/packages/core/src/i18n/i18nUtil.ts @@ -15,10 +15,9 @@ export const getI18nKeyPrefixBySchema = ( * Transforms a given path to a prefix which can be used for i18n keys. * Returns 'root' for empty paths and removes array indices */ -export const transformPathToI18nPrefix = (path: string) => { +export const transformPathToI18nPrefix = (path: string[]) => { return ( path - ?.split('.') .filter(segment => !/^\d+$/.test(segment)) .join('.') || 'root' ); @@ -27,7 +26,7 @@ export const transformPathToI18nPrefix = (path: string) => { export const getI18nKeyPrefix = ( schema: i18nJsonSchema | undefined, uischema: UISchemaElement | undefined, - path: string | undefined + path: string[] | undefined ): string | undefined => { return ( getI18nKeyPrefixBySchema(schema, uischema) ?? @@ -38,7 +37,7 @@ export const getI18nKeyPrefix = ( export const getI18nKey = ( schema: i18nJsonSchema | undefined, uischema: UISchemaElement | undefined, - path: string | undefined, + path: string[] | undefined, key: string ): string | undefined => { return `${getI18nKeyPrefix(schema, uischema, path)}.${key}`; @@ -89,7 +88,7 @@ export const getCombinedErrorMessage = ( t: Translator, schema?: i18nJsonSchema, uischema?: UISchemaElement, - path?: string + path?: string[], ) => { if (errors.length > 0 && t) { // check whether there is a special message which overwrites all others diff --git a/packages/core/src/reducers/core.ts b/packages/core/src/reducers/core.ts index 9f398982a4..71c1e971f8 100644 --- a/packages/core/src/reducers/core.ts +++ b/packages/core/src/reducers/core.ts @@ -39,12 +39,12 @@ import { SET_SCHEMA, SET_UISCHEMA, SET_VALIDATION_MODE, + UPDATE_CORE, UPDATE_DATA, UPDATE_ERRORS, - UPDATE_CORE, UpdateCoreAction } from '../actions'; -import { createAjv, Reducer } from '../util'; +import { pathsAreEqual, createAjv, pathStartsWith, Reducer } from '../util'; import { JsonSchema, UISchemaElement } from '../models'; export const validate = (validator: ValidateFunction | undefined, data: any): ErrorObject[] => { @@ -184,7 +184,7 @@ export const coreReducer: Reducer = ( state.ajv !== thisAjv || state.errors !== errors || state.validator !== validator || - state.validationMode !== validationMode + state.validationMode !== validationMode; return stateChanged ? { ...state, @@ -230,7 +230,8 @@ export const coreReducer: Reducer = ( case UPDATE_DATA: { if (action.path === undefined || action.path === null) { return state; - } else if (action.path === '') { + } + if (action.path.length === 0) { // empty path is ok const result = action.updater(cloneDeep(state.data)); const errors = validate(state.validator, result); @@ -317,32 +318,29 @@ export const getControlPath = (error: ErrorObject) => { return dataPath.replace(/\//g, '.').substr(1); } // dataPath was renamed to instancePath in AJV v8 - var controlPath: string = error.instancePath; + const controlPath = error.instancePath + .replace(/^\//, '') // remove leading slash + .split('/'); // convert to string[] - // change '/' chars to '.' - controlPath = controlPath.replace(/\//g, '.'); - const invalidProperty = getInvalidProperty(error); - if (invalidProperty !== undefined && !controlPath.endsWith(invalidProperty)) { - controlPath = `${controlPath}.${invalidProperty}`; + if (invalidProperty !== undefined && controlPath.at(-1) !== invalidProperty) { + controlPath.push(invalidProperty); } - - // remove '.' chars at the beginning of paths - controlPath = controlPath.replace(/^./, ''); + return controlPath; -} +}; export const errorsAt = ( - instancePath: string, + instancePath: string[], schema: JsonSchema, - matchPath: (path: string) => boolean + matchPath: (path: string[]) => boolean ) => (errors: ErrorObject[]): ErrorObject[] => { // Get data paths of oneOf and anyOf errors to later determine whether an error occurred inside a subschema of oneOf or anyOf. const combinatorPaths = filter( errors, error => error.keyword === 'oneOf' || error.keyword === 'anyOf' ).map(error => getControlPath(error)); - + return filter(errors, error => { // Filter errors that match any keyword that we don't want to show in the UI if (filteredErrorKeywords.indexOf(error.keyword) !== -1) { @@ -360,8 +358,8 @@ export const errorsAt = ( // because the parent schema can never match the property schema (e.g. for 'required' checks). const parentSchema: JsonSchema | undefined = error.parentSchema; if (result && !isObjectSchema(parentSchema) - && combinatorPaths.findIndex(p => instancePath.startsWith(p)) !== -1) { - result = result && isEqual(parentSchema, schema); + && combinatorPaths.some(combinatorPath => pathStartsWith(instancePath, combinatorPath))) { + result = isEqual(parentSchema, schema); } return result; }); @@ -372,7 +370,7 @@ export const errorsAt = ( */ const isObjectSchema = (schema?: JsonSchema): boolean => { return schema?.type === 'object' || !!schema?.properties; -} +}; /** * The error-type of an AJV error is defined by its `keyword` property. @@ -388,13 +386,16 @@ const isObjectSchema = (schema?: JsonSchema): boolean => { const filteredErrorKeywords = ['additionalProperties', 'allOf', 'anyOf', 'oneOf']; const getErrorsAt = ( - instancePath: string, + instancePath: string[], schema: JsonSchema, - matchPath: (path: string) => boolean + matchPath: (path: string[]) => boolean ) => (state: JsonFormsCore): ErrorObject[] => - errorsAt(instancePath, schema, matchPath)(state.validationMode === 'ValidateAndHide' ? [] : state.errors); + errorsAt(instancePath, schema, matchPath)( + state.validationMode === 'ValidateAndHide' ? [] : state.errors + ); + +export const errorAt = (instancePath: string[], schema: JsonSchema) => + getErrorsAt(instancePath, schema, path => pathsAreEqual(path, instancePath)); -export const errorAt = (instancePath: string, schema: JsonSchema) => - getErrorsAt(instancePath, schema, path => path === instancePath); -export const subErrorsAt = (instancePath: string, schema: JsonSchema) => - getErrorsAt(instancePath, schema, path => path.startsWith(instancePath)); +export const subErrorsAt = (instancePath: string[], schema: JsonSchema) => + getErrorsAt(instancePath, schema, path => pathStartsWith(path, instancePath)); diff --git a/packages/core/src/reducers/reducers.ts b/packages/core/src/reducers/reducers.ts index c719b3c95d..c51f4ed5d8 100644 --- a/packages/core/src/reducers/reducers.ts +++ b/packages/core/src/reducers/reducers.ts @@ -74,7 +74,7 @@ export const findUISchema = ( uischemas: JsonFormsUISchemaRegistryEntry[], schema: JsonSchema, schemaPath: string, - path: string, + path: string[], fallbackLayoutType = 'VerticalLayout', control?: ControlElement, rootSchema?: JsonSchema @@ -104,13 +104,13 @@ export const findUISchema = ( return uiSchema; }; -export const getErrorAt = (instancePath: string, schema: JsonSchema) => ( +export const getErrorAt = (instancePath: string[], schema: JsonSchema) => ( state: JsonFormsState ) => { return errorAt(instancePath, schema)(state.jsonforms.core); }; -export const getSubErrorsAt = (instancePath: string, schema: JsonSchema) => ( +export const getSubErrorsAt = (instancePath: string[], schema: JsonSchema) => ( state: JsonFormsState ) => subErrorsAt(instancePath, schema)(state.jsonforms.core); diff --git a/packages/core/src/reducers/uischemas.ts b/packages/core/src/reducers/uischemas.ts index 3ebe2e230f..631740e554 100644 --- a/packages/core/src/reducers/uischemas.ts +++ b/packages/core/src/reducers/uischemas.ts @@ -33,7 +33,7 @@ import { Reducer } from '../util'; export type UISchemaTester = ( schema: JsonSchema, schemaPath: string, - path: string + path: string[], ) => number; export interface JsonFormsUISchemaRegistryEntry { @@ -64,7 +64,7 @@ export const findMatchingUISchema = ( ) => ( jsonSchema: JsonSchema, schemaPath: string, - path: string + path: string[], ): UISchemaElement => { const match = maxBy(state, entry => entry.tester(jsonSchema, schemaPath, path) diff --git a/packages/core/src/util/combinators.ts b/packages/core/src/util/combinators.ts index c89496f5db..25ea71d7f3 100644 --- a/packages/core/src/util/combinators.ts +++ b/packages/core/src/util/combinators.ts @@ -70,7 +70,7 @@ export const createCombinatorRenderInfos = ( rootSchema: JsonSchema, keyword: CombinatorKeyword, control: ControlElement, - path: string, + path: string[], uischemas: JsonFormsUISchemaRegistryEntry[] ): CombinatorSubSchemaRenderInfo[] => combinatorSubSchemas.map((subSchema, subSchemaIndex) => ({ diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts index 24b4ebd8af..5a6caa8325 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -27,19 +27,8 @@ import isEmpty from 'lodash/isEmpty'; import range from 'lodash/range'; import { Scopable } from '../models'; -export const compose = (path1: string, path2: string) => { - let p1 = path1; - if (!isEmpty(path1) && !isEmpty(path2) && !path2.startsWith('[')) { - p1 = path1 + '.'; - } - - if (isEmpty(p1)) { - return path2; - } else if (isEmpty(path2)) { - return p1; - } else { - return `${p1}${path2}`; - } +export const compose = (path1: string[], path2: string[] | string) => { + return path1?.concat(path2 ?? []); }; export { compose as composePaths }; @@ -66,25 +55,41 @@ export const toDataPathSegments = (schemaPath: string): string[] => { const startIndex = startFromRoot ? 2 : 1; return range(startIndex, segments.length, 2).map(idx => segments[idx]); }; - +// TODO: `toDataPathSegments` and `toDataPath` are the same! /** * Remove all schema-specific keywords (e.g. 'properties') from a given path. * @example - * toDataPath('#/properties/foo/properties/bar') === '#/foo/bar') + * toDataPath('#/properties/foo/properties/bar') === ['foo', 'bar']) * * @param {string} schemaPath the schema path to be converted - * @returns {string} the path without schema-specific keywords + * @returns {string[]} the path without schema-specific keywords */ -export const toDataPath = (schemaPath: string): string => { - return toDataPathSegments(schemaPath).join('.'); -}; +export const toDataPath = (schemaPath: string): string[] => toDataPathSegments(schemaPath); -export const composeWithUi = (scopableUi: Scopable, path: string): string => { +export const composeWithUi = (scopableUi: Scopable, path: string[]): string[] => { const segments = toDataPathSegments(scopableUi.scope); if (isEmpty(segments) && path === undefined) { - return ''; + return []; } - return isEmpty(segments) ? path : compose(path, segments.join('.')); + return isEmpty(segments) ? path : compose(path, segments); }; + +/** + * Check if two paths are equal (section by section) + */ +export const pathsAreEqual = (path1: string[], path2: string[]) => + path2.length === path1.length && path2.every((section, i) => section === path1[i]); + +/** + * Check if a path starts with another path (`subPath`) + */ +export const pathStartsWith = (path: string[], subPath: string[]) => + subPath.length <= path.length && subPath.every((section, i) => section === path[i]); + +/** + * Convert path `array` to a `string`, injectively (in a reversible way) + */ +export const stringifyPath = (path: string[]) => + path.map(segment => encodeURIComponent(segment)).join('/'); diff --git a/packages/core/src/util/renderer.ts b/packages/core/src/util/renderer.ts index ebd6388dbc..a17cedd61b 100644 --- a/packages/core/src/util/renderer.ts +++ b/packages/core/src/util/renderer.ts @@ -238,7 +238,7 @@ export interface OwnPropsOfRenderer { * path can not be inferred via the UI schema element as * it is the case with nested controls. */ - path?: string; + path?: string[]; renderers?: JsonFormsRendererRegistryEntry[]; @@ -297,7 +297,7 @@ export interface StatePropsOfRenderer { /** * Instance path the data is written to, in case of a control. */ - path: string; + path: string[]; /** * All available renderers. @@ -378,7 +378,7 @@ export interface DispatchPropsOfControl { * @param {string} path the path to the data to be updated * @param {any} value the new value that should be written to the given path */ - handleChange(path: string, value: any): void; + handleChange(path: string[], value: any): void; } /** @@ -637,10 +637,10 @@ export interface StatePropsOfControlWithDetail extends StatePropsOfControl { export interface OwnPropsOfMasterListItem { index: number; selected: boolean; - path: string; + path: string[]; schema: JsonSchema; handleSelect(index: number): () => void; - removeItem(path: string, value: number): () => void; + removeItem(path: string[], value: number): () => void; } export interface StatePropsOfMasterItem extends OwnPropsOfMasterListItem { @@ -712,10 +712,10 @@ export const mapStateToArrayControlProps = ( * Dispatch props of a table control */ export interface DispatchPropsOfArrayControl { - addItem(path: string, value: any): () => void; - removeItems?(path: string, toDelete: number[]): () => void; - moveUp?(path: string, toMove: number): () => void; - moveDown?(path: string, toMove: number): () => void; + addItem(path: string[], value: any): () => void; + removeItems?(path: string[], toDelete: number[]): () => void; + moveUp?(path: string[], toMove: number): () => void; + moveDown?(path: string[], toMove: number): () => void; } /** @@ -727,7 +727,7 @@ export interface DispatchPropsOfArrayControl { export const mapDispatchToArrayControlProps = ( dispatch: Dispatch ): DispatchPropsOfArrayControl => ({ - addItem: (path: string, value: any) => () => { + addItem: (path: string[], value: any) => () => { dispatch( update(path, array => { if (array === undefined || array === null) { @@ -739,7 +739,7 @@ export const mapDispatchToArrayControlProps = ( }) ); }, - removeItems: (path: string, toDelete: number[]) => () => { + removeItems: (path: string[], toDelete: number[]) => () => { dispatch( update(path, array => { toDelete @@ -769,14 +769,14 @@ export const mapDispatchToArrayControlProps = ( }); export interface DispatchPropsOfMultiEnumControl { - addItem: (path: string, value: any) => void; - removeItem?: (path: string, toDelete: any) => void; + addItem: (path: string[], value: any) => void; + removeItem?: (path: string[], toDelete: any) => void; } export const mapDispatchToMultiEnumProps = ( dispatch: Dispatch ): DispatchPropsOfMultiEnumControl => ({ - addItem: (path: string, value: any) => { + addItem: (path: string[], value: any) => { dispatch( update(path, data => { if (data === undefined || data === null) { @@ -787,7 +787,7 @@ export const mapDispatchToMultiEnumProps = ( }) ); }, - removeItem: (path: string, toDelete: any) => { + removeItem: (path: string[], toDelete: any) => { dispatch( update(path, data => { const indexInData = data.indexOf(toDelete); @@ -808,12 +808,12 @@ export interface ArrayControlProps export const layoutDefaultProps: { visible: boolean; enabled: boolean; - path: string; + path: string[]; direction: 'row' | 'column'; } = { visible: true, enabled: true, - path: '', + path: [], direction: 'column' }; @@ -915,7 +915,7 @@ export const controlDefaultProps = { export interface StatePropsOfCombinator extends OwnPropsOfControl { rootSchema: JsonSchema; - path: string; + path: string[]; id: string; indexOfFittingSchema: number; uischemas: JsonFormsUISchemaRegistryEntry[]; diff --git a/packages/core/src/util/resolvers.ts b/packages/core/src/util/resolvers.ts index 9be6c1f300..2af931f9a9 100644 --- a/packages/core/src/util/resolvers.ts +++ b/packages/core/src/util/resolvers.ts @@ -41,21 +41,20 @@ const isArraySchema = (schema: JsonSchema): boolean => { return schema.type === 'array' && schema.items !== undefined; }; -export const resolveData = (instance: any, dataPath: string): any => { +export const resolveData = (instance: any, dataPath: string[]): any => { if (isEmpty(dataPath)) { return instance; } - const dataPathSegments = dataPath.split('.'); - return dataPathSegments - .map(segment => decodeURIComponent(segment)) - .reduce((curInstance, decodedSegment) => { - if (!curInstance || !curInstance.hasOwnProperty(decodedSegment)) { - return undefined; - } + // TODO: Replace with a `for` loop to improve performance (by breaking the loop on `undefined`) + return dataPath.reduce((curInstance, segment) => { + const decodedSegment = decodeURIComponent(segment); + if (!curInstance?.hasOwnProperty(decodedSegment)) { + return undefined; + } - return curInstance[decodedSegment]; - }, instance); + return curInstance[decodedSegment]; + }, instance); }; /** diff --git a/packages/core/src/util/runtime.ts b/packages/core/src/util/runtime.ts index d636f4c707..c6fdbd9a33 100644 --- a/packages/core/src/util/runtime.ts +++ b/packages/core/src/util/runtime.ts @@ -54,14 +54,14 @@ const isSchemaCondition = ( condition: Condition ): condition is SchemaBasedCondition => has(condition, 'schema'); -const getConditionScope = (condition: Scopable, path: string): string => { +const getConditionScope = (condition: Scopable, path: string[]): string[] => { return composeWithUi(condition, path); }; const evaluateCondition = ( data: any, condition: Condition, - path: string, + path: string[], ajv: Ajv ): boolean => { if (isAndCondition(condition)) { @@ -89,7 +89,7 @@ const evaluateCondition = ( const isRuleFulfilled = ( uischema: UISchemaElement, data: any, - path: string, + path: string[], ajv: Ajv ): boolean => { const condition = uischema.rule.condition; @@ -99,7 +99,7 @@ const isRuleFulfilled = ( export const evalVisibility = ( uischema: UISchemaElement, data: any, - path: string = undefined, + path: string[] = undefined, ajv: Ajv ): boolean => { const fulfilled = isRuleFulfilled(uischema, data, path, ajv); @@ -118,7 +118,7 @@ export const evalVisibility = ( export const evalEnablement = ( uischema: UISchemaElement, data: any, - path: string = undefined, + path: string[] = undefined, ajv: Ajv ): boolean => { const fulfilled = isRuleFulfilled(uischema, data, path, ajv); @@ -159,7 +159,7 @@ export const hasEnableRule = (uischema: UISchemaElement): boolean => { export const isVisible = ( uischema: UISchemaElement, data: any, - path: string = undefined, + path: string[] = undefined, ajv: Ajv ): boolean => { if (uischema.rule) { @@ -172,7 +172,7 @@ export const isVisible = ( export const isEnabled = ( uischema: UISchemaElement, data: any, - path: string = undefined, + path: string[] = undefined, ajv: Ajv ): boolean => { if (uischema.rule) { diff --git a/packages/core/src/util/util.ts b/packages/core/src/util/util.ts index 946d5b54d0..281f6f6a83 100644 --- a/packages/core/src/util/util.ts +++ b/packages/core/src/util/util.ts @@ -93,27 +93,27 @@ export const deriveTypes = (jsonSchema: JsonSchema): string[] => { }; /** -* Convenience wrapper around resolveData and resolveSchema. -*/ + * Convenience wrapper around resolveData and resolveSchema. + */ export const Resolve: { schema( schema: JsonSchema, schemaPath: string, rootSchema?: JsonSchema ): JsonSchema; - data(data: any, path: string): any; + data(data: any, path: string[]): any; } = { schema: resolveSchema, data: resolveData }; // Paths -- -const fromScopable = (scopable: Scopable) => +const fromScopable = (scopable: Scopable) => // TODO: Where this is used? toDataPathSegments(scopable.scope).join('.'); export const Paths = { compose: composePaths, - fromScopable + fromScopable, }; // Runtime -- diff --git a/packages/core/test/util/renderer.test.ts b/packages/core/test/util/renderer.test.ts index 1a1953efd7..4ecec6620d 100644 --- a/packages/core/test/util/renderer.test.ts +++ b/packages/core/test/util/renderer.test.ts @@ -168,7 +168,7 @@ test('mapStateToControlProps - visible via state with path from ownProps ', t => }; const ownProps = { uischema, - path: 'foo' + path: ['foo'] }; const state = { jsonforms: { @@ -238,7 +238,7 @@ test('mapStateToControlProps - enabled via state with path from ownProps ', t => const ownProps = { visible: true, uischema, - path: 'foo' + path: ['foo'] }; const state = { jsonforms: { diff --git a/packages/material/src/additional/MaterialListWithDetailRenderer.tsx b/packages/material/src/additional/MaterialListWithDetailRenderer.tsx index d9290d7a5e..5bcce316f1 100644 --- a/packages/material/src/additional/MaterialListWithDetailRenderer.tsx +++ b/packages/material/src/additional/MaterialListWithDetailRenderer.tsx @@ -64,8 +64,8 @@ export const MaterialListWithDetailRenderer = ({ }: ArrayLayoutProps) => { const [selectedIndex, setSelectedIndex] = useState(undefined); const handleRemoveItem = useCallback( - (p: string, value: any) => () => { - removeItems(p, [value])(); + (thePath: string[], value: any) => () => { + removeItems(thePath, [value])(); if (selectedIndex === value) { setSelectedIndex(undefined); } else if (selectedIndex > value) { diff --git a/packages/material/src/complex/CombinatorProperties.tsx b/packages/material/src/complex/CombinatorProperties.tsx index c691f85f1b..f475b32dfe 100644 --- a/packages/material/src/complex/CombinatorProperties.tsx +++ b/packages/material/src/complex/CombinatorProperties.tsx @@ -30,7 +30,7 @@ import { JsonFormsDispatch } from '@jsonforms/react'; interface CombinatorPropertiesProps { schema: JsonSchema; combinatorKeyword: 'oneOf' | 'anyOf'; - path: string; + path: string[]; } export const isLayout = (uischema: UISchemaElement): uischema is Layout => diff --git a/packages/material/src/complex/DeleteDialog.tsx b/packages/material/src/complex/DeleteDialog.tsx index c2147a68e7..e2ea611108 100644 --- a/packages/material/src/complex/DeleteDialog.tsx +++ b/packages/material/src/complex/DeleteDialog.tsx @@ -40,7 +40,7 @@ export interface DeleteDialogProps { } export interface WithDeleteDialogSupport { - openDeleteDialog(path: string, data: number): void; + openDeleteDialog(path: string[], data: number): void; } export const DeleteDialog = React.memo(({ open, onClose, onConfirm, onCancel }: DeleteDialogProps) => { diff --git a/packages/material/src/complex/MaterialArrayControlRenderer.tsx b/packages/material/src/complex/MaterialArrayControlRenderer.tsx index 496ae5239f..4dd532342e 100644 --- a/packages/material/src/complex/MaterialArrayControlRenderer.tsx +++ b/packages/material/src/complex/MaterialArrayControlRenderer.tsx @@ -35,15 +35,16 @@ export const MaterialArrayControlRenderer = (props: ArrayLayoutProps) => { const [rowData, setRowData] = useState(undefined); const { removeItems, visible } = props; - const openDeleteDialog = useCallback((p: string, rowIndex: number) => { + const openDeleteDialog = useCallback((thePath: string[], rowIndex: number) => { setOpen(true); - setPath(p); + setPath(thePath); setRowData(rowIndex); }, [setOpen, setPath, setRowData]); const deleteCancel = useCallback(() => setOpen(false), [setOpen]); const deleteConfirm = useCallback(() => { - const p = path.substring(0, path.lastIndexOf(('.'))); - removeItems(p, [rowData])(); + const parentPath = path; + parentPath.pop(); + removeItems(parentPath, [rowData])(); setOpen(false); }, [setOpen, path, rowData]); const deleteClose = useCallback(() => setOpen(false), [setOpen]); diff --git a/packages/material/src/complex/MaterialTableControl.tsx b/packages/material/src/complex/MaterialTableControl.tsx index 74968832ed..55ea288339 100644 --- a/packages/material/src/complex/MaterialTableControl.tsx +++ b/packages/material/src/complex/MaterialTableControl.tsx @@ -49,11 +49,13 @@ import { ControlElement, errorsAt, formatErrorMessage, + JsonFormsCellRendererRegistryEntry, + JsonFormsRendererRegistryEntry, JsonSchema, Paths, + pathsAreEqual, Resolve, - JsonFormsRendererRegistryEntry, - JsonFormsCellRendererRegistryEntry + stringifyPath, } from '@jsonforms/core'; import DeleteIcon from '@mui/icons-material/Delete'; import ArrowDownward from '@mui/icons-material/ArrowDownward'; @@ -86,7 +88,7 @@ const styles = { const generateCells = ( Cell: React.ComponentType, schema: JsonSchema, - rowPath: string, + rowPath: string[], enabled: boolean, cells?: JsonFormsCellRendererRegistryEntry[] ) => { @@ -102,7 +104,7 @@ const generateCells = ( enabled, cells }; - return ; + return ; }); } else { // primitives @@ -112,7 +114,7 @@ const generateCells = ( cellPath: rowPath, enabled }; - return ; + return ; } }; @@ -149,11 +151,11 @@ const TableHeaderCell = React.memo(({ title }: TableHeaderCellProps) => ( interface NonEmptyCellProps extends OwnPropsOfNonEmptyCell { rootSchema: JsonSchema; errors: string; - path: string; + path: string[]; enabled: boolean; } interface OwnPropsOfNonEmptyCell { - rowPath: string; + rowPath: string[]; propName?: string; schema: JsonSchema; enabled: boolean; @@ -164,15 +166,16 @@ const ctxToNonEmptyCellProps = ( ctx: JsonFormsStateContext, ownProps: OwnPropsOfNonEmptyCell ): NonEmptyCellProps => { - const path = - ownProps.rowPath + - (ownProps.schema.type === 'object' ? '.' + ownProps.propName : ''); + const path = Paths.compose( + ownProps.rowPath, + ownProps.schema.type === 'object' ? ownProps.propName : undefined + ); const errors = formatErrorMessage( union( errorsAt( path, ownProps.schema, - p => p === path + thePath => pathsAreEqual(thePath, path) )(ctx.core.errors).map((error: ErrorObject) => error.message) ) ); @@ -196,17 +199,17 @@ const controlWithoutLabel = (scope: string): ControlElement => ({ }); interface NonEmptyCellComponentProps { - path: string, - propName?: string, - schema: JsonSchema, - rootSchema: JsonSchema, - errors: string, - enabled: boolean, - renderers?: JsonFormsRendererRegistryEntry[], - cells?: JsonFormsCellRendererRegistryEntry[], - isValid: boolean + path: string[]; + propName?: string; + schema: JsonSchema; + rootSchema: JsonSchema; + errors: string; + enabled: boolean; + renderers?: JsonFormsRendererRegistryEntry[]; + cells?: JsonFormsCellRendererRegistryEntry[]; + isValid: boolean; } -const NonEmptyCellComponent = React.memo(({path, propName, schema,rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => { +const NonEmptyCellComponent = React.memo(({path, propName, schema, rootSchema, errors, enabled, renderers, cells, isValid}: NonEmptyCellComponentProps) => { return ( @@ -243,24 +246,24 @@ const NonEmptyCell = (ownProps: OwnPropsOfNonEmptyCell) => { const emptyCellProps = ctxToNonEmptyCellProps(ctx, ownProps); const isValid = isEmpty(emptyCellProps.errors); - return + return ; }; interface NonEmptyRowProps { - childPath: string; + childPath: string[]; schema: JsonSchema; rowIndex: number; - moveUpCreator: (path:string, position: number)=> ()=> void; - moveDownCreator: (path:string, position: number)=> ()=> void; + moveUpCreator(path: string[], position: number): () => void; + moveDownCreator(path: string[], position: number): () => void; enableUp: boolean; enableDown: boolean; showSortButtons: boolean; enabled: boolean; cells?: JsonFormsCellRendererRegistryEntry[]; - path: string; + path: string[]; } -const NonEmptyRowComponent = +const NonEmptyRowComponent = ({ childPath, schema, @@ -275,10 +278,10 @@ const NonEmptyRowComponent = cells, path }: NonEmptyRowProps & WithDeleteDialogSupport) => { - const moveUp = useMemo(() => moveUpCreator(path, rowIndex),[moveUpCreator, path, rowIndex]); - const moveDown = useMemo(() => moveDownCreator(path, rowIndex),[moveDownCreator, path, rowIndex]); + const moveUp = useMemo(() => moveUpCreator(path, rowIndex), [moveUpCreator, path, rowIndex]); + const moveDown = useMemo(() => moveDownCreator(path, rowIndex), [moveDownCreator, path, rowIndex]); return ( - + {generateCells(NonEmptyCell, schema, childPath, enabled, cells)} {enabled ? ( + size='large' + > @@ -312,7 +316,8 @@ const NonEmptyRowComponent = openDeleteDialog(childPath, rowIndex)} - size='large'> + size='large' + > @@ -325,14 +330,14 @@ const NonEmptyRowComponent = export const NonEmptyRow = React.memo(NonEmptyRowComponent); interface TableRowsProp { data: number; - path: string; + path: string[]; schema: JsonSchema; uischema: ControlElement; config?: any; enabled: boolean; cells?: JsonFormsCellRendererRegistryEntry[]; - moveUp?(path: string, toMove: number): () => void; - moveDown?(path: string, toMove: number): () => void; + moveUp?(path: string[], toMove: number): () => void; + moveDown?(path: string[], toMove: number): () => void; } const TableRows = ({ data, @@ -361,7 +366,7 @@ const TableRows = ({ return ( { - addItem = (path: string, value: any) => this.props.addItem(path, value); + addItem = (path: string[], value: any) => this.props.addItem(path, value); render() { const { label, diff --git a/packages/material/src/complex/TableToolbar.tsx b/packages/material/src/complex/TableToolbar.tsx index d36b560054..0d8d8fc180 100644 --- a/packages/material/src/complex/TableToolbar.tsx +++ b/packages/material/src/complex/TableToolbar.tsx @@ -42,12 +42,12 @@ export interface MaterialTableToolbarProps { numColumns: number; errors: string; label: string; - path: string; + path: string[]; uischema: ControlElement; schema: JsonSchema; rootSchema: JsonSchema; enabled: boolean; - addItem(path: string, value: any): () => void; + addItem(path: string[], value: any): () => void; } const fixedCellSmall = { diff --git a/packages/material/src/layouts/ArrayToolbar.tsx b/packages/material/src/layouts/ArrayToolbar.tsx index f8e93a36dc..ec88fddbdf 100644 --- a/packages/material/src/layouts/ArrayToolbar.tsx +++ b/packages/material/src/layouts/ArrayToolbar.tsx @@ -12,8 +12,8 @@ import ValidationIcon from '../complex/ValidationIcon'; export interface ArrayLayoutToolbarProps { label: string; errors: string; - path: string; - addItem(path: string, data: any): () => void; + path: string[]; + addItem(path: string[], data: any): () => void; createDefault(): any; } export const ArrayLayoutToolbar = React.memo( diff --git a/packages/material/src/layouts/ExpandPanelRenderer.tsx b/packages/material/src/layouts/ExpandPanelRenderer.tsx index e648fb5da1..fdc3bc8e45 100644 --- a/packages/material/src/layouts/ExpandPanelRenderer.tsx +++ b/packages/material/src/layouts/ExpandPanelRenderer.tsx @@ -20,7 +20,8 @@ import { JsonFormsUISchemaRegistryEntry, getFirstPrimitiveProp, createId, - removeId + removeId, + stringifyPath, } from '@jsonforms/core'; import { Accordion, @@ -39,7 +40,7 @@ const iconStyle: any = { float: 'right' }; interface OwnPropsOfExpandPanel { index: number; - path: string; + path: string[]; uischema: ControlElement; schema: JsonSchema; expanded: boolean; @@ -51,12 +52,12 @@ interface OwnPropsOfExpandPanel { enableMoveDown: boolean; config: any; childLabelProp?: string; - handleExpansion(panel: string): (event: any, expanded: boolean) => void; + handleExpansion(panel: string[]): (event: any, expanded: boolean) => void; } interface StatePropsOfExpandPanel extends OwnPropsOfExpandPanel { childLabel: string; - childPath: string; + childPath: string[]; enableMoveUp: boolean; enableMoveDown: boolean; } @@ -65,9 +66,9 @@ interface StatePropsOfExpandPanel extends OwnPropsOfExpandPanel { * Dispatch props of a table control */ export interface DispatchPropsOfExpandPanel { - removeItems(path: string, toDelete: number[]): (event: any) => void; - moveUp(path: string, toMove: number): (event: any) => void; - moveDown(path: string, toMove: number): (event: any) => void; + removeItems(path: string[], toDelete: number[]): (event: any) => void; + moveUp(path: string[], toMove: number): (event: any) => void; + moveDown(path: string[], toMove: number): (event: any) => void; } export interface ExpandPanelProps @@ -155,7 +156,8 @@ const ExpandPanelRendererComponent = (props: ExpandPanelProps) => { style={iconStyle} disabled={!enableMoveUp} aria-label={`Move up`} - size='large'> + size='large' + > @@ -165,7 +167,8 @@ const ExpandPanelRendererComponent = (props: ExpandPanelProps) => { style={iconStyle} disabled={!enableMoveDown} aria-label={`Move down`} - size='large'> + size='large' + > @@ -178,7 +181,8 @@ const ExpandPanelRendererComponent = (props: ExpandPanelProps) => { onClick={removeItems(path, [index])} style={iconStyle} aria-label={`Delete`} - size='large'> + size='large' + > @@ -193,7 +197,7 @@ const ExpandPanelRendererComponent = (props: ExpandPanelProps) => { schema={schema} uischema={foundUISchema} path={childPath} - key={childPath} + key={stringifyPath(childPath)} renderers={renderers} cells={cells} /> @@ -213,7 +217,7 @@ const ExpandPanelRenderer = React.memo(ExpandPanelRendererComponent); export const ctxDispatchToExpandPanelProps: ( dispatch: Dispatch> ) => DispatchPropsOfExpandPanel = dispatch => ({ - removeItems: useCallback((path: string, toDelete: number[]) => (event: any): void => { + removeItems: useCallback((path: string[], toDelete: number[]) => (event: any): void => { event.stopPropagation(); dispatch( update(path, array => { @@ -225,7 +229,7 @@ export const ctxDispatchToExpandPanelProps: ( }) ); }, [dispatch]), - moveUp: useCallback((path: string, toMove: number) => (event: any): void => { + moveUp: useCallback((path: string[], toMove: number) => (event: any): void => { event.stopPropagation(); dispatch( update(path, array => { @@ -234,7 +238,7 @@ export const ctxDispatchToExpandPanelProps: ( }) ); }, [dispatch]), - moveDown: useCallback((path: string, toMove: number) => (event: any): void => { + moveDown: useCallback((path: string[], toMove: number) => (event: any): void => { event.stopPropagation(); dispatch( update(path, array => { diff --git a/packages/material/src/layouts/MaterialArrayLayout.tsx b/packages/material/src/layouts/MaterialArrayLayout.tsx index 7168bb8715..b1a5bf8834 100644 --- a/packages/material/src/layouts/MaterialArrayLayout.tsx +++ b/packages/material/src/layouts/MaterialArrayLayout.tsx @@ -23,27 +23,28 @@ THE SOFTWARE. */ import range from 'lodash/range'; -import React, {useState, useCallback} from 'react'; +import React, { useCallback, useState } from 'react'; import { ArrayLayoutProps, composePaths, computeLabel, createDefaultValue, + pathsAreEqual, } from '@jsonforms/core'; import map from 'lodash/map'; import { ArrayLayoutToolbar } from './ArrayToolbar'; import ExpandPanelRenderer from './ExpandPanelRenderer'; import merge from 'lodash/merge'; -const MaterialArrayLayoutComponent = (props: ArrayLayoutProps)=> { - const [expanded, setExpanded] = useState(false); +const MaterialArrayLayoutComponent = (props: ArrayLayoutProps) => { + const [expanded, setExpanded] = useState(null); const innerCreateDefaultValue = useCallback(() => createDefaultValue(props.schema), [props.schema]); - const handleChange = useCallback((panel: string) => (_event: any, expandedPanel: boolean) => { - setExpanded(expandedPanel ? panel : false) + const handleChange = useCallback((panel: string[]) => (_event: any, expandedPanel: boolean) => { + setExpanded(expandedPanel ? panel : null); }, []); - const isExpanded = (index: number) => - expanded === composePaths(props.path, `${index}`); - + const isExpanded = (index: number) => + expanded ? pathsAreEqual(expanded, composePaths(props.path, `${index}`)) : false; + const { data, path, diff --git a/packages/material/src/layouts/MaterialArrayLayoutRenderer.tsx b/packages/material/src/layouts/MaterialArrayLayoutRenderer.tsx index ccb63388b2..ef8049a0ce 100644 --- a/packages/material/src/layouts/MaterialArrayLayoutRenderer.tsx +++ b/packages/material/src/layouts/MaterialArrayLayoutRenderer.tsx @@ -50,7 +50,7 @@ export const MaterialArrayLayoutRenderer = ({ uischemas, addItem }: ArrayLayoutProps) => { - const addItemCb = useCallback((p: string, value: any) => addItem(p, value), [ + const addItemCb = useCallback((thePath: string[], value: any) => addItem(thePath, value), [ addItem ]); return ( diff --git a/packages/material/src/util/datejs.ts b/packages/material/src/util/datejs.ts index 3c4cc1f09e..cdcfe52d0f 100644 --- a/packages/material/src/util/datejs.ts +++ b/packages/material/src/util/datejs.ts @@ -5,8 +5,8 @@ import customParsing from 'dayjs/plugin/customParseFormat'; dayjs.extend(customParsing); export const createOnChangeHandler = ( - path: string, - handleChange: (path: string, value: any) => void, + path: string[], + handleChange: (path: string[], value: any) => void, saveFormat: string | undefined ) => (time: dayjs.Dayjs) => { if (!time) { diff --git a/packages/material/src/util/debounce.ts b/packages/material/src/util/debounce.ts index c5c7739fab..02600e705b 100644 --- a/packages/material/src/util/debounce.ts +++ b/packages/material/src/util/debounce.ts @@ -23,11 +23,10 @@ THE SOFTWARE. */ import { debounce } from 'lodash'; -import { useState, useCallback, useEffect } from 'react' - +import { useCallback, useEffect, useState } from 'react'; const eventToValue = (ev: any) => ev.target.value; -export const useDebouncedChange = (handleChange: (path: string, value: any) => void, defaultValue: any, data: any, path: string, eventToValueFunction: (ev: any) => any = eventToValue, timeout = 300): [any, React.ChangeEventHandler, () => void] => { +export const useDebouncedChange = (handleChange: (path: string[], value: any) => void, defaultValue: any, data: any, path: string[], eventToValueFunction: (ev: any) => any = eventToValue, timeout = 300): [any, React.ChangeEventHandler, () => void] => { const [input, setInput] = useState(data ?? defaultValue); useEffect(() => { setInput(data ?? defaultValue); diff --git a/packages/material/src/util/layout.tsx b/packages/material/src/util/layout.tsx index 6281dbee8e..d38b0d1a7c 100644 --- a/packages/material/src/util/layout.tsx +++ b/packages/material/src/util/layout.tsx @@ -40,7 +40,7 @@ import { Grid, Hidden } from '@mui/material'; export const renderLayoutElements = ( elements: UISchemaElement[], schema: JsonSchema, - path: string, + path: string[], enabled: boolean, renderers?: JsonFormsRendererRegistryEntry[], cells?: JsonFormsCellRendererRegistryEntry[] diff --git a/packages/react/src/JsonForms.tsx b/packages/react/src/JsonForms.tsx index 39ee68ea06..94f07a81f9 100644 --- a/packages/react/src/JsonForms.tsx +++ b/packages/react/src/JsonForms.tsx @@ -95,7 +95,7 @@ const TestAndRender = React.memo( (props: { uischema: UISchemaElement; schema: JsonSchema; - path: string; + path: string[]; enabled: boolean; renderers: JsonFormsRendererRegistryEntry[]; cells: JsonFormsCellRendererRegistryEntry[]; @@ -144,18 +144,18 @@ const useJsonFormsDispatchRendererProps = (props: OwnPropsOfJsonFormsRenderer & return { schema: props.schema || ctx.core.schema, uischema: props.uischema || ctx.core.uischema, - path: props.path || '', + path: props.path || [], enabled: props.enabled, rootSchema: ctx.core.schema, renderers: props.renderers || ctx.renderers, cells: props.cells || ctx.cells, }; -} +}; export const JsonFormsDispatch = React.memo( (props: OwnPropsOfJsonFormsRenderer & JsonFormsReactProps) => { const renderProps = useJsonFormsDispatchRendererProps(props); - return + return ; } ); diff --git a/packages/react/test/renderers/JsonForms.test.tsx b/packages/react/test/renderers/JsonForms.test.tsx index ad4fce041f..ca74db217e 100644 --- a/packages/react/test/renderers/JsonForms.test.tsx +++ b/packages/react/test/renderers/JsonForms.test.tsx @@ -43,6 +43,7 @@ import { registerCell, registerRenderer, schemaMatches, + stringifyPath, uiTypeIs, unregisterRenderer, } from '@jsonforms/core'; @@ -220,7 +221,7 @@ test('ids should be unique within the same form', () => { uischema={e} schema={fixture.schema} path={props.path} - key={`${props.path}-${idx}`} + key={`${stringifyPath(props.path)}-${idx}`} /> )); return
{children}
; @@ -298,7 +299,7 @@ test('render schema with $ref', () => { const wrapper = mount( { const wrapper = shallow(