diff --git a/MIGRATION.md b/MIGRATION.md
index f21c331cb4..07c1fb7b0a 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -2,6 +2,17 @@
## Migration to JSON Forms 3.9
+### Angular Material detail renderers no longer modify the given UI schema
+
+The Angular Material array layout, object control and list-with-detail renderers modified the detail UI schema given via `options.detail` or the `uischemas` registry in place (see [#2343](https://github.com/eclipsesource/jsonforms/issues/2343)).
+They now work on a copy instead.
+
+- Nothing is written into your UI schema anymore, e.g. the `readonly` option. If you read such state back out of your own object, track it yourself.
+- The array layout no longer forces the `readonly` option to `false` on the controls of its detail. A `readonly`/`readOnly` option, a `readonly`/`readOnly` config entry and a `readOnly: true` in the JSON schema now take effect for controls inside an array's `options.detail`, so such a field may now be readonly where it previously was not.
+- The object control no longer overrides the `type` and `label` of a detail UI schema given via `options.detail` or the `uischemas` registry. If you relied on the previous label, set `label` in your detail UI schema.
+- In-place modifications of your UI schema are not picked up anymore. Provide a new UI schema object instead.
+- If you subclass `ArrayLayoutRenderer` and override `mapAdditionalProps`, call `super.mapAdditionalProps(props)`. The item props are precalculated there and exposed as `itemProps`; `getProps(index)` returns `undefined` for an out of range index.
+
### Data update paths treat all segments literally
Data updates (e.g. dispatched `update` actions) previously wrote to the form data via lodash's `set`/`unset`, which interpret bracket notation and array indices in paths.
diff --git a/packages/angular-material/src/library/layouts/array-layout.renderer.ts b/packages/angular-material/src/library/layouts/array-layout.renderer.ts
index 24626dc7a5..74020649ed 100644
--- a/packages/angular-material/src/library/layouts/array-layout.renderer.ts
+++ b/packages/angular-material/src/library/layouts/array-layout.renderer.ts
@@ -22,7 +22,13 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ inject,
+ OnInit,
+} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
@@ -36,7 +42,6 @@ import {
ArrayTranslations,
createDefaultValue,
defaultJsonFormsI18nState,
- findUISchema,
getArrayTranslations,
isObjectArrayWithNesting,
JsonFormsState,
@@ -46,12 +51,12 @@ import {
Paths,
RankedTester,
rankWith,
- setReadonly,
StatePropsOfArrayLayout,
UISchemaElement,
UISchemaTester,
- unsetReadonly,
} from '@jsonforms/core';
+import { depsChanged } from '../util/deps';
+import { createDetailUiSchemaResolver } from '../util/detail-uischema';
@Component({
selector: 'app-array-layout-renderer',
@@ -84,7 +89,7 @@ import {
{{ translations.noDataMessage }}
-
+
0 ? props.data : 0;
+ const deps = [detailUiSchema, props.schema, props.path, itemCount];
+ if (!depsChanged(this.itemPropsDeps, deps)) {
+ return;
}
- return {
- schema: this.scopedSchema,
- path: Paths.compose(this.propsPath, `${index}`),
- uischema,
- };
+ this.itemPropsDeps = deps;
+
+ this.itemProps = Array.from({ length: itemCount }, (_unused, index) => ({
+ schema: props.schema,
+ path: Paths.compose(props.path, `${index}`),
+ uischema: detailUiSchema,
+ }));
+ }
+ getProps(index: number): OwnPropsOfRenderer {
+ return this.itemProps[index];
}
trackByFn(index: number) {
return index;
diff --git a/packages/angular-material/src/library/other/master-detail/master.ts b/packages/angular-material/src/library/other/master-detail/master.ts
index e0b539592a..c23ea8dca8 100644
--- a/packages/angular-material/src/library/other/master-detail/master.ts
+++ b/packages/angular-material/src/library/other/master-detail/master.ts
@@ -45,7 +45,6 @@ import {
createDefaultValue,
decode,
defaultJsonFormsI18nState,
- findUISchema,
getArrayTranslations,
getFirstPrimitiveProp,
JsonFormsState,
@@ -53,11 +52,12 @@ import {
mapStateToArrayControlProps,
RankedTester,
rankWith,
- setReadonly,
StatePropsOfArrayControl,
uiTypeIs,
+ UISchemaElement,
} from '@jsonforms/core';
import { JsonFormsDetailComponent } from './detail';
+import { createDetailUiSchemaResolver } from '../../util/detail-uischema';
const keywords = ['#', 'properties', 'items'];
@@ -183,6 +183,8 @@ export class MasterListComponent
removeItems: (path: string, toDelete: number[]) => () => void;
highlightedIdx: number;
translations: ArrayTranslations;
+ detailUiSchema: UISchemaElement;
+ private resolveDetailUiSchema = createDetailUiSchemaResolver();
private changeDetectorRef = inject(ChangeDetectorRef);
@@ -210,20 +212,13 @@ export class MasterListComponent
const { data, path, schema, uischema } = props;
const controlElement = uischema as ControlElement;
this.propsPath = props.path;
- const detailUISchema = findUISchema(
- props.uischemas,
- schema,
+ this.detailUiSchema = this.resolveDetailUiSchema(
+ props,
+ this.isEnabled(),
`${controlElement.scope}/items`,
- props.path,
- 'VerticalLayout',
- controlElement,
- props.rootSchema
+ 'VerticalLayout'
);
- if (!this.isEnabled()) {
- setReadonly(detailUISchema);
- }
-
this.translations = props.translations;
const masterItems = (data || []).map((d: any, index: number) => {
@@ -238,7 +233,7 @@ export class MasterListComponent
data: d,
path: `${path}.${index}`,
schema,
- uischema: detailUISchema,
+ uischema: this.detailUiSchema,
};
return masterItem;
});
diff --git a/packages/angular-material/src/library/other/object.renderer.ts b/packages/angular-material/src/library/other/object.renderer.ts
index 1585c71484..7fde29a934 100644
--- a/packages/angular-material/src/library/other/object.renderer.ts
+++ b/packages/angular-material/src/library/other/object.renderer.ts
@@ -24,7 +24,13 @@
*/
import isEmpty from 'lodash/isEmpty';
import startCase from 'lodash/startCase';
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ inject,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
import {
JsonFormsControlWithDetail,
JsonFormsModule,
@@ -32,27 +38,25 @@ import {
import { MatCardModule } from '@angular/material/card';
import {
ControlWithDetailProps,
- findUISchema,
Generate,
GroupLayout,
isObjectControl,
+ OwnPropsOfRenderer,
RankedTester,
rankWith,
- setReadonly,
UISchemaElement,
} from '@jsonforms/core';
import cloneDeep from 'lodash/cloneDeep';
+import { createDetailUiSchemaResolver } from '../util/detail-uischema';
@Component({
selector: 'ObjectRenderer',
template: `
-
+ *ngIf="renderProps"
+ [renderProps]="renderProps"
+ >
`,
styles: [
@@ -63,40 +67,50 @@ import cloneDeep from 'lodash/cloneDeep';
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [JsonFormsModule, MatCardModule],
+ imports: [CommonModule, JsonFormsModule, MatCardModule],
})
export class ObjectControlRenderer extends JsonFormsControlWithDetail {
detailUiSchema: UISchemaElement;
+ /** Bound as a whole, as the outlet only re-dispatches when this is set. */
+ renderProps: OwnPropsOfRenderer;
+ private resolveDetailUiSchema = createDetailUiSchemaResolver();
+ private changeDetectorRef = inject(ChangeDetectorRef);
mapAdditionalProps(props: ControlWithDetailProps) {
- this.detailUiSchema = findUISchema(
- props.uischemas,
- props.schema,
+ const detailUiSchema = this.resolveDetailUiSchema(
+ props,
+ this.isEnabled(),
props.uischema.scope,
- props.path,
() => {
const newSchema = cloneDeep(props.schema);
// delete unsupported operators
delete newSchema.oneOf;
delete newSchema.anyOf;
delete newSchema.allOf;
- return Generate.uiSchema(
+ const generated = Generate.uiSchema(
newSchema,
'Group',
undefined,
- this.rootSchema
+ props.rootSchema
);
- },
- props.uischema,
- props.rootSchema
+ if (isEmpty(props.path)) {
+ generated.type = 'VerticalLayout';
+ } else {
+ (generated as GroupLayout).label = startCase(props.path);
+ }
+ return generated;
+ }
);
- if (isEmpty(props.path)) {
- this.detailUiSchema.type = 'VerticalLayout';
- } else {
- (this.detailUiSchema as GroupLayout).label = startCase(props.path);
- }
- if (!this.isEnabled()) {
- setReadonly(this.detailUiSchema);
+ if (detailUiSchema === this.detailUiSchema) {
+ return;
}
+ this.detailUiSchema = detailUiSchema;
+ this.renderProps = {
+ uischema: detailUiSchema,
+ schema: props.schema,
+ path: props.path,
+ };
+ // OnPush: nothing below reports the new detail, so schedule the check
+ this.changeDetectorRef.markForCheck();
}
}
export const ObjectControlRendererTester: RankedTester = rankWith(
diff --git a/packages/angular-material/src/library/util/deps.ts b/packages/angular-material/src/library/util/deps.ts
new file mode 100644
index 0000000000..f184bc31e8
--- /dev/null
+++ b/packages/angular-material/src/library/util/deps.ts
@@ -0,0 +1,34 @@
+/*
+ The MIT License
+
+ Copyright (c) 2017-2019 EclipseSource Munich
+ https://github.com/eclipsesource/jsonforms
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+*/
+/**
+ * Compares two dependency arrays by reference, like React's `useMemo` deps.
+ */
+export const depsChanged = (
+ previous: unknown[] | undefined,
+ next: unknown[]
+): boolean =>
+ previous === undefined ||
+ previous.length !== next.length ||
+ next.some((dep, index) => dep !== previous[index]);
diff --git a/packages/angular-material/src/library/util/detail-uischema.ts b/packages/angular-material/src/library/util/detail-uischema.ts
new file mode 100644
index 0000000000..64c43311fc
--- /dev/null
+++ b/packages/angular-material/src/library/util/detail-uischema.ts
@@ -0,0 +1,81 @@
+/*
+ The MIT License
+
+ Copyright (c) 2017-2019 EclipseSource Munich
+ https://github.com/eclipsesource/jsonforms
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+*/
+import {
+ findUISchema,
+ setReadonly,
+ StatePropsOfControlWithDetail,
+ UISchemaElement,
+} from '@jsonforms/core';
+import cloneDeep from 'lodash/cloneDeep';
+import { depsChanged } from './deps';
+
+/**
+ * Creates a memoized `findUISchema` for renderers rendering a detail.
+ *
+ * `findUISchema` can return the inline `options.detail` or a registered UI
+ * schema, i.e. objects owned by the user, so the result is always a copy, on
+ * which the `readonly` option is set when disabled. It is only recalculated
+ * when one of its inputs changed, so it can be compared by identity.
+ */
+export const createDetailUiSchemaResolver = () => {
+ let deps: unknown[] | undefined;
+ let detailUiSchema: UISchemaElement | undefined;
+ return (
+ props: StatePropsOfControlWithDetail,
+ enabled: boolean,
+ schemaPath: string,
+ fallback?: string | (() => UISchemaElement)
+ ): UISchemaElement => {
+ // `schemaPath` and `fallback` are derived from these as well
+ const nextDeps = [
+ props.uischema,
+ props.uischemas,
+ props.schema,
+ props.rootSchema,
+ props.path,
+ enabled,
+ ];
+ if (!depsChanged(deps, nextDeps)) {
+ return detailUiSchema;
+ }
+ deps = nextDeps;
+
+ detailUiSchema = cloneDeep(
+ findUISchema(
+ props.uischemas,
+ props.schema,
+ schemaPath,
+ props.path,
+ fallback,
+ props.uischema,
+ props.rootSchema
+ )
+ );
+ if (!enabled) {
+ setReadonly(detailUiSchema);
+ }
+ return detailUiSchema;
+ };
+};
diff --git a/packages/angular-material/test/array-layout.spec.ts b/packages/angular-material/test/array-layout.spec.ts
index ac8aeef55e..b9d9ac812d 100644
--- a/packages/angular-material/test/array-layout.spec.ts
+++ b/packages/angular-material/test/array-layout.spec.ts
@@ -22,6 +22,7 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
+import { ChangeDetectorRef } from '@angular/core';
import { ComponentFixture, waitForAsync } from '@angular/core/testing';
import { MatIcon } from '@angular/material/icon';
import { MatBadge } from '@angular/material/badge';
@@ -31,7 +32,13 @@ import {
MatCardContent,
MatCardActions,
} from '@angular/material/card';
-import { beforeEachLayoutTest, setupMockStore } from './common';
+import { Actions, Layout } from '@jsonforms/core';
+import cloneDeep from 'lodash/cloneDeep';
+import {
+ beforeEachLayoutTest,
+ getJsonFormsService,
+ setupMockStore,
+} from './common';
import {
ArrayLayoutRenderer,
ArrayLayoutRendererTester,
@@ -63,7 +70,8 @@ const TEST_SCHEMA = {
required: ['test'],
};
-const TEST_UISCHEMA = {
+// a factory, so that tests can't observe each other's modifications
+const createTestUiSchema = () => ({
type: 'Control',
scope: '#/properties/test',
options: {
@@ -81,40 +89,42 @@ const TEST_UISCHEMA = {
],
},
},
-};
+});
describe('Array layout tester', () => {
it('should succeed', () => {
expect(
- ArrayLayoutRendererTester(TEST_UISCHEMA, TEST_SCHEMA, {
+ ArrayLayoutRendererTester(createTestUiSchema(), TEST_SCHEMA, {
config: {},
rootSchema: {},
})
).toBe(4);
});
});
+const TEST_BED_CONFIG = {
+ declarations: [LayoutChildrenRenderPropsPipe],
+ imports: [
+ MatIcon,
+ MatBadge,
+ MatTooltip,
+ MatCard,
+ MatCardContent,
+ MatCardActions,
+ ],
+};
+
describe('Array layout', () => {
let fixture: ComponentFixture;
beforeEach(waitForAsync(() => {
- fixture = beforeEachLayoutTest(ArrayLayoutRenderer, {
- declarations: [LayoutChildrenRenderPropsPipe],
- imports: [
- MatIcon,
- MatBadge,
- MatTooltip,
- MatCard,
- MatCardContent,
- MatCardActions,
- ],
- });
+ fixture = beforeEachLayoutTest(ArrayLayoutRenderer, TEST_BED_CONFIG);
}));
it('render with no data the error count should be 1', () => {
setupMockStore(fixture, {
data: {},
schema: TEST_SCHEMA,
- uischema: TEST_UISCHEMA,
+ uischema: createTestUiSchema(),
});
fixture.componentInstance.ngOnInit();
fixture.detectChanges();
@@ -138,7 +148,7 @@ describe('Array layout', () => {
setupMockStore(fixture, {
data: { test: [{}] },
schema: TEST_SCHEMA,
- uischema: TEST_UISCHEMA,
+ uischema: createTestUiSchema(),
});
fixture.componentInstance.ngOnInit();
fixture.detectChanges();
@@ -159,7 +169,7 @@ describe('Array layout', () => {
setupMockStore(fixture, {
data: { test: [{}, {}] },
schema: TEST_SCHEMA,
- uischema: TEST_UISCHEMA,
+ uischema: createTestUiSchema(),
});
fixture.componentInstance.ngOnInit();
fixture.detectChanges();
@@ -176,3 +186,234 @@ describe('Array layout', () => {
});
});
});
+
+describe('Array layout ui schema handling', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ fixture = beforeEachLayoutTest(ArrayLayoutRenderer, TEST_BED_CONFIG);
+ }));
+
+ // https://github.com/eclipsesource/jsonforms/issues/2343
+ it('does not modify the given ui schema when rendering items', () => {
+ const uischema = createTestUiSchema();
+ const pristine = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ expect(uischema).toEqual(pristine);
+ });
+
+ it('does not hand out the inline options.detail instance', () => {
+ const uischema = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ const detail = fixture.componentInstance.getProps(0).uischema;
+ expect(detail).not.toBe(uischema.options.detail);
+ expect(detail.type).toBe('HorizontalLayout');
+ });
+
+ it('returns a stable copy of the detail ui schema across state emissions', () => {
+ const uischema = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ const detail = fixture.componentInstance.getProps(0).uischema;
+ expect(detail).not.toBe(uischema.options.detail);
+
+ getJsonFormsService(fixture.componentInstance).updateCore(
+ Actions.update('test', () => [{}, {}])
+ );
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.getProps(0).uischema).toBe(detail);
+ expect(fixture.componentInstance.getProps(1).uischema).toBe(detail);
+ });
+
+ it('schedules a check so that state changes reach the template', () => {
+ const uischema = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ // the renderer is OnPush, so without this nothing repaints for a change
+ // that did not originate from an event in its own view. As the fixture root
+ // it is checked unconditionally, hence the explicit expectation.
+ const changeDetectorRef = (fixture.componentInstance as any)
+ .changeDetectorRef as ChangeDetectorRef;
+ spyOn(changeDetectorRef, 'markForCheck').and.callThrough();
+
+ getJsonFormsService(fixture.componentInstance).updateCore(
+ Actions.update('test', () => [{}, {}])
+ );
+
+ expect(changeDetectorRef.markForCheck).toHaveBeenCalled();
+ });
+
+ it('renders no items for data that is not an array', () => {
+ const uischema = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ // schema violating data, which JSON Forms reports rather than rejects
+ data: { test: {} },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+
+ // must not render an item whose props were never calculated
+ expect(() => fixture.detectChanges()).not.toThrow();
+ expect(fixture.componentInstance.itemProps).toEqual([]);
+ expect(fixture.nativeElement.querySelectorAll('.array-item').length).toBe(
+ 0
+ );
+ });
+
+ it('reuses the item props instead of recreating them per change detection cycle', () => {
+ const uischema = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ const itemProps = fixture.componentInstance.getProps(0);
+ expect(fixture.componentInstance.getProps(0)).toBe(itemProps);
+
+ // an emission that leaves the array's structure alone must not invalidate
+ // them either, otherwise the outlet re-reads - and deep clones - the state
+ getJsonFormsService(fixture.componentInstance).updateCore(
+ Actions.update('test.0.test1', () => 'a value')
+ );
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.getProps(0)).toBe(itemProps);
+ });
+
+ it('clears the readonly option again when the array is re-enabled', () => {
+ const uischema = createTestUiSchema();
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ getJsonFormsService(fixture.componentInstance).setReadonly(true);
+ fixture.detectChanges();
+
+ let detail = fixture.componentInstance.getProps(0).uischema as Layout;
+ expect(detail.elements[0].options.readonly).toBe(true);
+
+ // the detail is rebuilt from the original, so this is not sticky
+ getJsonFormsService(fixture.componentInstance).setReadonly(false);
+ fixture.detectChanges();
+
+ detail = fixture.componentInstance.getProps(0).uischema as Layout;
+ expect(detail.elements[0].options?.readonly).toBeFalsy();
+ });
+
+ it('does not modify a ui schema coming from the registry', () => {
+ const registered = {
+ type: 'HorizontalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/test1' }],
+ };
+ const pristine = cloneDeep(registered);
+ // no options.detail, so the registry is consulted
+ const uischema = { type: 'Control', scope: '#/properties/test' };
+
+ fixture.componentInstance.disabled = true;
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ getJsonFormsService(fixture.componentInstance).setUiSchemas([
+ { tester: () => 2, uischema: registered },
+ ]);
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ const detail = fixture.componentInstance.getProps(0).uischema as Layout;
+ expect(registered).toEqual(pristine);
+ expect(detail).not.toBe(registered);
+ expect(detail.elements[0].options.readonly).toBe(true);
+ });
+
+ it('marks the detail ui schema readonly when disabled without touching the original', () => {
+ const uischema = createTestUiSchema();
+ const pristine = createTestUiSchema();
+
+ fixture.componentInstance.disabled = true;
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ const detail = fixture.componentInstance.getProps(0).uischema as Layout;
+ expect(detail.elements[0].options.readonly).toBe(true);
+ expect(uischema).toEqual(pristine);
+ });
+
+ it('keeps a readonly option set on a detail control when enabled', () => {
+ const uischema = {
+ type: 'Control',
+ scope: '#/properties/test',
+ options: {
+ detail: {
+ type: 'HorizontalLayout',
+ elements: [
+ {
+ type: 'Control',
+ scope: '#/properties/test1',
+ options: { readonly: true },
+ },
+ { type: 'Control', scope: '#/properties/test2' },
+ ],
+ },
+ },
+ };
+
+ setupMockStore(fixture, {
+ data: { test: [{}] },
+ schema: TEST_SCHEMA,
+ uischema,
+ });
+ fixture.componentInstance.ngOnInit();
+ fixture.detectChanges();
+
+ const detail = fixture.componentInstance.getProps(0).uischema as Layout;
+ expect(detail.elements[0].options.readonly).toBe(true);
+ expect(detail.elements[1].options?.readonly).toBeUndefined();
+ });
+});
diff --git a/packages/angular-material/test/common/util.ts b/packages/angular-material/test/common/util.ts
index 12b7a6f4d4..a1c645e6c5 100644
--- a/packages/angular-material/test/common/util.ts
+++ b/packages/angular-material/test/common/util.ts
@@ -24,7 +24,11 @@
*/
import type { Type } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
-import { JsonFormsAngularService, JsonFormsControl } from '@jsonforms/angular';
+import {
+ JsonFormsAbstractControl,
+ JsonFormsAngularService,
+ JsonFormsControl,
+} from '@jsonforms/angular';
import type {
JsonFormsRendererRegistryEntry,
JsonSchema,
@@ -63,7 +67,7 @@ export interface TestData {
}
export const getJsonFormsService = (
- component: JsonFormsControl
+ component: JsonFormsAbstractControl
): JsonFormsAngularService => {
return (component as any).jsonFormsService as JsonFormsAngularService;
};
diff --git a/packages/angular-material/test/master-detail.spec.ts b/packages/angular-material/test/master-detail.spec.ts
index 034587cff3..2e7e6a3566 100644
--- a/packages/angular-material/test/master-detail.spec.ts
+++ b/packages/angular-material/test/master-detail.spec.ts
@@ -36,6 +36,7 @@ import { JsonFormsDetailComponent } from '../src/library/other/master-detail/det
import { getJsonFormsService, setupMockStore } from './common';
import { Actions } from '@jsonforms/core';
import { MatTooltipModule } from '@angular/material/tooltip';
+import cloneDeep from 'lodash/cloneDeep';
describe('Master detail', () => {
let fixture: ComponentFixture;
@@ -400,4 +401,24 @@ describe('Master detail', () => {
expect(fixture.nativeElement.children[0].style.display).toBe('none');
});
}));
+
+ it('does not modify the given ui schema', () => {
+ const ownUischema = cloneDeep(uischema);
+ const pristine = cloneDeep(uischema);
+
+ setupMockStore(fixture, { uischema: ownUischema, schema, data });
+ getJsonFormsService(component).updateCore(Actions.init(data, schema));
+ component.ngOnInit();
+ getJsonFormsService(component).setReadonly(true);
+ fixture.detectChanges();
+
+ expect(ownUischema).toEqual(pristine);
+ expect(component.detailUiSchema).not.toBe(ownUischema.options.detail);
+ expect(component.detailUiSchema.elements[0].options.readonly).toBe(true);
+
+ // the detail is rebuilt from the original, so re-enabling actually restores it
+ getJsonFormsService(component).setReadonly(false);
+ fixture.detectChanges();
+ expect(component.detailUiSchema.elements[0].options?.readonly).toBeFalsy();
+ });
});
diff --git a/packages/angular-material/test/object-control.spec.ts b/packages/angular-material/test/object-control.spec.ts
index 5495118576..b3c15e8370 100644
--- a/packages/angular-material/test/object-control.spec.ts
+++ b/packages/angular-material/test/object-control.spec.ts
@@ -23,6 +23,7 @@
THE SOFTWARE.
*/
import { CommonModule } from '@angular/common';
+import { ChangeDetectorRef } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
@@ -30,7 +31,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { JsonFormsAngularService, JsonFormsModule } from '@jsonforms/angular';
+import { By } from '@angular/platform-browser';
import { ControlElement } from '@jsonforms/core';
+import cloneDeep from 'lodash/cloneDeep';
import {
GroupLayoutRenderer,
groupLayoutTester,
@@ -214,4 +217,159 @@ describe('Object Control', () => {
expect(fixture.nativeElement.querySelector('input').disabled).toBeFalsy();
});
}));
+
+ it('does not modify the given ui schema', () => {
+ const uischema = {
+ type: 'Control',
+ scope: '#/properties/foo',
+ options: {
+ detail: {
+ type: 'Group',
+ elements: [{ type: 'Control', scope: '#/properties/foo_1' }],
+ },
+ },
+ };
+ const pristine = cloneDeep(uischema);
+
+ component.uischema = uischema;
+ component.schema = schema2;
+ component.disabled = true;
+ getJsonFormsService(component).init({
+ core: {
+ data: {},
+ schema: schema2,
+ uischema: undefined,
+ },
+ });
+ getJsonFormsService(component).registerRenderers(renderers);
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(uischema).toEqual(pristine);
+ expect(component.detailUiSchema).not.toBe(uischema.options.detail);
+ expect(component.detailUiSchema.elements[0].options.readonly).toBe(true);
+ });
+
+ it('keeps the label of a given detail ui schema', () => {
+ const uischema = {
+ type: 'Control',
+ scope: '#/properties/foo',
+ options: {
+ detail: {
+ type: 'Group',
+ label: 'Custom',
+ elements: [{ type: 'Control', scope: '#/properties/foo_1' }],
+ },
+ },
+ };
+
+ component.uischema = uischema;
+ component.schema = schema2;
+ getJsonFormsService(component).init({
+ core: {
+ data: {},
+ schema: schema2,
+ uischema: undefined,
+ },
+ });
+ getJsonFormsService(component).registerRenderers(renderers);
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.detailUiSchema.label).toBe('Custom');
+ });
+
+ it('keeps the type of a given detail ui schema at the root', () => {
+ const uischema = {
+ type: 'Control',
+ scope: '#',
+ options: {
+ detail: {
+ type: 'Group',
+ label: 'Custom',
+ elements: [{ type: 'Control', scope: '#/properties/foo' }],
+ },
+ },
+ };
+
+ component.uischema = uischema;
+ component.schema = schema1;
+ getJsonFormsService(component).init({
+ core: {
+ data: {},
+ schema: schema1,
+ uischema: undefined,
+ },
+ });
+ getJsonFormsService(component).registerRenderers(renderers);
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.detailUiSchema.type).toBe('Group');
+ expect(component.detailUiSchema.label).toBe('Custom');
+ });
+
+ it('renders a generated detail at the root as a vertical layout', () => {
+ component.uischema = uischema1;
+ component.schema = schema1;
+ getJsonFormsService(component).init({
+ core: {
+ data: {},
+ schema: schema1,
+ uischema: undefined,
+ },
+ });
+ getJsonFormsService(component).registerRenderers(renderers);
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.detailUiSchema.type).toBe('VerticalLayout');
+ });
+
+ it('propagates a changed enabled state to the rendered detail', () => {
+ const uischema = {
+ type: 'Control',
+ scope: '#/properties/foo',
+ options: {
+ detail: {
+ type: 'Group',
+ elements: [{ type: 'Control', scope: '#/properties/foo_1' }],
+ },
+ },
+ };
+
+ component.uischema = uischema;
+ component.schema = schema2;
+ getJsonFormsService(component).init({
+ core: {
+ data: {},
+ schema: schema2,
+ uischema: undefined,
+ },
+ });
+ getJsonFormsService(component).registerRenderers(renderers);
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ const detailControl = () =>
+ fixture.debugElement.query(By.directive(TextControlRenderer))
+ .componentInstance;
+
+ expect(detailControl().uischema.options?.readonly).toBeFalsy();
+
+ // the component is OnPush and nothing below it reports the change, so in a
+ // real form it has to schedule the check itself. As the fixture root it is
+ // checked unconditionally, hence the explicit expectation below.
+ const changeDetectorRef = component.changeDetectorRef as ChangeDetectorRef;
+ spyOn(changeDetectorRef, 'markForCheck').and.callThrough();
+
+ getJsonFormsService(component).setReadonly(true);
+ expect(changeDetectorRef.markForCheck).toHaveBeenCalled();
+ fixture.detectChanges();
+ expect(detailControl().uischema.options.readonly).toBe(true);
+
+ getJsonFormsService(component).setReadonly(false);
+ fixture.detectChanges();
+ expect(detailControl().uischema.options?.readonly).toBeFalsy();
+ });
});
diff --git a/packages/examples/src/examples/categorization.ts b/packages/examples/src/examples/categorization.ts
index 6b70ba2fa1..287d3414c7 100644
--- a/packages/examples/src/examples/categorization.ts
+++ b/packages/examples/src/examples/categorization.ts
@@ -23,6 +23,7 @@
THE SOFTWARE.
*/
import { createTranslator, Translator } from '@jsonforms/core';
+import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import { registerExamples } from '../register';
@@ -286,6 +287,63 @@ const uischema_1713 = {
],
};
+const schema_2343 = {
+ type: 'object',
+ properties: {
+ parc: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ },
+};
+const arrayControl_2343 = {
+ type: 'Control',
+ scope: '#/properties/parc',
+ options: {
+ detail: {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'HorizontalLayout',
+ elements: [
+ {
+ type: 'Control',
+ scope: '#/properties/id',
+ },
+ ],
+ },
+ ],
+ },
+ },
+};
+const uischema_2343 = {
+ type: 'VerticalLayout',
+ elements: [
+ {
+ type: 'Categorization',
+ elements: [
+ {
+ type: 'Category',
+ label: 'A1',
+ elements: [cloneDeep(arrayControl_2343)],
+ },
+ {
+ type: 'Category',
+ label: 'A2',
+ elements: [cloneDeep(arrayControl_2343)],
+ },
+ ],
+ },
+ ],
+};
+
export const translations = {
categoryLabelKey: 'Basic',
address: {
@@ -312,4 +370,11 @@ registerExamples([
schema: schema_1713,
uischema: uischema_1713,
},
+ {
+ name: 'categorization_2343',
+ label: 'Categorization - Issue 2343',
+ data: {},
+ schema: schema_2343,
+ uischema: uischema_2343,
+ },
]);