Browse Source

Feature/templated array (#799)

* Template array.

* Temp.

* Temp

* Templated array.

* Fixes.
pull/800/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
ca07083098
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 53
      backend/src/Squidex/wwwroot/scripts/editor-plain.html
  2. 10
      frontend/app/features/content/shared/forms/array-editor.component.ts
  3. 2
      frontend/app/features/content/shared/forms/array-item.component.html
  4. 4
      frontend/app/features/content/shared/forms/array-item.component.ts
  5. 6
      frontend/app/features/content/shared/forms/component.component.html
  6. 6
      frontend/app/features/dashboard/pages/cards/api-calls-card.component.ts
  7. 6
      frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts
  8. 6
      frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts
  9. 2
      frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html
  10. 4
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html
  11. 4
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts
  12. 2
      frontend/app/features/settings/pages/roles/role.component.html
  13. 6
      frontend/app/features/settings/pages/roles/role.component.ts
  14. 8
      frontend/app/features/settings/pages/settings/settings-page.component.html
  15. 153
      frontend/app/framework/angular/forms/templated-form-array.spec.ts
  16. 86
      frontend/app/framework/angular/forms/templated-form-array.ts
  17. 64
      frontend/app/framework/angular/forms/templated-form-group.spec.ts
  18. 50
      frontend/app/framework/angular/forms/templated-form-group.ts
  19. 14
      frontend/app/framework/angular/forms/undefinable-form-array.ts
  20. 16
      frontend/app/framework/angular/forms/undefinable-form-group.ts
  21. 1
      frontend/app/framework/declarations.ts
  22. 2
      frontend/app/framework/services/dialog.service.spec.ts
  23. 3
      frontend/app/framework/utils/types.ts
  24. 4
      frontend/app/shared/components/assets/asset-dialog.component.html
  25. 4
      frontend/app/shared/services/auth.service.ts
  26. 4
      frontend/app/shared/services/usages.service.ts
  27. 108
      frontend/app/shared/state/apps.forms.ts
  28. 54
      frontend/app/shared/state/assets.forms.ts
  29. 157
      frontend/app/shared/state/contents.form-rules.ts
  30. 139
      frontend/app/shared/state/contents.forms-helpers.ts
  31. 3
      frontend/app/shared/state/contents.forms.spec.ts
  32. 424
      frontend/app/shared/state/contents.forms.ts
  33. 2
      frontend/app/shared/state/queries.ts
  34. 32
      frontend/app/shared/state/roles.forms.ts
  35. 128
      frontend/app/shared/state/schemas.forms.ts

53
backend/src/Squidex/wwwroot/scripts/editor-plain.html

@ -0,0 +1,53 @@
<html>
<head>
<meta charset="utf-8">
<!-- Load the editor sdk from the local folder or https://cloud.squidex.io/scripts/editor-sdk.js -->
<script src="editor-sdk.js"></script>
<style>
.editor {
border: 1px solid #eee;
border-radius: 4px;
height: 500px;
width: 100%;
}
</style>
</head>
<body style="margin: 0px; padding: 0px;">
<textarea name="content" class="editor" id="editor"></textarea>
<script>
var element = document.getElementById('editor');
// When the field is instantiated it notifies the UI that it has been loaded.
//
// Furthermore it sends the current size to the parent.
var field = new SquidexFormField();
field.onValueChanged(function (value) {
if (value) {
element.value = JSON.stringify(value);
} else {
element.value = '';
}
});
field.onDisabled(function (disabled) {
element.disabled = disabled;
});
element.addEventListener('change', function () {
var value = element.value;
if (value) {
field.valueChanged(JSON.parse(value));
} else {
field.valueChanged(undefined);
}
});
</script>
</body>
</html>

10
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<ReadonlyArray<ObjectForm>>) {
public sort(event: CdkDragDrop<ReadonlyArray<ObjectFormBase>>) {
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();

2
frontend/app/features/content/shared/forms/array-item.component.html

@ -43,7 +43,7 @@
</div>
<div class="card-body" [class.hidden]="snapshot.isCollapsed">
<div class="form-group" *ngFor="let section of formModel.sections">
<div class="form-group" *ngFor="let section of formModel.fieldSectionsChanges | async">
<sqx-component-section
[canUnset]="canUnset"
[form]="form"

4
frontend/app/features/content/shared/forms/array-item.component.ts

@ -6,7 +6,7 @@
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import { AppLanguageDto, ComponentForm, EditContentForm, FieldDto, FieldFormatter, FieldSection, invalid$, ObjectForm, RootFieldDto, StatefulComponent, Types, valueProjection$ } from '@app/shared';
import { AppLanguageDto, ComponentForm, EditContentForm, FieldDto, FieldFormatter, FieldSection, invalid$, ObjectFormBase, RootFieldDto, StatefulComponent, Types, valueProjection$ } from '@app/shared';
import { Observable } from 'rxjs';
import { ComponentSectionComponent } from './component-section.component';
@ -38,7 +38,7 @@ export class ArrayItemComponent extends StatefulComponent<State> implements OnCh
public formContext: any;
@Input()
public formModel: ObjectForm;
public formModel: ObjectFormBase;
@Input()
public canUnset?: boolean | null;

6
frontend/app/features/content/shared/forms/component.component.html

@ -1,10 +1,10 @@
<div class="component">
<div *ngIf="formModel.schema; else noSchema">
<div *ngIf="formModel.schemaChanges | async; let schema; else noSchema">
<sqx-form-hint>
{{formModel.schema.displayName}}
{{schema.displayName}}
</sqx-form-hint>
<div class="form-group" *ngFor="let section of formModel.sections">
<div class="form-group" *ngFor="let section of formModel.fieldSectionsChanges | async">
<sqx-component-section
[canUnset]="canUnset"
[form]="form"

6
frontend/app/features/dashboard/pages/cards/api-calls-card.component.ts

@ -39,13 +39,13 @@ export class ApiCallsCardComponent 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.totalCalls),
data: value.map(x => x.totalCalls),
})),
};
}

