Skip to content

Commit 6035e72

Browse files
committed
Rework Paths.compose to accept segments and increase usage
- 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
1 parent 7660f3d commit 6035e72

File tree

16 files changed

+235
-57
lines changed

16 files changed

+235
-57
lines changed

Diff for: MIGRATION.md

+116
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,121 @@
11
# Migration guide
22

3+
## Migrating to JSON Forms 4.0
4+
5+
### Unified internal path handling to JSON pointers
6+
7+
Previously, JSON Forms used two different ways to express paths:
8+
9+
- 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
10+
- The dot-separated paths (lodash format) to resolve entries in the form-wide data object
11+
12+
This led to confusion and prevented property names from containing dots (`.`) because lodash paths don't support escaping.
13+
14+
The rework unifies these paths to all use the JSON Pointer format.
15+
Therefore, this breaks custom renderers that manually modify or create paths to resolve additional data.
16+
They used the dot-separated paths and need to be migrated to use JSON Pointers instead.
17+
18+
To abstract the composition of paths away from renderers, the `Paths.compose` utility of `@jsonforms/core` should be used.
19+
It takes a valid JSON Pointer and an arbitrary number of _unencoded_ segments to append.
20+
The utility takes care of adding separators and encoding special characters in the given segments.
21+
22+
#### How to migrate
23+
24+
All paths that are manually composed or use the `Paths.compose` utility and add more than one segment need to be adapted.
25+
26+
```ts
27+
import { Paths } from '@jsonforms/core';
28+
29+
// Some base path we want to extend. This is usually available in the renderer props
30+
// or the empty string for the whole data object
31+
const path = '/foo'
32+
33+
// Previous: Calculate the path manually
34+
const oldManual = `${path}.foo.~bar`;
35+
// Previous: Use the Paths.compose util
36+
const oldWithUtil = Paths.compose(path, 'foo.~bar');
37+
38+
// Now: After the initial path, hand in each segment separately.
39+
// Segments must be unencoded. The util automatically encodes them.
40+
// In this case the ~ will be encoded.
41+
const new = Paths.compose(path, 'foo', '~bar');
42+
43+
// Calculate a path relative to the root data that the path is resolved against
44+
const oldFromRoot = 'nested.prop';
45+
const newFromRoot = Paths.compose('', 'nested', 'prop'); // The empty JSON Pointer '' points to the whole data.
46+
```
47+
48+
#### Custom Renderer Example
49+
50+
This example shows in a more elaborate way, how path composition might be used in a custom renderer.
51+
This example uses a custom renderer implemented for the React bindings.
52+
However, the approach is similar for all bindings.
53+
54+
To showcase how a migration could look like, assume a custom renderer that gets handed in this data object:
55+
56+
```ts
57+
const data = {
58+
foo: 'abc',
59+
'b/ar': {
60+
'~': 'tilde',
61+
},
62+
'array~Data': ['entry1', 'entry2'],
63+
};
64+
```
65+
66+
The renderer wants to resolve the `~` property to directly use it and iterate over the array and use the dispatch to render each entry.
67+
68+
<details>
69+
<summary>Renderer code</summary>
70+
71+
```tsx
72+
import { Paths, Resolve } from '@jsonforms/core';
73+
import { JsonFormsDispatch } from '@jsonforms/react';
74+
75+
export const CustomRenderer = (props: ControlProps & WithInput) => {
76+
const {
77+
// [...]
78+
data, // The data object to be rendered. See content above
79+
path, // Path to the data object handed into this renderer
80+
schema, // JSON Schema describing this renderers data
81+
} = props;
82+
83+
// Calculate path from the given data to the nested ~ property
84+
// You could also do this manually without the Resolve.data util
85+
const tildePath = Paths.compose('', 'b/ar', '~');
86+
const tildeValue = Resolve.data(data, tildePath);
87+
88+
const arrayData = data['array~Data'];
89+
// Resolve schema of array entries from this renderer's schema.
90+
const entrySchemaPath = Paths.compose(
91+
'#',
92+
'properties',
93+
'array~Data',
94+
'items'
95+
);
96+
const entrySchema = Resolve.schema(schema, entrySchemaPath);
97+
// Iterate over array~Data and dispatch for each entry
98+
// Dispatch needs the path from the root of JSON Forms's data
99+
// Thus, calculate it by extending this control's path
100+
const dispatchEntries = arrayData.map((arrayEntry, index) => {
101+
const entryPath = Paths.compose(path, 'array~Data', index);
102+
const schema = Resolve.schema();
103+
return (
104+
<JsonFormsDispatch
105+
key={index}
106+
schema={entrySchema}
107+
path={path}
108+
// [...] other props like cells, etc
109+
/>
110+
);
111+
});
112+
113+
// [...]
114+
};
115+
```
116+
117+
</details>
118+
3119
## Migrating to JSON Forms 3.3
4120

