diff --git a/backend/src/Squidex/wwwroot/scripts/editor-plain.html b/backend/src/Squidex/wwwroot/scripts/editor-plain.html new file mode 100644 index 000000000..9aa472084 --- /dev/null +++ b/backend/src/Squidex/wwwroot/scripts/editor-plain.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/features/content/shared/forms/array-editor.component.ts b/frontend/app/features/content/shared/forms/array-editor.component.ts index aa83e44cb..e96ac2f24 100644 --- a/frontend/app/features/content/shared/forms/array-editor.component.ts +++ b/frontend/app/features/content/shared/forms/array-editor.component.ts @@ -7,7 +7,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, Input, OnChanges, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; -import { AppLanguageDto, ComponentsFieldPropertiesDto, disabled$, EditContentForm, fadeAnimation, FieldArrayForm, LocalStoreService, ModalModel, ObjectForm, SchemaDto, Settings, sorted, Types } from '@app/shared'; +import { AppLanguageDto, ComponentsFieldPropertiesDto, disabled$, EditContentForm, fadeAnimation, FieldArrayForm, LocalStoreService, ModalModel, ObjectFormBase, SchemaDto, Settings, sorted, Types } from '@app/shared'; import { combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ArrayItemComponent } from './array-item.component'; @@ -89,7 +89,7 @@ export class ArrayEditorComponent implements OnChanges { this.formModel.removeItemAt(index); } - public addCopy(value: ObjectForm) { + public addCopy(value: ObjectFormBase) { this.formModel.addCopy(value); } @@ -102,16 +102,16 @@ export class ArrayEditorComponent implements OnChanges { } public clear() { - this.formModel.reset(); + this.formModel.setValue([]); } - public sort(event: CdkDragDrop>) { + public sort(event: CdkDragDrop>) { this.formModel.sort(sorted(event)); this.reset(); } - public move(item: ObjectForm, index: number) { + public move(item: ObjectFormBase, index: number) { this.formModel.move(index, item); this.reset(); diff --git a/frontend/app/features/content/shared/forms/array-item.component.html b/frontend/app/features/content/shared/forms/array-item.component.html index 73f58edbc..d966b8d87 100644 --- a/frontend/app/features/content/shared/forms/array-item.component.html +++ b/frontend/app/features/content/shared/forms/array-item.component.html @@ -43,7 +43,7 @@
-
+
implements OnCh public formContext: any; @Input() - public formModel: ObjectForm; + public formModel: ObjectFormBase; @Input() public canUnset?: boolean | null; diff --git a/frontend/app/features/content/shared/forms/component.component.html b/frontend/app/features/content/shared/forms/component.component.html index 4ec81c67f..c1ddadaf0 100644 --- a/frontend/app/features/content/shared/forms/component.component.html +++ b/frontend/app/features/content/shared/forms/component.component.html @@ -1,10 +1,10 @@
-
+
- {{formModel.schema.displayName}} + {{schema.displayName}} -
+
( + datasets: Object.entries(this.usage.details).map(([key, value], i) => ( { - label: ChartHelpers.label(k), + label: ChartHelpers.label(key), backgroundColor: ChartHelpers.getBackgroundColor(i), borderColor: ChartHelpers.getBorderColor(i), borderWidth: 1, - data: this.usage.details[k].map(x => x.totalCalls), + data: value.map(x => x.totalCalls), })), }; } diff --git a/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts b/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts index 5b414ab97..088aa7614 100644 --- a/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts +++ b/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts @@ -44,13 +44,13 @@ export class ApiPerformanceCardComponent implements OnChanges { this.chartData = { labels, - datasets: Object.keys(this.usage.details).map((k, i) => ( + datasets: Object.entries(this.usage.details).map(([key, value], i) => ( { - label: ChartHelpers.label(k), + label: ChartHelpers.label(key), backgroundColor: ChartHelpers.getBackgroundColor(i), borderColor: ChartHelpers.getBorderColor(i), borderWidth: 1, - data: this.usage.details[k].map(x => x.averageElapsedMs), + data: value.map(x => x.averageElapsedMs), })), }; diff --git a/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts b/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts index 1f5e9a7bb..7651cb887 100644 --- a/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts +++ b/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts @@ -44,13 +44,13 @@ export class ApiTrafficCardComponent implements OnChanges { this.chartData = { labels, - datasets: Object.keys(this.usage.details).map((k, i) => ( + datasets: Object.entries(this.usage.details).map(([key, value], i) => ( { - label: ChartHelpers.label(k), + label: ChartHelpers.label(key), backgroundColor: ChartHelpers.getBackgroundColor(i), borderColor: ChartHelpers.getBorderColor(i), borderWidth: 1, - data: this.usage.details[k].map(x => Math.round(100 * (x.totalBytes / (1024 * 1024))) / 100), + data: value.map(x => Math.round(100 * (x.totalBytes / (1024 * 1024))) / 100), })), }; diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html b/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html index 7f7a86431..5bdfa23cb 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html @@ -10,7 +10,7 @@ {{ 'schemas.field.tabValidation' | sqxTranslate }} -
-
diff --git a/frontend/app/features/settings/pages/roles/role.component.ts b/frontend/app/features/settings/pages/roles/role.component.ts index b55f92fe9..46306daa7 100644 --- a/frontend/app/features/settings/pages/roles/role.component.ts +++ b/frontend/app/features/settings/pages/roles/role.component.ts @@ -101,15 +101,11 @@ export class RoleComponent implements OnChanges { this.rolesState.delete(this.role); } - public removePermission(index: number) { - this.editForm.remove(index); - } - public addPermission() { const value = this.addPermissionForm.submit(); if (value) { - this.editForm.add(value.permission); + this.editForm.form.add(value.permission); this.addPermissionForm.submitCompleted(); this.addPermissionInput.focus(); diff --git a/frontend/app/features/settings/pages/settings/settings-page.component.html b/frontend/app/features/settings/pages/settings/settings-page.component.html index bb9e4bdef..9160f7be2 100644 --- a/frontend/app/features/settings/pages/settings/settings-page.component.html +++ b/frontend/app/features/settings/pages/settings/settings-page.component.html @@ -46,7 +46,7 @@
-
@@ -104,7 +104,7 @@
-
diff --git a/frontend/app/framework/angular/forms/templated-form-array.spec.ts b/frontend/app/framework/angular/forms/templated-form-array.spec.ts new file mode 100644 index 000000000..d9124acbe --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-array.spec.ts @@ -0,0 +1,153 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormControl, FormGroup } from '@angular/forms'; +import { FormArrayTemplate, TemplatedFormArray } from './templated-form-array'; + +describe('TemplatedFormArray', () => { + class Template implements FormArrayTemplate { + public clearCalled = 0; + public removeCalled: number[] = []; + + public createControl() { + return new FormGroup({ + value: new FormControl(), + }); + } + + public clearControls() { + this.clearCalled++; + } + + public removeControl(index: number) { + this.removeCalled.push(index); + } + } + + let formTemplate: Template; + let formArray: TemplatedFormArray; + + beforeEach(() => { + formTemplate = new Template(); + formArray = new TemplatedFormArray(formTemplate); + }); + + type Test = [ (value: any) => void, string]; + + const methods: Test[] = [ + [x => formArray.setValue(x), 'setValue'], + [x => formArray.patchValue(x), 'patchValue'], + [x => formArray.reset(x), 'reset'], + ]; + + methods.forEach(([method, name]) => { + it(`Should call template to construct items for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + + expect(formArray.value).toEqual(value1); + }); + + it(`Should call template to remove items for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }, { + value: 3, + }, { + value: 4, + }]; + + const value2 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + method(value2); + + expect(formArray.value).toEqual(value2); + expect(formTemplate.clearCalled).toEqual(0); + expect(formTemplate.removeCalled).toEqual([3, 2]); + }); + + it(`Should call template to clear items with undefined for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + method(undefined); + + expect(formArray.value).toEqual(undefined); + expect(formTemplate.clearCalled).toEqual(1); + expect(formTemplate.removeCalled).toEqual([]); + }); + + it(`Should call template to clear items with empty array for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + method([]); + + expect(formArray.value).toEqual([]); + expect(formTemplate.clearCalled).toEqual(1); + expect(formTemplate.removeCalled).toEqual([]); + }); + }); + + it('should add control', () => { + formArray.add(); + formArray.add(); + + expect(formArray.value).toEqual([{ + value: null, + }, { + value: null, + }]); + }); + + it('should call template when cleared', () => { + formArray.add(); + formArray.clear(); + + expect(formTemplate.clearCalled).toEqual(1); + }); + + it('should not call template when clearing empty form', () => { + formArray.clear(); + + expect(formTemplate.clearCalled).toEqual(0); + }); + + it('should call template when item removed', () => { + formArray.add(); + formArray.removeAt(0); + + expect(formTemplate.removeCalled).toEqual([0]); + }); + + it('should not call template when item to remove out of bounds', () => { + formArray.add(); + formArray.removeAt(1); + + expect(formTemplate.removeCalled).toEqual([]); + }); +}); diff --git a/frontend/app/framework/angular/forms/templated-form-array.ts b/frontend/app/framework/angular/forms/templated-form-array.ts new file mode 100644 index 000000000..4b2493567 --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-array.ts @@ -0,0 +1,86 @@ +/* +* Squidex Headless CMS +* +* @license +* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. +*/ + +import { AbstractControl, AbstractControlOptions, AsyncValidatorFn, ValidatorFn } from '@angular/forms'; +import { Types } from '@app/framework/internal'; +import { UndefinableFormArray } from './undefinable-form-array'; + +export interface FormArrayTemplate { + createControl(value: any, initialValue?: any): AbstractControl; + + removeControl?(index: number, control: AbstractControl) : void; + + clearControls?(): void; +} + +export class TemplatedFormArray extends UndefinableFormArray { + constructor(public readonly template: FormArrayTemplate, + validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, + ) { + super([], validatorOrOpts, asyncValidator); + } + + public setValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.prepare(value); + + super.setValue(value, options); + } + + public patchValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.prepare(value); + + super.patchValue(value, options); + } + + public reset(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.prepare(value); + + super.reset(value, options); + } + + public add(initialValue?: any) { + const control = this.template.createControl({}, initialValue); + + this.push(control); + + return control; + } + + public removeAt(index: number, options?: { emitEvent?: boolean }) { + if (this.template.removeControl && index >= 0 && index < this.controls.length) { + this.template.removeControl(index, this.controls[index]); + } + + super.removeAt(index, options); + } + + public clear(options?: { emitEvent?: boolean }) { + if (this.template.clearControls && this.controls.length > 0) { + this.template.clearControls(); + } + + super.clear(options); + } + + private prepare(value?: any[]) { + if (Types.isArray(value) && value.length > 0) { + let index = this.controls.length; + + while (this.controls.length < value.length) { + this.add(value[index]); + + index++; + } + + while (this.controls.length > value.length) { + this.removeAt(this.controls.length - 1, { emitEvent: false }); + } + } else { + this.clear(); + } + } +} diff --git a/frontend/app/framework/angular/forms/templated-form-group.spec.ts b/frontend/app/framework/angular/forms/templated-form-group.spec.ts new file mode 100644 index 000000000..0ce7b1c30 --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-group.spec.ts @@ -0,0 +1,64 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormControl, FormGroup } from '@angular/forms'; +import { FormGroupTemplate, TemplatedFormGroup } from './templated-form-group'; + +describe('TemplatedFormGroup', () => { + class Template implements FormGroupTemplate { + public clearCalled = 0; + public removeCalled: number[] = []; + + public setControls(form: FormGroup) { + form.setControl('value', new FormControl()); + } + + public clearControls() { + this.clearCalled++; + } + } + + let formTemplate: Template; + let formArray: TemplatedFormGroup; + + beforeEach(() => { + formTemplate = new Template(); + formArray = new TemplatedFormGroup(formTemplate); + }); + + type Test = [ (value: any) => void, string]; + + const methods: Test[] = [ + [x => formArray.setValue(x), 'setValue'], + [x => formArray.patchValue(x), 'patchValue'], + [x => formArray.reset(x), 'reset'], + ]; + + methods.forEach(([method, name]) => { + it(`Should call template to construct controls for ${name}`, () => { + const value1 = { + value: 1, + }; + + method(value1); + + expect(formArray.value).toEqual(value1); + }); + it(`Should call template to clear items with for ${name}`, () => { + const value1 = { + value: 1, + }; + + method(value1); + method(undefined); + + expect(formArray.value).toEqual(undefined); + expect(formTemplate.clearCalled).toEqual(1); + expect(formTemplate.removeCalled).toEqual([]); + }); + }); +}); diff --git a/frontend/app/framework/angular/forms/templated-form-group.ts b/frontend/app/framework/angular/forms/templated-form-group.ts new file mode 100644 index 000000000..097d49db3 --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-group.ts @@ -0,0 +1,50 @@ +/* +* Squidex Headless CMS +* +* @license +* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. +*/ + +import { AbstractControlOptions, AsyncValidatorFn, FormGroup, ValidatorFn } from '@angular/forms'; +import { Types } from '@app/framework/internal'; +import { UndefinableFormGroup } from './undefinable-form-group'; + +export interface FormGroupTemplate { + setControls(form: FormGroup, value: any): void; + + clearControls?(): void; +} + +export class TemplatedFormGroup extends UndefinableFormGroup { + constructor(public readonly template: FormGroupTemplate, + validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, + ) { + super({}, validatorOrOpts, asyncValidator); + } + + public setValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.build(value); + + super.setValue(value, options); + } + + public patchValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.build(value); + + super.patchValue(value, options); + } + + public reset(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.build(value); + + super.reset(value, options); + } + + public build(value?: {}) { + if (Types.isObject(value)) { + this.template?.setControls(this, value); + } else if (this.template?.clearControls) { + this.template?.clearControls(); + } + } +} diff --git a/frontend/app/framework/angular/forms/undefinable-form-array.ts b/frontend/app/framework/angular/forms/undefinable-form-array.ts index 6f4b1ee79..b86e29063 100644 --- a/frontend/app/framework/angular/forms/undefinable-form-array.ts +++ b/frontend/app/framework/angular/forms/undefinable-form-array.ts @@ -47,7 +47,7 @@ export class UndefinableFormArray extends FormArray { } public setValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { super.reset([], options); @@ -57,7 +57,7 @@ export class UndefinableFormArray extends FormArray { } public patchValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { super.reset([], options); @@ -67,11 +67,19 @@ export class UndefinableFormArray extends FormArray { } public reset(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); super.reset(value || [], options); } + private checkUndefined(value?: any[]) { + this.isUndefined = Types.isUndefined(value); + + if (this.isUndefined) { + this.clear({ emitEvent: false }); + } + } + public updateValueAndValidity(opts: { onlySelf?: boolean; emitEvent?: boolean } = {}) { super.updateValueAndValidity({ emitEvent: false, onlySelf: true }); diff --git a/frontend/app/framework/angular/forms/undefinable-form-group.ts b/frontend/app/framework/angular/forms/undefinable-form-group.ts index 74b60b6f4..d3632c335 100644 --- a/frontend/app/framework/angular/forms/undefinable-form-group.ts +++ b/frontend/app/framework/angular/forms/undefinable-form-group.ts @@ -24,8 +24,6 @@ export class UndefinableFormGroup extends FormGroup { return reduce.apply(this); } }; - - this.setValue(undefined); } public getRawValue() { @@ -37,31 +35,35 @@ export class UndefinableFormGroup extends FormGroup { } public setValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { - super.reset([], options); + super.reset({}, options); } else { super.setValue(value!, options); } } public patchValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { - super.reset([], options); + super.reset({}, options); } else { super.patchValue(value!, options); } } public reset(value?: {}, options: { onlySelf?: boolean; emitEvent?: boolean } = {}) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); super.reset(value || {}, options); } + private checkUndefined(value?: {}) { + this.isUndefined = Types.isUndefined(value); + } + public updateValueAndValidity(opts: { onlySelf?: boolean; emitEvent?: boolean } = {}) { super.updateValueAndValidity({ emitEvent: false, onlySelf: true }); diff --git a/frontend/app/framework/declarations.ts b/frontend/app/framework/declarations.ts index 08fb49255..126b840c0 100644 --- a/frontend/app/framework/declarations.ts +++ b/frontend/app/framework/declarations.ts @@ -31,6 +31,7 @@ export * from './angular/forms/forms-helper'; export * from './angular/forms/indeterminate-value.directive'; export * from './angular/forms/model'; export * from './angular/forms/progress-bar.component'; +export * from './angular/forms/templated-form-array'; export * from './angular/forms/transform-input.directive'; export * from './angular/forms/undefinable-form-array'; export * from './angular/forms/undefinable-form-group'; diff --git a/frontend/app/framework/services/dialog.service.spec.ts b/frontend/app/framework/services/dialog.service.spec.ts index 57366580c..33bcb8299 100644 --- a/frontend/app/framework/services/dialog.service.spec.ts +++ b/frontend/app/framework/services/dialog.service.spec.ts @@ -160,7 +160,7 @@ describe('DialogService', () => { it('should publish tooltip', () => { const dialogService = new DialogService(localStore.object); - const tooltip = new Tooltip('target', 'text', 'left'); + const tooltip = new Tooltip('target', 'text', 'left-center'); let publishedTooltip: Tooltip; diff --git a/frontend/app/framework/utils/types.ts b/frontend/app/framework/utils/types.ts index e71b7a034..5727bbf93 100644 --- a/frontend/app/framework/utils/types.ts +++ b/frontend/app/framework/utils/types.ts @@ -197,9 +197,8 @@ export module Types { return source; } - Object.keys(source).forEach(key => { + Object.entries(source).forEach(([key, sourceValue]) => { const targetValue = target[key]; - const sourceValue = source[key]; if (Types.isArray(targetValue) && Types.isArray(sourceValue)) { target[key] = targetValue.concat(sourceValue); diff --git a/frontend/app/shared/components/assets/asset-dialog.component.html b/frontend/app/shared/components/assets/asset-dialog.component.html index a9ced0f81..2041f397a 100644 --- a/frontend/app/shared/components/assets/asset-dialog.component.html +++ b/frontend/app/shared/components/assets/asset-dialog.component.html @@ -151,7 +151,7 @@
-
diff --git a/frontend/app/shared/services/auth.service.ts b/frontend/app/shared/services/auth.service.ts index f56310901..b5103c468 100644 --- a/frontend/app/shared/services/auth.service.ts +++ b/frontend/app/shared/services/auth.service.ts @@ -69,9 +69,7 @@ export class Profile { user: this.user, }; - for (const key of Object.keys(this.user.profile)) { - result[key] = this.user.profile[key]; - } + Object.assign(result, this.user.profile); return result; } diff --git a/frontend/app/shared/services/usages.service.ts b/frontend/app/shared/services/usages.service.ts index ab8764493..58c58f5fc 100644 --- a/frontend/app/shared/services/usages.service.ts +++ b/frontend/app/shared/services/usages.service.ts @@ -88,8 +88,8 @@ export class UsagesService { map(body => { const details: { [category: string]: CallsUsagePerDateDto[] } = {}; - for (const category of Object.keys(body.details)) { - details[category] = body.details[category].map((item: any) => + for (const [category, value] of Object.entries(body.details)) { + details[category] = (value as any).map((item: any) => new CallsUsagePerDateDto( DateTime.parseISO(item.date), item.totalBytes, diff --git a/frontend/app/shared/state/apps.forms.ts b/frontend/app/shared/state/apps.forms.ts index 32a3b467d..569f10554 100644 --- a/frontend/app/shared/state/apps.forms.ts +++ b/frontend/app/shared/state/apps.forms.ts @@ -7,8 +7,8 @@ /* eslint-disable no-useless-escape */ -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Form, ValidatorsEx } from '@app/framework'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Form, TemplatedFormArray, ValidatorsEx } from '@app/framework'; import { AppDto, AppSettingsDto, CreateAppDto, UpdateAppDto, UpdateAppSettingsDto } from './../services/apps.service'; export class CreateAppForm extends Form { @@ -39,95 +39,67 @@ export class UpdateAppForm extends Form { } export class EditAppSettingsForm extends Form { - public get patterns(): FormArray { - return this.form.controls['patterns']! as FormArray; + public get patterns() { + return this.form.controls['patterns']! as TemplatedFormArray; } public get patternsControls(): ReadonlyArray { return this.patterns.controls as any; } - public get editors(): FormArray { - return this.form.controls['editors']! as FormArray; + public get editors() { + return this.form.controls['editors']! as TemplatedFormArray; } public get editorsControls(): ReadonlyArray { return this.editors.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { + constructor(formBuilder: FormBuilder) { super(formBuilder.group({ - patterns: formBuilder.array([]), + patterns: new TemplatedFormArray(new PatternTemplate(formBuilder)), hideScheduler: false, hideDateTimeButtons: false, - editors: formBuilder.array([]), + editors: new TemplatedFormArray(new EditorTemplate(formBuilder)), })); } +} - public addPattern() { - this.patterns.push( - this.formBuilder.group({ - name: ['', - [ - Validators.required, - ], - ], - regex: ['', - [ - Validators.required, - ], - ], - message: '', - })); - } +class PatternTemplate { + constructor(private readonly formBuilder: FormBuilder) {} - public addEditor() { - this.editors.push( - this.formBuilder.group({ - name: ['', - [ - Validators.required, - ], + public createControl() { + return this.formBuilder.group({ + name: ['', + [ + Validators.required, ], - url: ['', - [ - Validators.required, - ], + ], + regex: ['', + [ + Validators.required, ], - })); - } - - public removePattern(index: number) { - this.patterns.removeAt(index); - } - - public removeEditor(index: number) { - this.editors.removeAt(index); + ], + message: '', + }); } +} - public transformLoad(value: AppSettingsDto) { - const patterns = this.patterns; - - while (patterns.controls.length < value.patterns.length) { - this.addPattern(); - } - - while (patterns.controls.length > value.patterns.length) { - this.removePattern(patterns.controls.length - 1); - } - - const editors = this.editors; - - while (editors.controls.length < value.editors.length) { - this.addEditor(); - } - - while (editors.controls.length > value.editors.length) { - this.removeEditor(editors.controls.length - 1); - } +class EditorTemplate { + constructor(private readonly formBuilder: FormBuilder) {} - return value; + public createControl() { + return this.formBuilder.group({ + name: ['', + [ + Validators.required, + ], + ], + url: ['', + [ + Validators.required, + ], + ], + }); } } diff --git a/frontend/app/shared/state/assets.forms.ts b/frontend/app/shared/state/assets.forms.ts index 74ff66d86..a96245331 100644 --- a/frontend/app/shared/state/assets.forms.ts +++ b/frontend/app/shared/state/assets.forms.ts @@ -5,22 +5,21 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Form, Mutable, Types } from '@app/framework'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Form, Mutable, TemplatedFormArray, Types } from '@app/framework'; import slugify from 'slugify'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, RenameAssetFolderDto, RenameAssetTagDto } from './../services/assets.service'; export class AnnotateAssetForm extends Form { public get metadata() { - return this.form.get('metadata')! as FormArray; + return this.form.get('metadata')! as TemplatedFormArray; } + public get metadataControls(): ReadonlyArray { return this.metadata.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { + constructor(formBuilder: FormBuilder) { super(formBuilder.group({ isProtected: [false, [ @@ -42,26 +41,10 @@ export class AnnotateAssetForm extends Form length) { - this.removeMetadata(this.metadata.controls.length - 1); - } - result.metadata = []; for (const name in value.metadata) { @@ -194,6 +167,21 @@ export class AnnotateAssetForm extends Form { constructor(formBuilder: FormBuilder) { super(formBuilder.group({ diff --git a/frontend/app/shared/state/contents.form-rules.ts b/frontend/app/shared/state/contents.form-rules.ts new file mode 100644 index 000000000..723dcf572 --- /dev/null +++ b/frontend/app/shared/state/contents.form-rules.ts @@ -0,0 +1,157 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +/* eslint-disable @typescript-eslint/no-implied-eval */ +/* eslint-disable no-useless-return */ + +import { Types } from '@app/framework'; +import { FieldRule, SchemaDto } from './../services/schemas.service'; + +export type RuleContext = { data: any; user?: any }; +export type RuleForm = { fieldPath: string }; + +export interface CompiledRules { + get rules(): ReadonlyArray; +} + +export interface RulesProvider { + compileRules(schema: SchemaDto): ReadonlyArray; + + getRules(form: RuleForm): CompiledRules; +} + +export class CompiledRule { + private readonly function: Function; + + public get field() { + return this.rule.field; + } + + public get action() { + return this.rule.action; + } + + constructor( + private readonly rule: FieldRule, + private readonly useItemData: boolean, + ) { + try { + this.function = new Function(`return function(user, ctx, data, itemData) { return ${rule.condition} }`)(); + } catch { + this.function = () => false; + } + } + + public eval(context: RuleContext, itemData: any) { + try { + const data = this.useItemData ? itemData || context.data : context.data; + + return this.function(context.user, context, data, itemData); + } catch { + return false; + } + } +} +const EMPTY_RULES: CompiledRule[] = []; +const EMPTY_RULES_STATIC = { rules: EMPTY_RULES }; + +class ComponentRules implements ComponentRules { + private previouSchema: SchemaDto; + private compiledRules: ReadonlyArray = []; + + public get rules() { + const schema = this.schema(); + + if (schema !== this.previouSchema) { + if (schema) { + this.compiledRules = Types.fastMerge(this.parent.getRules(this.form).rules, this.getRelativeRules(this.form, schema)); + } else { + this.compiledRules = EMPTY_RULES; + } + } + + return this.compiledRules; + } + + constructor( + private readonly form: RuleForm, + private readonly parentPath: string, + private readonly parent: RulesProvider, + private readonly schema: () => SchemaDto | undefined, + ) { + } + + private getRelativeRules(form: RuleForm, schema: SchemaDto) { + const rules = this.parent.compileRules(schema); + + if (rules.length === 0) { + return EMPTY_RULES; + } + + const pathField = form.fieldPath.substr(this.parentPath.length + 1); + const pathSimplified = pathField.replace('.iv.', '.'); + + return rules.filter(x => x.field === pathField || x.field === pathSimplified); + } +} + +export class ComponentRulesProvider implements RulesProvider { + constructor( + private readonly parentPath: string, + private readonly parent: RulesProvider, + private readonly schema: () => SchemaDto | undefined, + ) { + } + + public compileRules(schema: SchemaDto) { + return this.parent.compileRules(schema); + } + + public getRules(form: RuleForm) { + return new ComponentRules(form, this.parentPath, this.parent, this.schema); + } +} + +export class RootRulesProvider implements RulesProvider { + private readonly rulesCache: { [id: string]: ReadonlyArray } = {}; + private readonly rules: ReadonlyArray; + + constructor(schema: SchemaDto) { + this.rules = this.compileRules(schema); + } + + public compileRules(schema: SchemaDto) { + if (!schema) { + return EMPTY_RULES; + } + + let result = this.rulesCache[schema.id]; + + if (!result) { + result = schema.fieldRules.map(x => new CompiledRule(x, true)); + + this.rulesCache[schema.id] = result; + } + + return result; + } + + public getRules(form: RuleForm) { + const allRules = this.rules; + + if (allRules.length === 0) { + return EMPTY_RULES_STATIC; + } + + const pathField = form.fieldPath; + const pathSimplified = pathField.replace('.iv.', '.'); + + const rules = allRules.filter(x => x.field === pathField || x.field === pathSimplified); + + return { rules }; + } +} diff --git a/frontend/app/shared/state/contents.forms-helpers.ts b/frontend/app/shared/state/contents.forms-helpers.ts index 02c295d19..fd67c9dba 100644 --- a/frontend/app/shared/state/contents.forms-helpers.ts +++ b/frontend/app/shared/state/contents.forms-helpers.ts @@ -9,12 +9,13 @@ /* eslint-disable no-useless-return */ import { AbstractControl, ValidatorFn } from '@angular/forms'; -import { getRawValue, Types } from '@app/framework'; +import { getRawValue } from '@app/framework'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { AppLanguageDto } from './../services/app-languages.service'; -import { FieldDto, FieldRule, RootFieldDto, SchemaDto } from './../services/schemas.service'; +import { FieldDto, RootFieldDto, SchemaDto } from './../services/schemas.service'; import { fieldInvariant } from './../services/schemas.types'; +import { CompiledRules, RuleContext, RulesProvider } from './contents.form-rules'; export abstract class Hidden { private readonly hidden$ = new BehaviorSubject(false); @@ -106,41 +107,6 @@ export class PartitionConfig { } } -type RuleContext = { data: any; user?: any }; - -export class CompiledRule { - private readonly function: Function; - - public get field() { - return this.rule.field; - } - - public get action() { - return this.rule.action; - } - - constructor( - private readonly rule: FieldRule, - private readonly useItemData: boolean, - ) { - try { - this.function = new Function(`return function(user, ctx, data, itemData) { return ${rule.condition} }`)(); - } catch { - this.function = () => false; - } - } - - public eval(context: RuleContext, itemData: any) { - try { - const data = this.useItemData ? itemData || context.data : context.data; - - return this.function(context.user, context, data, itemData); - } catch { - return false; - } - } -} - export type AbstractContentFormState = { isDisabled?: boolean; isHidden?: boolean; @@ -154,96 +120,9 @@ export interface FormGlobals { remoteValidator?: ValidatorFn; } -const EMPTY_RULES: CompiledRule[] = []; - -export interface RulesProvider { - compileRules(schema: SchemaDto | undefined): ReadonlyArray; - - setSchema(schema?: SchemaDto): void; - - getRules(form: AbstractContentForm): ReadonlyArray; -} - -export class ComponentRulesProvider implements RulesProvider { - private schema?: SchemaDto; - - constructor( - private readonly parentPath: string, - private readonly parent: RulesProvider, - ) { - } - - public setSchema(schema?: SchemaDto) { - this.schema = schema; - } - - public compileRules(schema: SchemaDto | undefined): ReadonlyArray { - return this.parent.compileRules(schema); - } - - public getRules(form: AbstractContentForm) { - return Types.fastMerge(this.parent.getRules(form), this.getRelativeRules(form)); - } - - private getRelativeRules(form: AbstractContentForm) { - const rules = this.compileRules(this.schema); - - if (rules.length === 0) { - return EMPTY_RULES; - } - - const pathField = form.fieldPath.substr(this.parentPath.length + 1); - const pathSimplified = pathField.replace('.iv.', '.'); - - return rules.filter(x => x.field === pathField || x.field === pathSimplified); - } -} - -export class RootRulesProvider implements RulesProvider { - private readonly compiledRules: { [id: string]: ReadonlyArray } = {}; - private readonly rules: ReadonlyArray; - - constructor(schema: SchemaDto) { - this.rules = schema.fieldRules.map(x => new CompiledRule(x, false)); - } - - public setSchema() { - return; - } - - public compileRules(schema: SchemaDto | undefined) { - if (!schema) { - return EMPTY_RULES; - } - - let result = this.compileRules[schema.id]; - - if (!result) { - result = schema.fieldRules.map(x => new CompiledRule(x, true)); - - this.compiledRules[schema.id] = result; - } - - return result; - } - - public getRules(form: AbstractContentForm) { - const rules = this.rules; - - if (rules.length === 0) { - return EMPTY_RULES; - } - - const pathField = form.fieldPath; - const pathSimplified = pathField.replace('.iv.', '.'); - - return rules.filter(x => x.field === pathField || x.field === pathSimplified); - } -} - export abstract class AbstractContentForm extends Hidden { private readonly disabled$ = new BehaviorSubject(false); - private readonly currentRules: ReadonlyArray; + private readonly ruleSet: CompiledRules; public get disabled() { return this.disabled$.value; @@ -263,7 +142,7 @@ export abstract class AbstractContentForm { it('should hide components fields based on condition', () => { const componentId = MathHelper.guid(); const component = createSchema({ + id: 2, fields: [ createField({ id: 1, @@ -553,7 +554,7 @@ describe('ContentForm', () => { it('should reset array item', () => { const { array } = createArrayFormWith2Items(); - array.reset(); + array.setValue([]); expectLength(array, 0); expect(array.form.value).toEqual([]); diff --git a/frontend/app/shared/state/contents.forms.ts b/frontend/app/shared/state/contents.forms.ts index 03e4e87fc..5233f7cc8 100644 --- a/frontend/app/shared/state/contents.forms.ts +++ b/frontend/app/shared/state/contents.forms.ts @@ -6,13 +6,15 @@ */ import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { debounceTimeSafe, Form, getRawValue, Types, UndefinableFormArray, UndefinableFormGroup, value$ } from '@app/framework'; +import { debounceTimeSafe, Form, FormArrayTemplate, getRawValue, TemplatedFormArray, Types, value$ } from '@app/framework'; +import { FormGroupTemplate, TemplatedFormGroup } from '@app/framework/angular/forms/templated-form-group'; import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs'; import { AppLanguageDto } from './../services/app-languages.service'; import { LanguageDto } from './../services/languages.service'; import { FieldDto, RootFieldDto, SchemaDto, TableField } from './../services/schemas.service'; import { ComponentFieldPropertiesDto, fieldInvariant } from './../services/schemas.types'; -import { AbstractContentForm, AbstractContentFormState, ComponentRulesProvider, FieldSection, FormGlobals, groupFields, PartitionConfig, RootRulesProvider, RulesProvider } from './contents.forms-helpers'; +import { ComponentRulesProvider, RootRulesProvider, RulesProvider } from './contents.form-rules'; +import { AbstractContentForm, AbstractContentFormState, FieldSection, FormGlobals, groupFields, PartitionConfig } from './contents.forms-helpers'; import { FieldDefaultValue, FieldsValidators } from './contents.forms.visitors'; type SaveQueryFormType = { name: string; user: boolean }; @@ -150,10 +152,6 @@ export class EditContentForm extends Form { } public load(value: any, isInitial?: boolean) { - for (const key of Object.keys(this.fields)) { - this.fields[key].prepareLoad(value?.[key]); - } - super.load(value); if (isInitial) { @@ -238,12 +236,6 @@ export class FieldForm extends AbstractContentForm { } } - public prepareLoad(value: any) { - for (const key of Object.keys(this.partitions)) { - this.partitions[key].prepareLoad(value?.[key]); - } - } - protected updateCustomState(context: any, fieldData: any, itemData: any, state: AbstractContentFormState) { const isRequired = state.isRequired === true; @@ -270,8 +262,8 @@ export class FieldForm extends AbstractContentForm { } } - for (const key of Object.keys(this.partitions)) { - this.partitions[key].updateState(context, fieldData?.[key], itemData, state); + for (const [key, partition] of Object.entries(this.partitions)) { + partition.updateState(context, fieldData?.[key], itemData, state); } } @@ -283,14 +275,7 @@ export class FieldForm extends AbstractContentForm { export class FieldValueForm extends AbstractContentForm { private isRequired = false; - constructor( - globals: FormGlobals, - field: FieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - partition: string, - ) { + constructor(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) { super(globals, field, fieldPath, FieldValueForm.buildControl(field, isOptional, partition, globals), isOptional, rules); @@ -330,10 +315,10 @@ export class FieldValueForm extends AbstractContentForm { } } -export class FieldArrayForm extends AbstractContentForm { - private readonly item$ = new BehaviorSubject>([]); +export class FieldArrayForm extends AbstractContentForm { + private readonly item$ = new BehaviorSubject>([]); - public get itemChanges(): Observable> { + public get itemChanges(): Observable> { return this.item$; } @@ -341,83 +326,42 @@ export class FieldArrayForm extends AbstractContentForm) { + public set items(value: ReadonlyArray) { this.item$.next(value); } - constructor( - globals: FormGlobals, - field: FieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - private readonly partition: string, - private readonly isComponents: boolean, + constructor(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, + public readonly partition: string, + public readonly isComponents: boolean, ) { super(globals, field, fieldPath, FieldArrayForm.buildControl(field, isOptional), isOptional, rules); + + this.form.template['form'] = this; } public get(index: number) { return this.items[index]; } - public addCopy(source: ObjectForm) { - if (this.isComponents) { - const child = this.createComponent(); - - child.load(getRawValue(source.form)); - - this.addChild(child); - } else { - const child = this.createItem(); - - child.load(getRawValue(source.form)); - - this.addChild(child); - } + public addCopy(source: ObjectFormBase) { + this.form.add().reset(getRawValue(source.form)); } - public addComponent(schemaId?: string) { - const child = this.createComponent(schemaId); - - this.addChild(child); + public addComponent(schemaId: string) { + this.form.add().reset({ schemaId }); } public addItem() { - const child = this.createItem(); - - this.addChild(child); - } - - public addChild(child: ObjectForm) { - this.items = [...this.items, child]; - - this.form.push(child.form); - } - - public unset() { - this.items = []; - - super.unset(); - - this.form.clear(); - } - - public reset() { - this.items = []; - - this.form.clear(); + this.form.add(); } public removeItemAt(index: number) { - this.items = this.items.filter((_, i) => i !== index); - this.form.removeAt(index); } - public move(index: number, item: ObjectForm) { + public move(index: number, item: ObjectFormBase) { const children = [...this.items]; children.splice(children.indexOf(item), 1); @@ -428,86 +372,102 @@ export class FieldArrayForm extends AbstractContentForm) { + public sort(children: ReadonlyArray) { for (let i = 0; i < children.length; i++) { this.form.setControl(i, children[i].form); } } - public prepareLoad(value: any) { - if (Types.isArray(value)) { - while (this.items.length < value.length) { - if (this.isComponents) { - this.addComponent(); - } else { - this.addItem(); - } - } - - while (this.items.length > value.length) { - this.removeItemAt(this.items.length - 1); - } - } - - for (let i = 0; i < this.items.length; i++) { - this.items[i].prepareLoad(value?.[i]); - } - } - protected updateCustomState(context: any, fieldData: any, itemData: any, state: AbstractContentFormState) { for (let i = 0; i < this.items.length; i++) { this.items[i].updateState(context, fieldData?.[i], itemData, state); } } + private static buildControl(field: FieldDto, isOptional: boolean) { + return new TemplatedFormArray(new ArrayTemplate(), FieldsValidators.create(field, isOptional)); + } +} + +class ArrayTemplate implements FormArrayTemplate { + public form: FieldArrayForm; + + public createControl() { + const child = this.form.isComponents ? + this.createComponent() : + this.createItem(); + + this.form.items = [...this.form.items, child]; + + return child.form; + } + + public removeControl(index: number) { + this.form.items = this.form.items.filter((_, i) => i !== index); + } + + public clearControls() { + this.form.items = []; + } + private createItem() { return new ArrayItemForm( - this.globals, - this.field as RootFieldDto, - this.fieldPath, - this.isOptional, - this.rules, - this.partition); + this.form.globals, + this.form.field as RootFieldDto, + this.form.fieldPath, + this.form.isOptional, + this.form.rules, + this.form.partition); } - private createComponent(schemaId?: string) { + private createComponent() { return new ComponentForm( - this.globals, - this.field as RootFieldDto, - this.fieldPath, - this.isOptional, - this.rules, - this.partition, - schemaId); + this.form.globals, + this.form.field as RootFieldDto, + this.form.fieldPath, + this.form.isOptional, + this.form.rules, + this.form.partition); } +} - private static buildControl(field: FieldDto, isOptional: boolean) { - const validators = FieldsValidators.create(field, isOptional); +export type FieldItemForm = ComponentForm | FieldValueForm | FieldArrayForm; + +type FieldMap = { [name: string]: FieldItemForm }; - return new UndefinableFormArray([], validators); +export class ObjectFormBase extends AbstractContentForm { + private readonly fieldSections$ = new BehaviorSubject>>([]); + private readonly fields$ = new BehaviorSubject({}); + + public get fieldSectionsChanges(): Observable>> { + return this.fieldSections$; } -} -export type FieldItemForm = ComponentForm | FieldValueForm | FieldArrayForm; + public get fieldSections() { + return this.fieldSections$.value; + } -export class ObjectForm extends AbstractContentForm { - private fields: { [key: string]: FieldItemForm } = {}; - private fieldSections: FieldSection[] = []; + public set fieldSections(value: ReadonlyArray>) { + this.fieldSections$.next(value); + } - public get sections() { - return this.fieldSections; + public get fieldsChanges(): Observable { + return this.fields$; } - constructor( - globals: FormGlobals, - field: TField, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - private readonly partition: string, + public get fields() { + return this.fields$.value; + } + + public set fields(value: FieldMap) { + this.fields$.next(value); + } + + constructor(globals: FormGlobals, field: TField, fieldPath: string, isOptional: boolean, rules: RulesProvider, template: ObjectTemplate, + public readonly partition: string, ) { super(globals, field, fieldPath, - ObjectForm.buildControl(field, isOptional, false), + ObjectFormBase.buildControl(template), isOptional, rules); } @@ -515,146 +475,162 @@ export class ObjectForm extends AbstractCont return this.fields[field['name'] || field]; } - protected init(schema?: ReadonlyArray) { - this.fields = {}; - this.fieldSections = []; + protected updateCustomState(context: any, fieldData: any, _: any, state: AbstractContentFormState) { + for (const [key, field] of Object.entries(this.fields)) { + field.updateState(context, fieldData?.[key], fieldData, state); + } - for (const key of Object.keys(this.form.controls)) { - this.form.removeControl(key); + for (const section of this.fieldSections) { + section.updateHidden(); } + } - if (schema) { - this.form.reset({}); + private static buildControl(template: ObjectTemplate) { + return new TemplatedFormGroup(template); + } +} - for (const { separator, fields } of groupFields(schema)) { - const forms: FieldItemForm[] = []; +abstract class ObjectTemplate implements FormGroupTemplate { + private currentSchema: ReadonlyArray | undefined; + + protected get model() { + return this.modelProvider(); + } - for (const field of fields) { - const childForm = - buildForm( - this.globals, - field, - this.path(field.name), - this.isOptional, - this.rules, - this.partition); + constructor( + private readonly modelProvider: () => T, + ) { + } - this.form.setControl(field.name, childForm.form); + protected abstract getSchema(value: any, model: T): ReadonlyArray | undefined; - forms.push(childForm); + public setControls(form: FormGroup, value: any) { + const schema = this.getSchema(value, this.model); - this.fields[field.name] = childForm; - } + if (this.currentSchema !== schema) { + this.clearControlsCore(this.model); - this.fieldSections.push(new FieldSection(separator, forms)); + if (schema) { + this.setControlsCore(schema, value, this.model, form); } - } else { - this.form.reset(undefined); + + this.currentSchema = schema; } } - public load(data: any) { - this.prepareLoad(data); - - this.form.reset(data); - } + public clearControls() { + if (this.currentSchema !== undefined) { + this.clearControlsCore(this.model); - public prepareLoad(value: any) { - for (const key of Object.keys(this.fields)) { - this.fields[key].prepareLoad(value?.[key]); + this.currentSchema = undefined; } } - protected updateCustomState(context: any, fieldData: any, _: any, state: AbstractContentFormState) { - for (const key of Object.keys(this.fields)) { - this.fields[key].updateState(context, fieldData?.[key], fieldData, state); - } + protected setControlsCore(schema: ReadonlyArray, value: any, model: T, form: FormGroup) { + const fieldMap: FieldMap = {}; + const fieldSections: FieldSection[] = []; - for (const section of this.sections) { - section.updateHidden(); + for (const { separator, fields } of groupFields(schema)) { + const forms: FieldItemForm[] = []; + + for (const field of fields) { + const childForm = buildForm( + model.globals, + field, + model.path(field.name), + model.isOptional, + model.rules, + model.partition); + + form.setControl(field.name, childForm.form); + + forms.push(childForm); + + fieldMap[field.name] = childForm; + } + + fieldSections.push(new FieldSection(separator, forms)); } - } - private static buildControl(field: FieldDto, isOptional: boolean, validate: boolean) { - let validators = [Validators.nullValidator]; + model.fields = fieldMap; + model.fieldSections = fieldSections; + } - if (validate) { - validators = FieldsValidators.create(field, isOptional); + protected clearControlsCore(model: T) { + for (const name of Object.keys(model.form.controls)) { + model.form.removeControl(name); } - return new UndefinableFormGroup({}, validators); + model.fields = {}; + model.fieldSections = []; } } -export class ArrayItemForm extends ObjectForm { - constructor( - globals: FormGlobals, - field: RootFieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - partition: string, - ) { - super(globals, field, fieldPath, isOptional, rules, partition); +export class ArrayItemForm extends ObjectFormBase { + constructor(globals: FormGlobals, field: RootFieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) { + super(globals, field, fieldPath, isOptional, rules, + new ArrayItemTemplate(() => this), partition); - this.init(field.nested); + this.form.build({}); } } -export class ComponentForm extends ObjectForm { - private schemaId?: string; +class ArrayItemTemplate extends ObjectTemplate { + public getSchema() { + return this.model.field.nested; + } +} - public readonly properties: ComponentFieldPropertiesDto; +export class ComponentForm extends ObjectFormBase { + private readonly schema$ = new BehaviorSubject(undefined); - public get schema() { - return this.globals.schemas[this.schemaId!]; + public get schemaChanges(): Observable { + return this.schema$; } - constructor( - globals: FormGlobals, - field: FieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - partition: string, - schemaId?: string, - ) { - super(globals, field, fieldPath, isOptional, - new ComponentRulesProvider(fieldPath, rules), partition); + public get schema() { + return this.schema$.value; + } - this.properties = field.properties as ComponentFieldPropertiesDto; + public set schema(value: SchemaDto | undefined) { + this.schema$.next(value); + } - if (schemaId) { - this.selectSchema(schemaId); - } + public get properties() { + return this.field.properties as ComponentFieldPropertiesDto; } - public selectSchema(schemaId?: string) { - if (this.schemaId !== schemaId) { - this.schemaId = schemaId; + constructor(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) { + super(globals, field, fieldPath, isOptional, + new ComponentRulesProvider(fieldPath, rules, () => this.schema), + new ComponentTemplate(() => this), + partition); - if (this.schema) { - this.rules.setSchema(this.schema); + this.form.build(); + } - this.init(this.schema.fields); + public selectSchema(schemaId: string) { + this.form.reset({ schemaId }); + } +} - this.form.setControl('schemaId', new FormControl(schemaId)); - } else { - this.init(undefined); - } - } +class ComponentTemplate extends ObjectTemplate { + public getSchema(value: any, model: ComponentForm) { + return model.globals.schemas[value?.schemaId].fields; } - public unset() { - this.selectSchema(undefined); + protected setControlsCore(schema: ReadonlyArray, value: any, model: ComponentForm, form: FormGroup) { + form.setControl('schemaId', new FormControl()); + + this.model.schema = model.globals.schemas[value?.schemaId]; - super.unset(); + super.setControlsCore(schema, value, model, form); } - public prepareLoad(value: any) { - this.selectSchema(value?.['schemaId']); + protected clearControlsCore(model: ComponentForm) { + this.model.schema = undefined; - super.prepareLoad(value); + super.clearControlsCore(model); } } diff --git a/frontend/app/shared/state/queries.ts b/frontend/app/shared/state/queries.ts index 0ae0a406e..af640d82b 100644 --- a/frontend/app/shared/state/queries.ts +++ b/frontend/app/shared/state/queries.ts @@ -97,7 +97,7 @@ export class Queries { } function parseQueries(settings: {}) { - const queries = Object.keys(settings).map(name => parseStored(name, settings[name])); + const queries = Object.entries(settings).map(([name, value]) => parseStored(name, value as any)); return queries.sort((a, b) => compareStrings(a.name, b.name)); } diff --git a/frontend/app/shared/state/roles.forms.ts b/frontend/app/shared/state/roles.forms.ts index ee0af2bca..07bc9c15e 100644 --- a/frontend/app/shared/state/roles.forms.ts +++ b/frontend/app/shared/state/roles.forms.ts @@ -5,25 +5,17 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { Form, hasNoValue$, hasValue$ } from '@app/framework'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { Form, hasNoValue$, hasValue$, TemplatedFormArray } from '@app/framework'; import { CreateRoleDto, RoleDto, UpdateRoleDto } from './../services/roles.service'; -export class EditRoleForm extends Form { +export class EditRoleForm extends Form { public get controls() { return this.form.controls as FormControl[]; } constructor() { - super(new FormArray([])); - } - - public add(value?: string) { - this.form.push(new FormControl(value, Validators.required)); - } - - public remove(index: number) { - this.form.removeAt(index); + super(new TemplatedFormArray(new PermissionTemplate())); } public transformSubmit(value: any) { @@ -31,17 +23,13 @@ export class EditRoleForm extends Form { } public transformLoad(value: Partial) { - const permissions = value.permissions || []; - - while (this.form.controls.length < permissions.length) { - this.add(); - } - - while (permissions.length > this.form.controls.length) { - this.form.removeAt(this.form.controls.length - 1); - } + return value.permissions || []; + } +} - return value.permissions; +class PermissionTemplate { + public createControl(_: any, initialValue: string) { + return new FormControl(initialValue, Validators.required); } } diff --git a/frontend/app/shared/state/schemas.forms.ts b/frontend/app/shared/state/schemas.forms.ts index 9cc5ee09b..c4573ace2 100644 --- a/frontend/app/shared/state/schemas.forms.ts +++ b/frontend/app/shared/state/schemas.forms.ts @@ -7,8 +7,8 @@ /* eslint-disable no-useless-escape */ -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Form, ValidatorsEx, value$ } from '@app/framework'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Form, TemplatedFormArray, ValidatorsEx, value$ } from '@app/framework'; import { map } from 'rxjs/operators'; import { AddFieldDto, CreateSchemaDto, FieldRule, SchemaDto, SchemaPropertiesDto, SynchronizeSchemaDto, UpdateSchemaDto } from './../services/schemas.service'; import { createProperties, FieldPropertiesDto, FieldPropertiesVisitor } from './../services/schemas.types'; @@ -78,36 +78,17 @@ export class SynchronizeSchemaForm extends Form } } -export class ConfigureFieldRulesForm extends Form, SchemaDto> { +export class ConfigureFieldRulesForm extends Form, SchemaDto> { public get rulesControls(): ReadonlyArray { return this.form.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { - super(formBuilder.array([])); + constructor(formBuilder: FormBuilder) { + super(new TemplatedFormArray(new FieldRuleTemplate(formBuilder))); } public add(fieldNames: ReadonlyArray) { - this.form.push( - this.formBuilder.group({ - action: ['Disable', - [ - Validators.required, - ], - ], - field: [fieldNames[0], - [ - Validators.required, - ], - ], - condition: ['', - [ - Validators.required, - ], - ], - })); + this.form.add(fieldNames); } public remove(index: number) { @@ -115,71 +96,51 @@ export class ConfigureFieldRulesForm extends Form) { - const result = value.fieldRules || []; - - while (this.form.controls.length < result.length) { - this.add([]); - } + return value.fieldRules || []; + } +} - while (this.form.controls.length > result.length) { - this.remove(this.form.controls.length - 1); - } +class FieldRuleTemplate { + constructor(private readonly formBuilder: FormBuilder) {} - return result; + public createControl(_: any, fieldNames?: ReadonlyArray) { + return this.formBuilder.group({ + action: ['Disable', + [ + Validators.required, + ], + ], + field: [fieldNames?.[0], + [ + Validators.required, + ], + ], + condition: ['', + [ + Validators.required, + ], + ], + }); } } type ConfigurePreviewUrlsFormType = { [name: string]: string }; -export class ConfigurePreviewUrlsForm extends Form { +export class ConfigurePreviewUrlsForm extends Form { public get previewControls(): ReadonlyArray { return this.form.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { - super(formBuilder.array([])); - } - - public add() { - this.form.push( - this.formBuilder.group({ - name: ['', - [ - Validators.required, - ], - ], - url: ['', - [ - Validators.required, - ], - ], - })); - } - - public remove(index: number) { - this.form.removeAt(index); + constructor(formBuilder: FormBuilder) { + super(new TemplatedFormArray(new PreviewUrlTemplate(formBuilder))); } public transformLoad(value: Partial) { const result = []; - const previewUrls = value.previewUrls || {}; - - const length = Object.keys(previewUrls).length; - - while (this.form.controls.length < length) { - this.add(); - } - - while (this.form.controls.length > length) { - this.remove(this.form.controls.length - 1); - } - - for (const key in previewUrls) { - if (previewUrls.hasOwnProperty(key)) { - result.push({ name: key, url: previewUrls[key] }); + if (value.previewUrls) { + for (const [name, url] of Object.entries(value.previewUrls)) { + result.push({ name, url }); } } @@ -197,6 +158,25 @@ export class ConfigurePreviewUrlsForm extends Form { constructor(formBuilder: FormBuilder) { super(formBuilder.group({