6
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),
})),
};

6
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),
})),
};

2
frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html

@ -10,7 +10,7 @@
{{ 'schemas.field.tabValidation' | sqxTranslate }}
</a>
</li>
<li class="nav-item" [class.hidden]="!field.properties.isContentField || field.properties.fieldType === 'Array'">
<li class="nav-item" [class.hidden]="!field.properties.isContentField">
<a class="nav-link" (click)="selectTab(2)" [class.active]="selectedTab === 2">
{{ 'schemas.field.tabEditing' | sqxTranslate }}
</a>

4
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html

@ -27,7 +27,7 @@
<div class="col-auto">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.remove(i)"
(sqxConfirmClick)="editForm.form.removeAt(i)"
confirmTitle="i18n:schemas.deleteUrlConfirmTitle"
confirmText="i18n:schemas.deleteUrlConfirmText"
confirmRememberKey="removePreviewUrl">
@ -46,7 +46,7 @@
</div>
<div class="col-auto col-options">
<button type="button" class="btn btn-success" (click)="add()">
<button type="button" class="btn btn-success" (click)="editForm.form.add()">
<i class="icon-add"></i>
</button>
</div>

4
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts

@ -35,10 +35,6 @@ export class SchemaPreviewUrlsFormComponent implements OnChanges {
this.editForm.setEnabled(this.isEditable);
}
public add() {
this.editForm.add();
}
public saveSchema() {
if (!this.isEditable) {
return;

2
frontend/app/features/settings/pages/roles/role.component.html

@ -59,7 +59,7 @@
<sqx-autocomplete [formControl]="control" [source]="allPermissions"></sqx-autocomplete>
</div>
<div class="col-auto" *ngIf="isEditable">
<button type="button" class="btn btn-text-danger" (click)="removePermission(i)">
<button type="button" class="btn btn-text-danger" (click)="editForm.form.removeAt(i)">
<i class="icon-bin2"></i>
</button>
</div>

6
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();

8
frontend/app/features/settings/pages/settings/settings-page.component.html

@ -46,7 +46,7 @@
<div class="col-auto">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.removePattern(i)"
(sqxConfirmClick)="editForm.patterns.removeAt(i)"
confirmTitle="i18n:appSettings.patterns.deleteConfirmTitle"
confirmText="i18n:appSettings.patterns.deleteConfirmText"
confirmRememberKey="deletePattern">
@ -69,7 +69,7 @@
</div>
<div class="col-auto">
<button type="button" class="btn btn-success" (click)="editForm.addPattern()">
<button type="button" class="btn btn-success" (click)="editForm.patterns.add()">
<i class="icon-add"></i>
</button>
</div>
@ -104,7 +104,7 @@
<div class="col-auto">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.removeEditor(i)"
(sqxConfirmClick)="editForm.editors.removeAt(i)"
confirmTitle="i18n:appSettings.editors.deleteConfirmTitle"
confirmText="i18n:appSettings.editors.deleteConfirmText"
confirmRememberKey="deleteEditor">
@ -123,7 +123,7 @@
</div>
<div class="col-auto">
<button type="button" class="btn btn-success" (click)="editForm.addEditor()">
<button type="button" class="btn btn-success" (click)="editForm.editors.add()">
<i class="icon-add"></i>
</button>
</div>

153
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([]);
});
});

