Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify path handling to consistenly use JSON Pointer #2346

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
with:
node-version: 18

- uses: pnpm/action-setup@v2.2.4
- uses: pnpm/action-setup@v4.0.0
name: Install pnpm
id: pnpm-install
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
node-version: "18"
registry-url: "https://registry.npmjs.org"

- uses: "pnpm/action-setup@v2.2.4"
- uses: "pnpm/action-setup@v4.0.0"
name: "Install pnpm"
id: "pnpm-install"
with:
Expand Down
116 changes: 116 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,121 @@
# Migration guide

## Migrating to JSON Forms 4.0

### Unified internal path handling to JSON pointers

Previously, JSON Forms used two different ways to express paths:

- 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
- The dot-separated paths (lodash format) to resolve entries in the form-wide data object

This led to confusion and prevented property names from containing dots (`.`) because lodash paths don't support escaping.

The rework unifies these paths to all use the JSON Pointer format.
Therefore, this breaks custom renderers that manually modify or create paths to resolve additional data.
They used the dot-separated paths and need to be migrated to use JSON Pointers instead.

To abstract the composition of paths away from renderers, the `Paths.compose` utility of `@jsonforms/core` should be used.
It takes a valid JSON Pointer and an arbitrary number of _unencoded_ segments to append.
The utility takes care of adding separators and encoding special characters in the given segments.

#### How to migrate

All paths that are manually composed or use the `Paths.compose` utility and add more than one segment need to be adapted.

```ts
import { Paths } from '@jsonforms/core';

// Some base path we want to extend. This is usually available in the renderer props
// or the empty string for the whole data object
const path = '/foo'

// Previous: Calculate the path manually
const oldManual = `${path}.foo.~bar`;
// Previous: Use the Paths.compose util
const oldWithUtil = Paths.compose(path, 'foo.~bar');

// Now: After the initial path, hand in each segment separately.
// Segments must be unencoded. The util automatically encodes them.
// In this case the ~ will be encoded.
const new = Paths.compose(path, 'foo', '~bar');

// Calculate a path relative to the root data that the path is resolved against
const oldFromRoot = 'nested.prop';
const newFromRoot = Paths.compose('', 'nested', 'prop'); // The empty JSON Pointer '' points to the whole data.
```

#### Custom Renderer Example

This example shows in a more elaborate way, how path composition might be used in a custom renderer.
This example uses a custom renderer implemented for the React bindings.
However, the approach is similar for all bindings.

To showcase how a migration could look like, assume a custom renderer that gets handed in this data object:

```ts
const data = {
foo: 'abc',
'b/ar': {
'~': 'tilde',
},
'array~Data': ['entry1', 'entry2'],
};
```

The renderer wants to resolve the `~` property to directly use it and iterate over the array and use the dispatch to render each entry.

<details>
<summary>Renderer code</summary>

```tsx
import { Paths, Resolve } from '@jsonforms/core';
import { JsonFormsDispatch } from '@jsonforms/react';

export const CustomRenderer = (props: ControlProps & WithInput) => {
const {
// [...]
data, // The data object to be rendered. See content above
path, // Path to the data object handed into this renderer
schema, // JSON Schema describing this renderers data
} = props;

// Calculate path from the given data to the nested ~ property
// You could also do this manually without the Resolve.data util
const tildePath = Paths.compose('', 'b/ar', '~');
const tildeValue = Resolve.data(data, tildePath);

const arrayData = data['array~Data'];
// Resolve schema of array entries from this renderer's schema.
const entrySchemaPath = Paths.compose(
'#',
'properties',
'array~Data',
'items'
);
const entrySchema = Resolve.schema(schema, entrySchemaPath);
// Iterate over array~Data and dispatch for each entry
// Dispatch needs the path from the root of JSON Forms's data
// Thus, calculate it by extending this control's path
const dispatchEntries = arrayData.map((arrayEntry, index) => {
const entryPath = Paths.compose(path, 'array~Data', index);
const schema = Resolve.schema();
return (
<JsonFormsDispatch
key={index}
schema={entrySchema}
path={path}
// [...] other props like cells, etc
/>
);
});

// [...]
};
```

</details>

## Migrating to JSON Forms 3.3

