Browse Source

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.
pull/596/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
4bfba3cd46
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      backend/i18n/frontend_en.json
  2. 1
      backend/i18n/frontend_it.json
  3. 1
      backend/i18n/frontend_nl.json
  4. 1
      backend/i18n/source/frontend_en.json
  5. 17
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs
  6. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs
  7. 2
      backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs
  8. 2
      backend/src/Squidex.Infrastructure/Configuration/ConfigurationExtensions.cs
  9. 2
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs
  10. 42
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs
  11. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs
  12. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs
  13. 4
      frontend/app-config/karma.conf.js
  14. 4
      frontend/app-config/karma.coverage.conf.js
  15. 13
      frontend/app/features/content/pages/content/content-page.component.ts
  16. 77
      frontend/app/features/content/shared/forms/array-editor.component.html
  17. 27
      frontend/app/features/content/shared/forms/array-editor.component.ts
  18. 2
      frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts
  19. 6
      frontend/app/framework/angular/forms/control-errors.component.ts
  20. 33
      frontend/app/framework/angular/forms/error-formatting.spec.ts
  21. 11
      frontend/app/framework/angular/forms/error-formatting.ts
  22. 184
      frontend/app/framework/angular/forms/error-validator.spec.ts
  23. 76
      frontend/app/framework/angular/forms/error-validator.ts
  24. 64
      frontend/app/framework/angular/forms/forms-helper.spec.ts
  25. 95
      frontend/app/framework/angular/forms/forms-helper.ts
  26. 148
      frontend/app/framework/angular/forms/model.ts
  27. 32
      frontend/app/framework/angular/forms/validators.ts
  28. 1
      frontend/app/framework/declarations.ts
  29. 102
      frontend/app/framework/state.ts
  30. 51
      frontend/app/framework/utils/error.ts
  31. 8
      frontend/app/framework/utils/string-helper.spec.ts
  32. 10
      frontend/app/framework/utils/string-helper.ts
  33. 2
      frontend/app/shared/components/assets/asset.component.html
  34. 1
      frontend/app/shared/internal.ts
  35. 85
      frontend/app/shared/state/contents.forms-helpers.ts
  36. 17
      frontend/app/shared/state/contents.forms.spec.ts
  37. 217
      frontend/app/shared/state/contents.forms.ts

1
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",

1
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",

1
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",

1
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",

17
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<string, (bool IsOptional, IValidator Validator)>();
@ -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<IJsonValue>(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<IValidator> CreateContentValidators()
{
return factories.SelectMany(x => x.CreateContentValidators(context, CreateFieldValidator));
return factories.SelectMany(x => x.CreateContentValidators(context, CreateValueValidator));
}
private IEnumerable<IValidator> CreateValueValidators(IField field)
{
return factories.SelectMany(x => x.CreateValueValidators(context, field, CreateFieldValidator));
return factories.SelectMany(x => x.CreateValueValidators(context, field, CreateValueValidator));
}
private IEnumerable<IValidator> CreateFieldValidators(IField field)
{
return factories.SelectMany(x => x.CreateFieldValidators(context, field, CreateFieldValidator));
return factories.SelectMany(x => x.CreateFieldValidators(context, field, CreateValueValidator));
}
}
}

9
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] != '[')

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

2
backend/src/Squidex.Infrastructure/Configuration/ConfigurationExtensions.cs