5121
### Angular support now targets Angular 17 and Angular 18

Diff for: packages/angular-material/src/library/layouts/array-layout.renderer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ export class ArrayLayoutRenderer
243243
}
244244
return {
245245
schema: this.scopedSchema,
246-
path: Paths.compose(this.propsPath, `/${index}`),
246+
path: Paths.compose(this.propsPath, index),
247247
uischema,
248248
};
249249
}

Diff for: packages/angular-material/src/library/other/master-detail/master.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
JsonFormsState,
4949
mapDispatchToArrayControlProps,
5050
mapStateToArrayControlProps,
51+
Paths,
5152
RankedTester,
5253
rankWith,
5354
setReadonly,
@@ -229,7 +230,7 @@ export class MasterListComponent
229230
? d.toString()
230231
: get(d, labelRefInstancePath ?? getFirstPrimitiveProp(schema)),
231232
data: d,
232-
path: `${path}/${index}`,
233+
path: Paths.compose(path, index),
233234
schema,
234235
uischema: detailUISchema,
235236
};

Diff for: packages/angular-material/src/library/other/table.renderer.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import {
3434
ControlElement,
3535
createDefaultValue,
3636
deriveTypes,
37-
encode,
3837
isObjectArrayControl,
3938
isPrimitiveArrayControl,
4039
JsonSchema,
@@ -211,8 +210,9 @@ export class TableRenderer extends JsonFormsArrayControl implements OnInit {
211210
): ColumnDescription[] => {
212211
if (schema.type === 'object') {
213212
return this.getValidColumnProps(schema).map((prop) => {
214-
const encProp = encode(prop);
215-
const uischema = controlWithoutLabel(`#/properties/${encProp}`);
213+
const uischema = controlWithoutLabel(
214+
Paths.compose('#', 'properties', prop)
215+
);
216216
if (!this.isEnabled()) {
217217
setReadonly(uischema);
218218
}
@@ -275,7 +275,7 @@ export const controlWithoutLabel = (scope: string): ControlElement => ({
275275
@Pipe({ name: 'getProps' })
276276
export class GetProps implements PipeTransform {
277277
transform(index: number, props: OwnPropsOfRenderer) {
278-
const rowPath = Paths.compose(props.path, `/${index}`);
278+
const rowPath = Paths.compose(props.path, index);
279279
return {
280280
schema: props.schema,
281281
uischema: props.uischema,

Diff for: packages/core/src/mappers/renderer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,7 @@ export const mapStateToMasterListItemProps = (
713713
ownProps: OwnPropsOfMasterListItem
714714
): StatePropsOfMasterItem => {
715715
const { schema, path, uischema, childLabelProp, index } = ownProps;
716-
const childPath = composePaths(path, `${index}`);
716+
const childPath = composePaths(path, index);
717717
const childLabel = computeChildLabel(
718718
getData(state),
719719
childPath,

Diff for: packages/core/src/util/path.ts

+29-18
Original file line numberDiff line numberDiff line change
@@ -23,37 +23,48 @@
2323
THE SOFTWARE.
2424
*/
2525

26-
import isEmpty from 'lodash/isEmpty';
2726
import range from 'lodash/range';
2827

2928
/**
30-
* Composes two JSON pointer. Pointer2 is appended to pointer1.
31-
* Example: pointer1 `'/foo/0'` and pointer2 `'/bar'` results in `'/foo/0/bar'`.
29+
* Composes a valid JSON pointer with an arbitrary number of unencoded segments.
30+
* This method encodes the segments to escape JSON pointer's special characters.
31+
* `undefined` segments are skipped.
3232
*
33-
* @param {string} pointer1 Initial JSON pointer
34-
* @param {string} pointer2 JSON pointer to append to `pointer1`
33+
* Example:
34+
* ```ts
35+
* const composed = compose('/path/to/object', '~foo', 'b/ar');
36+
* // compose === '/path/to/object/~0foo/b~1ar'
37+
* ```
38+
*
39+
* The segments are appended in order to the JSON pointer and the special characters `~` and `/` are automatically encoded.
40+
*
41+
* @param {string} pointer Initial valid JSON pointer
42+
* @param {...(string | number)[]} segments **unencoded** path segments to append to the JSON pointer. May also be a number in case of indices.
3543
* @returns {string} resulting JSON pointer
3644
*/
37-
export const compose = (pointer1: string, pointer2: string) => {
38-
let p2 = pointer2;
39-
if (!isEmpty(pointer2) && !pointer2.startsWith('/')) {
40-
p2 = '/' + pointer2;
41-
}
45+
export const compose = (
46+
pointer: string,
47+
...segments: (string | number)[]
48+
): string => {
49+
// Remove undefined segments and encode string segments. Numbers don't need encoding.
50+
// Only skip undefined segments, as empty string segments are allowed
51+
// and reference a property that has the empty string as property name.
52+
const sanitizedSegments = segments
53+
.filter((s) => s !== undefined)
54+
.map((s) => (typeof s === 'string' ? encode(s) : s.toString()));
4255

43-
if (isEmpty(pointer1)) {
44-
return p2;
45-
} else if (isEmpty(pointer2)) {
46-
return pointer1;
47-
} else {
48-
return `${pointer1}${p2}`;
49-
}
56+
return sanitizedSegments.reduce(
57+
(currentPointer, segment) => `${currentPointer}/${segment}`,
58+
pointer ?? '' // Treat undefined and null the same as the empty string (root pointer)
59+
);
5060
};
5161

5262
export { compose as composePaths };
5363

5464
/**
5565
* Convert a schema path (i.e. JSON pointer) to an array by splitting
56-
* at the '/' character and removing all schema-specific keywords.
66+
* at the '/' character, removing all schema-specific keywords,
67+
* and decoding each segment to remove JSON pointer specific escaping.
5768
*
5869
* The returned value can be used to de-reference a root object by folding over it
5970
* and de-referencing the single segments to obtain a new object.

Diff for: packages/core/src/util/uischema.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,7 @@ export const composeWithUi = (scopableUi: Scopable, path: string): string => {
101101
}
102102

103103
const segments = toDataPathSegments(scopableUi.scope);
104-
105-
if (isEmpty(segments)) {
106-
return path ?? '';
107-
}
108-
109-
return compose(path, segments.join('.'));
104+
return compose(path, ...segments);
110105
};
111106

112107
export const isInternationalized = (

Diff for: packages/core/test/reducers/core.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1865,7 +1865,7 @@ test('core reducer helpers - getControlPath - fallback to AJV <=7 errors does no
18651865
t.is(controlPath, '');
18661866
});
18671867

1868-
test('core reducer helpers - getControlPath - decodes JSON Pointer escape sequences', (t) => {
1868+
test('core reducer helpers - getControlPath - does not decode JSON Pointer escape sequences', (t) => {
18691869
const errorObject = { instancePath: '/~0group/~1name' } as ErrorObject;
18701870
const controlPath = getControlPath(errorObject);
18711871
t.is(controlPath, '/~0group/~1name');

Diff for: packages/core/test/util/path.test.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*/
2525
import test from 'ava';
2626
import { JsonSchema } from '../../src/models';
27-
import { Resolve, toDataPath } from '../../src';
27+
import { compose, Resolve, toDataPath } from '../../src/util';
2828

2929
test('resolve ', (t) => {
3030
const schema: JsonSchema = {
@@ -269,3 +269,63 @@ test('resolve $ref complicated', (t) => {
269269
},
270270
});
271271
});
272+
273+
test('compose - encodes segments', (t) => {
274+
const result = compose('/foo', '/bar', '~~prop');
275+
t.is(result, '/foo/~1bar/~0~0prop');
276+
});
277+
278+
test('compose - does not re-encode initial pointer', (t) => {
279+
const result = compose('/f~0oo', 'bar');
280+
t.is(result, '/f~0oo/bar');
281+
});
282+
283+
/*
284+
* Unexpected edge case but the RFC6901 standard defines that empty segments point to a property with key `''`.
285+
* For instance, '/' points to a property with key `''` in the root object.
286+
*/
287+
test('compose - handles empty string segments', (t) => {
288+
const result = compose('/foo', '', 'bar');
289+
t.is(result, '/foo//bar');
290+
});
291+
292+
test('compose - returns initial pointer for no given segments', (t) => {
293+
const result = compose('/foo');
294+
t.is(result, '/foo');
295+
});
296+
297+
test("compose - accepts initial pointer starting with URI fragment '#'", (t) => {
298+
const result = compose('#/foo', 'bar');
299+
t.is(result, '#/foo/bar');
300+
});
301+
302+
test('compose - handles root json pointer', (t) => {
303+
const result = compose('', 'foo');
304+
t.is(result, '/foo');
305+
});
306+
307+
test('compose - handles numbers', (t) => {
308+
const result = compose('/foo', 0, 'bar');
309+
t.is(result, '/foo/0/bar');
310+
});
311+
312+
/*
313+
* Unexpected edge case but the RFC6901 standard defines that `/` points to a property with key `''`.
314+
* To point to the root object, the empty string `''` is used.
315+
*/
316+
test('compose - handles json pointer pointing to property with empty string as key', (t) => {
317+
const result = compose('/', 'foo');
318+
t.is(result, '//foo');
319+
});
320+
321+
/** undefined JSON pointers are not valid but we still expect compose to handle them gracefully. */
322+
test('compose - handles undefined root json pointer', (t) => {
323+
const result = compose(undefined as any, 'foo');
324+
t.is(result, '/foo');
325+
});
326+
327+
/** undefined segment elements are not valid but we still expect compose to handle them gracefully. */
328+
test('compose - ignores undefined segments', (t) => {
329+
const result = compose('/foo', undefined as any, 'bar');
330+
t.is(result, '/foo/bar');
331+
});

Diff for: packages/material-renderers/src/complex/MaterialEnumArrayRenderer.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const MaterialEnumArrayRenderer = ({
8080
</FormLabel>
8181
<FormGroup row>
8282
{options.map((option: any, index: number) => {
83-
const optionPath = Paths.compose(path, `/${index}`);
83+
const optionPath = Paths.compose(path, index);
8484
const checkboxValue = data?.includes(option.value)
8585
? option.value
8686
: undefined;

Diff for: packages/material-renderers/src/complex/MaterialTableControl.tsx

+6-5
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import {
5454
Resolve,
5555
JsonFormsRendererRegistryEntry,
5656
JsonFormsCellRendererRegistryEntry,
57-
encode,
5857
ArrayTranslations,
5958
} from '@jsonforms/core';
6059
import DeleteIcon from '@mui/icons-material/Delete';
@@ -94,7 +93,7 @@ const generateCells = (
9493
) => {
9594
if (schema.type === 'object') {
9695
return getValidColumnProps(schema).map((prop) => {
97-
const cellPath = Paths.compose(rowPath, '/' + prop);
96+
const cellPath = Paths.compose(rowPath, prop);
9897
const props = {
9998
propName: prop,
10099
schema,
@@ -231,10 +230,12 @@ const NonEmptyCellComponent = React.memo(function NonEmptyCellComponent({
231230
<DispatchCell
232231
schema={Resolve.schema(
233232
schema,
234-
`#/properties/${encode(propName)}`,
233+
Paths.compose('#', 'properties', propName),
235234
rootSchema
236235
)}
237-
uischema={controlWithoutLabel(`#/properties/${encode(propName)}`)}
236+
uischema={controlWithoutLabel(
237+
Paths.compose('#', 'properties', propName)
238+
)}
238239
path={path}
239240
enabled={enabled}
240241
renderers={renderers}
@@ -421,7 +422,7 @@ const TableRows = ({
421422
return (
422423
<React.Fragment>
423424
{range(data).map((index: number) => {
424-
const childPath = Paths.compose(path, `/${index}`);
425+
const childPath = Paths.compose(path, index);
425426

426427
return (
427428
<NonEmptyRow

0 commit comments

Comments
 (0)