diff --git a/packages/angular-material/src/other/master-detail/master.ts b/packages/angular-material/src/other/master-detail/master.ts index 8d4663dd07..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,10 +48,12 @@ import { const keywords = ['#', 'properties', 'items']; export const removeSchemaKeywords = (path: string) => { - return path - .split('/') - .filter(s => !some(keywords, key => key === s)) - .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 619a024b83..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,8 @@ export class TableRenderer extends JsonFormsArrayControl { ): ColumnDescription[] => { if (schema.type === 'object') { return this.getValidColumnProps(schema).map(prop => { - const uischema = controlWithoutLabel(`#/properties/${prop}`); + 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 6b76d7d2d6..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,7 +162,7 @@ const generateUISchema = ( const nextRef: string = currentRef + '/properties'; Object.keys(jsonSchema.properties).map(propName => { let value = jsonSchema.properties[propName]; - 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 05f2dced87..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,8 +37,7 @@ const deriveLabel = ( } if (typeof controlElement.scope === 'string') { const ref = controlElement.scope; - const label = ref.substr(ref.lastIndexOf('/') + 1); - + 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 24b4ebd8af..401cfd6853 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -62,9 +62,11 @@ export const toDataPathSegments = (schemaPath: string): string[] => { .replace(/oneOf\/[\d]\//g, ''); const segments = s.split('/'); - const startFromRoot = segments[0] === '#' || segments[0] === ''; + const decodedSegments = segments.map(decode); + + 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]); }; /** @@ -88,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/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..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,7 +116,7 @@ export const resolveSchema = ( if (isEmpty(schema)) { return undefined; } - const validPathSegments = schemaPath.split('/'); + const validPathSegments = schemaPath.split('/').map(decode); let resultSchema = schema; for (let i = 0; i < validPathSegments.length; i++) { let pathSegment = validPathSegments[i]; @@ -136,7 +137,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(encode).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..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) => { - +const NonEmptyCellComponent = React.memo(({path, propName, schema, rootSchema, errors, enabled, renderers, cells, isValid}:NonEmptyCellComponentProps) => { return ( {schema.properties ? (