Skip to content

Commit bb3be61

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
1 parent 750ccc4 commit bb3be61

File tree

10 files changed

+104
-53
lines changed

10 files changed

+104
-53
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export class ArrayLayoutRenderer
233233
}
234234
return {
235235
schema: this.scopedSchema,
236-
path: Paths.compose(this.propsPath, `/${index}`),
236+
path: Paths.compose(this.propsPath, `${index}`),
237237
uischema,
238238
};
239239
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
JsonFormsState,
4646
mapDispatchToArrayControlProps,
4747
mapStateToArrayControlProps,
48+
Paths,
4849
RankedTester,
4950
rankWith,
5051
setReadonly,
@@ -224,7 +225,7 @@ export class MasterListComponent
224225
? d.toString()
225226
: get(d, labelRefInstancePath ?? getFirstPrimitiveProp(schema)),
226227
data: d,
227-
path: `${path}/${index}`,
228+
path: Paths.compose(path, `${index}`),
228229
schema,
229230
uischema: detailUISchema,
230231
};

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

Lines changed: 4 additions & 4 deletions
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,
@@ -209,8 +208,9 @@ export class TableRenderer extends JsonFormsArrayControl implements OnInit {
209208
): ColumnDescription[] => {
210209
if (schema.type === 'object') {
211210
return this.getValidColumnProps(schema).map((prop) => {
212-
const encProp = encode(prop);
213-
const uischema = controlWithoutLabel(`#/properties/${encProp}`);
211+
const uischema = controlWithoutLabel(
212+
Paths.compose('#', 'properties', prop)
213+
);
214214
if (!this.isEnabled()) {
215215
setReadonly(uischema);
216216
}
@@ -273,7 +273,7 @@ export const controlWithoutLabel = (scope: string): ControlElement => ({
273273
@Pipe({ name: 'getProps' })
274274
export class GetProps implements PipeTransform {
275275
transform(index: number, props: OwnPropsOfRenderer) {
276-
const rowPath = Paths.compose(props.path, `/${index}`);
276+
const rowPath = Paths.compose(props.path, `${index}`);
277277
return {
278278
schema: props.schema,
279279
uischema: props.uischema,

packages/core/src/util/path.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,37 +23,42 @@
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+
* Empty segments (i.e. empty strings) 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[]} segments **unencoded** path segments to append to the JSON pointer
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-
}
42-
43-
if (isEmpty(pointer1)) {
44-
return p2;
45-
} else if (isEmpty(pointer2)) {
46-
return pointer1;
47-
} else {
48-
return `${pointer1}${p2}`;
49-
}
45+
export const compose = (pointer: string, ...segments: string[]): string => {
46+
return segments.reduce((currentPointer, segment) => {
47+
// Only skip undefined segments, as empty string segments are allowed
48+
// and reference a property that has the empty string as property name.
49+
if (segment === undefined) {
50+
return currentPointer;
51+
}
52+
return `${currentPointer}/${encode(segment)}`;
53+
}, pointer ?? '');
5054
};
5155

5256
export { compose as composePaths };
5357

5458
/**
5559
* Convert a schema path (i.e. JSON pointer) to an array by splitting
56-
* at the '/' character and removing all schema-specific keywords.
60+
* at the '/' character, removing all schema-specific keywords,
61+
* and decoding each segment to remove JSON pointer specific escaping.
5762
*
5863
* The returned value can be used to de-reference a root object by folding over it
5964
* and de-referencing the single segments to obtain a new object.

packages/core/src/util/uischema.ts

Lines changed: 1 addition & 6 deletions
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 = (

packages/core/test/reducers/core.test.ts

Lines changed: 1 addition & 1 deletion
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');

packages/core/test/util/path.test.ts

Lines changed: 56 additions & 1 deletion
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,58 @@ 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+
/*
308+
* Unexpected edge case but the RFC6901 standard defines that `/` points to a property with key `''`.
309+
* To point to the root object, the empty string `''` is used.
310+
*/
311+
test('compose - handles json pointer pointing to property with empty string as key', (t) => {
312+
const result = compose('/', 'foo');
313+
t.is(result, '//foo');
314+
});
315+
316+
/** undefined JSON pointers are not valid but we still expect compose to handle them gracefully. */
317+
test('compose - handles undefined root json pointer', (t) => {
318+
const result = compose(undefined as any, 'foo');
319+
t.is(result, '/foo');
320+
});
321+
322+
/** undefined segment elements are not valid but we still expect compose to handle them gracefully. */
323+
test('compose - ignores undefined segments', (t) => {
324+
const result = compose('/foo', undefined as any, 'bar');
325+
t.is(result, '/foo/bar');
326+
});

packages/material-renderers/src/complex/MaterialEnumArrayRenderer.tsx

Lines changed: 1 addition & 1 deletion
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;

packages/material-renderers/src/complex/MaterialTableControl.tsx

Lines changed: 6 additions & 5 deletions
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

packages/vanilla-renderers/src/complex/TableArrayControl.tsx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import {
4040
Resolve,
4141
Test,
4242
getControlPath,
43-
encode,
4443
} from '@jsonforms/core';
4544
import { DispatchCell, withJsonFormsArrayControlProps } from '@jsonforms/react';
4645
import { withVanillaControlProps } from '../util';
@@ -97,7 +96,8 @@ class TableArrayControl extends React.Component<
9796
const createControlElement = (key?: string): ControlElement => ({
9897
type: 'Control',
9998
label: false,
100-
scope: schema.type === 'object' ? `#/properties/${key}` : '#',
99+
scope:
100+
schema.type === 'object' ? Paths.compose('#', 'properties', key) : '#',
101101
});
102102
const isValid = errors.length === 0;
103103
const divClassNames = [validationClass]
@@ -146,8 +146,7 @@ class TableArrayControl extends React.Component<
146146
</tr>
147147
) : (
148148
data.map((_child, index) => {
149-
const childPath = Paths.compose(path, `/${index}`);
150-
// TODO
149+
const childPath = Paths.compose(path, `${index}`);
151150
const errorsPerEntry: any[] = filter(childErrors, (error) => {
152151
const errorPath = getControlPath(error);
153152
return errorPath.startsWith(childPath);
@@ -173,29 +172,24 @@ class TableArrayControl extends React.Component<
173172
(prop) => schema.properties[prop].type !== 'array'
174173
),
175174
fpmap((prop) => {
176-
const childPropPath = Paths.compose(
177-
childPath,
178-
'/' + prop.toString()
179-
);
175+
const childPropPath = Paths.compose(childPath, prop);
180176
return (
181177
<td key={childPropPath}>
182178
<DispatchCell
183179
schema={Resolve.schema(
184180
schema,
185-
`#/properties/${encode(prop)}`,
181+
Paths.compose('#', 'properties', prop),
186182
rootSchema
187183
)}
188-
uischema={createControlElement(encode(prop))}
189-
path={childPath + '/' + prop}
184+
uischema={createControlElement(prop)}
185+
path={childPropPath}
190186
/>
191187
</td>
192188
);
193189
})
194190
)(schema.properties)
195191
) : (
196-
<td
197-
key={Paths.compose(childPath, '/' + index.toString())}
198-
>
192+
<td key={Paths.compose(childPath, `${index}`)}>
199193
<DispatchCell
200194
schema={schema}
201195
uischema={createControlElement()}

0 commit comments

Comments
 (0)