From c0107aecd6ea433c96185bb861dfe209dee1da21 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 1 Sep 2021 17:38:25 +0200 Subject: [PATCH] Support rules in components. (#752) * Support rules in components. * Performance improvements. * Smaller forms class. * Bugfixes. --- .../schema-field-rules-form.component.html | 2 +- .../app/framework/utils/rxjs-extensions.ts | 12 +- frontend/app/framework/utils/types.ts | 12 + .../shared/state/contents.forms-helpers.ts | 161 ++++++++++-- .../app/shared/state/contents.forms.spec.ts | 50 +++- frontend/app/shared/state/contents.forms.ts | 245 ++++++++++-------- 6 files changed, 353 insertions(+), 129 deletions(-) diff --git a/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html b/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html index 3c1ae255d..2b1f1b69f 100644 --- a/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html +++ b/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html @@ -8,7 +8,7 @@ {{ 'schemas.rules.empty' | sqxTranslate }} -
+
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); } }