Skip to content

Commit

Permalink
Rework Paths.compose to accept segments and increase usage
Browse files Browse the repository at this point in the history
- Rework Paths.compose to accept a JSON pointer and a variable number of unencoded segments to add
- Add unit tests for Path.compose
- Increase usage of Paths.compose across renderers
- Remove obsolete leading slashes of segments
- Allow handing in numbers (indices) to path composition
- improve migration guide
  • Loading branch information
lucas-koehler committed Oct 24, 2024
1 parent 7660f3d commit 6035e72
Show file tree
Hide file tree
Showing 16 changed files with 235 additions and 57 deletions.
116 changes: 116 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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.

<details>
<summary>Renderer code</summary>

```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 (
<JsonFormsDispatch
key={index}
schema={entrySchema}
path={path}
// [...] other props like cells, etc
/>
);
});

// [...]
};
```

</details>

## Migrating to JSON Forms 3.3

### Angular support now targets Angular 17 and Angular 18
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export class ArrayLayoutRenderer
}
return {
schema: this.scopedSchema,
path: Paths.compose(this.propsPath, `/${index}`),
path: Paths.compose(this.propsPath, index),
uischema,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
JsonFormsState,
mapDispatchToArrayControlProps,
mapStateToArrayControlProps,
Paths,
RankedTester,
rankWith,
setReadonly,
Expand Down Expand Up @@ -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,
};
Expand Down
8 changes: 4 additions & 4 deletions packages/angular-material/src/library/other/table.renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {
ControlElement,
createDefaultValue,
deriveTypes,
encode,
isObjectArrayControl,
isPrimitiveArrayControl,
JsonSchema,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/mappers/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 29 additions & 18 deletions packages/core/src/util/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 1 addition & 6 deletions packages/core/src/util/uischema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/reducers/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
62 changes: 61 additions & 1 deletion packages/core/test/util/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const MaterialEnumArrayRenderer = ({
</FormLabel>
<FormGroup row>
{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;
Expand Down
11 changes: 6 additions & 5 deletions packages/material-renderers/src/complex/MaterialTableControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import {
Resolve,
JsonFormsRendererRegistryEntry,
JsonFormsCellRendererRegistryEntry,
encode,
ArrayTranslations,
} from '@jsonforms/core';
import DeleteIcon from '@mui/icons-material/Delete';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -231,10 +230,12 @@ const NonEmptyCellComponent = React.memo(function NonEmptyCellComponent({
<DispatchCell
schema={Resolve.schema(
schema,
`#/properties/${encode(propName)}`,
Paths.compose('#', 'properties', propName),
rootSchema
)}
uischema={controlWithoutLabel(`#/properties/${encode(propName)}`)}
uischema={controlWithoutLabel(
Paths.compose('#', 'properties', propName)
)}
path={path}
enabled={enabled}
renderers={renderers}
Expand Down Expand Up @@ -421,7 +422,7 @@ const TableRows = ({
return (
<React.Fragment>
{range(data).map((index: number) => {
const childPath = Paths.compose(path, `/${index}`);
const childPath = Paths.compose(path, index);

return (
<NonEmptyRow
Expand Down
Loading

0 comments on commit 6035e72

Please sign in to comment.