From 383e6b8b06d25eec7db1159ec59ddedd06baedb1 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Thu, 9 May 2019 16:13:14 +0200 Subject: [PATCH] Dropdown field for references. --- .../Schemas/ReferencesFieldEditor.cs | 15 ++ .../Schemas/ReferencesFieldProperties.cs | 2 + .../Guards/FieldPropertiesValidator.cs | 6 + .../Fields/ReferencesFieldPropertiesDto.cs | 5 + .../app/features/content/declarations.ts | 1 + src/Squidex/app/features/content/module.ts | 2 + .../pages/content/content-page.component.ts | 4 +- .../shared/field-editor.component.html | 25 ++- .../shared/references-dropdown.component.ts | 163 ++++++++++++++++++ .../pages/schema/field-wizard.component.ts | 2 +- .../schema/types/references-ui.component.html | 21 ++- .../schema/types/references-ui.component.ts | 13 +- .../pages/patterns/pattern.component.ts | 6 +- src/Squidex/app/framework/state.ts | 9 +- .../app/shared/services/schemas.types.ts | 7 +- .../app/shared/state/contents.forms.spec.ts | 2 +- .../ReferencesFieldPropertiesTests.cs | 14 ++ 17 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldEditor.cs create mode 100644 src/Squidex/app/features/content/shared/references-dropdown.component.ts diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldEditor.cs new file mode 100644 index 000000000..8b6483d01 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldEditor.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public enum ReferencesFieldEditor + { + List, + Dropdown + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs index 632e5b9bf..b23ee085e 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs @@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Core.Schemas public bool AllowDuplicates { get; set; } + public ReferencesFieldEditor Editor { get; set; } + public Guid SchemaId { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs index 5b3453edf..fff833c57 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs @@ -190,6 +190,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards public IEnumerable Visit(ReferencesFieldProperties properties) { + if (!properties.Editor.IsEnumValue()) + { + yield return new ValidationError(Not.Valid("Editor"), + nameof(properties.Editor)); + } + if (properties.MaxItems.HasValue && properties.MinItems.HasValue && properties.MinItems.Value > properties.MaxItems.Value) { yield return new ValidationError(Not.GreaterEquals("Max items", "min items"), diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs index 8e73ed212..0b5bd9042 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ReferencesFieldPropertiesDto.cs @@ -28,6 +28,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields /// public bool AllowDuplicates { get; set; } + /// + /// The editor that is used to manage this field. + /// + public ReferencesFieldEditor Editor { get; set; } + /// /// The id of the referenced schema. /// diff --git a/src/Squidex/app/features/content/declarations.ts b/src/Squidex/app/features/content/declarations.ts index 759513526..853a1a061 100644 --- a/src/Squidex/app/features/content/declarations.ts +++ b/src/Squidex/app/features/content/declarations.ts @@ -24,4 +24,5 @@ export * from './shared/contents-selector.component'; export * from './shared/due-time-selector.component'; export * from './shared/field-editor.component'; export * from './shared/preview-button.component'; +export * from './shared/references-dropdown.component'; export * from './shared/references-editor.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/content/module.ts b/src/Squidex/app/features/content/module.ts index 6215501d9..4c6f8a9d1 100644 --- a/src/Squidex/app/features/content/module.ts +++ b/src/Squidex/app/features/content/module.ts @@ -38,6 +38,7 @@ import { FieldEditorComponent, FieldLanguagesComponent, PreviewButtonComponent, + ReferencesDropdownComponent, ReferencesEditorComponent, SchemasPageComponent } from './declarations'; @@ -122,6 +123,7 @@ const routes: Routes = [ FieldEditorComponent, FieldLanguagesComponent, PreviewButtonComponent, + ReferencesDropdownComponent, ReferencesEditorComponent, SchemasPageComponent ] diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index d81cc4909..b607f2400 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -138,14 +138,14 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD if (asProposal) { this.contentsState.proposeUpdate(this.content, value) .subscribe(() => { - this.contentForm.submitCompleted(); + this.contentForm.submitCompleted({ noReset: true }); }, error => { this.contentForm.submitFailed(error); }); } else { this.contentsState.update(this.content, value) .subscribe(() => { - this.contentForm.submitCompleted(); + this.contentForm.submitCompleted({ noReset: true }); }, error => { this.contentForm.submitFailed(error); }); diff --git a/src/Squidex/app/features/content/shared/field-editor.component.html b/src/Squidex/app/features/content/shared/field-editor.component.html index 33edc9747..3e2082589 100644 --- a/src/Squidex/app/features/content/shared/field-editor.component.html +++ b/src/Squidex/app/features/content/shared/field-editor.component.html @@ -124,13 +124,24 @@ - - + + + + + + + + + + diff --git a/src/Squidex/app/features/content/shared/references-dropdown.component.ts b/src/Squidex/app/features/content/shared/references-dropdown.component.ts new file mode 100644 index 000000000..721edc3ec --- /dev/null +++ b/src/Squidex/app/features/content/shared/references-dropdown.component.ts @@ -0,0 +1,163 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; +import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { throwError } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + AppLanguageDto, + AppsState, + ContentDto, + ContentsService, + FieldFormatter, + fieldInvariant, + ImmutableArray, + MathHelper, + RootFieldDto, + SchemaDetailsDto, + SchemasService, + StatefulControlComponent, + Types +} from '@app/shared'; + +export const SQX_REFERENCES_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferencesDropdownComponent), multi: true +}; + +interface State { + schema?: SchemaDetailsDto | null; + + contentItems: ImmutableArray; + contentNames: ImmutableArray; +} + +type ContentName = { name: string, id: string }; + +@Component({ + selector: 'sqx-references-dropdown', + template: ` + + `, + providers: [SQX_REFERENCES_DROPDOWN_CONTROL_VALUE_ACCESSOR], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ReferencesDropdownComponent extends StatefulControlComponent implements OnInit { + private languageField: AppLanguageDto; + + @Input() + public schemaId: string; + + @Input() + public set language(value: AppLanguageDto) { + this.languageField = value; + + this.next(s => ({ ...s, contentNames: this.createContentNames(s.schema, s.contentItems) })); + } + + public selectedId = new FormControl(''); + + constructor(changeDetector: ChangeDetectorRef, + private readonly appsState: AppsState, + private readonly contentsService: ContentsService, + private readonly schemasService: SchemasService + ) { + super(changeDetector, { + schema: null, + contentItems: ImmutableArray.empty(), + contentNames: ImmutableArray.empty() + }); + + this.own( + this.selectedId.valueChanges + .subscribe(value => { + if (value) { + this.callTouched(); + this.callChange([value]); + } else { + this.callTouched(); + this.callChange([]); + } + })); + } + + public ngOnInit() { + if (this.schemaId === MathHelper.EMPTY_GUID) { + this.selectedId.disable(); + return; + } + + this.schemasService.getSchema(this.appsState.appName, this.schemaId).pipe( + switchMap(schema => { + if (schema) { + return this.contentsService.getContents(this.appsState.appName, this.schemaId, 100, 0); + } else { + return throwError('Invalid schema'); + } + }, (schema, contents) => ({ schema, contents }))) + .subscribe(({ schema, contents }) => { + const contentItems = ImmutableArray.of(contents.items); + const contentNames = this.createContentNames(schema, contentItems); + + this.next(s => ({ ...s, schema, contentItems, contentNames })); + }, () => { + this.selectedId.disable(); + }); + } + + public writeValue(obj: any) { + if (Types.isArrayOfString(obj)) { + this.selectedId.setValue(obj[0], { emitEvent: false }); + } else { + this.selectedId.setValue(undefined, { emitEvent: false }); + } + } + + private createContentNames(schema: SchemaDetailsDto | undefined | null, contents: ImmutableArray): ImmutableArray { + if (contents.length === 0 || !schema) { + return ImmutableArray.empty(); + } + + function getRawValue(field: RootFieldDto, data: any, language: AppLanguageDto): any { + const contentField = data[field.name]; + + if (contentField) { + if (field.isLocalizable) { + return contentField[language.iso2Code]; + } else { + return contentField[fieldInvariant]; + } + } + + return undefined; + } + + return contents.map(content => { + const values: string[] = []; + + for (let field of schema.listFields) { + const value = getRawValue(field, content.data, this.languageField); + + if (!Types.isUndefined(value)) { + values.push(FieldFormatter.format(field, value)); + } + } + + const name = values.join(', '); + + return { name, id: content.id }; + }); + } + + public trackByContent(content: ContentDto) { + return content.id; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts index f89b9ebb3..f98d6485e 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts @@ -75,7 +75,7 @@ export class FieldWizardComponent implements OnInit { .subscribe(dto => { this.field = dto; - this.addFieldForm.submitCompleted({ ...DEFAULT_FIELD }); + this.addFieldForm.submitCompleted({ newValue: { ...DEFAULT_FIELD } }); if (addNew) { if (Types.isFunction(this.nameInput.nativeElement.focus)) { diff --git a/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.html b/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.html index 833ec5b59..3b0b264ba 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.html @@ -1,3 +1,22 @@
- Nothing to setup +
+ + +
+ + +
+
\ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.ts b/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.ts index e85eac767..8f3ee4acd 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/types/references-ui.component.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, Input } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FieldDto, ReferencesFieldPropertiesDto } from '@app/shared'; @@ -15,7 +15,7 @@ import { FieldDto, ReferencesFieldPropertiesDto } from '@app/shared'; styleUrls: ['references-ui.component.scss'], templateUrl: 'references-ui.component.html' }) -export class ReferencesUIComponent { +export class ReferencesUIComponent implements OnInit { @Input() public editForm: FormGroup; @@ -24,4 +24,11 @@ export class ReferencesUIComponent { @Input() public properties: ReferencesFieldPropertiesDto; + + public ngOnInit() { + this.editForm.setControl('editor', + new FormControl(this.properties.editor, [ + Validators.required + ])); + } } \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts b/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts index 2540ffc0e..657c6a675 100644 --- a/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts +++ b/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts @@ -36,7 +36,7 @@ export class PatternComponent implements OnInit { } public cancel() { - this.editForm.submitCompleted(this.pattern); + this.editForm.submitCompleted({ newValue: this.pattern }); } public delete() { @@ -49,8 +49,8 @@ export class PatternComponent implements OnInit { if (value) { if (this.pattern) { this.patternsState.update(this.pattern, value) - .subscribe(() => { - this.editForm.submitCompleted(); + .subscribe(newPattern => { + this.editForm.submitCompleted({ newValue: newPattern }); }, error => { this.editForm.submitFailed(error); }); diff --git a/src/Squidex/app/framework/state.ts b/src/Squidex/app/framework/state.ts index 9d15cbb32..32da0e089 100644 --- a/src/Squidex/app/framework/state.ts +++ b/src/Squidex/app/framework/state.ts @@ -79,11 +79,16 @@ export class Form { } } - public submitCompleted(newValue?: V) { + public submitCompleted(options?: { newValue?: V, noReset?: boolean }) { this.state.next(() => ({ submitted: false, error: null })); this.enable(); - this.setValue(newValue); + + if (options && options.noReset) { + this.form.markAsPristine(); + } else { + this.setValue(options ? options.newValue : undefined); + } } public submitFailed(error?: string | ErrorDto) { diff --git a/src/Squidex/app/shared/services/schemas.types.ts b/src/Squidex/app/shared/services/schemas.types.ts index 87beb6ac9..695fe7b47 100644 --- a/src/Squidex/app/shared/services/schemas.types.ts +++ b/src/Squidex/app/shared/services/schemas.types.ts @@ -79,7 +79,7 @@ export function createProperties(fieldType: FieldType, values: Object | null = n properties = new NumberFieldPropertiesDto('Input'); break; case 'References': - properties = new ReferencesFieldPropertiesDto(); + properties = new ReferencesFieldPropertiesDto('List'); break; case 'String': properties = new StringFieldPropertiesDto('Input'); @@ -308,6 +308,7 @@ export class ReferencesFieldPropertiesDto extends FieldPropertiesDto { public readonly minItems?: number; public readonly maxItems?: number; + public readonly editor: string; public readonly schemaId?: string; public readonly allowDuplicates?: boolean; @@ -315,10 +316,10 @@ export class ReferencesFieldPropertiesDto extends FieldPropertiesDto { return false; } - constructor( + constructor(editor: string, props?: Partial ) { - super('Default', props); + super(editor, props); } public accept(visitor: FieldPropertiesVisitor): T { diff --git a/src/Squidex/app/shared/state/contents.forms.spec.ts b/src/Squidex/app/shared/state/contents.forms.spec.ts index bd1ff1cda..acdeb7d49 100644 --- a/src/Squidex/app/shared/state/contents.forms.spec.ts +++ b/src/Squidex/app/shared/state/contents.forms.spec.ts @@ -324,7 +324,7 @@ describe('NumberField', () => { }); describe('ReferencesField', () => { - const field = createField(new ReferencesFieldPropertiesDto({ isRequired: true, minItems: 1, maxItems: 5 })); + const field = createField(new ReferencesFieldPropertiesDto('List', { isRequired: true, minItems: 1, maxItems: 5 })); it('should create validators', () => { expect(FieldValidatorsFactory.createValidators(field, false).length).toBe(3); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/FieldProperties/ReferencesFieldPropertiesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/FieldProperties/ReferencesFieldPropertiesTests.cs index 34efa28c3..c7b7e1e98 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/FieldProperties/ReferencesFieldPropertiesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/FieldProperties/ReferencesFieldPropertiesTests.cs @@ -30,6 +30,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards.FieldProperties }); } + [Fact] + public void Should_add_error_if_editor_is_not_valid() + { + var sut = new ReferencesFieldProperties { Editor = (ReferencesFieldEditor)123 }; + + var errors = FieldPropertiesValidator.Validate(sut).ToList(); + + errors.Should().BeEquivalentTo( + new List + { + new ValidationError("Editor is not a valid value.", "Editor") + }); + } + [Fact] public void Should_not_add_error_if_min_items_greater_equals_to_max_items() {