86
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();
}
}
}

64
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([]);
});
});
});

50
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();
}
}
}

14
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 });

16
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 });

1
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';

2
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;

3
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);

4
frontend/app/shared/components/assets/asset-dialog.component.html

@ -151,7 +151,7 @@
<div class="col-auto col-options">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="annotateForm.removeMetadata(i)"
(sqxConfirmClick)="annotateForm.metadata.removeAt(i)"
confirmTitle="i18n:assets.deleteMetadataConfirmTitle"
confirmText="i18n:assets.deleteMetadataConfirmText"
confirmRememberKey="removeAssetMetadata">
@ -161,7 +161,7 @@
</div>
<div class="form-group">
<button type="button" class="btn btn-success" (click)="annotateForm.addMetadata()" [disabled]="!isEditable">
<button type="button" class="btn btn-success" (click)="annotateForm.metadata.add()" [disabled]="!isEditable">
{{ 'assets.metadataAdd' | sqxTranslate }}
</button>
</div>

4
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;
}

4
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,

108
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<FormGroup, CreateAppDto> {
@ -39,95 +39,67 @@ export class UpdateAppForm extends Form<FormGroup, UpdateAppDto, AppDto> {
}
export class EditAppSettingsForm extends Form<FormGroup, UpdateAppSettingsDto, AppSettingsDto> {
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<FormGroup> {
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<FormGroup> {
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,
],
],
});
}
}

54
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<FormGroup, AnnotateAssetDto, AssetDto> {
public get metadata() {
return this.form.get('metadata')! as FormArray;
return this.form.get('metadata')! as TemplatedFormArray;
}
public get metadataControls(): ReadonlyArray<FormGroup> {
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<FormGroup, AnnotateAssetDto, AssetDt
Validators.nullValidator,
],
],
metadata: formBuilder.array([]),
metadata: new TemplatedFormArray(new MetadataTemplate(formBuilder)),
}));
}
public addMetadata() {
this.metadata.push(
this.formBuilder.group({
name: ['',
[
Validators.required,
],
],
value: [''],
}));
}
public removeMetadata(index: number) {
this.metadata.removeAt(index);
}
public transformSubmit(value: any) {
const result = { ...value, metadata: {} };
@ -143,16 +126,6 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
}
if (Types.isObject(value.metadata)) {
const length = Object.keys(value.metadata).length;
while (this.metadata.controls.length < length) {
this.addMetadata();
}
while (this.metadata.controls.length > 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<FormGroup, AnnotateAssetDto, AssetDt
}
}
class MetadataTemplate {
constructor(private readonly formBuilder: FormBuilder) {}
public createControl() {
return this.formBuilder.group({
name: ['',
[
Validators.required,
],
],
value: [''],
});
}
}
export class EditAssetScriptsForm extends Form<FormGroup, {}, object> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({

157
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<CompiledRule>;
}
export interface RulesProvider {
compileRules(schema: SchemaDto): ReadonlyArray<CompiledRule>;
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<CompiledRule> = [];
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<CompiledRule> } = {};
private readonly rules: ReadonlyArray<CompiledRule>;
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 };
}
}