@ -16,7 +16,7 @@ namespace Microsoft.Extensions.Configuration
{
public static T GetOptionalValue<T>(this IConfiguration config, string path, T defaultValue = default)
{
var value = config.GetValue(path, defaultValue);
var value = config.GetValue(path, defaultValue!);
return value;
}

2
backend/src/Squidex.Infrastructure/EventSourcing/Grains/BatchSubscriber.cs

@ -82,7 +82,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
var handle = new ActionBlock<IList<Job>>(async jobs =>
{
var sender = eventSubscription.Sender;
var sender = eventSubscription?.Sender;
foreach (var jobsBySender in jobs.GroupBy(x => x.Sender))
{

42
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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<ValidationError>
{
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")
});
}

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

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

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

4
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.
*/

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

77
frontend/app/features/content/shared/forms/array-editor.component.html

@ -1,43 +1,48 @@
<ng-container *ngIf="hasFields; else noFields">
<div class="array-container" *ngIf="formModel.items.length > 0"
cdkDropList
[cdkDropListDisabled]="false"
[cdkDropListData]="formModel.items"
(cdkDropListDropped)="sort($event)">
<div *ngFor="let itemForm of formModel.items; index as i; last as isLast; first as isFirst" class="table-drag item" cdkDrag cdkDragLockAxis="y">
<sqx-array-item
[canUnset]="canUnset"
[form]="form"
[formContext]="formContext"
[formModel]="itemForm"
[index]="i"
[isDisabled]="formModel.form.disabled"
[isFirst]="isFirst"
[isLast]="isLast"
[language]="language"
[languages]="languages"
(clone)="itemAdd(itemForm)" (move)="move(itemForm, $event)" (remove)="itemRemove(i)">
<i cdkDragHandle class="icon-drag2"></i>
</sqx-array-item>
<ng-container *ngIf="formModel.itemChanges | async; let items">
<div class="array-container" *ngIf="items.length > 0"
cdkDropList
[cdkDropListDisabled]="false"
[cdkDropListData]="items"
(cdkDropListDropped)="sort($event)">
<div *ngFor="let itemForm of items; index as i; last as isLast; first as isFirst" class="table-drag item"
cdkDrag
cdkDragLockAxis="y">
<sqx-array-item
(remove)="removeItem(i)"
[canUnset]="canUnset"
[form]="form"
[formContext]="formContext"
[formModel]="itemForm"
[index]="i"
[isDisabled]="isDisabled | async"
[isFirst]="isFirst"
[isLast]="isLast"
[language]="language"
[languages]="languages"
(clone)="addItem(itemForm)" (move)="move(itemForm, $event)" >
<i cdkDragHandle class="icon-drag2"></i>
</sqx-array-item>
</div>
</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="btn btn-outline-success" [disabled]="!canAdd || formModel.form.disabled" (click)="itemAdd(undefined)">
{{ 'contents.arrayAddItem' | sqxTranslate }}
</button>
<div class="row">
<div class="col">
<button type="button" class="btn btn-outline-success" [disabled]="isFull | async" (click)="addItem()">
{{ 'contents.arrayAddItem' | sqxTranslate }}
</button>
</div>
<div class="col-auto" *ngIf="items.length > 0">
<button type="button" class="btn btn-text-secondary" (click)="expandAll()" title="i18n:contents.arrayExpandAll">
<i class="icon-plus-square"></i>
</button>
<button type="button" class="btn btn-text-secondary" (click)="collapseAll()" title="i18n:contents.arrayCollapseAll">
<i class="icon-minus-square"></i>
</button>
</div>
</div>
<div class="col-auto" *ngIf="formModel.items.length > 0">
<button type="button" class="btn btn-text-secondary" (click)="expandAll()" title="i18n:contents.arrayExpandAll">
<i class="icon-plus-square"></i>
</button>
<button type="button" class="btn btn-text-secondary" (click)="collapseAll()" title="i18n:contents.arrayCollapseAll">
<i class="icon-minus-square"></i>
</button>
</div>
</div>
</ng-container>
</ng-container>
<ng-template #noFields>

27
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<ArrayItemComponent>;
public maxItems: number;
public isDisabled: Observable<boolean>;
public isFull: Observable<boolean>;
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);
}

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

6
frontend/app/framework/angular/forms/control-errors.component.ts

@ -124,8 +124,12 @@ export class ControlErrorsComponent extends StatefulComponent<State> 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);
}
}
}
}

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

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

184
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.']
}
});
});
});

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

64
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');
});
});
});

95
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<AbstractContr
}
}
export function updateAll(form: AbstractControl) {
form.updateValueAndValidity({ onlySelf: true, emitEvent: true });
for (const child of formControls(form)) {
updateAll(child);
}
}
export function addValidator(form: AbstractControl, validator: ValidatorFn) {
if (form.validator) {
form.setValidators([form.validator, validator]);
} else {
form.setValidators(validator);
}
for (const child of formControls(form)) {
addValidator(child, validator);
}
}
export function getControlPath(control: AbstractControl | undefined | null, apiCompatible = false): string {
if (!control || !control.parent) {
return '';
}
let name = '';
if (control.parent instanceof FormGroup) {
for (const key in control.parent.controls) {
if (control.parent.controls[key] === control) {
name = key;
}
}
} else if (control.parent instanceof FormArray) {
for (let i = 0; i < control.parent.controls.length; i++) {
if (control.parent.controls[i] === control) {
if (apiCompatible) {
name = `[${i + 1}]`;
} else {
name = i.toString();
}
break;
}
}
}
if (!name) {
return '';
}
const parentName = getControlPath(control.parent, apiCompatible);
if (parentName) {
if (name.startsWith('[')) {
return `${parentName}${name}`;
} else {
return `${parentName}.${name}`;
}
}
return name;
}
export function disabled$(form: AbstractControl): Observable<boolean> {
return form.statusChanges.pipe(map(() => form.disabled), startWith(form.disabled), distinctUntilChanged());
}
export function invalid$(form: AbstractControl): Observable<boolean> {
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;
}
}

