diff --git a/frontend/app/framework/utils/rxjs-extensions.ts b/frontend/app/framework/utils/rxjs-extensions.ts
index 78db8135e..0b1b3d5cb 100644
--- a/frontend/app/framework/utils/rxjs-extensions.ts
+++ b/frontend/app/framework/utils/rxjs-extensions.ts
@@ -6,7 +6,7 @@
*/
import { EMPTY, Observable, ReplaySubject, throwError } from 'rxjs';
-import { catchError, distinctUntilChanged, filter, map, onErrorResumeNext, share, switchMap } from 'rxjs/operators';
+import { catchError, debounceTime, distinctUntilChanged, filter, map, onErrorResumeNext, share, switchMap } from 'rxjs/operators';
import { DialogService } from './../services/dialog.service';
import { Version, versioned, Versioned } from './version';
@@ -51,6 +51,16 @@ export function shareMapSubscribed
(dialogs: DialogService, project: (v
};
}
+export function debounceTimeSafe(duration: number) {
+ return function mapOperation(source: Observable) {
+ if (duration > 0) {
+ return source.pipe(debounceTime(duration), onErrorResumeNext());
+ } else {
+ return source.pipe(onErrorResumeNext());
+ }
+ };
+}
+
export function defined() {
return function mapOperation(source: Observable): Observable {
return source.pipe(filter(x => !!x), map(x => x!), distinctUntilChanged());
diff --git a/frontend/app/framework/utils/types.ts b/frontend/app/framework/utils/types.ts
index 03b30d930..e71b7a034 100644
--- a/frontend/app/framework/utils/types.ts
+++ b/frontend/app/framework/utils/types.ts
@@ -103,6 +103,18 @@ export module Types {
return Types.isUndefined(value) === true || Types.isNull(value) === true;
}
+ export function fastMerge(lhs: ReadonlyArray, rhs: ReadonlyArray) {
+ if (rhs.length === 0) {
+ return lhs;
+ }
+
+ if (lhs.length === 0) {
+ return rhs;
+ }
+
+ return [...lhs, ...rhs];
+ }
+
export function clone(lhs: T): T {
const any: any = lhs;
diff --git a/frontend/app/shared/state/contents.forms-helpers.ts b/frontend/app/shared/state/contents.forms-helpers.ts
index 3d2763381..4769eaa7e 100644
--- a/frontend/app/shared/state/contents.forms-helpers.ts
+++ b/frontend/app/shared/state/contents.forms-helpers.ts
@@ -5,9 +5,11 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
+/* eslint-disable @typescript-eslint/no-implied-eval */
/* eslint-disable no-useless-return */
import { AbstractControl, ValidatorFn } from '@angular/forms';
+import { Types } from '@app/framework';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppLanguageDto } from './../services/app-languages.service';
@@ -36,6 +38,32 @@ export abstract class Hidden {
}
}
+export function groupFields(fields: ReadonlyArray): { separator?: T; fields: ReadonlyArray }[] {
+ const result: { separator?: T; fields: ReadonlyArray }[] = [];
+
+ let currentSeparator: T | undefined;
+ let currentFields: T[] = [];
+
+ for (const field of fields) {
+ if (field.properties.isContentField) {
+ currentFields.push(field);
+ } else {
+ if (currentFields.length > 0) {
+ result.push({ separator: currentSeparator, fields: currentFields });
+ }
+
+ currentFields = [];
+ currentSeparator = field;
+ }
+ }
+
+ if (currentFields.length > 0) {
+ result.push({ separator: currentSeparator, fields: currentFields });
+ }
+
+ return result;
+}
+
export class FieldSection extends Hidden {
constructor(
public readonly separator: TSeparator | undefined,
@@ -78,7 +106,7 @@ export class PartitionConfig {
}
}
-type RuleContext = { data: any; itemData?: any; user?: any };
+type RuleContext = { data: any; user?: any };
export class CompiledRule {
private readonly function: Function;
@@ -93,18 +121,20 @@ export class CompiledRule {
constructor(
private readonly rule: FieldRule,
+ private readonly useItemData: boolean,
) {
try {
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
this.function = new Function(`return function(user, ctx, data, itemData) { return ${rule.condition} }`)();
} catch {
this.function = () => false;
}
}
- public eval(context: RuleContext) {
+ public eval(context: RuleContext, itemData: any) {
try {
- return this.function(context.user, context, context.data, context.itemData);
+ const data = this.useItemData ? itemData || context.data : context.data;
+
+ return this.function(context.user, context, data, itemData);
} catch {
return false;
}
@@ -118,16 +148,102 @@ export type AbstractContentFormState = {
};
export interface FormGlobals {
- allRules: ReadonlyArray;
partitions: PartitionConfig;
schema: SchemaDto;
schemas: { [id: string ]: SchemaDto };
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 rules: ReadonlyArray;
+ private readonly currentRules: ReadonlyArray;
public get disabled() {
return this.disabled$.value;
@@ -139,35 +255,36 @@ export abstract class AbstractContentForm x.field === fieldPath || x.field === simplifiedPath);
+ public path(relative: string) {
+ return `${this.fieldPath}.${relative}`;
}
- public updateState(context: RuleContext, parentState: AbstractContentFormState) {
+ public updateState(context: RuleContext, fieldData: any, itemData: any, parentState: AbstractContentFormState) {
const state = {
isDisabled: this.field.isDisabled || parentState.isDisabled === true,
isHidden: parentState.isHidden === true,
isRequired: this.field.properties.isRequired && !this.isOptional,
};
- if (this.rules) {
- for (const rule of this.rules) {
- if (rule.eval(context)) {
- if (rule.action === 'Disable') {
- state.isDisabled = true;
- } else if (rule.action === 'Hide') {
- state.isHidden = true;
- } else {
- state.isRequired = true;
- }
+ for (const rule of this.currentRules) {
+ if (rule.eval(context, itemData)) {
+ if (rule.action === 'Disable') {
+ state.isDisabled = true;
+ } else if (rule.action === 'Hide') {
+ state.isHidden = true;
+ } else {
+ state.isRequired = true;
}
}
}
@@ -182,14 +299,14 @@ export abstract class AbstractContentForm {
expect(array.get(1)!.get('nested42')!.hidden).toBeFalsy();
});
+ it('should hide components fields based on condition', () => {
+ const componentId = MathHelper.guid();
+ const component = createSchema({
+ fields: [
+ createField({
+ id: 1,
+ properties: createProperties('String'),
+ partitioning: 'invariant',
+ }),
+ ],
+ fieldRules: [{
+ field: 'field1', action: 'Hide', condition: 'data.field1 > 100',
+ }],
+ });
+
+ const contentForm = createForm([
+ createField({
+ id: 4,
+ properties: createProperties('Components'),
+ partitioning: 'invariant',
+ }),
+ ], [], {
+ [componentId]: component,
+ });
+
+ const array = contentForm.get(complexSchema.fields[3])!.get(languages[0]) as FieldArrayForm;
+
+ contentForm.load({
+ field4: {
+ iv: [{
+ schemaId: componentId,
+ field1: 120,
+ }, {
+ schemaId: componentId,
+ field1: 99,
+ }],
+ },
+ });
+
+ expect(array.get(0)!.get('field1')!.hidden).toBeTruthy();
+ expect(array.get(1)!.get('field1')!.hidden).toBeFalsy();
+ });
+
it('should load with array and not enable disabled nested fields', () => {
const { contentForm, array } = createArrayFormWith2Items();
@@ -690,8 +734,8 @@ describe('ContentForm', () => {
});
});
- function createForm(fields: RootFieldDto[], fieldRules: FieldRule[] = []) {
+ function createForm(fields: RootFieldDto[], fieldRules: FieldRule[] = [], schemas: { [id: string]: SchemaDto } = {}) {
return new EditContentForm(languages,
- createSchema({ fields, fieldRules }), {}, {}, 0);
+ createSchema({ fields, fieldRules }), schemas, {}, 0);
}
});
diff --git a/frontend/app/shared/state/contents.forms.ts b/frontend/app/shared/state/contents.forms.ts
index 60c782299..1ef072ce2 100644
--- a/frontend/app/shared/state/contents.forms.ts
+++ b/frontend/app/shared/state/contents.forms.ts
@@ -6,14 +6,13 @@
*/
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
-import { Form, getRawValue, Types, UndefinableFormArray, UndefinableFormGroup, valueAll$ } from '@app/framework';
+import { debounceTimeSafe, Form, getRawValue, Types, UndefinableFormArray, UndefinableFormGroup, valueAll$ } from '@app/framework';
import { BehaviorSubject, Observable } from 'rxjs';
-import { debounceTime, onErrorResumeNext } from 'rxjs/operators';
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, CompiledRule, FieldSection, FormGlobals, PartitionConfig } from './contents.forms-helpers';
+import { AbstractContentForm, AbstractContentFormState, ComponentRulesProvider, FieldSection, FormGlobals, groupFields, PartitionConfig, RootRulesProvider, RulesProvider } from './contents.forms-helpers';
import { FieldDefaultValue, FieldsValidators } from './contents.forms.visitors';
type SaveQueryFormType = { name: string; user: boolean };
@@ -93,53 +92,36 @@ export class EditContentForm extends Form {
super(new FormGroup({}));
const globals: FormGlobals = {
- allRules: schema.fieldRules.map(x => new CompiledRule(x)),
schema,
schemas,
partitions: new PartitionConfig(languages),
remoteValidator: this.remoteValidator,
};
- const sections: FieldSection[] = [];
+ const rules = new RootRulesProvider(schema);
- let currentSeparator: RootFieldDto | undefined;
- let currentFields: FieldForm[] = [];
+ this.sections = groupFields(schema.fields).map(({ separator, fields }) => {
+ const forms: FieldForm[] = [];
- for (const field of schema.fields) {
- if (field.properties.isContentField) {
- const childPath = field.name;
- const childForm = new FieldForm(globals, childPath, field);
-
- currentFields.push(childForm);
-
- this.fields[field.name] = childForm;
+ for (const field of fields) {
+ const childForm =
+ new FieldForm(
+ globals,
+ field,
+ field.name,
+ rules);
this.form.setControl(field.name, childForm.form);
- } else {
- if (currentFields.length > 0) {
- sections.push(new FieldSection(currentSeparator, currentFields));
- }
-
- currentFields = [];
- currentSeparator = field;
- }
- }
-
- if (currentFields.length > 0) {
- sections.push(new FieldSection(currentSeparator, currentFields));
- }
- this.sections = sections;
+ forms.push(childForm);
- let change$ = valueAll$(this.form);
+ this.fields[field.name] = childForm;
+ }
- if (debounce > 0) {
- change$ = change$.pipe(debounceTime(debounce), onErrorResumeNext());
- } else {
- change$ = change$.pipe(onErrorResumeNext());
- }
+ return new FieldSection(separator, forms);
+ });
- change$.subscribe(value => {
+ valueAll$(this.form).pipe(debounceTimeSafe(debounce)).subscribe(value => {
this.valueChange$.next(value);
this.updateState(value);
@@ -202,7 +184,7 @@ export class EditContentForm extends Form {
const context = { ...this.context || {}, data };
for (const field of Object.values(this.fields)) {
- field.updateState(context, { isDisabled: this.form.disabled });
+ field.updateState(context, data[field.field.name], data, { isDisabled: this.form.disabled });
}
for (const section of this.sections) {
@@ -219,12 +201,23 @@ export class FieldForm extends AbstractContentForm {
private readonly partitions: { [partition: string]: FieldItemForm } = {};
private isRequired: boolean;
- constructor(globals: FormGlobals, fieldPath: string, field: RootFieldDto) {
- super(globals, fieldPath, field, FieldForm.buildForm(), false);
+ constructor(
+ globals: FormGlobals,
+ field: RootFieldDto,
+ fieldPath: string,
+ rules: RulesProvider,
+ ) {
+ super(globals, field, fieldPath, FieldForm.buildForm(), false, rules);
for (const { key, isOptional } of globals.partitions.getAll(field)) {
- const childPath = `${fieldPath}.${key}`;
- const childForm = buildForm(this.globals, childPath, field, isOptional, key);
+ const childForm =
+ buildForm(
+ this.globals,
+ field,
+ this.path(key),
+ isOptional,
+ rules,
+ key);
this.partitions[key] = childForm;
@@ -263,7 +256,13 @@ export class FieldForm extends AbstractContentForm {
}
}
- protected updateCustomState(context: any, state: AbstractContentFormState) {
+ 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;
if (this.isRequired !== isRequired) {
@@ -289,14 +288,8 @@ export class FieldForm extends AbstractContentForm {
}
}
- for (const partition of Object.values(this.partitions)) {
- partition.updateState(context, state);
- }
- }
-
- public prepareLoad(value: any) {
for (const key of Object.keys(this.partitions)) {
- this.partitions[key].prepareLoad(value?.[key]);
+ this.partitions[key].updateState(context, fieldData?.[key], itemData, state);
}
}
@@ -308,18 +301,22 @@ export class FieldForm extends AbstractContentForm {
export class FieldValueForm extends AbstractContentForm {
private isRequired = false;
- constructor(globals: FormGlobals, path: string, field: FieldDto,
- isOptional: boolean, partition: string,
+ constructor(
+ globals: FormGlobals,
+ field: FieldDto,
+ fieldPath: string,
+ isOptional: boolean,
+ rules: RulesProvider,
+ partition: string,
) {
- super(globals, path, field,
+ super(globals, field, fieldPath,
FieldValueForm.buildControl(field, isOptional, partition, globals),
- isOptional,
- );
+ isOptional, rules);
this.isRequired = field.properties.isRequired && !isOptional;
}
- protected updateCustomState(_: any, state: AbstractContentFormState) {
+ protected updateCustomState(_context: any, _fieldData: any, _itemData: any, state: AbstractContentFormState) {
const isRequired = state.isRequired === true;
if (!this.isOptional && this.isRequired !== isRequired) {
@@ -366,14 +363,18 @@ export class FieldArrayForm extends AbstractContentForm extends AbstractCont
return this.fieldSections;
}
- constructor(globals: FormGlobals, path: string, field: TField, isOptional: boolean,
+ constructor(
+ globals: FormGlobals,
+ field: TField,
+ fieldPath: string,
+ isOptional: boolean,
+ rules: RulesProvider,
private readonly partition: string,
) {
- super(globals, path, field, ObjectForm.buildControl(field, isOptional, false), isOptional);
+ super(globals, field, fieldPath,
+ ObjectForm.buildControl(field, isOptional, false),
+ isOptional, rules);
}
public get(field: string | { name: string }): FieldItemForm | undefined {
@@ -515,31 +544,27 @@ export class ObjectForm extends AbstractCont
if (schema) {
this.form.reset({});
- let currentSeparator: FieldDto | undefined;
- let currentFields: FieldItemForm[] = [];
+ for (const { separator, fields } of groupFields(schema)) {
+ const forms: FieldItemForm[] = [];
- for (const field of schema) {
- if (field.properties.isContentField) {
- const childPath = `${this.fieldPath}.${field.name}`;
- const childForm = buildForm(this.globals, childPath, field, this.isOptional, this.partition);
+ for (const field of fields) {
+ const childForm =
+ buildForm(
+ this.globals,
+ field,
+ this.path(field.name),
+ this.isOptional,
+ this.rules,
+ this.partition);
this.form.setControl(field.name, childForm.form);
- currentFields.push(childForm);
+ forms.push(childForm);
this.fields[field.name] = childForm;
- } else {
- if (currentFields.length > 0) {
- this.fieldSections.push(new FieldSection(currentSeparator, currentFields));
- }
-
- currentFields = [];
- currentSeparator = field;
}
- }
- if (currentFields.length > 0) {
- this.fieldSections.push(new FieldSection(currentSeparator, currentFields));
+ this.fieldSections.push(new FieldSection(separator, forms));
}
} else {
this.form.reset(undefined);
@@ -558,11 +583,9 @@ export class ObjectForm extends AbstractCont
}
}
- protected updateCustomState(context: any, state: AbstractContentFormState) {
- const itemData = this.form.getRawValue();
-
- for (const field of Object.values(this.fields)) {
- field.updateState({ ...context, itemData }, state);
+ 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);
}
for (const section of this.sections) {
@@ -582,8 +605,15 @@ export class ObjectForm extends AbstractCont
}
export class ArrayItemForm extends ObjectForm {
- constructor(globals: FormGlobals, path: string, field: RootFieldDto, isOptional: boolean, partition: string) {
- super(globals, path, field, isOptional, partition);
+ constructor(
+ globals: FormGlobals,
+ field: RootFieldDto,
+ fieldPath: string,
+ isOptional: boolean,
+ rules: RulesProvider,
+ partition: string,
+ ) {
+ super(globals, field, fieldPath, isOptional, rules, partition);
this.init(field.nested);
}
@@ -598,8 +628,17 @@ export class ComponentForm extends ObjectForm {
return this.globals.schemas[this.schemaId!];
}
- constructor(globals: FormGlobals, path: string, field: FieldDto, isOptional: boolean, partition: string, schemaId?: string) {
- super(globals, path, field, isOptional, partition);
+ 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);
this.properties = field.properties as ComponentFieldPropertiesDto;
@@ -613,6 +652,8 @@ export class ComponentForm extends ObjectForm {
this.schemaId = schemaId;
if (this.schema) {
+ this.rules.setSchema(this.schema);
+
this.init(this.schema.fields);
this.form.setControl('schemaId', new FormControl(schemaId));
@@ -635,15 +676,15 @@ export class ComponentForm extends ObjectForm {
}
}
-function buildForm(globals: FormGlobals, path: string, field: FieldDto, isOptional: boolean, partition: string) {
+function buildForm(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) {
switch (field.properties.fieldType) {
case 'Array':
- return new FieldArrayForm(globals, path, field, isOptional, partition, false);
+ return new FieldArrayForm(globals, field, fieldPath, isOptional, rules, partition, false);
case 'Component':
- return new ComponentForm(globals, path, field, isOptional, partition);
+ return new ComponentForm(globals, field, fieldPath, isOptional, rules, partition);
case 'Components':
- return new FieldArrayForm(globals, path, field, isOptional, partition, true);
+ return new FieldArrayForm(globals, field, fieldPath, isOptional, rules, partition, true);
default:
- return new FieldValueForm(globals, path, field, isOptional, partition);
+ return new FieldValueForm(globals, field, fieldPath, isOptional, rules, partition);
}
}