From be63a89142631affbb5d37e4cb267859d15d0dbf Mon Sep 17 00:00:00 2001 From: Max Elia Date: Mon, 15 Nov 2021 17:28:46 +0100 Subject: [PATCH 1/2] Encoding of special characters in prop name - Encode '/' in ui schema generator - Decode '/' and '~' in: - Resolvers - Label - MaterialTableControl - Vanilla TableArrayControl - Angular table.renderer - Add test cases for uischema, resolver and path Signed-off-by: Max Elia Schweigkofler --- .../src/other/master-detail/master.ts | 4 +++- .../src/other/table.renderer.ts | 3 ++- packages/core/src/generators/uischema.ts | 1 + packages/core/src/util/label.ts | 3 ++- packages/core/src/util/path.ts | 8 ++++++-- packages/core/src/util/renderer.ts | 1 + packages/core/src/util/resolvers.ts | 6 ++++-- packages/core/test/generators/uischema.test.ts | 18 ++++++++++++++++++ packages/core/test/util/path.test.ts | 3 +++ packages/core/test/util/resolvers.test.ts | 15 ++++++++++++++- .../src/complex/MaterialTableControl.tsx | 2 +- .../vanilla/src/complex/TableArrayControl.tsx | 7 ++++--- 12 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/angular-material/src/other/master-detail/master.ts b/packages/angular-material/src/other/master-detail/master.ts index 8d4663dd07..77caea6020 100644 --- a/packages/angular-material/src/other/master-detail/master.ts +++ b/packages/angular-material/src/other/master-detail/master.ts @@ -50,7 +50,9 @@ export const removeSchemaKeywords = (path: string) => { return path .split('/') .filter(s => !some(keywords, key => key === s)) - .join('.'); + .join('.') + .split('~1') + .join('/') }; @Component({ diff --git a/packages/angular-material/src/other/table.renderer.ts b/packages/angular-material/src/other/table.renderer.ts index 619a024b83..3d570dc2c4 100644 --- a/packages/angular-material/src/other/table.renderer.ts +++ b/packages/angular-material/src/other/table.renderer.ts @@ -101,7 +101,8 @@ export class TableRenderer extends JsonFormsArrayControl { ): ColumnDescription[] => { if (schema.type === 'object') { return this.getValidColumnProps(schema).map(prop => { - const uischema = controlWithoutLabel(`#/properties/${prop}`); + const encProp = prop.split('/').join('~1'); + const uischema = controlWithoutLabel(`#/properties/${encProp}`); if (!this.isEnabled()) { setReadonly(uischema); } diff --git a/packages/core/src/generators/uischema.ts b/packages/core/src/generators/uischema.ts index 6b76d7d2d6..7f57952198 100644 --- a/packages/core/src/generators/uischema.ts +++ b/packages/core/src/generators/uischema.ts @@ -162,6 +162,7 @@ const generateUISchema = ( const nextRef: string = currentRef + '/properties'; Object.keys(jsonSchema.properties).map(propName => { let value = jsonSchema.properties[propName]; + propName = propName.split('/').join('~1'); const ref = `${nextRef}/${propName}`; if (value.$ref !== undefined) { value = resolveSchema(rootSchema, value.$ref); diff --git a/packages/core/src/util/label.ts b/packages/core/src/util/label.ts index 05f2dced87..dbfe943ee0 100644 --- a/packages/core/src/util/label.ts +++ b/packages/core/src/util/label.ts @@ -36,7 +36,8 @@ const deriveLabel = ( } if (typeof controlElement.scope === 'string') { const ref = controlElement.scope; - const label = ref.substr(ref.lastIndexOf('/') + 1); + let label = ref.substr(ref.lastIndexOf('/') + 1); + label = label.split('~1').join('/').split('~0').join('~'); return startCase(label); } diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts index 24b4ebd8af..c6a3afe941 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -62,9 +62,13 @@ export const toDataPathSegments = (schemaPath: string): string[] => { .replace(/oneOf\/[\d]\//g, ''); const segments = s.split('/'); - const startFromRoot = segments[0] === '#' || segments[0] === ''; + const decodedSegments = segments.map(segment => { + return segment.split('~1').join('/').split('~0').join('~'); + }); + + const startFromRoot = decodedSegments[0] === '#' || decodedSegments[0] === ''; const startIndex = startFromRoot ? 2 : 1; - return range(startIndex, segments.length, 2).map(idx => segments[idx]); + return range(startIndex, decodedSegments.length, 2).map(idx => decodedSegments[idx]); }; /** diff --git a/packages/core/src/util/renderer.ts b/packages/core/src/util/renderer.ts index 2f8eca2d8c..2be9bd9bc4 100644 --- a/packages/core/src/util/renderer.ts +++ b/packages/core/src/util/renderer.ts @@ -65,6 +65,7 @@ const isRequired = ( ): boolean => { const pathSegments = schemaPath.split('/'); const lastSegment = pathSegments[pathSegments.length - 1]; + // Skip "properties", "items" etc. to resolve the parent const nextHigherSchemaSegments = pathSegments.slice( 0, pathSegments.length - 2 diff --git a/packages/core/src/util/resolvers.ts b/packages/core/src/util/resolvers.ts index 9be6c1f300..f0e4ebdc20 100644 --- a/packages/core/src/util/resolvers.ts +++ b/packages/core/src/util/resolvers.ts @@ -115,7 +115,9 @@ export const resolveSchema = ( if (isEmpty(schema)) { return undefined; } - const validPathSegments = schemaPath.split('/'); + const validPathSegments = schemaPath.split('/').map(segment => { + return segment.split('~1').join('/').split('~0').join('~'); + }); let resultSchema = schema; for (let i = 0; i < validPathSegments.length; i++) { let pathSegment = validPathSegments[i]; @@ -136,7 +138,7 @@ export const resolveSchema = ( resultSchema?.anyOf ?? [] ); for (let item of schemas) { - curSchema = resolveSchema(item, validPathSegments.slice(i).join('/')); + curSchema = resolveSchema(item, validPathSegments.slice(i).map(s => s.split('/').join('~1')).join('/')); if (curSchema) { break; } diff --git a/packages/core/test/generators/uischema.test.ts b/packages/core/test/generators/uischema.test.ts index fe02ae52c2..7cc2350d3a 100644 --- a/packages/core/test/generators/uischema.test.ts +++ b/packages/core/test/generators/uischema.test.ts @@ -556,3 +556,21 @@ test('generate control for nested oneOf', t => { }; t.deepEqual(generateDefaultUISchema(schema), uischema); }); + +test('encode "/" in generated ui schema', t => { + const schema: JsonSchema = { + properties: { + 'some / initial / value': { + type : 'integer' + } + } + }; + const uischema: Layout = { + type: 'VerticalLayout', + elements: [{ + type: 'Control', + scope: '#/properties/some ~1 initial ~1 value' + }] as ControlElement[] + }; + t.deepEqual(generateDefaultUISchema(schema), uischema); +}); diff --git a/packages/core/test/util/path.test.ts b/packages/core/test/util/path.test.ts index d6aeb978f8..80cdb24d63 100644 --- a/packages/core/test/util/path.test.ts +++ b/packages/core/test/util/path.test.ts @@ -87,6 +87,9 @@ test('toDataPath use of encoded paths relative without /', t => { const fooBar = encodeURIComponent('foo/bar'); t.is(toDataPath(`properties/${fooBar}`), `${fooBar}`); }); +test('toDataPath use of encoded special character in pathname', t => { + t.is(toDataPath('properties/foo~0bar~1baz'), 'foo~bar/baz'); +}); test('resolve instance', t => { const instance = { foo: 123 }; const result = Resolve.data(instance, toDataPath('#/properties/foo')); diff --git a/packages/core/test/util/resolvers.test.ts b/packages/core/test/util/resolvers.test.ts index 7bc293a9d6..80f1095368 100644 --- a/packages/core/test/util/resolvers.test.ts +++ b/packages/core/test/util/resolvers.test.ts @@ -63,4 +63,17 @@ test('resolveSchema - resolves schema with any ', t => { t.deepEqual(resolveSchema(schema, '#/properties/description/properties/index'), {type: 'number'}); t.deepEqual(resolveSchema(schema, '#/properties/description/properties/exist'), {type: 'boolean'}); t.is(resolveSchema(schema, '#/properties/description/properties/notfound'), undefined); -}); \ No newline at end of file +}); + +test('resolveSchema - resolves schema with encoded characters', t => { + const schema = { + type: 'object', + properties: { + 'foo / ~ bar': { + type: 'integer' + } + } + }; + t.deepEqual(resolveSchema(schema, '#/properties/foo ~1 ~0 bar'), {type: 'integer'}); + t.is(resolveSchema(schema, '#/properties/foo / bar'), undefined); +}); diff --git a/packages/material/src/complex/MaterialTableControl.tsx b/packages/material/src/complex/MaterialTableControl.tsx index 74968832ed..85e8e06e70 100644 --- a/packages/material/src/complex/MaterialTableControl.tsx +++ b/packages/material/src/complex/MaterialTableControl.tsx @@ -207,7 +207,7 @@ interface NonEmptyCellComponentProps { isValid: boolean } const NonEmptyCellComponent = React.memo(({path, propName, schema,rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => { - + propName = propName?.split('/').join('~1'); return ( {schema.properties ? ( diff --git a/packages/vanilla/src/complex/TableArrayControl.tsx b/packages/vanilla/src/complex/TableArrayControl.tsx index b210a5cf64..689deb2070 100644 --- a/packages/vanilla/src/complex/TableArrayControl.tsx +++ b/packages/vanilla/src/complex/TableArrayControl.tsx @@ -167,12 +167,13 @@ class TableArrayControl extends React.Component From 40b23671dae7c5383cdc5ff207183143ed72c8b9 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Thu, 3 Feb 2022 16:38:54 +0100 Subject: [PATCH 2/2] Use encode/decode instead of manual splits --- .../src/other/master-detail/master.ts | 13 +++++++------ .../angular-material/src/other/table.renderer.ts | 3 ++- packages/core/src/generators/uischema.ts | 5 ++--- packages/core/src/util/label.ts | 5 ++--- packages/core/src/util/path.ts | 15 ++++++++++++--- packages/core/src/util/resolvers.ts | 7 +++---- .../material/src/complex/MaterialTableControl.tsx | 10 +++++----- .../vanilla/src/complex/TableArrayControl.tsx | 9 ++++----- 8 files changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/angular-material/src/other/master-detail/master.ts b/packages/angular-material/src/other/master-detail/master.ts index 77caea6020..e5b582b623 100644 --- a/packages/angular-material/src/other/master-detail/master.ts +++ b/packages/angular-material/src/other/master-detail/master.ts @@ -33,6 +33,7 @@ import { ArrayControlProps, ControlElement, createDefaultValue, + decode, findUISchema, getFirstPrimitiveProp, JsonFormsState, @@ -47,12 +48,12 @@ import { const keywords = ['#', 'properties', 'items']; export const removeSchemaKeywords = (path: string) => { - return path - .split('/') - .filter(s => !some(keywords, key => key === s)) - .join('.') - .split('~1') - .join('/') + return decode( + path + .split('/') + .filter(s => !some(keywords, key => key === s)) + .join('.') + ); }; @Component({ diff --git a/packages/angular-material/src/other/table.renderer.ts b/packages/angular-material/src/other/table.renderer.ts index 3d570dc2c4..0e19c3205b 100644 --- a/packages/angular-material/src/other/table.renderer.ts +++ b/packages/angular-material/src/other/table.renderer.ts @@ -32,6 +32,7 @@ import { ArrayControlProps, ControlElement, deriveTypes, + encode, isObjectArrayControl, isPrimitiveArrayControl, JsonSchema, @@ -101,7 +102,7 @@ export class TableRenderer extends JsonFormsArrayControl { ): ColumnDescription[] => { if (schema.type === 'object') { return this.getValidColumnProps(schema).map(prop => { - const encProp = prop.split('/').join('~1'); + const encProp = encode(prop); const uischema = controlWithoutLabel(`#/properties/${encProp}`); if (!this.isEnabled()) { setReadonly(uischema); diff --git a/packages/core/src/generators/uischema.ts b/packages/core/src/generators/uischema.ts index 7f57952198..865259392b 100644 --- a/packages/core/src/generators/uischema.ts +++ b/packages/core/src/generators/uischema.ts @@ -35,7 +35,7 @@ import { Layout, UISchemaElement } from '../models'; -import { deriveTypes, resolveSchema } from '../util'; +import { deriveTypes, encode, resolveSchema } from '../util'; /** * Creates a new ILayout. @@ -162,8 +162,7 @@ const generateUISchema = ( const nextRef: string = currentRef + '/properties'; Object.keys(jsonSchema.properties).map(propName => { let value = jsonSchema.properties[propName]; - propName = propName.split('/').join('~1'); - const ref = `${nextRef}/${propName}`; + const ref = `${nextRef}/${encode(propName)}`; if (value.$ref !== undefined) { value = resolveSchema(rootSchema, value.$ref); } diff --git a/packages/core/src/util/label.ts b/packages/core/src/util/label.ts index dbfe943ee0..909027e82a 100644 --- a/packages/core/src/util/label.ts +++ b/packages/core/src/util/label.ts @@ -26,6 +26,7 @@ import startCase from 'lodash/startCase'; import { ControlElement, JsonSchema, LabelDescription } from '../models'; +import { decode } from './path'; const deriveLabel = ( controlElement: ControlElement, @@ -36,9 +37,7 @@ const deriveLabel = ( } if (typeof controlElement.scope === 'string') { const ref = controlElement.scope; - let label = ref.substr(ref.lastIndexOf('/') + 1); - label = label.split('~1').join('/').split('~0').join('~'); - + const label = decode(ref.substr(ref.lastIndexOf('/') + 1)); return startCase(label); } diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts index c6a3afe941..401cfd6853 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -62,9 +62,7 @@ export const toDataPathSegments = (schemaPath: string): string[] => { .replace(/oneOf\/[\d]\//g, ''); const segments = s.split('/'); - const decodedSegments = segments.map(segment => { - return segment.split('~1').join('/').split('~0').join('~'); - }); + const decodedSegments = segments.map(decode); const startFromRoot = decodedSegments[0] === '#' || decodedSegments[0] === ''; const startIndex = startFromRoot ? 2 : 1; @@ -92,3 +90,14 @@ export const composeWithUi = (scopableUi: Scopable, path: string): string => { return isEmpty(segments) ? path : compose(path, segments.join('.')); }; + +/** + * Encodes the given segment to be used as part of a JSON Pointer + * + * JSON Pointer has special meaning for "/" and "~", therefore these must be encoded + */ +export const encode = (segment: string) => segment?.replace(/~/g, '~0').replace(/\//g, '~1'); +/** + * Decodes a given JSON Pointer segment to its "normal" representation + */ +export const decode = (pointerSegment: string) => pointerSegment?.replace(/~1/g, '/').replace(/~0/, '~'); \ No newline at end of file diff --git a/packages/core/src/util/resolvers.ts b/packages/core/src/util/resolvers.ts index f0e4ebdc20..5a4c62d70f 100644 --- a/packages/core/src/util/resolvers.ts +++ b/packages/core/src/util/resolvers.ts @@ -26,6 +26,7 @@ import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; import { JsonSchema } from '../models'; +import { decode, encode } from './path'; /** * Map for storing refs and the respective schemas they are pointing to. @@ -115,9 +116,7 @@ export const resolveSchema = ( if (isEmpty(schema)) { return undefined; } - const validPathSegments = schemaPath.split('/').map(segment => { - return segment.split('~1').join('/').split('~0').join('~'); - }); + const validPathSegments = schemaPath.split('/').map(decode); let resultSchema = schema; for (let i = 0; i < validPathSegments.length; i++) { let pathSegment = validPathSegments[i]; @@ -138,7 +137,7 @@ export const resolveSchema = ( resultSchema?.anyOf ?? [] ); for (let item of schemas) { - curSchema = resolveSchema(item, validPathSegments.slice(i).map(s => s.split('/').join('~1')).join('/')); + curSchema = resolveSchema(item, validPathSegments.slice(i).map(encode).join('/')); if (curSchema) { break; } diff --git a/packages/material/src/complex/MaterialTableControl.tsx b/packages/material/src/complex/MaterialTableControl.tsx index 85e8e06e70..9f608cd19e 100644 --- a/packages/material/src/complex/MaterialTableControl.tsx +++ b/packages/material/src/complex/MaterialTableControl.tsx @@ -53,7 +53,8 @@ import { Paths, Resolve, JsonFormsRendererRegistryEntry, - JsonFormsCellRendererRegistryEntry + JsonFormsCellRendererRegistryEntry, + encode } from '@jsonforms/core'; import DeleteIcon from '@mui/icons-material/Delete'; import ArrowDownward from '@mui/icons-material/ArrowDownward'; @@ -206,18 +207,17 @@ interface NonEmptyCellComponentProps { cells?: JsonFormsCellRendererRegistryEntry[], isValid: boolean } -const NonEmptyCellComponent = React.memo(({path, propName, schema,rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => { - propName = propName?.split('/').join('~1'); +const NonEmptyCellComponent = React.memo(({path, propName, schema, rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => { return ( {schema.properties ? (