148
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<T extends AbstractControl, TOut, TIn = TOut> {
private readonly state = new State<FormState>({ 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<TIn>) {
if (value) {
this.form.reset(this.transformLoad(value));
} else {
this.form.reset();
}
}
protected transformLoad(value: Partial<TIn>): any {
return value;
}
protected transformSubmit(value: any): TOut {
return value;
}
public load(value: Partial<TIn> | 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;
}

32
frontend/app/framework/angular/forms/validators.ts

@ -139,15 +139,15 @@ export module ValidatorsEx {
}
}
export function validValues<T>(values: ReadonlyArray<T>): ValidatorFn {
if (!values) {
export function validValues<T>(allowed: ReadonlyArray<T>): 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<T>(values: ReadonlyArray<T>): ValidatorFn {
if (!values) {
export function validArrayValues<T>(allowed: ReadonlyArray<T>): 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;
}
}

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

102
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<T> = {
-readonly [P in keyof T ]: T[P]
};
export interface FormState {
submitted: boolean;
error?: ErrorDto | null;
}
export class Form<T extends AbstractControl, TOut, TIn = TOut> {
private readonly state = new State<FormState>({ 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<TIn>) {
if (value) {
this.form.reset(this.transformLoad(value));
} else {
this.form.reset();
}
}
protected transformLoad(value: Partial<TIn>): any {
return value;
}
protected transformSubmit(value: any): TOut {
return value;
}
public load(value: Partial<TIn> | 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<T> {
public with(value: Partial<T>, validOnly = false): T {
return this.clone(value, validOnly);

51
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<string> = [];
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<ErrorDetailsDto> = [];
constructor(
public readonly statusCode: number,
public readonly message: string,
public readonly details: ReadonlyArray<string> = [],
details?: ReadonlyArray<string> | ReadonlyArray<ErrorDetailsDto>,
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;
}
}

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

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

2
frontend/app/shared/components/assets/asset.component.html

@ -120,7 +120,7 @@
<table class="table-fixed">
<tr>
<td class="col-name">
<div class="file-name editable" (click)="edit()">
<div class="file-name truncate editable" (click)="edit()">
<i class="icon-lock" *ngIf="asset?.isProtected"></i>
{{asset.fileName}}

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

85
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<TSeparator, TChild extends { hidden: boolean }> extends Hidden {
constructor(
public readonly separator: TSeparator | undefined,
public readonly fields: ReadonlyArray<TChild>
public readonly fields: ReadonlyArray<TChild>,
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;
}
}
}
}
export type AbstractContentFormState = {
isDisabled?: boolean;
isHidden?: boolean;
isRequired?: boolean
};
export abstract class AbstractContentForm<T extends FieldDto, TForm extends AbstractControl> extends Hidden {
private readonly disabled$ = new BehaviorSubject<boolean>(false);
public get disabled() {
return this.disabled$.value;
}
public get disabledChanges(): Observable<boolean> {
return this.disabled$;
}
constructor(
public readonly field: T,
public readonly form: TForm,
public readonly isOptional: boolean,
private readonly rules?: ReadonlyArray<CompiledRule>
) {
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 };

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

217
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<FormGroup, any> {
export class EditContentForm extends Form<FormGroup, any> {
private readonly fields: { [name: string]: FieldForm } = {};
private readonly valueChange$ = new BehaviorSubject<any>(this.form.value);
private initialData: any;
public readonly sections: ReadonlyArray<FieldSection<RootFieldDto, FieldForm>>;
public readonly value = new BehaviorSubject<any>(this.form.value);
public get valueChanges(): Observable<any> {
return this.valueChange$;
}
public get value() {
return this.valueChange$.value;
}
constructor(languages: ReadonlyArray<AppLanguageDto>, schema: SchemaDetailsDto,
private readonly user: any = {}
private readonly user: any = {}, debounce = 100
) {
super(new FormGroup({}));
@ -98,7 +106,7 @@ export class EditContentForm extends Form<FormGroup, any> {
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<FormGroup, any> {
this.form.setControl(field.name, child.form);
} else {
sections.push(new FieldSection<RootFieldDto, FieldForm>(currentSeparator, currentFields));
sections.push(new FieldSection<RootFieldDto, FieldForm>(currentSeparator, currentFields, this.remoteValidator));
currentFields = [];
currentSeparator = field;
@ -114,13 +122,21 @@ export class EditContentForm extends Form<FormGroup, any> {
}
if (currentFields.length > 0) {
sections.push(new FieldSection<RootFieldDto, FieldForm>(currentSeparator, currentFields));
sections.push(new FieldSection<RootFieldDto, FieldForm>(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<FormGroup, any> {
}
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<FormGroup, any> {
}
}
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<FormGroup, any> {
}
}
export abstract class AbstractContentForm<T extends FieldDto, TForm extends AbstractControl> 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<RootFieldDto, FormGroup> {
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<RootFieldDto, FormGroup> {
}
}
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<RootFieldDto, FormGroup> {
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<RootFieldDto, FormGroup> {
}
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<RootFieldDto, FormGroup> {
}
export class FieldValueForm extends AbstractContentForm<RootFieldDto, FormControl> {
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<RootFieldDto, FormArray> {
public items: FieldArrayItemForm[] = [];
private readonly item$ = new BehaviorSubject<ReadonlyArray<FieldArrayItemForm>>([]);
public get itemChanges(): Observable<ReadonlyArray<FieldArrayItemForm>> {
return this.item$;
}
public get items() {
return this.item$.value;
}
public set items(value: ReadonlyArray<FieldArrayItemForm>) {
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<RootFieldDto, FormArray>
}
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<RootFieldDto, FormArray>
}
}
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<RootFieldDto, FormArray>
}
export class FieldArrayItemForm extends AbstractContentForm<RootFieldDto, FormGroup> {
private fields: { [key: string]: FieldArrayItemValueForm } = {};
private readonly fields: { [key: string]: FieldArrayItemValueForm } = {};
public readonly sections: ReadonlyArray<FieldSection<NestedFieldDto, FieldArrayItemValueForm>>;
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<RootFieldDto, FormGr
for (const nestedField of field.nested) {
if (nestedField.properties.isContentField) {
const child = new FieldArrayItemValueForm(nestedField, field, allRules, isOptional, source);
const child = new FieldArrayItemValueForm(nestedField, field, allRules, isOptional, source, this.remoteValidator);
currentFields.push(child);
@ -434,7 +421,7 @@ export class FieldArrayItemForm extends AbstractContentForm<RootFieldDto, FormGr
this.form.setControl(nestedField.name, child.form);
} else {
sections.push(new FieldSection<NestedFieldDto, FieldArrayItemValueForm>(currentSeparator, currentFields));
sections.push(new FieldSection<NestedFieldDto, FieldArrayItemValueForm>(currentSeparator, currentFields, this.remoteValidator));
currentFields = [];
currentSeparator = nestedField;
@ -442,7 +429,7 @@ export class FieldArrayItemForm extends AbstractContentForm<RootFieldDto, FormGr
}
if (currentFields.length > 0) {
sections.push(new FieldSection<NestedFieldDto, FieldArrayItemValueForm>(currentSeparator, currentFields));
sections.push(new FieldSection<NestedFieldDto, FieldArrayItemValueForm>(currentSeparator, currentFields, this.remoteValidator));
}
this.sections = sections;
@ -452,11 +439,11 @@ export class FieldArrayItemForm extends AbstractContentForm<RootFieldDto, FormGr
return this.fields[field['name'] || field];
}
protected updateCustomState(_: State, user: any, data: any) {
protected updateCustomState(context: any, state: AbstractContentFormState) {
const itemData = this.form.getRawValue();
for (const field of Object.values(this.fields)) {
field.updateState(user, data, itemData);
field.updateState({ ...context, itemData }, state);
}
for (const section of this.sections) {
@ -468,10 +455,11 @@ export class FieldArrayItemForm extends AbstractContentForm<RootFieldDto, FormGr
export class FieldArrayItemValueForm extends AbstractContentForm<NestedFieldDto, FormControl> {
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<NestedFieldDto,
this.isRequired = field.properties.isRequired && !isOptional;
}
protected updateCustomState({ isRequired }: State) {
protected updateCustomState(_: any, state: AbstractContentFormState) {
const isRequired = state.isRequired === true;
if (!this.isOptional && this.isRequired !== isRequired) {
this.isRequired = isRequired;
@ -502,7 +492,7 @@ export class FieldArrayItemValueForm extends AbstractContentForm<NestedFieldDto,
return rules.filter(x => 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<NestedFieldDto,
const validators = FieldsValidators.create(field, isOptional);
if (remoteValidator) {
validators.push(remoteValidator);
}
return new FormControl(value, { validators });
}
}
type State = {
isRequired: boolean,
isHidden: boolean,
isDisabled: boolean
};
const NO_EMIT = { emitEvent: false };
const NO_EMIT_SELF = { emitEvent: false, onlySelf: true };
}
Loading…
Cancel
Save