139
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<boolean>(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<CompiledRule>;
setSchema(schema?: SchemaDto): void;
getRules(form: AbstractContentForm<any, any>): ReadonlyArray<CompiledRule>;
}
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<CompiledRule> {
return this.parent.compileRules(schema);
}
public getRules(form: AbstractContentForm<any, any>) {
return Types.fastMerge(this.parent.getRules(form), this.getRelativeRules(form));
}
private getRelativeRules(form: AbstractContentForm<any, any>) {
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<CompiledRule> } = {};
private readonly rules: ReadonlyArray<CompiledRule>;
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<any, any>) {
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<T extends FieldDto, TForm extends AbstractControl> extends Hidden {
private readonly disabled$ = new BehaviorSubject<boolean>(false);
private readonly currentRules: ReadonlyArray<CompiledRule>;
private readonly ruleSet: CompiledRules;
public get disabled() {
return this.disabled$.value;
@ -263,7 +142,7 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
) {
super();
this.currentRules = rules.getRules(this);
this.ruleSet = rules.getRules(this);
}
public path(relative: string) {
@ -277,7 +156,7 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
isRequired: this.field.properties.isRequired && !this.isOptional,
};
for (const rule of this.currentRules) {
for (const rule of this.ruleSet.rules) {
if (rule.eval(context, itemData)) {
if (rule.action === 'Disable') {
state.isDisabled = true;
@ -307,8 +186,6 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
}
public setValue(value: any) {
this.prepareLoad(value);
this.form.reset(value);
}
@ -319,10 +196,6 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
protected updateCustomState(_context: RuleContext, _fieldData: any, _itemData: any, _state: AbstractContentFormState): void {
return;
}
public prepareLoad(_data: any): void {
return;
}
}
const SELF = { onlySelf: true };

3
frontend/app/shared/state/contents.forms.spec.ts

@ -462,6 +462,7 @@ describe('ContentForm', () => {
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([]);

424
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<FormGroup, any> {
}
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<RootFieldDto, FormGroup> {
}
}
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<RootFieldDto, FormGroup> {
}
}
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<RootFieldDto, FormGroup> {
export class FieldValueForm extends AbstractContentForm<FieldDto, FormControl> {
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<FieldDto, FormControl> {
}
}
export class FieldArrayForm extends AbstractContentForm<FieldDto, UndefinableFormArray> {
private readonly item$ = new BehaviorSubject<ReadonlyArray<ObjectForm>>([]);
export class FieldArrayForm extends AbstractContentForm<FieldDto, TemplatedFormArray> {
private readonly item$ = new BehaviorSubject<ReadonlyArray<ObjectFormBase>>([]);
public get itemChanges(): Observable<ReadonlyArray<ObjectForm>> {
public get itemChanges(): Observable<ReadonlyArray<ObjectFormBase>> {
return this.item$;
}
@ -341,83 +326,42 @@ export class FieldArrayForm extends AbstractContentForm<FieldDto, UndefinableFor
return this.item$.value;
}
public set items(value: ReadonlyArray<ObjectForm>) {
public set items(value: ReadonlyArray<ObjectFormBase>) {
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<FieldDto, UndefinableFor
this.sort(children);
}
public sort(children: ReadonlyArray<ObjectForm>) {
public sort(children: ReadonlyArray<ObjectFormBase>) {
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<TField extends FieldDto = FieldDto> extends AbstractContentForm<TField, TemplatedFormGroup> {
private readonly fieldSections$ = new BehaviorSubject<ReadonlyArray<FieldSection<FieldDto, FieldItemForm>>>([]);
private readonly fields$ = new BehaviorSubject<FieldMap>({});
public get fieldSectionsChanges(): Observable<ReadonlyArray<FieldSection<FieldDto, FieldItemForm>>> {
return this.fieldSections$;
}
}
export type FieldItemForm = ComponentForm | FieldValueForm | FieldArrayForm;
public get fieldSections() {
return this.fieldSections$.value;
}
export class ObjectForm<TField extends FieldDto = FieldDto> extends AbstractContentForm<TField, UndefinableFormGroup> {
private fields: { [key: string]: FieldItemForm } = {};
private fieldSections: FieldSection<FieldDto, FieldItemForm>[] = [];
public set fieldSections(value: ReadonlyArray<FieldSection<FieldDto, FieldItemForm>>) {
this.fieldSections$.next(value);
}
public get sections() {
return this.fieldSections;
public get fieldsChanges(): Observable<FieldMap> {
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<TField extends FieldDto = FieldDto> extends AbstractCont
return this.fields[field['name'] || field];
}
protected init(schema?: ReadonlyArray<FieldDto>) {
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<T extends ObjectFormBase = ObjectFormBase> implements FormGroupTemplate {
private currentSchema: ReadonlyArray<FieldDto> | 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<FieldDto> | 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<FieldDto, FieldItemForm>(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<FieldDto>, value: any, model: T, form: FormGroup) {
const fieldMap: FieldMap = {};
const fieldSections: FieldSection<FieldDto, FieldItemForm>[] = [];
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<FieldDto, FieldItemForm>(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<RootFieldDto> {
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<RootFieldDto> {
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<ArrayItemForm> {
public getSchema() {
return this.model.field.nested;
}
}
public readonly properties: ComponentFieldPropertiesDto;
export class ComponentForm extends ObjectFormBase {
private readonly schema$ = new BehaviorSubject<SchemaDto | undefined>(undefined);
public get schema() {
return this.globals.schemas[this.schemaId!];
public get schemaChanges(): Observable<SchemaDto | undefined> {
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<ComponentForm> {
public getSchema(value: any, model: ComponentForm) {
return model.globals.schemas[value?.schemaId].fields;
}
public unset() {
this.selectSchema(undefined);
protected setControlsCore(schema: ReadonlyArray<FieldDto>, 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);
}
}

2
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));
}

32
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<FormArray, UpdateRoleDto, RoleDto> {
export class EditRoleForm extends Form<TemplatedFormArray, UpdateRoleDto, RoleDto> {
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<FormArray, UpdateRoleDto, RoleDto> {
}
public transformLoad(value: Partial<UpdateRoleDto>) {
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);
}
}

128
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<FormGroup, SynchronizeSchemaDto>
}
}
export class ConfigureFieldRulesForm extends Form<FormArray, ReadonlyArray<FieldRule>, SchemaDto> {
export class ConfigureFieldRulesForm extends Form<TemplatedFormArray, ReadonlyArray<FieldRule>, SchemaDto> {
public get rulesControls(): ReadonlyArray<FormGroup> {
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<string>) {
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<FormArray, ReadonlyArray<Field
}
public transformLoad(value: Partial<SchemaDto>) {
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<string>) {
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<FormArray, ConfigurePreviewUrlsFormType, SchemaDto> {
export class ConfigurePreviewUrlsForm extends Form<TemplatedFormArray, ConfigurePreviewUrlsFormType, SchemaDto> {
public get previewControls(): ReadonlyArray<FormGroup> {
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<SchemaDto>) {
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<FormArray, ConfigurePreviewUr
}
}
class PreviewUrlTemplate {
constructor(private readonly formBuilder: FormBuilder) {}
public createControl() {
return this.formBuilder.group({
name: ['',
[
Validators.required,
],
],
url: ['',
[
Validators.required,
],
],
});
}
}
export class EditSchemaScriptsForm extends Form<FormGroup, {}, object> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({

Loading…
Cancel
Save