### Angular support now targets Angular 17 and Angular 18
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export class ArrayLayoutRenderer
}
return {
schema: this.scopedSchema,
path: Paths.compose(this.propsPath, `${index}`),
path: Paths.compose(this.propsPath, index),
uischema,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
JsonFormsState,
mapDispatchToArrayControlProps,
mapStateToArrayControlProps,
Paths,
RankedTester,
rankWith,
setReadonly,
Expand Down Expand Up @@ -91,7 +92,7 @@ export const removeSchemaKeywords = (path: string) => {
<button
mat-icon-button
class="button item-button hide"
(click)="onDeleteClick(i)"
(click)="onDeleteClick($event, i)"
[ngClass]="{ show: highlightedIdx == i }"
*ngIf="isEnabled()"
>
Expand Down Expand Up @@ -224,7 +225,7 @@ export class MasterListComponent
? d.toString()
: get(d, labelRefInstancePath ?? getFirstPrimitiveProp(schema)),
data: d,
path: `${path}.${index}`,
path: Paths.compose(path, index),
schema,
uischema: detailUISchema,
};
Expand Down Expand Up @@ -278,7 +279,8 @@ export class MasterListComponent
)();
}

onDeleteClick(item: number) {
onDeleteClick(e: any, item: number) {
e.stopPropagation();
this.removeItems(this.propsPath, [item])();
}

Expand Down
8 changes: 4 additions & 4 deletions packages/angular-material/src/library/other/table.renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {
ControlElement,
createDefaultValue,
deriveTypes,
encode,
isObjectArrayControl,
isPrimitiveArrayControl,
JsonSchema,
Expand Down Expand Up @@ -209,8 +208,9 @@ export class TableRenderer extends JsonFormsArrayControl implements OnInit {
): ColumnDescription[] => {
if (schema.type === 'object') {
return this.getValidColumnProps(schema).map((prop) => {
const encProp = encode(prop);
const uischema = controlWithoutLabel(`#/properties/${encProp}`);
const uischema = controlWithoutLabel(
Paths.compose('#', 'properties', prop)
);
if (!this.isEnabled()) {
setReadonly(uischema);
}
Expand Down Expand Up @@ -273,7 +273,7 @@ export const controlWithoutLabel = (scope: string): ControlElement => ({
@Pipe({ name: 'getProps' })
export class GetProps implements PipeTransform {
transform(index: number, props: OwnPropsOfRenderer) {
const rowPath = Paths.compose(props.path, `${index}`);
const rowPath = Paths.compose(props.path, index);
return {
schema: props.schema,
uischema: props.uischema,
Expand Down
6 changes: 3 additions & 3 deletions packages/angular-material/test/master-detail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ describe('Master detail', () => {
// delete 1st item
spyOn(component, 'removeItems').and.callFake(() => () => {
getJsonFormsService(component).updateCore(
Actions.update('orders', () => moreData.orders.slice(1))
Actions.update('/orders', () => moreData.orders.slice(1))
);
fixture.detectChanges();
});
Expand Down Expand Up @@ -267,7 +267,7 @@ describe('Master detail', () => {
const copy = moreData.orders.slice();
copy.splice(1, 1);
getJsonFormsService(component).updateCore(
Actions.update('orders', () => copy)
Actions.update('/orders', () => copy)
);
fixture.detectChanges();
});
Expand Down Expand Up @@ -382,7 +382,7 @@ describe('Master detail', () => {
customer: { name: 'ACME' },
title: 'Carrots',
},
path: 'orders.0',
path: '/orders/0',
schema: schema.definitions.order,
uischema: {
type: 'VerticalLayout',
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/i18n/i18nUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
formatErrorMessage,
getControlPath,
isInternationalized,
toLodashPath,
} from '../util';

export const getI18nKeyPrefixBySchema = (
Expand All @@ -31,7 +32,7 @@ export const getI18nKeyPrefixBySchema = (
*/
export const transformPathToI18nPrefix = (path: string): string => {
return (
path
toLodashPath(path)
?.split('.')
.filter((segment) => !/^\d+$/.test(segment))
.join('.') || 'root'
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/mappers/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ export const mapStateToMasterListItemProps = (
ownProps: OwnPropsOfMasterListItem
): StatePropsOfMasterItem => {
const { schema, path, uischema, childLabelProp, index } = ownProps;
const childPath = composePaths(path, `${index}`);
const childPath = composePaths(path, index);
const childLabel = computeChildLabel(
getData(state),
childPath,
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/reducers/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
import { JsonFormsCore, Reducer, ValidationMode } from '../store';
import Ajv, { ErrorObject } from 'ajv';
import { isFunction } from 'lodash';
import { createAjv, validate } from '../util';
import { createAjv, toLodashSegments, validate } from '../util';

export const initState: JsonFormsCore = {
data: {},
Expand Down Expand Up @@ -237,18 +237,19 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
errors,
};
} else {
const oldData: any = get(state.data, action.path);
const lodashDataPathSegments = toLodashSegments(action.path);
const oldData: any = get(state.data, lodashDataPathSegments);
const newData = action.updater(cloneDeep(oldData));
let newState: any;
if (newData !== undefined) {
newState = setFp(
action.path,
lodashDataPathSegments,
newData,
state.data === undefined ? {} : state.data
);
} else {
newState = unsetFp(
action.path,
lodashDataPathSegments,
state.data === undefined ? {} : state.data
);
}
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/reducers/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ import {
UPDATE_I18N,
} from '../actions';
import { Reducer } from '../store/type';
import { JsonFormsI18nState } from '../store';
import { ErrorTranslator, JsonFormsI18nState, Translator } from '../store';
import { UISchemaElement } from '../models';
import { toLodashPath } from '../util';
import { ErrorObject } from 'ajv';

export const defaultJsonFormsI18nState: Required<JsonFormsI18nState> = {
locale: 'en',
Expand All @@ -50,7 +53,6 @@ export const i18nReducer: Reducer<JsonFormsI18nState, I18nActions> = (
action.translator ?? defaultJsonFormsI18nState.translate;
const translateError =
action.errorTranslator ?? defaultJsonFormsI18nState.translateError;

if (
locale !== state.locale ||
translate !== state.translate ||
Expand Down Expand Up @@ -80,3 +82,19 @@ export const i18nReducer: Reducer<JsonFormsI18nState, I18nActions> = (
return state;
}
};

export const wrapTranslateFunction = (translator: Translator): Translator => {
return (id: string, defaultMessage?: string | undefined, values?: any) => {
return translator(toLodashPath(id), defaultMessage, values);
};
};

export const wrapErrorTranslateFunction = (
translator: ErrorTranslator
): ErrorTranslator => {
return (
error: ErrorObject,
translate: Translator,
uischema?: UISchemaElement
) => translator(error, wrapTranslateFunction(translate), uischema);
};
2 changes: 1 addition & 1 deletion packages/core/src/store/jsonFormsCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const errorAt = (instancePath: string, schema: JsonSchema) =>
getErrorsAt(instancePath, schema, (path) => path === instancePath);
export const subErrorsAt = (instancePath: string, schema: JsonSchema) =>
getErrorsAt(instancePath, schema, (path) =>
path.startsWith(instancePath + '.')
path.startsWith(instancePath + '/')
);

export const getErrorAt =
Expand Down
12 changes: 2 additions & 10 deletions packages/core/src/util/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
*/

import { ErrorObject } from 'ajv';
import { decode } from './path';
import { composePaths } from './path';
import { JsonSchema } from '../models';
import { isOneOfEnumSchema } from './schema';
import filter from 'lodash/filter';
Expand All @@ -47,19 +47,11 @@ export const getControlPath = (error: ErrorObject) => {
// With AJV v8 the property was renamed to 'instancePath'
let controlPath = (error as any).dataPath || error.instancePath || '';

// change '/' chars to '.'
controlPath = controlPath.replace(/\//g, '.');

const invalidProperty = getInvalidProperty(error);
if (invalidProperty !== undefined && !controlPath.endsWith(invalidProperty)) {
controlPath = `${controlPath}.${invalidProperty}`;
controlPath = composePaths(controlPath, invalidProperty);
}

// remove '.' chars at the beginning of paths
controlPath = controlPath.replace(/^./, '');

// decode JSON Pointer escape sequences
controlPath = decode(controlPath);
return controlPath;
};

Expand Down
Loading
Loading