diff --git a/MIGRATION.md b/MIGRATION.md index e44fcfbd1..fbc284dd1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,121 @@ # Migration guide +## Migrating to JSON Forms 4.0 + +### Unified internal path handling to JSON pointers + +Previously, JSON Forms used two different ways to express paths: + +- The `scope` JSON Pointer (see [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) paths used in UI Schemas to resolve subschemas of the provided JSON Schema +- The dot-separated paths (lodash format) to resolve entries in the form-wide data object + +This led to confusion and prevented property names from containing dots (`.`) because lodash paths don't support escaping. + +The rework unifies these paths to all use the JSON Pointer format. +Therefore, this breaks custom renderers that manually modify or create paths to resolve additional data. +They used the dot-separated paths and need to be migrated to use JSON Pointers instead. + +To abstract the composition of paths away from renderers, the `Paths.compose` utility of `@jsonforms/core` should be used. +It takes a valid JSON Pointer and an arbitrary number of _unencoded_ segments to append. +The utility takes care of adding separators and encoding special characters in the given segments. + +#### How to migrate + +All paths that are manually composed or use the `Paths.compose` utility and add more than one segment need to be adapted. + +```ts +import { Paths } from '@jsonforms/core'; + +// Some base path we want to extend. This is usually available in the renderer props +// or the empty string for the whole data object +const path = '/foo' + +// Previous: Calculate the path manually +const oldManual = `${path}.foo.~bar`; +// Previous: Use the Paths.compose util +const oldWithUtil = Paths.compose(path, 'foo.~bar'); + +// Now: After the initial path, hand in each segment separately. +// Segments must be unencoded. The util automatically encodes them. +// In this case the ~ will be encoded. +const new = Paths.compose(path, 'foo', '~bar'); + +// Calculate a path relative to the root data that the path is resolved against +const oldFromRoot = 'nested.prop'; +const newFromRoot = Paths.compose('', 'nested', 'prop'); // The empty JSON Pointer '' points to the whole data. +``` + +#### Custom Renderer Example + +This example shows in a more elaborate way, how path composition might be used in a custom renderer. +This example uses a custom renderer implemented for the React bindings. +However, the approach is similar for all bindings. + +To showcase how a migration could look like, assume a custom renderer that gets handed in this data object: + +```ts +const data = { + foo: 'abc', + 'b/ar': { + '~': 'tilde', + }, + 'array~Data': ['entry1', 'entry2'], +}; +``` + +The renderer wants to resolve the `~` property to directly use it and iterate over the array and use the dispatch to render each entry. + +
+Renderer code + +```tsx +import { Paths, Resolve } from '@jsonforms/core'; +import { JsonFormsDispatch } from '@jsonforms/react'; + +export const CustomRenderer = (props: ControlProps & WithInput) => { + const { + // [...] + data, // The data object to be rendered. See content above + path, // Path to the data object handed into this renderer + schema, // JSON Schema describing this renderers data + } = props; + + // Calculate path from the given data to the nested ~ property + // You could also do this manually without the Resolve.data util + const tildePath = Paths.compose('', 'b/ar', '~'); + const tildeValue = Resolve.data(data, tildePath); + + const arrayData = data['array~Data']; + // Resolve schema of array entries from this renderer's schema. + const entrySchemaPath = Paths.compose( + '#', + 'properties', + 'array~Data', + 'items' + ); + const entrySchema = Resolve.schema(schema, entrySchemaPath); + // Iterate over array~Data and dispatch for each entry + // Dispatch needs the path from the root of JSON Forms's data + // Thus, calculate it by extending this control's path + const dispatchEntries = arrayData.map((arrayEntry, index) => { + const entryPath = Paths.compose(path, 'array~Data', index); + const schema = Resolve.schema(); + return ( + + ); + }); + + // [...] +}; +``` + +
+ ## Migrating to JSON Forms 3.3 ### Angular support now targets Angular 17 and Angular 18 diff --git a/packages/angular-material/src/library/layouts/array-layout.renderer.ts b/packages/angular-material/src/library/layouts/array-layout.renderer.ts index 2be91554d..1b55fe6af 100644 --- a/packages/angular-material/src/library/layouts/array-layout.renderer.ts +++ b/packages/angular-material/src/library/layouts/array-layout.renderer.ts @@ -243,7 +243,7 @@ export class ArrayLayoutRenderer } return { schema: this.scopedSchema, - path: Paths.compose(this.propsPath, `/${index}`), + path: Paths.compose(this.propsPath, index), uischema, }; } diff --git a/packages/angular-material/src/library/other/master-detail/master.ts b/packages/angular-material/src/library/other/master-detail/master.ts index 6bce50391..700918258 100644 --- a/packages/angular-material/src/library/other/master-detail/master.ts +++ b/packages/angular-material/src/library/other/master-detail/master.ts @@ -48,6 +48,7 @@ import { JsonFormsState, mapDispatchToArrayControlProps, mapStateToArrayControlProps, + Paths, RankedTester, rankWith, setReadonly, @@ -229,7 +230,7 @@ export class MasterListComponent ? d.toString() : get(d, labelRefInstancePath ?? getFirstPrimitiveProp(schema)), data: d, - path: `${path}/${index}`, + path: Paths.compose(path, index), schema, uischema: detailUISchema, }; diff --git a/packages/angular-material/src/library/other/table.renderer.ts b/packages/angular-material/src/library/other/table.renderer.ts index 5300c3c29..1aab4aaf3 100644 --- a/packages/angular-material/src/library/other/table.renderer.ts +++ b/packages/angular-material/src/library/other/table.renderer.ts @@ -34,7 +34,6 @@ import { ControlElement, createDefaultValue, deriveTypes, - encode, isObjectArrayControl, isPrimitiveArrayControl, JsonSchema, @@ -211,8 +210,9 @@ export class TableRenderer extends JsonFormsArrayControl implements OnInit { ): ColumnDescription[] => { if (schema.type === 'object') { return this.getValidColumnProps(schema).map((prop) => { - const encProp = encode(prop); - const uischema = controlWithoutLabel(`#/properties/${encProp}`); + const uischema = controlWithoutLabel( + Paths.compose('#', 'properties', prop) + ); if (!this.isEnabled()) { setReadonly(uischema); } @@ -275,7 +275,7 @@ export const controlWithoutLabel = (scope: string): ControlElement => ({ @Pipe({ name: 'getProps' }) export class GetProps implements PipeTransform { transform(index: number, props: OwnPropsOfRenderer) { - const rowPath = Paths.compose(props.path, `/${index}`); + const rowPath = Paths.compose(props.path, index); return { schema: props.schema, uischema: props.uischema, diff --git a/packages/core/src/mappers/renderer.ts b/packages/core/src/mappers/renderer.ts index 9883d5063..8711b5089 100644 --- a/packages/core/src/mappers/renderer.ts +++ b/packages/core/src/mappers/renderer.ts @@ -713,7 +713,7 @@ export const mapStateToMasterListItemProps = ( ownProps: OwnPropsOfMasterListItem ): StatePropsOfMasterItem => { const { schema, path, uischema, childLabelProp, index } = ownProps; - const childPath = composePaths(path, `${index}`); + const childPath = composePaths(path, index); const childLabel = computeChildLabel( getData(state), childPath, diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts index 3c48728ca..eb37e1add 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -23,37 +23,48 @@ THE SOFTWARE. */ -import isEmpty from 'lodash/isEmpty'; import range from 'lodash/range'; /** - * Composes two JSON pointer. Pointer2 is appended to pointer1. - * Example: pointer1 `'/foo/0'` and pointer2 `'/bar'` results in `'/foo/0/bar'`. + * Composes a valid JSON pointer with an arbitrary number of unencoded segments. + * This method encodes the segments to escape JSON pointer's special characters. + * `undefined` segments are skipped. * - * @param {string} pointer1 Initial JSON pointer - * @param {string} pointer2 JSON pointer to append to `pointer1` + * Example: + * ```ts + * const composed = compose('/path/to/object', '~foo', 'b/ar'); + * // compose === '/path/to/object/~0foo/b~1ar' + * ``` + * + * The segments are appended in order to the JSON pointer and the special characters `~` and `/` are automatically encoded. + * + * @param {string} pointer Initial valid JSON pointer + * @param {...(string | number)[]} segments **unencoded** path segments to append to the JSON pointer. May also be a number in case of indices. * @returns {string} resulting JSON pointer */ -export const compose = (pointer1: string, pointer2: string) => { - let p2 = pointer2; - if (!isEmpty(pointer2) && !pointer2.startsWith('/')) { - p2 = '/' + pointer2; - } +export const compose = ( + pointer: string, + ...segments: (string | number)[] +): string => { + // Remove undefined segments and encode string segments. Numbers don't need encoding. + // Only skip undefined segments, as empty string segments are allowed + // and reference a property that has the empty string as property name. + const sanitizedSegments = segments + .filter((s) => s !== undefined) + .map((s) => (typeof s === 'string' ? encode(s) : s.toString())); - if (isEmpty(pointer1)) { - return p2; - } else if (isEmpty(pointer2)) { - return pointer1; - } else { - return `${pointer1}${p2}`; - } + return sanitizedSegments.reduce( + (currentPointer, segment) => `${currentPointer}/${segment}`, + pointer ?? '' // Treat undefined and null the same as the empty string (root pointer) + ); }; export { compose as composePaths }; /** * Convert a schema path (i.e. JSON pointer) to an array by splitting - * at the '/' character and removing all schema-specific keywords. + * at the '/' character, removing all schema-specific keywords, + * and decoding each segment to remove JSON pointer specific escaping. * * The returned value can be used to de-reference a root object by folding over it * and de-referencing the single segments to obtain a new object. diff --git a/packages/core/src/util/uischema.ts b/packages/core/src/util/uischema.ts index 1c05c7057..1bde5d132 100644 --- a/packages/core/src/util/uischema.ts +++ b/packages/core/src/util/uischema.ts @@ -101,12 +101,7 @@ export const composeWithUi = (scopableUi: Scopable, path: string): string => { } const segments = toDataPathSegments(scopableUi.scope); - - if (isEmpty(segments)) { - return path ?? ''; - } - - return compose(path, segments.join('.')); + return compose(path, ...segments); }; export const isInternationalized = ( diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index 8808d9d1a..00bc5c902 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -1865,7 +1865,7 @@ test('core reducer helpers - getControlPath - fallback to AJV <=7 errors does no t.is(controlPath, ''); }); -test('core reducer helpers - getControlPath - decodes JSON Pointer escape sequences', (t) => { +test('core reducer helpers - getControlPath - does not decode JSON Pointer escape sequences', (t) => { const errorObject = { instancePath: '/~0group/~1name' } as ErrorObject; const controlPath = getControlPath(errorObject); t.is(controlPath, '/~0group/~1name'); diff --git a/packages/core/test/util/path.test.ts b/packages/core/test/util/path.test.ts index 0179d89c0..2e32c4ec7 100644 --- a/packages/core/test/util/path.test.ts +++ b/packages/core/test/util/path.test.ts @@ -24,7 +24,7 @@ */ import test from 'ava'; import { JsonSchema } from '../../src/models'; -import { Resolve, toDataPath } from '../../src'; +import { compose, Resolve, toDataPath } from '../../src/util'; test('resolve ', (t) => { const schema: JsonSchema = { @@ -269,3 +269,63 @@ test('resolve $ref complicated', (t) => { }, }); }); + +test('compose - encodes segments', (t) => { + const result = compose('/foo', '/bar', '~~prop'); + t.is(result, '/foo/~1bar/~0~0prop'); +}); + +test('compose - does not re-encode initial pointer', (t) => { + const result = compose('/f~0oo', 'bar'); + t.is(result, '/f~0oo/bar'); +}); + +/* + * Unexpected edge case but the RFC6901 standard defines that empty segments point to a property with key `''`. + * For instance, '/' points to a property with key `''` in the root object. + */ +test('compose - handles empty string segments', (t) => { + const result = compose('/foo', '', 'bar'); + t.is(result, '/foo//bar'); +}); + +test('compose - returns initial pointer for no given segments', (t) => { + const result = compose('/foo'); + t.is(result, '/foo'); +}); + +test("compose - accepts initial pointer starting with URI fragment '#'", (t) => { + const result = compose('#/foo', 'bar'); + t.is(result, '#/foo/bar'); +}); + +test('compose - handles root json pointer', (t) => { + const result = compose('', 'foo'); + t.is(result, '/foo'); +}); + +test('compose - handles numbers', (t) => { + const result = compose('/foo', 0, 'bar'); + t.is(result, '/foo/0/bar'); +}); + +/* + * Unexpected edge case but the RFC6901 standard defines that `/` points to a property with key `''`. + * To point to the root object, the empty string `''` is used. + */ +test('compose - handles json pointer pointing to property with empty string as key', (t) => { + const result = compose('/', 'foo'); + t.is(result, '//foo'); +}); + +/** undefined JSON pointers are not valid but we still expect compose to handle them gracefully. */ +test('compose - handles undefined root json pointer', (t) => { + const result = compose(undefined as any, 'foo'); + t.is(result, '/foo'); +}); + +/** undefined segment elements are not valid but we still expect compose to handle them gracefully. */ +test('compose - ignores undefined segments', (t) => { + const result = compose('/foo', undefined as any, 'bar'); + t.is(result, '/foo/bar'); +}); diff --git a/packages/material-renderers/src/complex/MaterialEnumArrayRenderer.tsx b/packages/material-renderers/src/complex/MaterialEnumArrayRenderer.tsx index e5cc55e9a..1f5a89f15 100644 --- a/packages/material-renderers/src/complex/MaterialEnumArrayRenderer.tsx +++ b/packages/material-renderers/src/complex/MaterialEnumArrayRenderer.tsx @@ -80,7 +80,7 @@ export const MaterialEnumArrayRenderer = ({ {options.map((option: any, index: number) => { - const optionPath = Paths.compose(path, `/${index}`); + const optionPath = Paths.compose(path, index); const checkboxValue = data?.includes(option.value) ? option.value : undefined; diff --git a/packages/material-renderers/src/complex/MaterialTableControl.tsx b/packages/material-renderers/src/complex/MaterialTableControl.tsx index a4d4991dc..b5ad3453f 100644 --- a/packages/material-renderers/src/complex/MaterialTableControl.tsx +++ b/packages/material-renderers/src/complex/MaterialTableControl.tsx @@ -54,7 +54,6 @@ import { Resolve, JsonFormsRendererRegistryEntry, JsonFormsCellRendererRegistryEntry, - encode, ArrayTranslations, } from '@jsonforms/core'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -94,7 +93,7 @@ const generateCells = ( ) => { if (schema.type === 'object') { return getValidColumnProps(schema).map((prop) => { - const cellPath = Paths.compose(rowPath, '/' + prop); + const cellPath = Paths.compose(rowPath, prop); const props = { propName: prop, schema, @@ -231,10 +230,12 @@ const NonEmptyCellComponent = React.memo(function NonEmptyCellComponent({ {range(data).map((index: number) => { - const childPath = Paths.compose(path, `/${index}`); + const childPath = Paths.compose(path, index); return ( { return computeChildLabel( diff --git a/packages/material-renderers/src/layouts/MaterialArrayLayout.tsx b/packages/material-renderers/src/layouts/MaterialArrayLayout.tsx index 6846fd421..32fa6daf5 100644 --- a/packages/material-renderers/src/layouts/MaterialArrayLayout.tsx +++ b/packages/material-renderers/src/layouts/MaterialArrayLayout.tsx @@ -51,7 +51,7 @@ const MaterialArrayLayoutComponent = ( [] ); const isExpanded = (index: number) => - expanded === composePaths(props.path, `${index}`); + expanded === composePaths(props.path, index); const { enabled, diff --git a/packages/vanilla-renderers/src/complex/TableArrayControl.tsx b/packages/vanilla-renderers/src/complex/TableArrayControl.tsx index 7fe25ec28..62c48bf89 100644 --- a/packages/vanilla-renderers/src/complex/TableArrayControl.tsx +++ b/packages/vanilla-renderers/src/complex/TableArrayControl.tsx @@ -40,7 +40,6 @@ import { Resolve, Test, getControlPath, - encode, ArrayTranslations, } from '@jsonforms/core'; import { @@ -105,7 +104,8 @@ class TableArrayControl extends React.Component< const createControlElement = (key?: string): ControlElement => ({ type: 'Control', label: false, - scope: schema.type === 'object' ? `#/properties/${key}` : '#', + scope: + schema.type === 'object' ? Paths.compose('#', 'properties', key) : '#', }); const isValid = errors.length === 0; const divClassNames = [validationClass] @@ -155,8 +155,7 @@ class TableArrayControl extends React.Component< ) : ( data.map((_child, index) => { - const childPath = Paths.compose(path, `/${index}`); - // TODO + const childPath = Paths.compose(path, index); const errorsPerEntry: any[] = filter(childErrors, (error) => { const errorPath = getControlPath(error); return errorPath.startsWith(childPath); @@ -182,29 +181,24 @@ class TableArrayControl extends React.Component< (prop) => schema.properties[prop].type !== 'array' ), fpmap((prop) => { - const childPropPath = Paths.compose( - childPath, - '/' + prop.toString() - ); + const childPropPath = Paths.compose(childPath, prop); return ( ); }) )(schema.properties) ) : ( - + {data ? ( range(0, data.length).map((index) => { - const childPath = composePaths(path, `${index}`); + const childPath = composePaths(path, index); return (