Skip to content

Commit 4f1c4cd

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 922a592 commit 4f1c4cd

File tree

9 files changed

+104
-53
lines changed

9 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: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,38 +23,43 @@
2323
THE SOFTWARE.
2424
*/
2525

26-
import isEmpty from 'lodash/isEmpty';
2726
import range from 'lodash/range';
2827
import { isScoped, Scopable } from '../models';
2928

3029
/**
31-
* Composes two JSON pointer. Pointer2 is appended to pointer1.
32-
* Example: pointer1 `'/foo/0'` and pointer2 `'/bar'` results in `'/foo/0/bar'`.
30+
* Composes a valid JSON pointer with an arbitrary number of unencoded segments.
31+
* This method encodes the segments to escape JSON pointer's special characters.
32+
* Empty segments (i.e. empty strings) are skipped.
3333
*
34-
* @param {string} pointer1 Initial JSON pointer
35-
* @param {string} pointer2 JSON pointer to append to `pointer1`
34+
* Example:
35+
* ```ts
36+
* const composed = compose('/path/to/object', '~foo', 'b/ar');
37+
* // compose === '/path/to/object/~0foo/b~1ar'
38+
* ```
39+
*
40+
* The segments are appended in order to the JSON pointer and the special characters `~` and `/` are automatically encoded.
41+
*
42+
* @param {string} pointer Initial valid JSON pointer
43+
* @param {...string[]} segments **unencoded** path segments to append to the JSON pointer
3644
* @returns {string} resulting JSON pointer
3745
*/
38-
export const compose = (pointer1: string, pointer2: string) => {
39-
let p2 = pointer2;
40-
if (!isEmpty(pointer2) && !pointer2.startsWith('/')) {
41-
p2 = '/' + pointer2;
42-
}
43-
44-
if (isEmpty(pointer1)) {
45-
return p2;
46-
} else if (isEmpty(pointer2)) {
47-
return pointer1;
48-
} else {
49-
return `${pointer1}${p2}`;
50-
}
46+
export const compose = (pointer: string, ...segments: string[]): string => {
47+
return segments.reduce((currentPointer, segment) => {
48+
// Only skip undefined segments, as empty string segments are allowed
49+
// and reference a property that has the empty string as property name.
50+
if (segment === undefined) {
51+
return currentPointer;
52+
}
53+
return `${currentPointer}/${encode(segment)}`;
54+
}, pointer ?? '');
5155
};
5256

5357
export { compose as composePaths };
5458

5559
/**
5660
* Convert a schema path (i.e. JSON pointer) to an array by splitting
57-
* at the '/' character and removing all schema-specific keywords.
61+
* at the '/' character, removing all schema-specific keywords,
62+
* and decoding each segment to remove JSON pointer specific escaping.
5863
*
5964
* The returned value can be used to de-reference a root object by folding over it
6065
* and de-referencing the single segments to obtain a new object.
@@ -99,12 +104,7 @@ export const composeWithUi = (scopableUi: Scopable, path: string): string => {
99104
}
100105

101106
const segments = toDataPathSegments(scopableUi.scope);
102-
103-
if (isEmpty(segments)) {
104-
return path ?? '';
105-
}
106-
107-
return compose(path, '/' + segments.join('/'));
107+
return compose(path, ...segments);
108108
};
109109

110110
export const toLodashSegments = (jsonPointer: string): string[] => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1870,7 +1870,7 @@ test('core reducer helpers - getControlPath - fallback to AJV <=7 errors does no
18701870
t.is(controlPath, '');
18711871
});
18721872

1873-
test('core reducer helpers - getControlPath - decodes JSON Pointer escape sequences', (t) => {
1873+
test('core reducer helpers - getControlPath - does not decode JSON Pointer escape sequences', (t) => {
18741874
const errorObject = { instancePath: '/~0group/~1name' } as ErrorObject;
18751875
const controlPath = getControlPath(errorObject);
18761876
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';
27-
import { Resolve, toDataPath } from '../../src/util';
27+
import { Resolve, toDataPath, compose } 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 {
@@ -96,7 +95,7 @@ const generateCells = (
9695
) => {
9796
if (schema.type === 'object') {
9897
return getValidColumnProps(schema).map((prop) => {
99-
const cellPath = Paths.compose(rowPath, '/' + prop);
98+
const cellPath = Paths.compose(rowPath, prop);
10099
const props = {
101100
propName: prop,
102101
schema,
@@ -233,10 +232,12 @@ const NonEmptyCellComponent = React.memo(function NonEmptyCellComponent({
233232
<DispatchCell
234233
schema={Resolve.schema(
235234
schema,
236-
`#/properties/${encode(propName)}`,
235+
Paths.compose('#', 'properties', propName),
237236
rootSchema
238237
)}
239-
uischema={controlWithoutLabel(`#/properties/${encode(propName)}`)}
238+
uischema={controlWithoutLabel(
239+
Paths.compose('#', 'properties', propName)
240+
)}
240241
path={path}
241242
enabled={enabled}
242243
renderers={renderers}
@@ -423,7 +424,7 @@ const TableRows = ({
423424
return (
424425
<React.Fragment>
425426
{range(data).map((index: number) => {
426-
const childPath = Paths.compose(path, `/${index}`);
427+
const childPath = Paths.compose(path, `${index}`);
427428

428429
return (
429430
<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)