From 4bfba3cd46d001d32a077ca4c0d18d06de38be4c Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Fri, 13 Nov 2020 08:32:01 +0100 Subject: [PATCH] Local errors and rx fixes. (#594) * Local errors and rx fixes. * Performance and recursive fixes. * User experience fixed. * Errors fixed. * Build fix. * Try to disable watch. * Watchers test * Remove stuff. --- backend/i18n/frontend_en.json | 1 + backend/i18n/frontend_it.json | 1 + backend/i18n/frontend_nl.json | 1 + backend/i18n/source/frontend_en.json | 1 + .../ValidateContent/ContentValidator.cs | 17 +- .../ValidateContent/ObjectPath.cs | 9 - .../States/MongoSnapshotStore.cs | 2 +- .../Configuration/ConfigurationExtensions.cs | 2 +- .../EventSourcing/Grains/BatchSubscriber.cs | 2 +- .../ValidateContent/ContentValidationTests.cs | 42 ++-- .../ValidateContent/UIFieldTests.cs | 2 +- .../Validators/UniqueValidatorTests.cs | 4 +- frontend/app-config/karma.conf.js | 4 - frontend/app-config/karma.coverage.conf.js | 4 - .../pages/content/content-page.component.ts | 13 +- .../shared/forms/array-editor.component.html | 77 ++++--- .../shared/forms/array-editor.component.ts | 27 ++- .../import-contributors-dialog.component.ts | 2 +- .../angular/forms/control-errors.component.ts | 6 +- .../angular/forms/error-formatting.spec.ts | 33 +++ .../angular/forms/error-formatting.ts | 11 +- .../angular/forms/error-validator.spec.ts | 184 +++++++++++++++ .../angular/forms/error-validator.ts | 76 ++++++ .../angular/forms/forms-helper.spec.ts | 64 +++++- .../framework/angular/forms/forms-helper.ts | 95 +++++++- frontend/app/framework/angular/forms/model.ts | 148 ++++++++++++ .../app/framework/angular/forms/validators.ts | 32 +-- frontend/app/framework/declarations.ts | 1 + frontend/app/framework/state.ts | 102 -------- frontend/app/framework/utils/error.ts | 51 ++-- .../app/framework/utils/string-helper.spec.ts | 8 + frontend/app/framework/utils/string-helper.ts | 10 + .../components/assets/asset.component.html | 2 +- frontend/app/shared/internal.ts | 1 + .../shared/state/contents.forms-helpers.ts | 85 ++++++- .../app/shared/state/contents.forms.spec.ts | 17 +- frontend/app/shared/state/contents.forms.ts | 217 ++++++++---------- 37 files changed, 980 insertions(+), 374 deletions(-) create mode 100644 frontend/app/framework/angular/forms/error-validator.spec.ts create mode 100644 frontend/app/framework/angular/forms/error-validator.ts create mode 100644 frontend/app/framework/angular/forms/model.ts diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index c162c1dda..d4f95ce37 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -200,6 +200,7 @@ "common.aspectRatio": "AspectRatio", "common.assets": "Assets", "common.back": "Back", + "common.backendError": "Backend ERROR", "common.backups": "Backups", "common.bytes": "bytes", "common.cancel": "Cancel", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index fffe715cd..941047edb 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -200,6 +200,7 @@ "common.aspectRatio": "Proporzioni", "common.assets": "Risorse", "common.back": "Indietro", + "common.backendError": "Backend ERROR", "common.backups": "Backup", "common.bytes": "byte", "common.cancel": "Annulla", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 7218dc962..13a11cd4c 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -200,6 +200,7 @@ "common.aspectRatio": "AspectRatio", "common.assets": "Bestanden", "common.back": "Terug", + "common.backendError": "Backend ERROR", "common.backups": "Back-ups", "common.bytes": "bytes", "common.cancel": "Annuleren", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index c162c1dda..d4f95ce37 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -200,6 +200,7 @@ "common.aspectRatio": "AspectRatio", "common.assets": "Assets", "common.back": "Back", + "common.backendError": "Backend ERROR", "common.backups": "Backups", "common.bytes": "bytes", "common.cancel": "Cancel", diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs index a0f72dc42..f1585d521 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs @@ -96,7 +96,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent private IValidator CreateFieldValidator(IRootField field, bool isPartial) { - var fieldValidator = CreateFieldValidator(field); + var valueValidator = CreateValueValidator(field); var partitioning = partitionResolver(field.Partitioning); var partitioningValidators = new Dictionary(); @@ -105,7 +105,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { var optional = partitioning.IsOptional(partitionKey); - partitioningValidators[partitionKey] = (optional, fieldValidator); + partitioningValidators[partitionKey] = (optional, valueValidator); } var typeName = partitioning.ToString()!; @@ -116,29 +116,24 @@ namespace Squidex.Domain.Apps.Core.ValidateContent new ObjectValidator(partitioningValidators, isPartial, typeName), 1)), log); } - private IValidator CreateFieldValidator(IField field) - { - return new FieldValidator(CreateValueValidator(field), field); - } - private IValidator CreateValueValidator(IField field) { - return new AggregateValidator(CreateValueValidators(field), log); + return new FieldValidator(new AggregateValidator(CreateValueValidators(field), log), field); } private IEnumerable CreateContentValidators() { - return factories.SelectMany(x => x.CreateContentValidators(context, CreateFieldValidator)); + return factories.SelectMany(x => x.CreateContentValidators(context, CreateValueValidator)); } private IEnumerable CreateValueValidators(IField field) { - return factories.SelectMany(x => x.CreateValueValidators(context, field, CreateFieldValidator)); + return factories.SelectMany(x => x.CreateValueValidators(context, field, CreateValueValidator)); } private IEnumerable CreateFieldValidators(IField field) { - return factories.SelectMany(x => x.CreateFieldValidators(context, field, CreateFieldValidator)); + return factories.SelectMany(x => x.CreateFieldValidators(context, field, CreateValueValidator)); } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs index 1ce083fa0..b86ff9271 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs @@ -24,15 +24,6 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { sb.Append(property); } - else if (index == 1) - { - if (!property.Equals(InvariantPartitioning.Key, StringComparison.OrdinalIgnoreCase)) - { - sb.Append("("); - sb.Append(property); - sb.Append(")"); - } - } else { if (property[0] != '[') diff --git a/backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs b/backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs index 23a10be07..774a14323 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs @@ -55,7 +55,7 @@ namespace Squidex.Infrastructure.States return (existing.Doc, existing.Version); } - return (default, EtagVersion.NotFound); + return (default!, EtagVersion.NotFound); } } diff --git a/backend/src/Squidex.Infrastructure/Configuration/ConfigurationExtensions.cs b/backend/src/Squidex.Infrastructure/Configuration/ConfigurationExtensions.cs index 6b6d1691a..efc33c2d8 100644 --- a/backend/src/Squidex.Infrastructure/Configuration/ConfigurationExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Configuration/ConfigurationExtensions.cs @@ -16,7 +16,7 @@ namespace Microsoft.Extensions.Configuration { public static T GetOptionalValue(this IConfiguration config, string path, T defaultValue = default) { - var value = config.GetValue(path, defaultValue); + var value = config.GetValue(path, defaultValue!); return value; } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs b/backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs index 1785f2358..0fa79dac7 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs @@ -82,7 +82,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains var handle = new ActionBlock>(async jobs => { - var sender = eventSubscription.Sender; + var sender = eventSubscription?.Sender; foreach (var jobsBySender in jobs.GroupBy(x => x.Sender)) { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs index 5b4319b3a..0a7273c4d 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs @@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Validation failed with internal error.", "my-field") + new ValidationError("Validation failed with internal error.", "my-field.iv") }); } @@ -125,7 +125,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Must be less or equal to 100.", "my-field") + new ValidationError("Must be less or equal to 100.", "my-field.iv") }); } @@ -146,8 +146,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Not a known invariant value.", "my-field(es)"), - new ValidationError("Not a known invariant value.", "my-field(it)") + new ValidationError("Not a known invariant value.", "my-field.es"), + new ValidationError("Not a known invariant value.", "my-field.it") }); } @@ -165,8 +165,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Field is required.", "my-field(de)"), - new ValidationError("Field is required.", "my-field(en)") + new ValidationError("Field is required.", "my-field.de"), + new ValidationError("Field is required.", "my-field.en") }); } @@ -184,7 +184,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Field is required.", "my-field") + new ValidationError("Field is required.", "my-field.iv") }); } @@ -202,7 +202,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Field is required.", "my-field") + new ValidationError("Field is required.", "my-field.iv") }); } @@ -216,14 +216,14 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddField("my-field", new ContentFieldData() .AddValue("de", 1) - .AddValue("xx", 1)); + .AddValue("ru", 1)); await data.ValidateAsync(languagesConfig.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List { - new ValidationError("Not a known language.", "my-field(xx)") + new ValidationError("Not a known language.", "my-field.ru") }); } @@ -267,8 +267,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Not a known language.", "my-field(es)"), - new ValidationError("Not a known language.", "my-field(it)") + new ValidationError("Not a known language.", "my-field.es"), + new ValidationError("Not a known language.", "my-field.it") }); } @@ -306,7 +306,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Must be less or equal to 100.", "my-field") + new ValidationError("Must be less or equal to 100.", "my-field.iv") }); } @@ -327,8 +327,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Not a known invariant value.", "my-field(es)"), - new ValidationError("Not a known invariant value.", "my-field(it)") + new ValidationError("Not a known invariant value.", "my-field.es"), + new ValidationError("Not a known invariant value.", "my-field.it") }); } @@ -370,14 +370,14 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent .AddField("my-field", new ContentFieldData() .AddValue("de", 1) - .AddValue("xx", 1)); + .AddValue("ru", 1)); await data.ValidatePartialAsync(languagesConfig.ToResolver(), errors, schema); errors.Should().BeEquivalentTo( new List { - new ValidationError("Not a known language.", "my-field(xx)") + new ValidationError("Not a known language.", "my-field.ru") }); } @@ -398,8 +398,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Not a known language.", "my-field(es)"), - new ValidationError("Not a known language.", "my-field(it)") + new ValidationError("Not a known language.", "my-field.es"), + new ValidationError("Not a known language.", "my-field.it") }); } @@ -424,8 +424,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent errors.Should().BeEquivalentTo( new List { - new ValidationError("Field is required.", "my-field[1].my-nested"), - new ValidationError("Field is required.", "my-field[3].my-nested") + new ValidationError("Field is required.", "my-field.iv[1].my-nested"), + new ValidationError("Field is required.", "my-field.iv[3].my-nested") }); } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs index 8cc72247d..35b9892bb 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent dataErrors.Should().BeEquivalentTo( new[] { - new ValidationError("Value must not be defined.", "my-array[1].my-ui") + new ValidationError("Value must not be defined.", "my-array.iv[1].my-ui") }); } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs index d1e8ede07..1d5d7c8af 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators await sut.ValidateAsync("hi", errors, updater: c => c.Nested("property").Nested("iv")); errors.Should().BeEquivalentTo( - new[] { "property: Another content with the same value exists." }); + new[] { "property.iv: Another content with the same value exists." }); Assert.Equal("Data.property.iv == 'hi'", filter); } @@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators await sut.ValidateAsync(12.5, errors, updater: c => c.Nested("property").Nested("iv")); errors.Should().BeEquivalentTo( - new[] { "property: Another content with the same value exists." }); + new[] { "property.iv: Another content with the same value exists." }); Assert.Equal("Data.property.iv == 12.5", filter); } diff --git a/frontend/app-config/karma.conf.js b/frontend/app-config/karma.conf.js index f4776e18a..b5411f2bd 100644 --- a/frontend/app-config/karma.conf.js +++ b/frontend/app-config/karma.conf.js @@ -28,10 +28,6 @@ module.exports = function (config) { webpackMiddleware: { stats: 'errors-only' }, - - webpackServer: { - noInfo: true - }, /** * Leave Jasmine Spec Runner output visible in browser. diff --git a/frontend/app-config/karma.coverage.conf.js b/frontend/app-config/karma.coverage.conf.js index 8a9b849bf..9b4dff01e 100644 --- a/frontend/app-config/karma.coverage.conf.js +++ b/frontend/app-config/karma.coverage.conf.js @@ -29,10 +29,6 @@ module.exports = function (config) { stats: 'errors-only' }, - webpackServer: { - noInfo: true - }, - /** * Use a mocha style console reporter, html reporter and the code coverage reporter. */ diff --git a/frontend/app/features/content/pages/content/content-page.component.ts b/frontend/app/features/content/pages/content/content-page.component.ts index 7947d9bd8..e03b603ba 100644 --- a/frontend/app/features/content/pages/content/content-page.component.ts +++ b/frontend/app/features/content/pages/content/content-page.component.ts @@ -9,9 +9,9 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, FieldForm, FieldSection, LanguagesState, ModalModel, ResourceOwner, RootFieldDto, SchemaDetailsDto, SchemasState, TempService, valueAll$, Version } from '@app/shared'; +import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, FieldForm, FieldSection, LanguagesState, ModalModel, ResourceOwner, RootFieldDto, SchemaDetailsDto, SchemasState, TempService, Version } from '@app/shared'; import { Observable, of } from 'rxjs'; -import { debounceTime, filter, tap } from 'rxjs/operators'; +import { filter, tap } from 'rxjs/operators'; @Component({ selector: 'sqx-content-page', @@ -115,11 +115,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD })); this.own( - valueAll$(this.contentForm.form).pipe( - filter(_ => !this.isLoadingContent), - filter(_ => this.contentForm.form.enabled), - debounceTime(2000) - ).subscribe(value => { + this.contentForm.valueChanges.pipe(filter(_ => !this.isLoadingContent && this.contentForm.form.enabled)) + .subscribe(value => { this.autoSaveService.set(this.autoSaveKey, value); })); } @@ -172,7 +169,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD }); } } else { - this.contentForm.submitFailed('i18n:contents.contentNotValid'); + this.contentForm.submitFailed('i18n:contents.contentNotValid', false); } } diff --git a/frontend/app/features/content/shared/forms/array-editor.component.html b/frontend/app/features/content/shared/forms/array-editor.component.html index b2ef3fddb..2071c4114 100644 --- a/frontend/app/features/content/shared/forms/array-editor.component.html +++ b/frontend/app/features/content/shared/forms/array-editor.component.html @@ -1,43 +1,48 @@ -
-
- - - + +
+
+ + + +
-
-
-
- +
+
+ +
+ +
+ + +
- -
- - -
-
+ diff --git a/frontend/app/features/content/shared/forms/array-editor.component.ts b/frontend/app/features/content/shared/forms/array-editor.component.ts index 1e59e0f22..149dd901b 100644 --- a/frontend/app/features/content/shared/forms/array-editor.component.ts +++ b/frontend/app/features/content/shared/forms/array-editor.component.ts @@ -7,7 +7,9 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, Input, OnChanges, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; -import { AppLanguageDto, ArrayFieldPropertiesDto, EditContentForm, FieldArrayForm, FieldArrayItemForm, sorted } from '@app/shared'; +import { AppLanguageDto, ArrayFieldPropertiesDto, disabled$, EditContentForm, FieldArrayForm, FieldArrayItemForm, sorted } from '@app/shared'; +import { combineLatest, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { ArrayItemComponent } from './array-item.component'; @Component({ @@ -38,7 +40,9 @@ export class ArrayEditorComponent implements OnChanges { @ViewChildren(ArrayItemComponent) public children: QueryList; - public maxItems: number; + public isDisabled: Observable; + + public isFull: Observable; public get field() { return this.formModel.field; @@ -48,23 +52,28 @@ export class ArrayEditorComponent implements OnChanges { return this.field.nested.length > 0; } - public get canAdd() { - return this.formModel.items.length < this.maxItems; - } - public ngOnChanges(changes: SimpleChanges) { if (changes['formModel']) { const properties = this.field.properties as ArrayFieldPropertiesDto; - this.maxItems = properties.maxItems || Number.MAX_VALUE; + const maxItems = properties.maxItems || Number.MAX_VALUE; + + this.isDisabled = disabled$(this.formModel.form); + + this.isFull = combineLatest([ + this.isDisabled, + this.formModel.itemChanges + ]).pipe(map(([disabled, items]) => { + return disabled || items.length >= maxItems; + })); } } - public itemRemove(index: number) { + public removeItem(index: number) { this.formModel.removeItemAt(index); } - public itemAdd(value?: FieldArrayItemForm) { + public addItem(value?: FieldArrayItemForm) { this.formModel.addItem(value); } diff --git a/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts b/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts index 4b8d4f678..39b149bd1 100644 --- a/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts +++ b/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts @@ -91,7 +91,7 @@ function createRequest(status: ImportStatus) { } function getError(error: ErrorDto): string { - return error.details[0]; + return error.details[0].originalMessage; } function getSuccess(created: boolean | undefined): string { diff --git a/frontend/app/framework/angular/forms/control-errors.component.ts b/frontend/app/framework/angular/forms/control-errors.component.ts index 2ceaedcca..4dd217379 100644 --- a/frontend/app/framework/angular/forms/control-errors.component.ts +++ b/frontend/app/framework/angular/forms/control-errors.component.ts @@ -124,8 +124,12 @@ export class ControlErrorsComponent extends StatefulComponent implements if (this.control.errors.hasOwnProperty(key)) { const message = formatError(this.localizer, this.displayFieldName, key, this.control.errors[key], this.control.value); - if (message) { + if (Types.isString(message)) { errors.push(message); + } else if (Types.isArray(message)) { + for (const error of message) { + errors.push(error); + } } } } diff --git a/frontend/app/framework/angular/forms/error-formatting.spec.ts b/frontend/app/framework/angular/forms/error-formatting.spec.ts index d5cca057b..9ae0633a4 100644 --- a/frontend/app/framework/angular/forms/error-formatting.spec.ts +++ b/frontend/app/framework/angular/forms/error-formatting.spec.ts @@ -12,6 +12,7 @@ import { ValidatorsEx } from './validators'; describe('formatErrors', () => { const localizer = new LocalizerService({ + 'common.backendError': 'Backend Error', 'users.passwordConfirmValidationMessage': 'Passwords must be the same.', 'validation.between': '{field} must be between \'{min}\' and \'{max}\'.', 'validation.betweenlength': '{field|upper} must have between {minlength} and {maxlength} item(s).', @@ -37,6 +38,38 @@ describe('formatErrors', () => { 'validation.validvalues': '{field|upper} is not a valid value.' }); + it('should format custom', () => { + const error = formatError(localizer, 'field', 'custom', { + errors: [ + 'My Message.' + ] + }, 123); + + expect(error).toEqual(['Backend Error: My Message.']); + }); + + it('should format custom errors', () => { + const error = formatError(localizer, 'field', 'custom', { + errors: [ + 'My Message1.', + 'My Message2.' + ] + }, 123); + + expect(error).toEqual(['Backend Error: My Message1.', 'Backend Error: My Message2.']); + }); + + it('should format custom errors without dots', () => { + const error = formatError(localizer, 'field', 'custom', { + errors: [ + 'My Message1', + 'My Message2' + ] + }, 123); + + expect(error).toEqual(['Backend Error: My Message1.', 'Backend Error: My Message2.']); + }); + it('should format min', () => { const error = validate(1, Validators.min(2)); diff --git a/frontend/app/framework/angular/forms/error-formatting.ts b/frontend/app/framework/angular/forms/error-formatting.ts index 6feec8670..162b72a36 100644 --- a/frontend/app/framework/angular/forms/error-formatting.ts +++ b/frontend/app/framework/angular/forms/error-formatting.ts @@ -5,12 +5,17 @@ * Copyright (c) Sebastian Stehle. All rights r vbeserved */ -import { Types } from '@app/framework/internal'; -import { LocalizerService } from '@app/shared'; +import { LocalizerService, StringHelper, Types } from '@app/framework/internal'; -export function formatError(localizer: LocalizerService, field: string, type: string, properties: any, value: any, errors?: any) { +export function formatError(localizer: LocalizerService, field: string, type: string, properties: any, value: any, errors?: any): string | readonly string[] { type = type.toLowerCase(); + if (type === 'custom' && Types.isArrayOfString(properties.errors)) { + const backendError = localizer.get('common.backendError'); + + return properties.errors.map((error: string) => StringHelper.appendLast(`${backendError}: ${error}`, '.')); + } + if (Types.isString(value)) { if (type === 'minlength') { type = 'minlengthstring'; diff --git a/frontend/app/framework/angular/forms/error-validator.spec.ts b/frontend/app/framework/angular/forms/error-validator.spec.ts new file mode 100644 index 000000000..055fdec66 --- /dev/null +++ b/frontend/app/framework/angular/forms/error-validator.spec.ts @@ -0,0 +1,184 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormArray, FormControl, FormGroup } from '@angular/forms'; +import { ErrorDto } from '@app/shared'; +import { ErrorValidator } from './error-validator'; + +describe('ErrorValidator', () => { + const validator = new ErrorValidator(); + + const control = new FormGroup({ + nested1: new FormArray([ + new FormGroup({ + nested2: new FormControl() + }) + ]) + }); + + beforeEach(() => { + control.reset([]); + }); + + it('should return no message when error is null', () => { + validator.setError(null); + + const error = validator.validator(control); + + expect(error).toBeNull(); + }); + + it('should return no message when error does not match', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1Property: My Error.' + ])); + + const error = validator.validator(control.get('nested1')!); + + expect(error).toBeNull(); + }); + + it('should return matching error', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'other, nested1: My Error.' + ])); + + const error = validator.validator(control.get('nested1')!); + + expect(error).toEqual({ + custom: { + errors: ['My Error.'] + } + }); + }); + + it('should return matching error twice if value does not change', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1: My Error.' + ])); + + const error1 = validator.validator(control.get('nested1')!); + const error2 = validator.validator(control.get('nested1')!); + + expect(error1).toEqual({ + custom: { + errors: ['My Error.'] + } + }); + + expect(error2).toEqual({ + custom: { + errors: ['My Error.'] + } + }); + }); + + it('should not return matching error again if value has changed', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1[1].nested2: My Error.' + ])); + + const nested = control.get('nested1.0.nested2'); + + nested?.setValue('a'); + const error1 = validator.validator(nested!); + + nested?.setValue('b'); + const error2 = validator.validator(nested!); + + expect(error1).toEqual({ + custom: { + errors: ['My Error.'] + } + }); + + expect(error2).toBeNull(); + }); + + it('should not return matching error again if value has changed to initial', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1[1].nested2: My Error.' + ])); + + const nested = control.get('nested1.0.nested2'); + + nested?.setValue('a'); + const error1 = validator.validator(nested!); + + nested?.setValue('b'); + const error2 = validator.validator(nested!); + + nested?.setValue('a'); + const error3 = validator.validator(nested!); + + expect(error1).toEqual({ + custom: { + errors: ['My Error.'] + } + }); + + expect(error2).toBeNull(); + expect(error3).toBeNull(); + }); + + it('should return matching errors', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1: My Error1.', + 'nested1: My Error2.' + ])); + + const error = validator.validator(control.get('nested1')!); + + expect(error).toEqual({ + custom: { + errors: ['My Error1.', 'My Error2.'] + } + }); + }); + + it('should return deeply matching error', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1[1].nested2: My Error.' + ])); + + const error = validator.validator(control.get('nested1.0.nested2')!); + + expect(error).toEqual({ + custom: { + errors: ['My Error.'] + } + }); + }); + + it('should return partial matching error', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1[1].nested2: My Error.' + ])); + + const error = validator.validator(control.get('nested1.0')!); + + expect(error).toEqual({ + custom: { + errors: ['nested2: My Error.'] + } + }); + }); + + it('should return partial matching index error', () => { + validator.setError(new ErrorDto(500, 'Error', [ + 'nested1[1].nested2: My Error.' + ])); + + const error = validator.validator(control.get('nested1')!); + + expect(error).toEqual({ + custom: { + errors: ['[1].nested2: My Error.'] + } + }); + }); +}); \ No newline at end of file diff --git a/frontend/app/framework/angular/forms/error-validator.ts b/frontend/app/framework/angular/forms/error-validator.ts new file mode 100644 index 000000000..d8931206e --- /dev/null +++ b/frontend/app/framework/angular/forms/error-validator.ts @@ -0,0 +1,76 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ValidatorFn } from '@angular/forms'; +import { ErrorDto } from '@app/framework/internal'; +import { getControlPath } from './forms-helper'; + +export class ErrorValidator { + private values: { [path: string]: { value: any } } = {}; + private error: ErrorDto | undefined | null; + + public validator: ValidatorFn = control => { + if (!this.error) { + return null; + } + + const path = getControlPath(control, true); + + if (!path) { + return null; + } + + const value = control.value; + + const current = this.values[path]; + + if (current && current.value !== value) { + this.values[path] = { value }; + return null; + } + + const errors: string[] = []; + + for (const details of this.error.details) { + for (const property of details.properties) { + if (property.startsWith(path)) { + const subProperty = property.substr(path.length); + + const first = subProperty[0]; + + if (!first) { + errors.push(details.message); + break; + } else if (first === '[') { + errors.push(`${subProperty}: ${details.message}`); + break; + } else if (first === '.') { + errors.push(`${subProperty.substr(1)}: ${details.message}`); + break; + } + } + } + } + + if (errors.length > 0) { + this.values[path] = { value }; + + return { + custom: { + errors + } + }; + } + + return null; + } + + public setError(error: ErrorDto | undefined | null) { + this.values = {}; + this.error = error; + } +} \ No newline at end of file diff --git a/frontend/app/framework/angular/forms/forms-helper.spec.ts b/frontend/app/framework/angular/forms/forms-helper.spec.ts index 86725ff02..a3263c504 100644 --- a/frontend/app/framework/angular/forms/forms-helper.spec.ts +++ b/frontend/app/framework/angular/forms/forms-helper.spec.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { FormControl, Validators } from '@angular/forms'; -import { value$ } from './forms-helper'; +import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; +import { getControlPath, value$ } from './forms-helper'; describe('FormHelpers', () => { describe('value$', () => { @@ -43,4 +43,64 @@ describe('FormHelpers', () => { expect(values).toEqual(['1', '2', '3', '4']); }); }); + + describe('getControlPath', () => { + it('should calculate path for standalone control', () => { + const control = new FormControl(); + + const path = getControlPath(control); + + expect(path).toEqual(''); + }); + + it('should calculate path for nested control', () => { + const control = new FormGroup({ + nested: new FormControl() + }); + + const path = getControlPath(control.get('nested')); + + expect(path).toEqual('nested'); + }); + + it('should calculate path for deeply nested control', () => { + const control = new FormGroup({ + nested1: new FormGroup({ + nested2: new FormControl() + }) + }); + + const path = getControlPath(control.get('nested1.nested2')); + + expect(path).toEqual('nested1.nested2'); + }); + + it('should calculate path for deeply nested array control', () => { + const control = new FormGroup({ + nested1: new FormArray([ + new FormGroup({ + nested2: new FormControl() + }) + ]) + }); + + const path = getControlPath(control.get('nested1.0.nested2')); + + expect(path).toEqual('nested1.0.nested2'); + }); + + it('should calculate api compatible path for deeply nested array control', () => { + const control = new FormGroup({ + nested1: new FormArray([ + new FormGroup({ + nested2: new FormControl() + }) + ]) + }); + + const path = getControlPath(control.get('nested1.0.nested2'), true); + + expect(path).toEqual('nested1[1].nested2'); + }); + }); }); \ No newline at end of file diff --git a/frontend/app/framework/angular/forms/forms-helper.ts b/frontend/app/framework/angular/forms/forms-helper.ts index f0ca9f35f..a376acbe2 100644 --- a/frontend/app/framework/angular/forms/forms-helper.ts +++ b/frontend/app/framework/angular/forms/forms-helper.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { AbstractControl, FormArray, FormGroup } from '@angular/forms'; +import { AbstractControl, FormArray, FormGroup, ValidatorFn } from '@angular/forms'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { Types } from './../../utils/types'; @@ -24,6 +24,73 @@ export function formControls(form: AbstractControl): ReadonlyArray { + return form.statusChanges.pipe(map(() => form.disabled), startWith(form.disabled), distinctUntilChanged()); +} + export function invalid$(form: AbstractControl): Observable { return form.statusChanges.pipe(map(() => form.invalid), startWith(form.invalid), distinctUntilChanged()); } @@ -52,4 +119,30 @@ export function getRawValue(form: AbstractControl): any { } else { return form.value; } +} + +export function hasNonCustomError(form: AbstractControl) { + if (form.errors) { + for (const key in form.errors) { + if (key !== 'custom') { + return true; + } + } + } + + if (Types.is(form, FormGroup)) { + for (const key in form.controls) { + if (hasNonCustomError(form.controls[key])) { + return true; + } + } + } else if (Types.is(form, FormArray)) { + for (const control of form.controls) { + if (hasNonCustomError(control)) { + return true; + } + } + } else { + return false; + } } \ No newline at end of file diff --git a/frontend/app/framework/angular/forms/model.ts b/frontend/app/framework/angular/forms/model.ts new file mode 100644 index 000000000..7e758bed4 --- /dev/null +++ b/frontend/app/framework/angular/forms/model.ts @@ -0,0 +1,148 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { ErrorDto, Types } from '@app/framework/internal'; +import { State } from './../../state'; +import { ErrorValidator } from './error-validator'; +import { addValidator, getRawValue, hasNonCustomError, updateAll } from './forms-helper'; + +export interface FormState { + // The number of submits. + submitCount: number; + + // True, when the submitting is in progress. + submitting: boolean; + + // The current remote error. + error?: ErrorDto | null; +} + +export class Form { + private readonly state = new State({ submitCount: 0, submitting: false }); + private readonly errorValidator = new ErrorValidator(); + + public submitCount = + this.state.project(s => s.submitCount); + + public submitted = + this.state.project(s => s.submitCount > 0); + + public submitting = + this.state.project(s => s.submitting); + + public error = + this.state.project(s => s.error); + + public get remoteValidator(): ValidatorFn { + return this.errorValidator.validator; + } + + constructor( + public readonly form: T + ) { + addValidator(form, this.errorValidator.validator); + } + + public setEnabled(isEnabled: boolean) { + if (isEnabled) { + this.enable(); + } else { + this.disable(); + } + } + + protected enable() { + this.form.enable(); + } + + protected disable() { + this.form.disable(); + } + + protected setValue(value?: Partial) { + if (value) { + this.form.reset(this.transformLoad(value)); + } else { + this.form.reset(); + } + } + + protected transformLoad(value: Partial): any { + return value; + } + + protected transformSubmit(value: any): TOut { + return value; + } + + public load(value: Partial | undefined) { + this.state.resetState(); + + this.setValue(value); + } + + public submit(): TOut | null { + this.updateSubmitState(null, true); + + this.form.markAllAsTouched(); + + if (!hasNonCustomError(this.form)) { + const value = this.transformSubmit(getRawValue(this.form)); + + if (value) { + this.disable(); + } + + return value; + } else { + return null; + } + } + + public submitCompleted(options?: { newValue?: TOut, noReset?: boolean }) { + this.updateSubmitState(null, false); + + this.enable(); + + if (options && options.noReset) { + this.form.markAsPristine(); + } else { + this.setValue(options?.newValue); + } + } + + public submitFailed(errorOrMessage?: string | ErrorDto, replaceDetails = true) { + this.updateSubmitState(errorOrMessage, false, replaceDetails); + + this.enable(); + } + + private updateSubmitState(errorOrMessage: string | ErrorDto | null | undefined, submitting: boolean, replaceDetails = true) { + const error = getError(errorOrMessage); + + this.state.next(s => ({ + submitCount: s.submitCount + (submitting ? 1 : 0), + submitting, + error + })); + + if (replaceDetails) { + this.errorValidator.setError(error); + + updateAll(this.form); + } + } +} + +function getError(error?: string | ErrorDto | null): ErrorDto | undefined | null { + if (Types.isString(error)) { + return new ErrorDto(500, error); + } + + return error; +} \ No newline at end of file diff --git a/frontend/app/framework/angular/forms/validators.ts b/frontend/app/framework/angular/forms/validators.ts index b11ecb2bc..51bbc9d9b 100644 --- a/frontend/app/framework/angular/forms/validators.ts +++ b/frontend/app/framework/angular/forms/validators.ts @@ -139,15 +139,15 @@ export module ValidatorsEx { } } - export function validValues(values: ReadonlyArray): ValidatorFn { - if (!values) { + export function validValues(allowed: ReadonlyArray): ValidatorFn { + if (!allowed || allowed.length === 0) { return Validators.nullValidator; } return (control: AbstractControl) => { - const n: T = control.value; + const value: T = control.value; - if (values.indexOf(n) < 0) { + if (allowed.indexOf(value) < 0) { return { validvalues: false }; } @@ -155,18 +155,18 @@ export module ValidatorsEx { }; } - export function validArrayValues(values: ReadonlyArray): ValidatorFn { - if (!values) { + export function validArrayValues(allowed: ReadonlyArray): ValidatorFn { + if (!allowed || allowed.length === 0) { return Validators.nullValidator; } return (control: AbstractControl) => { - const ns: T[] = control.value; + const values: T[] = control.value; - if (ns) { - for (const n of ns) { - if (values.indexOf(n) < 0) { - return { validarrayvalues: { invalidvalue: n } }; + if (values) { + for (const value of values) { + if (allowed.indexOf(value) < 0) { + return { validarrayvalues: { invalidvalue: value } }; } } } @@ -181,14 +181,14 @@ export module ValidatorsEx { return null; } - const a: string[] = control.value; - const unique: { [key: string]: boolean } = {}; + const values: string[] = control.value; + const valuesUnique: { [key: string]: boolean } = {}; - for (const value of a) { - if (unique[value]) { + for (const value of values) { + if (valuesUnique[value]) { return { uniquestrings: false }; } else { - unique[value] = true; + valuesUnique[value] = true; } } diff --git a/frontend/app/framework/declarations.ts b/frontend/app/framework/declarations.ts index 27c7541c4..c9647f2a6 100644 --- a/frontend/app/framework/declarations.ts +++ b/frontend/app/framework/declarations.ts @@ -30,6 +30,7 @@ export * from './angular/forms/form-error.component'; export * from './angular/forms/form-hint.component'; 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/transform-input.directive'; export * from './angular/forms/validators'; diff --git a/frontend/app/framework/state.ts b/frontend/app/framework/state.ts index d62f22e1f..24fe34164 100644 --- a/frontend/app/framework/state.ts +++ b/frontend/app/framework/state.ts @@ -5,11 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { AbstractControl } from '@angular/forms'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; -import { getRawValue } from './angular/forms/forms-helper'; -import { ErrorDto } from './utils/error'; import { ResourceLinks } from './utils/hateos'; import { Types } from './utils/types'; @@ -17,105 +14,6 @@ export type Mutable = { -readonly [P in keyof T ]: T[P] }; -export interface FormState { - submitted: boolean; - - error?: ErrorDto | null; -} - -export class Form { - private readonly state = new State({ submitted: false }); - - public submitted = - this.state.project(s => s.submitted); - - public error = - this.state.project(s => s.error); - - constructor( - public readonly form: T - ) { - } - - public setEnabled(isEnabled: boolean) { - if (isEnabled) { - this.enable(); - } else { - this.disable(); - } - } - - protected enable() { - this.form.enable(); - } - - protected disable() { - this.form.disable(); - } - - protected setValue(value?: Partial) { - if (value) { - this.form.reset(this.transformLoad(value)); - } else { - this.form.reset(); - } - } - - protected transformLoad(value: Partial): any { - return value; - } - - protected transformSubmit(value: any): TOut { - return value; - } - - public load(value: Partial | undefined) { - this.state.next({ submitted: false, error: null }); - - this.setValue(value); - } - - public submit(): TOut | null { - this.form.markAllAsTouched(); - - this.state.next({ submitted: true, error: null }); - - if (this.form.valid) { - const value = this.transformSubmit(getRawValue(this.form)); - - if (value) { - this.disable(); - } - - return value; - } else { - return null; - } - } - - public submitCompleted(options?: { newValue?: TOut, noReset?: boolean }) { - this.state.next({ submitted: false, error: null }); - - this.enable(); - - if (options && options.noReset) { - this.form.markAsPristine(); - } else { - this.setValue(options?.newValue); - } - } - - public submitFailed(error?: string | ErrorDto) { - if (Types.isString(error)) { - error = new ErrorDto(500, error); - } - - this.state.next({ submitted: false, error }); - - this.enable(); - } -} - export class Model { public with(value: Partial, validOnly = false): T { return this.clone(value, validOnly); diff --git a/frontend/app/framework/utils/error.ts b/frontend/app/framework/utils/error.ts index f2f9da5df..6eec29457 100644 --- a/frontend/app/framework/utils/error.ts +++ b/frontend/app/framework/utils/error.ts @@ -6,26 +6,59 @@ */ import { LocalizerService } from './../services/localizer.service'; +import { StringHelper } from './string-helper'; +import { Types } from './types'; + +export class ErrorDetailsDto { + public readonly message: string; + public readonly properties: ReadonlyArray = []; + + constructor( + public readonly originalMessage: string + ) { + const propertySeparator = originalMessage.indexOf(': '); + + if (propertySeparator > 0 && propertySeparator < originalMessage.length - 1) { + this.properties = + originalMessage + .substr(0, propertySeparator) + .split(', ') + .map(x => x.trim()).filter(x => x.length > 0); + + this.message = originalMessage.substr(propertySeparator + 2); + } else { + this.message = originalMessage; + } + + } +} export class ErrorDto { + public readonly details: ReadonlyArray = []; + constructor( public readonly statusCode: number, public readonly message: string, - public readonly details: ReadonlyArray = [], + details?: ReadonlyArray | ReadonlyArray, public readonly inner?: any ) { + if (Types.isArrayOfString(details)) { + this.details = details.map(x => new ErrorDetailsDto(x)); + } else if (Types.isArray(details)) { + this.details = details; + } } public translate(localizer: LocalizerService) { - let result = appendLast(localizer.getOrKey(this.message), '.'); + let result = StringHelper.appendLast(localizer.getOrKey(this.message), '.'); if (this.details && this.details.length > 0) { result += '\n\n'; for (const detail of this.details) { - const translated = localizer.getOrKey(detail); + const translated = localizer.getOrKey(detail.originalMessage); - result += ` * ${appendLast(translated, '.')}\n`; + result += ` * ${StringHelper.appendLast(translated, '.')}\n`; } } @@ -35,14 +68,4 @@ export class ErrorDto { public toString() { return `ErrorDto(${JSON.stringify(this)})`; } -} - -function appendLast(row: string, char: string) { - const last = row[row.length - 1]; - - if (last !== char) { - return row + char; - } else { - return row; - } } \ No newline at end of file diff --git a/frontend/app/framework/utils/string-helper.spec.ts b/frontend/app/framework/utils/string-helper.spec.ts index edc12badd..9b35cddda 100644 --- a/frontend/app/framework/utils/string-helper.spec.ts +++ b/frontend/app/framework/utils/string-helper.spec.ts @@ -39,6 +39,14 @@ describe('StringHelper', () => { expect(StringHelper.firstNonEmpty(null!, undefined!, '')).toBe(''); }); + it('should append dot if not added', () => { + expect(StringHelper.appendLast('text', '.')).toBe('text.'); + }); + + it('should not append dot if already added', () => { + expect(StringHelper.appendLast('text.', '.')).toBe('text.'); + }); + it('should append query string to url when url already contains query', () => { const url = StringHelper.appendToUrl('http://squidex.io?query=value', 'other', 1); diff --git a/frontend/app/framework/utils/string-helper.ts b/frontend/app/framework/utils/string-helper.ts index 39664ec0e..b3b4feb89 100644 --- a/frontend/app/framework/utils/string-helper.ts +++ b/frontend/app/framework/utils/string-helper.ts @@ -35,4 +35,14 @@ export module StringHelper { return url; } + + export function appendLast(row: string, char: string) { + const last = row[row.length - 1]; + + if (last !== char) { + return row + char; + } else { + return row; + } + } } \ No newline at end of file diff --git a/frontend/app/shared/components/assets/asset.component.html b/frontend/app/shared/components/assets/asset.component.html index 76e72dfdc..2ae2f7f5a 100644 --- a/frontend/app/shared/components/assets/asset.component.html +++ b/frontend/app/shared/components/assets/asset.component.html @@ -120,7 +120,7 @@
-
+
{{asset.fileName}} diff --git a/frontend/app/shared/internal.ts b/frontend/app/shared/internal.ts index 7cd6d124b..54d68e61c 100644 --- a/frontend/app/shared/internal.ts +++ b/frontend/app/shared/internal.ts @@ -48,6 +48,7 @@ export * from './state/clients.state'; export * from './state/comments.form'; export * from './state/comments.state'; export * from './state/contents.forms'; +export * from './state/contents.forms-helpers'; export * from './state/contents.forms.visitors'; export * from './state/contents.state'; export * from './state/contributors.forms'; diff --git a/frontend/app/shared/state/contents.forms-helpers.ts b/frontend/app/shared/state/contents.forms-helpers.ts index cc09196d9..ed7c89535 100644 --- a/frontend/app/shared/state/contents.forms-helpers.ts +++ b/frontend/app/shared/state/contents.forms-helpers.ts @@ -5,9 +5,10 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +import { AbstractControl, ValidatorFn } from '@angular/forms'; import { BehaviorSubject, Observable } from 'rxjs'; import { AppLanguageDto } from './../services/app-languages.service'; -import { FieldRule, RootFieldDto } from './../services/schemas.service'; +import { FieldDto, FieldRule, RootFieldDto } from './../services/schemas.service'; import { fieldInvariant } from './../services/schemas.types'; export abstract class Hidden { @@ -31,7 +32,8 @@ export abstract class Hidden { export class FieldSection extends Hidden { constructor( public readonly separator: TSeparator | undefined, - public readonly fields: ReadonlyArray + public readonly fields: ReadonlyArray, + public readonly remoteValidator?: ValidatorFn ) { super(); } @@ -70,6 +72,8 @@ export class PartitionConfig { } } +type RuleContext = { data: any, itemData?: any, user?: any }; + export class CompiledRule { private readonly function: Function; @@ -91,11 +95,82 @@ export class CompiledRule { } } - public eval(user: any, data: any, itemData?: any) { + public eval(context: RuleContext) { try { - return this.function(user, data, itemData); + return this.function(context.user, context.data, context.itemData); } catch { return false; } } -} \ No newline at end of file +} + +export type AbstractContentFormState = { + isDisabled?: boolean; + isHidden?: boolean; + isRequired?: boolean +}; + +export abstract class AbstractContentForm extends Hidden { + private readonly disabled$ = new BehaviorSubject(false); + + public get disabled() { + return this.disabled$.value; + } + + public get disabledChanges(): Observable { + return this.disabled$; + } + + constructor( + public readonly field: T, + public readonly form: TForm, + public readonly isOptional: boolean, + private readonly rules?: ReadonlyArray + ) { + super(); + } + + public updateState(context: RuleContext, 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; + } + } + } + } + + this.setHidden(state.isHidden); + + if (state.isDisabled !== this.form.disabled) { + if (state.isDisabled) { + this.form.disable(SELF); + } else { + this.form.enable(SELF); + } + } + + this.updateCustomState(context, state); + } + + protected updateCustomState(_context: RuleContext, _state: AbstractContentFormState) { + return; + } + + public prepareLoad(_data: any) { + return; + } +} + +const SELF = { onlySelf: true }; \ No newline at end of file diff --git a/frontend/app/shared/state/contents.forms.spec.ts b/frontend/app/shared/state/contents.forms.spec.ts index 930697c63..b4622267d 100644 --- a/frontend/app/shared/state/contents.forms.spec.ts +++ b/frontend/app/shared/state/contents.forms.spec.ts @@ -726,10 +726,10 @@ describe('ContentForm', () => { field: 'field1', action: 'Require', condition: 'data.field2.iv < 100' }]); - const field1 = contentForm.get('field1'); + const field1 = contentForm.get('field1')!.get('iv'); const field2 = contentForm.get('field2'); - expect(field1!.form.disabled).toBeFalsy(); + expect(field1!.form.valid).toBeFalsy(); contentForm.load({ field2: { @@ -753,6 +753,8 @@ describe('ContentForm', () => { }]); const field1 = contentForm.get('field1'); + const field1_iv = contentForm.get('field1')!.get('iv'); + const field2 = contentForm.get('field2'); expect(field1!.form.disabled).toBeFalsy(); @@ -767,10 +769,12 @@ describe('ContentForm', () => { }); expect(field1!.form.disabled).toBeTruthy(); + expect(field1_iv!.form.disabled).toBeTruthy(); field2?.get('iv')!.form.setValue(99); expect(field1!.form.disabled).toBeFalsy(); + expect(field1_iv!.form.disabled).toBeFalsy(); }); it('should hide field based on condition', () => { @@ -782,6 +786,8 @@ describe('ContentForm', () => { }]); const field1 = contentForm.get('field1'); + const field1_iv = contentForm.get('field1')!.get('iv'); + const field2 = contentForm.get('field2'); expect(field1!.hidden).toBeFalsy(); @@ -796,10 +802,13 @@ describe('ContentForm', () => { }); expect(field1!.hidden).toBeTruthy(); + expect(field1_iv!.hidden).toBeTruthy(); field2?.get('iv')!.form.setValue(99); expect(field1!.hidden).toBeFalsy(); + expect(field1_iv!.hidden).toBeFalsy(); + }); it('should disable nested fields based on condition', () => { @@ -1002,7 +1011,7 @@ describe('ContentForm', () => { let value: any; - simpleForm.value.subscribe(v => { + simpleForm.valueChanges.subscribe(v => { value = v; }); @@ -1057,7 +1066,7 @@ describe('ContentForm', () => { function createForm(fields: RootFieldDto[], fieldRules: FieldRule[] = []) { return new EditContentForm(languages, - createSchema({ fields, fieldRules })); + createSchema({ fields, fieldRules }), {}, 0); } }); diff --git a/frontend/app/shared/state/contents.forms.ts b/frontend/app/shared/state/contents.forms.ts index 4f7d14a83..76f753ae1 100644 --- a/frontend/app/shared/state/contents.forms.ts +++ b/frontend/app/shared/state/contents.forms.ts @@ -7,14 +7,15 @@ // tslint:disable: readonly-array -import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { Form, Types, valueAll$ } from '@app/framework'; -import { BehaviorSubject } from 'rxjs'; +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, NestedFieldDto, RootFieldDto, SchemaDetailsDto, TableField } from './../services/schemas.service'; +import { NestedFieldDto, RootFieldDto, SchemaDetailsDto, TableField } from './../services/schemas.service'; import { fieldInvariant } from './../services/schemas.types'; -import { CompiledRule, FieldSection, Hidden, PartitionConfig } from './contents.forms-helpers'; +import { AbstractContentForm, AbstractContentFormState, CompiledRule, FieldSection, PartitionConfig } from './contents.forms-helpers'; import { FieldDefaultValue, FieldsValidators } from './contents.forms.visitors'; export { FieldSection } from './contents.forms-helpers'; @@ -77,14 +78,21 @@ export class PatchContentForm extends Form { export class EditContentForm extends Form { private readonly fields: { [name: string]: FieldForm } = {}; + private readonly valueChange$ = new BehaviorSubject(this.form.value); private initialData: any; public readonly sections: ReadonlyArray>; - public readonly value = new BehaviorSubject(this.form.value); + public get valueChanges(): Observable { + return this.valueChange$; + } + + public get value() { + return this.valueChange$.value; + } constructor(languages: ReadonlyArray, schema: SchemaDetailsDto, - private readonly user: any = {} + private readonly user: any = {}, debounce = 100 ) { super(new FormGroup({})); @@ -98,7 +106,7 @@ export class EditContentForm extends Form { for (const field of schema.fields) { if (field.properties.isContentField) { - const child = new FieldForm(field, compiledPartitions, compiledConditions); + const child = new FieldForm(field, compiledPartitions, compiledConditions, this.remoteValidator); currentFields.push(child); @@ -106,7 +114,7 @@ export class EditContentForm extends Form { this.form.setControl(field.name, child.form); } else { - sections.push(new FieldSection(currentSeparator, currentFields)); + sections.push(new FieldSection(currentSeparator, currentFields, this.remoteValidator)); currentFields = []; currentSeparator = field; @@ -114,13 +122,21 @@ export class EditContentForm extends Form { } if (currentFields.length > 0) { - sections.push(new FieldSection(currentSeparator, currentFields)); + sections.push(new FieldSection(currentSeparator, currentFields, this.remoteValidator)); } this.sections = sections; - valueAll$(this.form).subscribe(value => { - this.value.next(value); + let change$ = valueAll$(this.form); + + if (debounce > 0) { + change$ = change$.pipe(debounceTime(debounce), onErrorResumeNext()); + } else { + change$ = change$.pipe(onErrorResumeNext()); + } + + change$.subscribe(value => { + this.valueChange$.next(value); this.updateState(value); }); @@ -137,15 +153,11 @@ export class EditContentForm extends Form { } public hasChanged() { - const currentValue = this.form.getRawValue(); - - return !Types.equals(this.initialData, currentValue, true); + return !Types.equals(this.initialData, this.value, true); } public hasChanges(changes: any) { - const currentValue = this.form.getRawValue(); - - return !Types.equals(changes, currentValue, true); + return !Types.equals(changes, this.value, true); } public load(value: any, isInitial?: boolean) { @@ -162,25 +174,27 @@ export class EditContentForm extends Form { } } - public submitCompleted(options?: { newValue?: any, noReset?: boolean }) { - super.submitCompleted(options); - - this.updateInitialData(); - } - protected disable() { - this.form.disable(NO_EMIT); + this.form.disable(); } protected enable() { - this.form.enable(NO_EMIT_SELF); + this.form.enable({ onlySelf: true }); - this.updateState(this.form.getRawValue()); + this.updateState(this.value); + } + + public submitCompleted(options?: { newValue?: any, noReset?: boolean }) { + super.submitCompleted(options); + + this.updateInitialData(); } private updateState(data: any) { + const context = { user: this.user, data }; + for (const field of Object.values(this.fields)) { - field.updateState(this.user, data); + field.updateState(context, { isDisabled: this.form.disabled }); } for (const section of this.sections) { @@ -193,72 +207,20 @@ export class EditContentForm extends Form { } } -export abstract class AbstractContentForm extends Hidden { - constructor( - public readonly field: T, - public readonly form: TForm, - public readonly isOptional: boolean, - private readonly rules?: CompiledRule[] - ) { - super(); - } - - public updateState(user: any, data: any, itemData?: any) { - const state = { - isDisabled: this.field.isDisabled, - isHidden: false, - isRequired: this.field.properties.isRequired && !this.isOptional - }; - - if (this.rules) { - for (const rule of this.rules) { - if (rule.eval(user, data, itemData)) { - if (rule.action === 'Disable') { - state.isDisabled = true; - } else if (rule.action === 'Hide') { - state.isHidden = true; - } else { - state.isRequired = true; - } - } - } - } - - this.setHidden(state.isHidden); - - if (state.isDisabled !== this.form.disabled) { - if (state.isDisabled) { - this.form.disable(NO_EMIT); - } else { - this.form.enable(NO_EMIT_SELF); - } - } - - this.updateCustomState(state, user, data, itemData); - } - - protected updateCustomState(_state: State, _user: any, _data: any, _itemData: any) { - return; - } - - public prepareLoad(_data: any) { - return; - } -} - export class FieldForm extends AbstractContentForm { private readonly partitions: { [partition: string]: (FieldValueForm | FieldArrayForm) } = {}; private isRequired: boolean; - constructor(field: RootFieldDto, partitions: PartitionConfig, rules: CompiledRule[] + constructor(field: RootFieldDto, partitions: PartitionConfig, rules: CompiledRule[], + private readonly remoteValidator?: ValidatorFn ) { super(field, new FormGroup({}), false, FieldForm.buildRules(field, rules)); for (const { key, isOptional } of partitions.getAll(field)) { const child = field.isArray ? - new FieldArrayForm(field, isOptional, rules) : - new FieldValueForm(field, isOptional); + new FieldArrayForm(field, isOptional, rules, this.remoteValidator) : + new FieldValueForm(field, isOptional, this.remoteValidator); this.partitions[key] = child; @@ -284,7 +246,9 @@ export class FieldForm extends AbstractContentForm { } } - protected updateCustomState({ isRequired }: State, user: any, data: any) { + protected updateCustomState(context: any, state: AbstractContentFormState) { + const isRequired = state.isRequired === true; + if (this.isRequired !== isRequired) { this.isRequired = isRequired; @@ -298,6 +262,10 @@ export class FieldForm extends AbstractContentForm { validators = validators.filter(x => x !== Validators.required); } + if (this.remoteValidator) { + validators.push(this.remoteValidator); + } + partition.form.setValidators(validators); partition.form.updateValueAndValidity(); } @@ -305,7 +273,7 @@ export class FieldForm extends AbstractContentForm { } for (const partition of Object.values(this.partitions)) { - partition.updateState(user, data); + partition.updateState(context, state); } } @@ -327,25 +295,43 @@ export class FieldForm extends AbstractContentForm { } export class FieldValueForm extends AbstractContentForm { - constructor(field: RootFieldDto, isOptional: boolean + constructor(field: RootFieldDto, isOptional: boolean, + remoteValidator?: ValidatorFn ) { - super(field, FieldValueForm.buildControl(field, isOptional), isOptional); + super(field, FieldValueForm.buildControl(field, isOptional, remoteValidator), isOptional); } - private static buildControl(field: RootFieldDto, isOptional: boolean) { + private static buildControl(field: RootFieldDto, isOptional: boolean, remoteValidator?: ValidatorFn) { const value = FieldDefaultValue.get(field); const validators = FieldsValidators.create(field, isOptional); + if (remoteValidator) { + validators.push(remoteValidator); + } + return new FormControl(value, { validators }); } } export class FieldArrayForm extends AbstractContentForm { - public items: FieldArrayItemForm[] = []; + private readonly item$ = new BehaviorSubject>([]); + + public get itemChanges(): Observable> { + return this.item$; + } + + public get items() { + return this.item$.value; + } + + public set items(value: ReadonlyArray) { + this.item$.next(value); + } constructor(field: RootFieldDto, isOptional: boolean, - private readonly allRules: CompiledRule[] + private readonly allRules: CompiledRule[], + private readonly remoteValidator?: ValidatorFn ) { super(field, FieldArrayForm.buildControl(field, isOptional), isOptional); } @@ -355,15 +341,15 @@ export class FieldArrayForm extends AbstractContentForm } public addItem(source?: FieldArrayItemForm) { - const child = new FieldArrayItemForm(this.field, this.isOptional, this.allRules, source); + const child = new FieldArrayItemForm(this.field, this.isOptional, this.allRules, source, this.remoteValidator); - this.items.push(child); + this.items = [...this.items, child]; this.form.push(child.form); } public removeItemAt(index: number) { - this.items.splice(index, 1); + this.items = this.items.filter((_, i) => i !== index); this.form.removeAt(index); } @@ -385,9 +371,9 @@ export class FieldArrayForm extends AbstractContentForm } } - protected updateCustomState(_: State, user: any, data: any) { + protected updateCustomState(context: any, state: AbstractContentFormState) { for (const item of this.items) { - item.updateState(user, data); + item.updateState(context, state); } } @@ -411,11 +397,12 @@ export class FieldArrayForm extends AbstractContentForm } export class FieldArrayItemForm extends AbstractContentForm { - private fields: { [key: string]: FieldArrayItemValueForm } = {}; + private readonly fields: { [key: string]: FieldArrayItemValueForm } = {}; public readonly sections: ReadonlyArray>; - constructor(field: RootFieldDto, isOptional: boolean, allRules: CompiledRule[], source?: FieldArrayItemForm + constructor(field: RootFieldDto, isOptional: boolean, allRules: CompiledRule[], source: FieldArrayItemForm | undefined, + private readonly remoteValidator?: ValidatorFn ) { super(field, new FormGroup({}), isOptional); @@ -426,7 +413,7 @@ export class FieldArrayItemForm extends AbstractContentForm(currentSeparator, currentFields)); + sections.push(new FieldSection(currentSeparator, currentFields, this.remoteValidator)); currentFields = []; currentSeparator = nestedField; @@ -442,7 +429,7 @@ export class FieldArrayItemForm extends AbstractContentForm 0) { - sections.push(new FieldSection(currentSeparator, currentFields)); + sections.push(new FieldSection(currentSeparator, currentFields, this.remoteValidator)); } this.sections = sections; @@ -452,11 +439,11 @@ export class FieldArrayItemForm extends AbstractContentForm { private isRequired = false; - constructor(field: NestedFieldDto, parent: RootFieldDto, rules: CompiledRule[], isOptional: boolean, source?: FieldArrayItemForm + constructor(field: NestedFieldDto, parent: RootFieldDto, rules: CompiledRule[], isOptional: boolean, source: FieldArrayItemForm | undefined, + remoteValidator?: ValidatorFn ) { super(field, - FieldArrayItemValueForm.buildControl(field, isOptional, source), + FieldArrayItemValueForm.buildControl(field, isOptional, remoteValidator, source), isOptional, FieldArrayItemValueForm.buildRules(field, parent, rules) ); @@ -479,7 +467,9 @@ export class FieldArrayItemValueForm extends AbstractContentForm x.field === fullName); } - private static buildControl(field: NestedFieldDto, isOptional: boolean, source?: FieldArrayItemForm) { + private static buildControl(field: NestedFieldDto, isOptional: boolean, remoteValidator?: ValidatorFn, source?: FieldArrayItemForm) { let value = FieldDefaultValue.get(field); if (source) { @@ -515,15 +505,10 @@ export class FieldArrayItemValueForm extends AbstractContentForm