From b71521f4d3d842e61fecf93f8e5619b2140faf20 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 19 Jul 2022 11:57:29 +0200 Subject: [PATCH 1/5] Fix URLS for read singleton. --- .../Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index 8d16d78b0..c3975ee8d 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -154,7 +154,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models AddSelfLink(resources.Url(x => nameof(x.GetSchema), values)); - if (resources.CanReadContent(Name) && Type == SchemaType.Default) + if (resources.CanReadContent(Name) && Type != SchemaType.Component) { AddGetLink("contents", resources.Url(x => nameof(x.GetContents), values)); } From ab2a05f4c06ca75f9f86d66b5583be08a2849a88 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 19 Jul 2022 12:26:48 +0200 Subject: [PATCH 2/5] Release notes for 6.10.0 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a919089c8..2da53c2e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.10.0] - 2022-07-19 + +### Fixed + +* **Contents**: Fixed a bug which was hiding singleton schemas in the frontend. + ## [6.9.0] - 2022-07-14 ### Fixed From fb68f04501243a75106de1183e371e1dfdbd77fc Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 27 Jul 2022 22:07:37 +0200 Subject: [PATCH 3/5] Better table fields (#905) * Better table fields. * Fix radio table. * Radio groups. * Just some cleanup. --- .../contents/contents-page.component.html | 12 +- .../custom-view-editor.component.html | 4 +- .../contents/custom-view-editor.component.ts | 33 ++--- .../shared/forms/field-editor.component.html | 18 +-- .../content/shared/list/content.component.ts | 6 +- .../references/reference-item.component.html | 8 +- .../references/reference-item.component.ts | 4 +- .../fields/types/string-ui.component.ts | 2 +- .../pages/schema/ui/field-list.component.html | 4 +- .../pages/schema/ui/field-list.component.ts | 18 +-- .../forms/editors/autocomplete.stories.ts | 14 +- .../editors/checkbox-group.component.html | 4 +- .../forms/editors/checkbox-group.component.ts | 88 ++++-------- .../forms/editors/checkbox-group.stories.ts | 91 ++++++++++++ .../forms/editors/radio-group.component.html | 14 ++ .../forms/editors/radio-group.component.scss | 11 ++ .../forms/editors/radio-group.component.ts | 135 ++++++++++++++++++ .../forms/editors/radio-group.stories.ts | 91 ++++++++++++ .../forms/editors/tag-editor.component.ts | 60 ++------ frontend/src/app/framework/declarations.ts | 1 + frontend/src/app/framework/internal.ts | 1 + frontend/src/app/framework/module.ts | 4 +- .../src/app/framework/utils/tag-values.ts | 6 +- .../src/app/framework/utils/text-measurer.ts | 63 ++++++++ .../contents/content-list-cell.directive.ts | 15 +- .../content-list-field.component.html | 6 +- .../contents/content-list-field.component.ts | 18 +-- .../content-list-header.component.html | 65 ++------- .../contents/content-list-header.component.ts | 45 +++--- .../contents/content-value.component.scss | 1 + .../contents/content-value.component.ts | 8 +- .../forms/markdown-editor.component.ts | 5 +- .../content-selector-item.component.html | 8 +- .../content-selector-item.component.ts | 4 +- .../content-selector.component.html | 8 +- .../references/content-selector.component.ts | 4 +- .../components/table-header.component.ts | 52 +++---- .../app/shared/services/schemas.service.ts | 119 ++++++++++----- .../src/app/shared/services/schemas.spec.ts | 42 ++++-- .../src/app/shared/state/contents.forms.ts | 2 +- .../app/shared/state/table-settings.spec.ts | 94 ++++-------- .../src/app/shared/state/table-settings.ts | 39 +++-- frontend/src/app/shell/module.ts | 3 +- 43 files changed, 770 insertions(+), 460 deletions(-) create mode 100644 frontend/src/app/framework/angular/forms/editors/checkbox-group.stories.ts create mode 100644 frontend/src/app/framework/angular/forms/editors/radio-group.component.html create mode 100644 frontend/src/app/framework/angular/forms/editors/radio-group.component.scss create mode 100644 frontend/src/app/framework/angular/forms/editors/radio-group.component.ts create mode 100644 frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts create mode 100644 frontend/src/app/framework/utils/text-measurer.ts diff --git a/frontend/src/app/features/content/pages/contents/contents-page.component.html b/frontend/src/app/features/content/pages/contents/contents-page.component.html index 0b087154b..ea6b20bfe 100644 --- a/frontend/src/app/features/content/pages/contents/contents-page.component.html +++ b/frontend/src/app/features/content/pages/contents/contents-page.component.html @@ -4,7 +4,7 @@
- +
@@ -33,7 +33,7 @@
diff --git a/frontend/src/app/features/content/pages/contents/custom-view-editor.component.ts b/frontend/src/app/features/content/pages/contents/custom-view-editor.component.ts index 7b923d607..162675071 100644 --- a/frontend/src/app/features/content/pages/contents/custom-view-editor.component.ts +++ b/frontend/src/app/features/content/pages/contents/custom-view-editor.component.ts @@ -7,6 +7,7 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { TableField } from '@app/shared'; @Component({ selector: 'sqx-custom-view-editor[allFields][listFields]', @@ -19,39 +20,39 @@ export class CustomViewEditorComponent implements OnChanges { public reset = new EventEmitter(); @Output() - public listFieldsChange = new EventEmitter>(); + public listFieldsChange = new EventEmitter>(); @Input() - public listFields!: string[]; + public listFields!: TableField[]; @Input() - public allFields!: ReadonlyArray; + public allFields!: ReadonlyArray; - public fieldsNotAdded!: ReadonlyArray; + public fieldsNotAdded!: ReadonlyArray; public ngOnChanges() { - this.fieldsNotAdded = this.allFields.filter(n => !this.listFields.includes(n)); + this.fieldsNotAdded = this.allFields.filter(lhs => !this.listFields.find(rhs => rhs.name === lhs.name)); } - public drop(event: CdkDragDrop) { + public drop(event: CdkDragDrop) { moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); - this.updateFieldNames(event.container.data); + this.updateListFields(event.container.data); } - public resetDefault() { - this.reset.emit(); + public addField(field: TableField) { + this.updateListFields([...this.listFields, field]); } - public addField(field: string) { - this.updateFieldNames([...this.listFields, field]); + public removeField(field: TableField) { + this.updateListFields(this.listFields.removed(field)); } - public removeField(field: string) { - this.updateFieldNames(this.listFields.removed(field)); + private updateListFields(fields: ReadonlyArray) { + this.listFieldsChange.emit(fields); } - private updateFieldNames(fieldNames: ReadonlyArray) { - this.listFieldsChange.emit(fieldNames); + public resetDefault() { + this.reset.emit(); } -} +} \ No newline at end of file diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.html b/frontend/src/app/features/content/shared/forms/field-editor.component.html index a2d51ef46..2f7490153 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.html +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.html @@ -109,20 +109,15 @@ + + + - -
- - -
-
@@ -212,12 +207,7 @@ -
- - -
+
diff --git a/frontend/src/app/features/content/shared/list/content.component.ts b/frontend/src/app/features/content/shared/list/content.component.ts index 1094a2b3b..a2f75e384 100644 --- a/frontend/src/app/features/content/shared/list/content.component.ts +++ b/frontend/src/app/features/content/shared/list/content.component.ts @@ -6,7 +6,7 @@ */ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; -import { AppLanguageDto, ContentDto, ContentListFieldComponent, ContentsState, ModalModel, PatchContentForm, RootFieldDto, TableField, TableSettings, Types } from '@app/shared'; +import { AppLanguageDto, ContentDto, ContentListFieldComponent, ContentsState, ModalModel, PatchContentForm, TableField, TableSettings } from '@app/shared'; /* tslint:disable: component-selector */ @@ -103,8 +103,8 @@ export class ContentComponent implements OnChanges { } public shouldStop(field: TableField) { - if (Types.is(field, RootFieldDto)) { - return this.isDirty || (field.isInlineEditable && this.patchAllowed); + if (field.rootField) { + return this.isDirty || (field.rootField.isInlineEditable && this.patchAllowed); } else { return this.isDirty; } diff --git a/frontend/src/app/features/content/shared/references/reference-item.component.html b/frontend/src/app/features/content/shared/references/reference-item.component.html index 942ea103c..8adc687aa 100644 --- a/frontend/src/app/features/content/shared/references/reference-item.component.html +++ b/frontend/src/app/features/content/shared/references/reference-item.component.html @@ -3,8 +3,8 @@ - - + + @@ -16,8 +16,8 @@ INVALID - - + + diff --git a/frontend/src/app/features/content/shared/references/reference-item.component.ts b/frontend/src/app/features/content/shared/references/reference-item.component.ts index cea5b83ec..fa8591e09 100644 --- a/frontend/src/app/features/content/shared/references/reference-item.component.ts +++ b/frontend/src/app/features/content/shared/references/reference-item.component.ts @@ -8,7 +8,7 @@ /* tslint:disable: component-selector */ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; -import { AppLanguageDto, ContentDto, getContentValue } from '@app/shared'; +import { AppLanguageDto, ContentDto, getContentValue, MetaFields } from '@app/shared'; @Component({ selector: '[sqxReferenceItem][language]', @@ -17,6 +17,8 @@ import { AppLanguageDto, ContentDto, getContentValue } from '@app/shared'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReferenceItemComponent implements OnChanges { + public readonly metaFields = MetaFields; + @Output() public delete = new EventEmitter(); diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts b/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts index 271eb1969..5eab5548b 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts +++ b/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts @@ -8,7 +8,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; -import { FieldDto, ResourceOwner, STRING_FIELD_EDITORS, StringFieldPropertiesDto, valueProjection$, SchemaTagSource } from '@app/shared'; +import { FieldDto, ResourceOwner, SchemaTagSource, STRING_FIELD_EDITORS, StringFieldPropertiesDto, valueProjection$ } from '@app/shared'; @Component({ selector: 'sqx-string-ui[field][fieldForm][properties]', diff --git a/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html b/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html index 2214749ca..679e00808 100644 --- a/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html +++ b/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html @@ -10,7 +10,7 @@
- {{field}} + {{field.name}}
@@ -23,6 +23,6 @@
- {{field}} + {{field.name}}
\ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts b/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts index 0daf35b58..9512ab04a 100644 --- a/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts +++ b/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts @@ -7,9 +7,9 @@ import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; -import { MetaFields, SchemaDto } from '@app/shared'; +import { MetaFields, SchemaDto, TableField } from '@app/shared'; -const META_FIELD_NAMES = Object.values(MetaFields); +const META_FIELD_NAMES = Object.values(MetaFields).filter(x => x !== MetaFields.empty); @Component({ selector: 'sqx-field-list[fieldNames][schema]', @@ -33,21 +33,21 @@ export class FieldListComponent implements OnChanges { @Output() public fieldNamesChange = new EventEmitter>(); - public fieldsAdded!: string[]; - public fieldsNotAdded!: string[]; + public fieldsAdded!: TableField[]; + public fieldsNotAdded!: TableField[]; public ngOnChanges() { - let allFields = this.schema.contentFields.map(x => x.name); + let allFields = this.schema.contentFields; if (this.withMetaFields) { allFields = [...allFields, ...META_FIELD_NAMES]; } - this.fieldsAdded = this.fieldNames.filter(n => allFields.includes(n)); - this.fieldsNotAdded = allFields.filter(n => !this.fieldNames.includes(n)); + this.fieldsAdded = allFields.filter(x => this.fieldNames.includes(x.name)); + this.fieldsNotAdded = allFields.filter(x => !this.fieldNames.includes(x.name)); } - public drop(event: CdkDragDrop) { + public drop(event: CdkDragDrop) { if (event.previousContainer === event.container) { moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); } else { @@ -58,7 +58,7 @@ export class FieldListComponent implements OnChanges { event.currentIndex); } - const newNames = this.fieldsAdded; + const newNames = this.fieldsAdded.map(x => x.name); this.fieldNamesChange.emit(newNames); } diff --git a/frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts b/frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts index e3b0caa58..8fdff4ee0 100644 --- a/frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts +++ b/frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts @@ -86,13 +86,6 @@ Icon.args = { icon: 'user', }; -export const IconLoading = Template.bind({}); - -IconLoading.args = { - source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000), - icon: 'user', -}; - export const StyleEmpty = Template.bind({}); StyleEmpty.args = { @@ -103,4 +96,11 @@ export const StyleUnderlined = Template.bind({}); StyleUnderlined.args = { inputStyle: 'underlined', +}; + +export const IconLoading = Template.bind({}); + +IconLoading.args = { + source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000), + icon: 'user', }; \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.html b/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.html index 942a6e7c2..b20e70e29 100644 --- a/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.html +++ b/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.html @@ -1,5 +1,5 @@
-
- +
\ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.ts b/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.ts index 9ed2feff9..4cd859cf5 100644 --- a/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.ts +++ b/frontend/src/app/framework/angular/forms/editors/checkbox-group.component.ts @@ -7,14 +7,12 @@ import { AfterViewChecked, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { getTagValues, MathHelper, StatefulControlComponent, TagValue, Types } from '@app/framework/internal'; +import { getTagValues, MathHelper, StatefulControlComponent, TagValue, TextMeasurer, Types } from '@app/framework/internal'; export const SQX_CHECKBOX_GROUP_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxGroupComponent), multi: true, }; -let CACHED_FONT: string; - interface State { // The checked values. checkedValues: ReadonlyArray; @@ -33,6 +31,7 @@ interface State { changeDetection: ChangeDetectionStrategy.OnPush, }) export class CheckboxGroupComponent extends StatefulControlComponent implements AfterViewInit, AfterViewChecked, OnChanges { + private readonly textMeasurer: TextMeasurer; private childrenWidth = 0; private checkedValuesRaw: any; private containerWidth = 0; @@ -44,7 +43,10 @@ export class CheckboxGroupComponent extends StatefulControlComponent; @Input() - public layout: 'Auto' | 'Singletine' | 'Multiline' = 'Auto'; + public layout: 'Auto' | 'Singleline' | 'Multiline' = 'Auto'; + + @Input() + public unsorted = true; @Input() public set disabled(value: boolean | undefined | null) { @@ -53,17 +55,23 @@ export class CheckboxGroupComponent extends StatefulControlComponent) { - this.valuesSorted = getTagValues(value); + this.tagValuesUnsorted = getTagValues(value, false); + this.tagValuesSorted = this.tagValuesUnsorted.sortedByString(x => x.lowerCaseName); this.writeValue(this.checkedValuesRaw); } - public valuesSorted: ReadonlyArray = []; + public get tagValues() { + return !this.unsorted ? this.tagValuesSorted : this.tagValuesUnsorted; + } + + public tagValuesSorted: ReadonlyArray = []; + public tagValuesUnsorted: ReadonlyArray = []; constructor(changeDetector: ChangeDetectorRef) { - super(changeDetector, { - checkedValues: [], - }); + super(changeDetector, { checkedValues: [] }); + + this.textMeasurer = new TextMeasurer(() => this.containerElement); } public ngAfterViewInit() { @@ -87,50 +95,33 @@ export class CheckboxGroupComponent extends StatefulControlComponent 0) { - checkedValues = this.valuesSorted.filter(x => obj.includes(x.value)); + checkedValues = this.tagValuesUnsorted.filter(x => obj.includes(x.value)); } this.next({ checkedValues }); @@ -191,5 +163,3 @@ export class CheckboxGroupComponent extends StatefulControlComponent = (args: CheckboxGroupComponent) => ({ + props: args, + template: ` +
+ + +
+ `, +}); + +export const Default = Template.bind({}); + +Default.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + model: [], +}; + +export const Unsorted = Template.bind({}); + +Unsorted.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + unsorted: true, +}; + +export const Small = Template.bind({}); + +Small.args = { + values: ['Lorem', 'ipsum', 'dolor'], + layout: 'Auto', +}; + +export const SmallMultiline = Template.bind({}); + +SmallMultiline.args = { + values: ['Lorem', 'ipsum', 'dolor'], + layout: 'Multiline', +}; + +export const Disabled = Template.bind({}); + +Disabled.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + disabled: true, +}; + +export const Checked = Template.bind({}); + +Checked.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + model: ['Lorem', 'ipsum'], +}; \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/radio-group.component.html b/frontend/src/app/framework/angular/forms/editors/radio-group.component.html new file mode 100644 index 000000000..7e44abcbd --- /dev/null +++ b/frontend/src/app/framework/angular/forms/editors/radio-group.component.html @@ -0,0 +1,14 @@ +
+
+ + + +
+
\ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/radio-group.component.scss b/frontend/src/app/framework/angular/forms/editors/radio-group.component.scss new file mode 100644 index 000000000..9269cceb4 --- /dev/null +++ b/frontend/src/app/framework/angular/forms/editors/radio-group.component.scss @@ -0,0 +1,11 @@ +@import 'mixins'; +@import 'vars'; + +.form-check-block { + margin-bottom: .5rem; + margin-left: 0; + + .form-check-input { + margin-top: .3rem; + } +} \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/radio-group.component.ts b/frontend/src/app/framework/angular/forms/editors/radio-group.component.ts new file mode 100644 index 000000000..c3be42332 --- /dev/null +++ b/frontend/src/app/framework/angular/forms/editors/radio-group.component.ts @@ -0,0 +1,135 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { AfterViewChecked, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, ViewChild } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { getTagValues, MathHelper, StatefulControlComponent, TagValue, TextMeasurer } from '@app/framework/internal'; + +export const SQX_RADIO_GROUP_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true, +}; + +interface State { + // True when all checkboxes can be shown as single line. + isSingleline?: boolean; +} + +@Component({ + selector: 'sqx-radio-group', + styleUrls: ['./radio-group.component.scss'], + templateUrl: './radio-group.component.html', + providers: [ + SQX_RADIO_GROUP_CONTROL_VALUE_ACCESSOR, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RadioGroupComponent extends StatefulControlComponent implements AfterViewInit, AfterViewChecked, OnChanges { + private readonly textMeasurer: TextMeasurer; + private childrenWidth = 0; + private containerWidth = 0; + private labelsMeasured = false; + + public readonly controlId = MathHelper.guid(); + + @ViewChild('container', { static: false }) + public containerElement!: ElementRef; + + @Input() + public layout: 'Auto' | 'Singleline' | 'Multiline' = 'Auto'; + + @Input() + public unsorted = true; + + @Input() + public set disabled(value: boolean | undefined | null) { + this.setDisabledState(value === true); + } + + @Input() + public set values(value: ReadonlyArray) { + this.tagValuesUnsorted = getTagValues(value, false); + this.tagValuesSorted = this.tagValuesUnsorted.sortedByString(x => x.lowerCaseName); + } + + public get tagValues() { + return !this.unsorted ? this.tagValuesSorted : this.tagValuesUnsorted; + } + + public tagValuesSorted: ReadonlyArray = []; + public tagValuesUnsorted: ReadonlyArray = []; + + public valueModel: any; + + constructor(changeDetector: ChangeDetectorRef) { + super(changeDetector, {}); + + this.textMeasurer = new TextMeasurer(() => this.containerElement); + } + + public ngAfterViewInit() { + this.calculateWidth(); + } + + public ngAfterViewChecked() { + this.calculateWidth(); + } + + public ngOnChanges() { + this.labelsMeasured = false; + + this.calculateWidth(); + } + + public updateContainerWidth(width: number) { + this.containerWidth = width; + + this.calculateSingleLine(); + } + + private calculateWidth() { + if (this.labelsMeasured) { + this.calculateSingleLine(); + return; + } + + let width = 0; + + for (const value of this.tagValuesUnsorted) { + width += 40; + width += this.textMeasurer.getTextSize(value.name); + } + + if (width < 0) { + return; + } + + this.childrenWidth = width; + this.calculateSingleLine(); + + this.labelsMeasured = true; + } + + private calculateSingleLine() { + let isSingleline = false; + + if (this.layout !== 'Auto') { + isSingleline = this.layout === 'Singleline'; + } else { + isSingleline = this.childrenWidth < this.containerWidth; + } + + this.next({ isSingleline }); + } + + public writeValue(obj: any) { + this.valueModel = obj; + } + + public trackByValue(_index: number, tag: TagValue) { + return tag.id; + } +} diff --git a/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts b/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts new file mode 100644 index 000000000..ed9db4759 --- /dev/null +++ b/frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts @@ -0,0 +1,91 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { moduleMetadata } from '@storybook/angular'; +import { Meta, Story } from '@storybook/angular/types-6-0'; +import { RadioGroupComponent, SqxFrameworkModule } from '@app/framework'; + +export default { + title: 'Framework/RadioGroup', + component: RadioGroupComponent, + argTypes: { + disabled: { + control: 'boolean', + }, + unsorted: { + control: 'boolean', + }, + }, + decorators: [ + moduleMetadata({ + imports: [ + BrowserAnimationsModule, + SqxFrameworkModule, + SqxFrameworkModule.forRoot(), + ], + }), + ], +} as Meta; + +const Template: Story = (args: RadioGroupComponent) => ({ + props: args, + template: ` +
+ + +
+ `, +}); + +export const Default = Template.bind({}); + +Default.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + model: [], +}; + +export const Unsorted = Template.bind({}); + +Unsorted.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + unsorted: false, +}; + +export const Small = Template.bind({}); + +Small.args = { + values: ['Lorem', 'ipsum', 'dolor'], + layout: 'Auto', +}; + +export const SmallMultiline = Template.bind({}); + +SmallMultiline.args = { + values: ['Lorem', 'ipsum', 'dolor'], + layout: 'Multiline', +}; + +export const Disabled = Template.bind({}); + +Disabled.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + disabled: true, +}; + +export const Checked = Template.bind({}); + +Checked.args = { + values: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], + model: 'ipsum', +}; \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts b/frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts index da96c29cb..634102b0f 100644 --- a/frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts +++ b/frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts @@ -8,14 +8,12 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { getTagValues, Keys, ModalModel, StatefulControlComponent, StringConverter, TagValue, Types } from '@app/framework/internal'; +import { getTagValues, Keys, ModalModel, StatefulControlComponent, StringConverter, TagValue, TextMeasurer, Types } from '@app/framework/internal'; export const SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagEditorComponent), multi: true, }; -let CACHED_FONT: string; - interface State { // True, when the item has the focus. hasFocus: boolean; @@ -40,6 +38,7 @@ interface State { changeDetection: ChangeDetectionStrategy.OnPush, }) export class TagEditorComponent extends StatefulControlComponent> implements AfterViewInit, OnChanges, OnInit { + private readonly textMeasurer: TextMeasurer; private latestValue: any; private latestInput?: string; @@ -136,6 +135,8 @@ export class TagEditorComponent extends StatefulControlComponent this.inputElement); } public ngAfterViewInit() { @@ -243,35 +244,19 @@ export class TagEditorComponent extends StatefulControlComponent { this.formElement.nativeElement.scrollLeft = this.formElement.nativeElement.scrollWidth; @@ -279,25 +264,6 @@ export class TagEditorComponent extends StatefulControlComponent | undefined | null) { +export function getTagValues(values: ReadonlyArray | undefined | null, sorted = true) { if (!Types.isArray(values)) { return []; } @@ -138,5 +138,9 @@ export function getTagValues(values: ReadonlyArray | undefine } } + if (!sorted) { + return result; + } + return result.sortByString(x => x.lowerCaseName); } diff --git a/frontend/src/app/framework/utils/text-measurer.ts b/frontend/src/app/framework/utils/text-measurer.ts new file mode 100644 index 000000000..60c5e1afb --- /dev/null +++ b/frontend/src/app/framework/utils/text-measurer.ts @@ -0,0 +1,63 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ElementRef } from '@angular/core'; +import { Types } from '../internal'; + +let CANVAS: HTMLCanvasElement | null = null; + +export class TextMeasurer { + private font?: string; + + constructor( + private readonly element: () => any | ElementRef, + ) { + } + + public getTextSize(text: string) { + if (!CANVAS) { + CANVAS = document.createElement('canvas'); + } + + if (!this.font) { + let currentElement = this.element(); + + if (Types.is(currentElement, ElementRef)) { + currentElement = currentElement.nativeElement; + } + + if (!currentElement) { + return -1000; + } + + const style = window.getComputedStyle(currentElement); + + const fontSize = style.getPropertyValue('font-size'); + const fontFamily = style.getPropertyValue('font-family'); + + if (!fontSize || !fontFamily) { + return -1000; + } + + this.font = `${fontSize} ${fontFamily}`; + } + + if (!this.font) { + return -1000; + } + + const ctx = CANVAS.getContext('2d'); + + if (!ctx) { + return -1000; + } + + ctx.font = this.font; + + return ctx.measureText(text).width; + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/contents/content-list-cell.directive.ts b/frontend/src/app/shared/components/contents/content-list-cell.directive.ts index 2abc149fa..b75f76abe 100644 --- a/frontend/src/app/shared/components/contents/content-list-cell.directive.ts +++ b/frontend/src/app/shared/components/contents/content-list-cell.directive.ts @@ -10,11 +10,7 @@ import { ResourceOwner } from '@app/framework'; import { ContentDto, FieldSizes, MetaFields, RootFieldDto, TableField, TableSettings, Types } from '@app/shared/internal'; export function getCellWidth(field: TableField, sizes: FieldSizes | undefined | null) { - if (Types.is(field, RootFieldDto)) { - field = field.name; - } - - const size = sizes?.[field] || 0; + const size = sizes?.[field.name] || 0; if (size > 0) { return size; @@ -125,12 +121,9 @@ export class ContentListWidthDirective extends ResourceOwner implements OnChange export class ContentListCellDirective extends ResourceOwner implements OnChanges { private sizes?: FieldSizes; private size = -1; - private fieldName?: string; @Input() - public set field(value: TableField) { - this.fieldName = Types.is(value, RootFieldDto) ? value.name : value; - } + public field!: TableField; @Input('fields') public set tableFields(value: TableSettings | undefined | null) { @@ -155,11 +148,11 @@ export class ContentListCellDirective extends ResourceOwner implements OnChanges } private updateSize() { - if (!this.fieldName) { + if (!this.field.name) { return; } - const size = getCellWidth(this.fieldName, this.sizes); + const size = getCellWidth(this.field, this.sizes); if (size === this.size) { return; diff --git a/frontend/src/app/shared/components/contents/content-list-field.component.html b/frontend/src/app/shared/components/contents/content-list-field.component.html index d292d1e25..1edfbb306 100644 --- a/frontend/src/app/shared/components/contents/content-list-field.component.html +++ b/frontend/src/app/shared/components/contents/content-list-field.component.html @@ -1,4 +1,4 @@ - + {{content.id}} @@ -94,8 +94,8 @@ {{content.version.value}} - - + + diff --git a/frontend/src/app/shared/components/contents/content-list-field.component.ts b/frontend/src/app/shared/components/contents/content-list-field.component.ts index 3867f2c53..f35e572be 100644 --- a/frontend/src/app/shared/components/contents/content-list-field.component.ts +++ b/frontend/src/app/shared/components/contents/content-list-field.component.ts @@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core'; import { FormGroup } from '@angular/forms'; -import { ContentDto, FieldValue, getContentValue, LanguageDto, MetaFields, RootFieldDto, StatefulComponent, TableField, TableSettings, Types } from '@app/shared/internal'; +import { ContentDto, FieldValue, getContentValue, LanguageDto, MetaFields, StatefulComponent, TableField, TableSettings } from '@app/shared/internal'; interface State { // The formatted value. @@ -21,6 +21,8 @@ interface State { changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentListFieldComponent extends StatefulComponent implements OnChanges { + public readonly metaFields = MetaFields; + @Input() public field!: TableField; @@ -39,16 +41,8 @@ export class ContentListFieldComponent extends StatefulComponent implemen @Input() public language!: LanguageDto; - public get metaFields() { - return MetaFields; - } - public get isInlineEditable() { - return Types.is(this.field, RootFieldDto) ? this.field.isInlineEditable : false; - } - - public get fieldName() { - return Types.is(this.field, RootFieldDto) ? this.field.name : this.field; + return this.field.rootField?.isInlineEditable === true; } constructor(changeDetector: ChangeDetectorRef) { @@ -62,8 +56,8 @@ export class ContentListFieldComponent extends StatefulComponent implemen } public reset() { - if (Types.is(this.field, RootFieldDto)) { - const { value, formatted } = getContentValue(this.content, this.language, this.field); + if (this.field.rootField) { + const { value, formatted } = getContentValue(this.content, this.language, this.field.rootField); if (this.patchForm) { const formControl = this.patchForm.controls[this.field.name]; diff --git a/frontend/src/app/shared/components/contents/content-list-header.component.html b/frontend/src/app/shared/components/contents/content-list-header.component.html index cc3d75f53..1d748ab05 100644 --- a/frontend/src/app/shared/components/contents/content-list-header.component.html +++ b/frontend/src/app/shared/components/contents/content-list-header.component.html @@ -1,56 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/frontend/src/app/shared/components/contents/content-list-header.component.ts b/frontend/src/app/shared/components/contents/content-list-header.component.ts index 71a59938d..7e23476db 100644 --- a/frontend/src/app/shared/components/contents/content-list-header.component.ts +++ b/frontend/src/app/shared/components/contents/content-list-header.component.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { LanguageDto, MetaFields, Query, RootFieldDto, TableField, Types } from '@app/shared/internal'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { LanguageDto, MetaFields, Query, SortMode, TableField } from '@app/shared/internal'; @Component({ selector: 'sqx-content-list-header[field][language]', @@ -14,7 +14,9 @@ import { LanguageDto, MetaFields, Query, RootFieldDto, TableField, Types } from templateUrl: './content-list-header.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ContentListHeaderComponent { +export class ContentListHeaderComponent implements OnChanges { + public readonly metaFields = MetaFields; + @Input() public field!: TableField; @@ -27,29 +29,26 @@ export class ContentListHeaderComponent { @Input() public language!: LanguageDto; - public get metaFields() { - return MetaFields; - } + public sortPath?: string; + public sortDefault?: SortMode; - public get isSortable() { - return Types.is(this.field, RootFieldDto) ? this.field.properties.isSortable : false; - } - - public get fieldName() { - return Types.is(this.field, RootFieldDto) ? this.field.name : this.field; - } + public ngOnChanges() { + const { field, language } = this; - public get fieldDisplayName() { - return Types.is(this.field, RootFieldDto) ? this.field.displayName : ''; - } - - public get fieldPath() { - if (Types.isString(this.field)) { - return this.field; - } else if (this.field.isLocalizable && this.language) { - return `data.${this.field.name}.${this.language.iso2Code}`; + if (field === MetaFields.created) { + this.sortPath = 'created'; + } else if (field === MetaFields.lastModified) { + this.sortPath = 'lastModified'; + } else if (field.rootField?.properties.isSortable !== true) { + this.sortPath = undefined; + } else if (field.rootField.isLocalizable && language) { + this.sortPath = `data.${field.name}.${language.iso2Code}`; } else { - return `data.${this.field.name}.iv`; + this.sortPath = `data.${field.name}.iv`; + } + + if (field === MetaFields.lastModified) { + this.sortDefault = 'descending'; } } } diff --git a/frontend/src/app/shared/components/contents/content-value.component.scss b/frontend/src/app/shared/components/contents/content-value.component.scss index 1f74f9d2a..7d24cc453 100644 --- a/frontend/src/app/shared/components/contents/content-value.component.scss +++ b/frontend/src/app/shared/components/contents/content-value.component.scss @@ -46,6 +46,7 @@ } .value-container { + cursor: default; max-height: 10rem; overflow-x: hidden; overflow-y: auto; diff --git a/frontend/src/app/shared/components/contents/content-value.component.ts b/frontend/src/app/shared/components/contents/content-value.component.ts index a809b12e1..4b9eb8d3d 100644 --- a/frontend/src/app/shared/components/contents/content-value.component.ts +++ b/frontend/src/app/shared/components/contents/content-value.component.ts @@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { ResourceOwner } from '@app/framework'; -import { FieldWrappings, HtmlValue, RootFieldDto, TableField, TableSettings, Types } from '@app/shared/internal'; +import { FieldWrappings, HtmlValue, TableField, TableSettings, Types } from '@app/shared/internal'; @Component({ selector: 'sqx-content-value[value]', @@ -28,7 +28,7 @@ export class ContentValueComponent extends ResourceOwner implements OnChanges { public wrapping = false; public get isString() { - return Types.is(this.field, RootFieldDto) && this.field.properties.fieldType === 'String'; + return this.field.rootField?.properties.fieldType === 'String'; } public get isPlain() { @@ -57,11 +57,11 @@ export class ContentValueComponent extends ResourceOwner implements OnChanges { } public toggle() { - this.fields?.toggleWrapping(this.field?.['name']); + this.fields?.toggleWrapping(this.field?.name); } private updateWrapping(wrappings: FieldWrappings) { - const wrapping = wrappings[this.field?.['name']]; + const wrapping = wrappings[this.field?.name]; if (wrapping === this.wrapping) { return; diff --git a/frontend/src/app/shared/components/forms/markdown-editor.component.ts b/frontend/src/app/shared/components/forms/markdown-editor.component.ts index b279b7953..6c8ecc84b 100644 --- a/frontend/src/app/shared/components/forms/markdown-editor.component.ts +++ b/frontend/src/app/shared/components/forms/markdown-editor.component.ts @@ -8,8 +8,7 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, Renderer2, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { marked } from 'marked'; -import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; -import { ContentDto } from '@app/shared'; +import { ApiUrlConfig, AssetDto, AssetUploaderState, ContentDto, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; declare const SimpleMDE: any; @@ -199,7 +198,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent - - + + - - + + \ No newline at end of file diff --git a/frontend/src/app/shared/components/references/content-selector-item.component.ts b/frontend/src/app/shared/components/references/content-selector-item.component.ts index e819edfff..b5709f9be 100644 --- a/frontend/src/app/shared/components/references/content-selector-item.component.ts +++ b/frontend/src/app/shared/components/references/content-selector-item.component.ts @@ -8,7 +8,7 @@ /* tslint:disable: component-selector */ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { ContentDto, LanguageDto, SchemaDto } from '@app/shared/internal'; +import { ContentDto, LanguageDto, MetaFields, SchemaDto } from '@app/shared/internal'; @Component({ selector: '[sqxContentSelectorItem][language][schema]', @@ -17,6 +17,8 @@ import { ContentDto, LanguageDto, SchemaDto } from '@app/shared/internal'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentSelectorItemComponent { + public readonly metaFields = MetaFields; + @Output() public selectedChange = new EventEmitter(); diff --git a/frontend/src/app/shared/components/references/content-selector.component.html b/frontend/src/app/shared/components/references/content-selector.component.html index 9439f89b9..d3018fbd9 100644 --- a/frontend/src/app/shared/components/references/content-selector.component.html +++ b/frontend/src/app/shared/components/references/content-selector.component.html @@ -49,8 +49,8 @@ [ngModel]="selectedAll" (ngModelChange)="selectAll($event)"> - - + + - - + + diff --git a/frontend/src/app/shared/components/references/content-selector.component.ts b/frontend/src/app/shared/components/references/content-selector.component.ts index 033a3a423..69b23b5f2 100644 --- a/frontend/src/app/shared/components/references/content-selector.component.ts +++ b/frontend/src/app/shared/components/references/content-selector.component.ts @@ -8,7 +8,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { BehaviorSubject, of } from 'rxjs'; import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; -import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDto, Query, ResourceOwner, SchemaDto, SchemasService, SchemasState } from '@app/shared/internal'; +import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDto, MetaFields, Query, ResourceOwner, SchemaDto, SchemasService, SchemasState } from '@app/shared/internal'; @Component({ selector: 'sqx-content-selector[language][languages]', @@ -19,6 +19,8 @@ import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDt ], }) export class ContentSelectorComponent extends ResourceOwner implements OnInit { + public readonly metaFields = MetaFields; + @Output() public select = new EventEmitter>(); diff --git a/frontend/src/app/shared/components/table-header.component.ts b/frontend/src/app/shared/components/table-header.component.ts index 521b672c3..e7bd11cb0 100644 --- a/frontend/src/app/shared/components/table-header.component.ts +++ b/frontend/src/app/shared/components/table-header.component.ts @@ -25,50 +25,52 @@ export class TableHeaderComponent implements OnChanges { public text = ''; @Input() - public fieldPath?: string | undefined | null; + public language!: LanguageDto; @Input() - public language!: LanguageDto; + public sortPath?: string | undefined | null; @Input() public sortable?: boolean | null; @Input() - public defaultOrder: SortMode | undefined | null; + public sortDefault: SortMode | undefined | null; public order: SortMode | undefined | null; public ngOnChanges() { - if (this.sortable) { - const { sort } = this.query || {}; - - if (this.fieldPath && sort && sort.length === 1 && sort[0].path === this.fieldPath) { - this.order = sort[0].order; - } else if (this.defaultOrder && (!sort || sort.length === 0)) { - this.order = this.defaultOrder; - } else { - this.order = null; - } + const { query, sortDefault, sortable, sortPath } = this; + + if (!sortable) { + this.order = null; + } else if (sortPath && query?.sort?.length === 1 && query.sort[0].path === sortPath) { + this.order = query.sort[0].order; + } else if (sortDefault && !query?.sort?.length) { + this.order = sortDefault; } else { this.order = null; } } public sort() { - if (this.sortable && this.fieldPath) { - if (!this.order || this.order !== 'ascending') { - this.order = 'ascending'; - } else { - this.order = 'descending'; - } - - const newQuery = Types.clone(this.query || {}); + const { order, query, sortable, sortPath } = this; - newQuery.sort = [ - { path: this.fieldPath, order: this.order! }, - ]; + if (!sortable || !sortPath) { + return; + } - this.queryChange.emit(newQuery); + if (!order || order !== 'ascending') { + this.order = 'ascending'; + } else { + this.order = 'descending'; } + + const newQuery = Types.clone(query || {}); + + newQuery.sort = [ + { path: sortPath, order: this.order! }, + ]; + + this.queryChange.emit(newQuery); } } diff --git a/frontend/src/app/shared/services/schemas.service.ts b/frontend/src/app/shared/services/schemas.service.ts index b50d4b759..0f381074e 100644 --- a/frontend/src/app/shared/services/schemas.service.ts +++ b/frontend/src/app/shared/services/schemas.service.ts @@ -14,17 +14,54 @@ import { QueryModel } from './query'; import { createProperties, FieldPropertiesDto } from './schemas.types'; export const MetaFields = { - id: 'meta.id', - created: 'meta.created', - createdByAvatar: 'meta.createdBy.avatar', - createdByName: 'meta.createdBy.name', - lastModified: 'meta.lastModified', - lastModifiedByAvatar: 'meta.lastModifiedBy.avatar', - lastModifiedByName: 'meta.lastModifiedBy.name', - status: 'meta.status', - statusColor: 'meta.status.color', - statusNext: 'meta.status.next', - version: 'meta.version', + empty: { + name: '', + label: '', + }, + id: { + name: 'meta.id', + label: 'i18n:contents.tableHeaders.id', + }, + created: { + name: 'meta.created', + label: 'i18n:contents.tableHeaders.created', + }, + createdByAvatar: { + name: 'meta.createdBy.avatar', + label: 'i18n:contents.tableHeaders.createdByShort', + }, + createdByName: { + name: 'meta.createdBy.name', + label: 'i18n:contents.tableHeaders.createdBy', + }, + lastModified: { + name: 'meta.lastModified', + label: 'i18n:contents.tableHeaders.lastModified', + }, + lastModifiedByAvatar: { + name: 'meta.lastModifiedBy.avatar', + label: 'i18n:contents.tableHeaders.lastModifiedByShort', + }, + lastModifiedByName: { + name: 'meta.lastModifiedBy.name', + label: 'i18n:contents.tableHeaders.lastModifiedBy', + }, + status: { + name: 'meta.status', + label: 'i18n:contents.tableHeaders.status', + }, + statusColor: { + name: 'meta.status.color', + label: 'i18n:contents.tableHeaders.status', + }, + statusNext: { + name: 'meta.status.next', + label: 'i18n:contents.tableHeaders.nextStatus', + }, + version: { + name: 'meta.version', + label: 'i18n:contents.tableHeaders.version', + }, }; export type SchemaType = 'Default' | 'Singleton' | 'Component'; @@ -53,7 +90,7 @@ export class SchemaDto { public readonly displayName: string; - public readonly contentFields: ReadonlyArray = []; + public readonly contentFields: ReadonlyArray = []; public readonly defaultListFields: ReadonlyArray = []; public readonly defaultReferenceFields: ReadonlyArray = []; @@ -99,17 +136,37 @@ export class SchemaDto { this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name); if (fields) { - this.contentFields = fields.filter(x => x.properties.isContentField); + this.contentFields = fields.filter(x => x.properties.isContentField).map(tableField); + + function tableFields(names: ReadonlyArray, fields: ReadonlyArray): TableField[] { + const result: TableField[] = []; + + for (const name of names) { + const metaField = MetaFields[name]; + + if (metaField) { + result.push(metaField); + } else { + const field = fields.find(x => x.name === name && x.properties.isContentField); + + if (field) { + result.push(tableField(field)); + } + } + } + + return result; + } - const listFields = findFields(fieldsInLists, this.contentFields); + const listFields = tableFields(fieldsInLists, fields); if (listFields.length === 0) { listFields.push(MetaFields.lastModifiedByAvatar); if (fields.length > 0) { - listFields.push(this.fields[0]); + listFields.push(tableField(this.fields[0])); } else { - listFields.push(''); + listFields.push(MetaFields.empty); } listFields.push(MetaFields.statusColor); @@ -118,15 +175,17 @@ export class SchemaDto { this.defaultListFields = listFields; - this.defaultReferenceFields = findFields(fieldsInReferences, this.contentFields); + const referenceFields = tableFields(fieldsInReferences, fields); - if (this.defaultReferenceFields.length === 0) { + if (referenceFields.length === 0) { if (fields.length > 0) { - this.defaultReferenceFields = [fields[0]]; + referenceFields.push(tableField(this.fields[0])); } else { - this.defaultReferenceFields = ['']; + referenceFields.push(MetaFields.empty); } } + + this.defaultReferenceFields = referenceFields; } } @@ -187,22 +246,8 @@ export class SchemaDto { } } -function findFields(names: ReadonlyArray, fields: ReadonlyArray): TableField[] { - const result: TableField[] = []; - - for (const name of names) { - if (name.startsWith('meta.')) { - result.push(name); - } else { - const field = fields.find(x => x.name === name); - - if (field) { - result.push(field); - } - } - } - - return result; +export function tableField(rootField: RootFieldDto) { + return { name: rootField.name, label: rootField.displayName, rootField }; } export class FieldDto { @@ -304,7 +349,7 @@ export const FIELD_RULE_ACTIONS: ReadonlyArray = [ type Tags = readonly string[]; -export type TableField = RootFieldDto | string; +export type TableField = { name: string; label: string; rootField?: RootFieldDto }; export type FieldRuleAction = 'Disable' | 'Hide' | 'Require'; export type FieldRule = { field: string; action: FieldRuleAction; condition: string }; diff --git a/frontend/src/app/shared/services/schemas.spec.ts b/frontend/src/app/shared/services/schemas.spec.ts index cf994537d..5b7d19f69 100644 --- a/frontend/src/app/shared/services/schemas.spec.ts +++ b/frontend/src/app/shared/services/schemas.spec.ts @@ -39,37 +39,57 @@ describe('SchemaDto', () => { it('should return configured fields as list fields if fields are declared', () => { const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInLists: ['field1', 'field3'] }); - expect(schema.defaultListFields).toEqual([field1, field3]); + expect(schema.defaultListFields.map(x => x.name)).toEqual([ + field1.name, + field3.name, + ]); + }); + + it('should return configured fields as references fields if fields are declared', () => { + const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInReferences: ['field1', 'field3'] }); + + expect(schema.defaultReferenceFields.map(x => x.name)).toEqual([ + field1.name, + field3.name, + ]); }); it('should return first fields as list fields if no field is declared', () => { const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] }); - expect(schema.defaultListFields).toEqual([MetaFields.lastModifiedByAvatar, field1, MetaFields.statusColor, MetaFields.lastModified]); + expect(schema.defaultListFields.map(x => x.name)).toEqual([ + MetaFields.lastModifiedByAvatar.name, + field1.name, + MetaFields.statusColor.name, + MetaFields.lastModified.name, + ]); }); it('should return preset with empty content field as list fields if fields is empty', () => { const schema = createSchema({ properties: new SchemaPropertiesDto() }); - expect(schema.defaultListFields).toEqual([MetaFields.lastModifiedByAvatar, '', MetaFields.statusColor, MetaFields.lastModified]); - }); - - it('should return configured fields as references fields if fields are declared', () => { - const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInReferences: ['field1', 'field3'] }); - - expect(schema.defaultReferenceFields).toEqual([field1, field3]); + expect(schema.defaultListFields.map(x => x.name)).toEqual([ + MetaFields.lastModifiedByAvatar.name, + MetaFields.empty.name, + MetaFields.statusColor.name, + MetaFields.lastModified.name, + ]); }); it('should return first field as reference fields if no field is declared', () => { const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] }); - expect(schema.defaultReferenceFields).toEqual([field1]); + expect(schema.defaultReferenceFields.map(x => x.name)).toEqual([ + field1.name, + ]); }); it('should return noop field as reference field if list is empty', () => { const schema = createSchema({ properties: new SchemaPropertiesDto() }); - expect(schema.defaultReferenceFields).toEqual(['']); + expect(schema.defaultReferenceFields.map(x => x.name)).toEqual([ + MetaFields.empty.name, + ]); }); }); diff --git a/frontend/src/app/shared/state/contents.forms.ts b/frontend/src/app/shared/state/contents.forms.ts index 746277343..616cbb9b8 100644 --- a/frontend/src/app/shared/state/contents.forms.ts +++ b/frontend/src/app/shared/state/contents.forms.ts @@ -42,7 +42,7 @@ export class PatchContentForm extends Form { ) { super(new ExtendedFormGroup({})); - this.editableFields = this.listFields.filter(x => Types.is(x, RootFieldDto) && x.isInlineEditable) as any; + this.editableFields = this.listFields.filter(x => x.rootField?.isInlineEditable).map(x => x.rootField!); for (const field of this.editableFields) { const validators = FieldsValidators.create(field, this.language.isOptional); diff --git a/frontend/src/app/shared/state/table-settings.spec.ts b/frontend/src/app/shared/state/table-settings.spec.ts index 5ba129e69..b9f8150cd 100644 --- a/frontend/src/app/shared/state/table-settings.spec.ts +++ b/frontend/src/app/shared/state/table-settings.spec.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { DateTime, Version } from '@app/framework'; -import { createProperties, FieldSizes, MetaFields, RootFieldDto, SchemaDto, TableField, TableSettings, UIState } from '@app/shared/internal'; +import { createProperties, FieldSizes, MetaFields, RootFieldDto, SchemaDto, TableSettings, UIState } from '@app/shared/internal'; import { FieldWrappings } from '..'; describe('TableSettings', () => { @@ -33,17 +33,17 @@ describe('TableSettings', () => { uiState = Mock.ofType(); }); + const INVALID_FIELD = { name: 'invalid', label: 'invalid' }; const INVALID_CONFIGS = [ { case: 'blank', fields: [] }, - { case: 'broken', fields: ['invalid'] }, + { case: 'broken', fields: [{ name: 'invalid', label: 'invalid' }] }, ]; const EMPTY = { fields: [], sizes: {}, wrappings: {} }; INVALID_CONFIGS.forEach(test => { it(`should provide default fields if config is ${test.case}`, () => { - let listFields: ReadonlyArray; - let listFieldNames: ReadonlyArray; + let listFields: ReadonlyArray; let fieldSizes: FieldSizes; let fieldWrappings: FieldWrappings; @@ -65,11 +65,7 @@ describe('TableSettings', () => { const tableSettings = new TableSettings(uiState.object, schema); tableSettings.listFields.subscribe(result => { - listFields = result; - }); - - tableSettings.listFieldNames.subscribe(result => { - listFieldNames = result; + listFields = result.map(x => x.name); }); tableSettings.fieldSizes.subscribe(result => { @@ -81,17 +77,10 @@ describe('TableSettings', () => { }); expect(listFields!).toEqual([ - MetaFields.lastModifiedByAvatar, - schema.fields[0], - MetaFields.statusColor, - MetaFields.lastModified, - ]); - - expect(listFieldNames!).toEqual([ - MetaFields.lastModifiedByAvatar, + MetaFields.lastModifiedByAvatar.name, schema.fields[0].name, - MetaFields.statusColor, - MetaFields.lastModified, + MetaFields.statusColor.name, + MetaFields.lastModified.name, ]); expect(fieldSizes!).toEqual({ @@ -122,28 +111,19 @@ describe('TableSettings', () => { }); it('should eliminate invalid fields from the config', () => { - let listFields: ReadonlyArray; - let listFieldNames: ReadonlyArray; + let listFields: ReadonlyArray; uiState.setup(x => x.getUser('schemas.my-schema.config', {})) - .returns(() => of(({ fields: ['invalid', MetaFields.version] }))); + .returns(() => of(({ fields: ['invalid', MetaFields.version.name] }))); const tableSettings = new TableSettings(uiState.object, schema); tableSettings.listFields.subscribe(result => { - listFields = result; - }); - - tableSettings.listFieldNames.subscribe(result => { - listFieldNames = result; + listFields = result.map(x => x.name); }); expect(listFields!).toEqual([ - MetaFields.version, - ]); - - expect(listFieldNames!).toEqual([ - MetaFields.version, + MetaFields.version.name, ]); }); @@ -153,11 +133,11 @@ describe('TableSettings', () => { const tableSettings = new TableSettings(uiState.object, schema); - const config = ['invalid', MetaFields.version]; + const config = [INVALID_FIELD, MetaFields.version]; tableSettings.updateFields(config, true); - uiState.verify(x => x.set('schemas.my-schema.config', { ...EMPTY, fields: [MetaFields.version] }, true), Times.once()); + uiState.verify(x => x.set('schemas.my-schema.config', { ...EMPTY, fields: [MetaFields.version.name] }, true), Times.once()); expect().nothing(); }); @@ -181,7 +161,7 @@ describe('TableSettings', () => { const tableSettings = new TableSettings(uiState.object, schema); - const config = ['invalid', MetaFields.version]; + const config = [INVALID_FIELD, MetaFields.version]; tableSettings.updateFields(config, false); @@ -202,11 +182,11 @@ describe('TableSettings', () => { fieldSizes = result; }); - tableSettings.updateSize(MetaFields.version, 100, true); + tableSettings.updateSize(MetaFields.version.name, 100, true); - uiState.verify(x => x.set('schemas.my-schema.config', { ...EMPTY, sizes: { [MetaFields.version]: 100 } }, true), Times.once()); + uiState.verify(x => x.set('schemas.my-schema.config', { ...EMPTY, sizes: { [MetaFields.version.name]: 100 } }, true), Times.once()); - expect(fieldSizes!).toEqual({ [MetaFields.version]: 100 }); + expect(fieldSizes!).toEqual({ [MetaFields.version.name]: 100 }); }); it('should update config if sizes are only updated', () => { @@ -221,11 +201,11 @@ describe('TableSettings', () => { fieldSizes = result; }); - tableSettings.updateSize(MetaFields.version, 100, false); + tableSettings.updateSize(MetaFields.version.name, 100, false); uiState.verify(x => x.set('schemas.my-schema.config', It.isAny(), true), Times.never()); - expect(fieldSizes!).toEqual({ [MetaFields.version]: 100 }); + expect(fieldSizes!).toEqual({ [MetaFields.version.name]: 100 }); }); it('should update config if wrapping is toggled', () => { @@ -240,11 +220,11 @@ describe('TableSettings', () => { fieldWrappings = result; }); - tableSettings.toggleWrapping(MetaFields.version, true); + tableSettings.toggleWrapping(MetaFields.version.name, true); - uiState.verify(x => x.set('schemas.my-schema.config', { ...EMPTY, wrappings: { [MetaFields.version]: true } }, true), Times.once()); + uiState.verify(x => x.set('schemas.my-schema.config', { ...EMPTY, wrappings: { [MetaFields.version.name]: true } }, true), Times.once()); - expect(fieldWrappings!).toEqual({ [MetaFields.version]: true }); + expect(fieldWrappings!).toEqual({ [MetaFields.version.name]: true }); }); it('should update config if wrapping is toggled and only updated', () => { @@ -259,22 +239,21 @@ describe('TableSettings', () => { fieldWrappings = result; }); - tableSettings.toggleWrapping(MetaFields.version, false); + tableSettings.toggleWrapping(MetaFields.version.name, false); uiState.verify(x => x.set('schemas.my-schema.config', It.isAny(), true), Times.never()); - expect(fieldWrappings!).toEqual({ [MetaFields.version]: true }); + expect(fieldWrappings!).toEqual({ [MetaFields.version.name]: true }); }); it('should provide default fields if reset', () => { - let listFields: ReadonlyArray; - let listFieldNames: ReadonlyArray; + let listFields: ReadonlyArray; let fieldSizes: FieldSizes; let fieldWrappings: FieldWrappings; const config = { fields: [ - MetaFields.version, + MetaFields.version.name, ], sizes: { field1: 100, @@ -292,11 +271,7 @@ describe('TableSettings', () => { const tableSettings = new TableSettings(uiState.object, schema); tableSettings.listFields.subscribe(result => { - listFields = result; - }); - - tableSettings.listFieldNames.subscribe(result => { - listFieldNames = result; + listFields = result.map(x => x.name); }); tableSettings.fieldSizes.subscribe(result => { @@ -310,17 +285,10 @@ describe('TableSettings', () => { tableSettings.reset(); expect(listFields!).toEqual([ - MetaFields.lastModifiedByAvatar, - schema.fields[0], - MetaFields.statusColor, - MetaFields.lastModified, - ]); - - expect(listFieldNames!).toEqual([ - MetaFields.lastModifiedByAvatar, + MetaFields.lastModifiedByAvatar.name, schema.fields[0].name, - MetaFields.statusColor, - MetaFields.lastModified, + MetaFields.statusColor.name, + MetaFields.lastModified.name, ]); expect(fieldSizes!).toEqual({}); diff --git a/frontend/src/app/shared/state/table-settings.ts b/frontend/src/app/shared/state/table-settings.ts index 3f2ac0a4c..a4e619046 100644 --- a/frontend/src/app/shared/state/table-settings.ts +++ b/frontend/src/app/shared/state/table-settings.ts @@ -10,14 +10,14 @@ import { State, Types } from '@app/framework'; import { MetaFields, SchemaDto, TableField } from './../services/schemas.service'; import { UIState } from './ui.state'; -const META_FIELD_NAMES = Object.values(MetaFields); +const META_FIELD_NAMES = Object.values(MetaFields).filter(x => x !== MetaFields.empty); export type FieldSizes = { [name: string]: number }; export type FieldWrappings = { [name: string]: boolean }; interface Snapshot { // The table fields in the right order. - fields: ReadonlyArray; + fields: ReadonlyArray; // The sizes of the columns if overriden. sizes: FieldSizes; @@ -29,8 +29,8 @@ interface Snapshot { export class TableSettings extends State { private readonly settingsKey: string; - public readonly schemaFields: ReadonlyArray; - public readonly schemaDefaults: ReadonlyArray; + public readonly schemaFields: ReadonlyArray; + public readonly schemaDefaults: ReadonlyArray; public fieldSizes = this.project(x => x.sizes); @@ -41,11 +41,8 @@ export class TableSettings extends State { public fields = this.project(x => x.fields); - public listFieldNames = - this.projectFrom(this.fields, x => this.getListFieldNames(x)); - public listFields = - this.projectFrom(this.listFieldNames, x => this.getListFields(x)); + this.projectFrom(this.fields, x => x.length > 0 ? x : this.schemaDefaults); constructor( private readonly uiState: UIState, @@ -53,8 +50,8 @@ export class TableSettings extends State { ) { super({ fields: [], sizes: {}, wrappings: {} }); - this.schemaFields = [...schema.contentFields.map(x => x.name), ...META_FIELD_NAMES].sort(); - this.schemaDefaults = schema.defaultListFields.map(x => x['name'] || x); + this.schemaFields = [...schema.contentFields, ...META_FIELD_NAMES]; + this.schemaDefaults = schema.defaultListFields; this.settingsKey = `schemas.${this.schema.name}.config`; @@ -112,8 +109,8 @@ export class TableSettings extends State { } } - public updateFields(fields: ReadonlyArray, save = true) { - this.publishFields(fields); + public updateFields(fields: ReadonlyArray, save = true) { + this.publishFields(fields.map(x => x.name)); if (save) { this.saveConfig(); @@ -128,8 +125,10 @@ export class TableSettings extends State { this.next({ wrappings }); } - private publishFields(fields: ReadonlyArray) { - this.next({ fields: fields.filter(x => this.schemaFields.includes(x)) }); + private publishFields(names: ReadonlyArray) { + const fields = names.map(n => this.schemaFields.find(f => f.name === n)).filter(x => !!x) as any; + + this.next({ fields }); } private saveConfig() { @@ -138,15 +137,9 @@ export class TableSettings extends State { if (Object.keys(sizes).length === 0 && Object.keys(wrappings).length === 0 && fields.length === 0) { this.uiState.removeUser(this.settingsKey); } else { - this.uiState.set(this.settingsKey, this.snapshot, true); - } - } + const update = { sizes, wrappings, fields: fields.map(x => x.name) }; - private getListFields(names: ReadonlyArray): ReadonlyArray { - return names.map(n => this.schema.fields.find(f => f.name === n) || n); - } - - private getListFieldNames(names: ReadonlyArray): ReadonlyArray { - return names.length === 0 ? this.schemaDefaults : names; + this.uiState.set(this.settingsKey, update, true); + } } } diff --git a/frontend/src/app/shell/module.ts b/frontend/src/app/shell/module.ts index 8078446ea..ff3bb7639 100644 --- a/frontend/src/app/shell/module.ts +++ b/frontend/src/app/shell/module.ts @@ -7,8 +7,7 @@ import { NgModule } from '@angular/core'; import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; -import { NotificationDropdownComponent } from '.'; -import { AppAreaComponent, AppsMenuComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LeftMenuComponent, LoginPageComponent, LogoComponent, LogoutPageComponent, NotFoundPageComponent, NotificationsMenuComponent, ProfileMenuComponent, SearchMenuComponent } from './declarations'; +import { AppAreaComponent, AppsMenuComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LeftMenuComponent, LoginPageComponent, LogoComponent, LogoutPageComponent, NotFoundPageComponent, NotificationDropdownComponent, NotificationsMenuComponent, ProfileMenuComponent, SearchMenuComponent } from './declarations'; @NgModule({ imports: [ From 27dcc5253e6c3f877a221ba24ad0c889c98e1b82 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Fri, 29 Jul 2022 16:14:05 +0200 Subject: [PATCH 4/5] Update MongoAssetRepository.cs --- .../Assets/MongoAssetRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index b4f4bb412..170f3f8ad 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -259,7 +259,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets Filter.Gt(x => x.Id, DomainId.Create(string.Empty)), Filter.Gt(x => x.IndexedAppId, appId), Filter.Ne(x => x.IsDeleted, true), - Filter.Ne(x => x.ParentId, parentId)); + Filter.Eq(x => x.ParentId, parentId)); } } } From 99edcfd86dd0345b1c9cd0b621dd4cd145cb8abc Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 29 Jul 2022 19:30:00 +0200 Subject: [PATCH 5/5] Changelog and fix for asset deleter. --- CHANGELOG.md | 6 + .../Assets/MongoAssetRepository.cs | 2 +- .../Assets/RecursiveDeleter.cs | 48 +++---- .../Contents/Queries/Steps/ConvertData.cs | 2 - .../Backups/Models/BackupJobDto.cs | 1 - .../TestSuite.ApiTests/AssetTests.cs | 32 +++-- .../Fixtures/ClientExtensions.cs | 117 ++++++++++++++++++ 7 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da53c2e7..6af149469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.11.0] - 2022-07-29 + +### Fixed + +* **Assets**: Fix recursive asset deletion. Query was selecting the wrong assets. + ## [6.10.0] - 2022-07-19 ### Fixed diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 170f3f8ad..d30f92592 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -257,7 +257,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets return Filter.And( Filter.Gt(x => x.LastModified, default), Filter.Gt(x => x.Id, DomainId.Create(string.Empty)), - Filter.Gt(x => x.IndexedAppId, appId), + Filter.Eq(x => x.IndexedAppId, appId), Filter.Ne(x => x.IsDeleted, true), Filter.Eq(x => x.ParentId, parentId)); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs index 07247dec3..4135fb83c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs @@ -65,37 +65,39 @@ namespace Squidex.Domain.Apps.Entities.Assets return; } - if (@event.Payload is AssetFolderDeleted folderDeleted) + if (@event.Payload is not AssetFolderDeleted folderDeleted) { - async Task PublishAsync(SquidexCommand command) + return; + } + + async Task PublishAsync(SquidexCommand command) + { + try { - try - { - command.Actor = folderDeleted.Actor; - - await commandBus.PublishAsync(command); - } - catch (Exception ex) - { - log.LogError(ex, "Failed to delete asset recursively."); - } + command.Actor = folderDeleted.Actor; + + await commandBus.PublishAsync(command); + } + catch (Exception ex) + { + log.LogError(ex, "Failed to delete asset recursively."); } + } - var appId = folderDeleted.AppId; + var appId = folderDeleted.AppId; - var childAssetFolders = await assetFolderRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); + var childAssetFolders = await assetFolderRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); - foreach (var assetFolderId in childAssetFolders) - { - await PublishAsync(new DeleteAssetFolder { AppId = appId, AssetFolderId = assetFolderId }); - } + foreach (var assetFolderId in childAssetFolders) + { + await PublishAsync(new DeleteAssetFolder { AppId = appId, AssetFolderId = assetFolderId }); + } - var childAssets = await assetRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); + var childAssets = await assetRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); - foreach (var assetId in childAssets) - { - await PublishAsync(new DeleteAsset { AppId = appId, AssetId = assetId }); - } + foreach (var assetId in childAssets) + { + await PublishAsync(new DeleteAsset { AppId = appId, AssetId = assetId }); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs index cff201ea0..016983a16 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs @@ -23,7 +23,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps public sealed class ConvertData : IContentEnricherStep { private readonly IUrlGenerator urlGenerator; - private readonly IJsonSerializer jsonSerializer; private readonly IAssetRepository assetRepository; private readonly IContentRepository contentRepository; private readonly ExcludeChangedTypes excludeChangedTypes; @@ -32,7 +31,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps IAssetRepository assetRepository, IContentRepository contentRepository) { this.urlGenerator = urlGenerator; - this.jsonSerializer = jsonSerializer; this.assetRepository = assetRepository; this.contentRepository = contentRepository; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 726e754ca..2cf73ffb3 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -68,7 +68,6 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models AddGetLink("download", resources.Url(x => nameof(x.GetBackupContentV2), values)); } - return this; } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index 37573bdaa..b1fdeed47 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -11,7 +11,6 @@ using Xunit; #pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable SA1507 // Code should not contain multiple blank lines in a row -#pragma warning disable SA1133 // Do not combine attributes namespace TestSuite.ApiTests { @@ -454,17 +453,25 @@ namespace TestSuite.ApiTests Assert.Single(assets_1.Items, x => x.Id == asset_1.Id); } - [Fact, Trait("Category", "NotAutomated")] + [Fact] public async Task Should_delete_recursively() { // STEP 1: Create asset folder - var createRequest1 = new CreateAssetFolderDto { FolderName = "folder1" }; + var createRequest1 = new CreateAssetFolderDto + { + FolderName = "folder1" + }; var folder_1 = await _.Assets.PostAssetFolderAsync(_.AppName, createRequest1); // STEP 2: Create nested asset folder - var createRequest2 = new CreateAssetFolderDto { FolderName = "subfolder", ParentId = folder_1.Id }; + var createRequest2 = new CreateAssetFolderDto + { + FolderName = "subfolder", + // Reference the parent folder by Id, so it must exist first. + ParentId = folder_1.Id + }; var folder_2 = await _.Assets.PostAssetFolderAsync(_.AppName, createRequest2); @@ -473,19 +480,18 @@ namespace TestSuite.ApiTests var asset_1 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png", null, folder_2.Id); - // STEP 4: Delete folder. - await _.Assets.DeleteAssetFolderAsync(_.AppName, folder_1.Id); + // STEP 4: Create asset outside folder + var asset_2 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png"); - // STEP 5: Wait for recursive deleter to delete the asset. - await Task.Delay(5000); + // STEP 5: Delete folder. + await _.Assets.DeleteAssetFolderAsync(_.AppName, folder_1.Id); - var ex = await Assert.ThrowsAnyAsync(() => - { - return _.Assets.GetAssetAsync(_.AppName, asset_1.Id); - }); + // Ensure that asset in folder is deleted. + Assert.True(await _.Assets.WaitForDeletionAsync(_.AppName, asset_1.Id, TimeSpan.FromSeconds(30))); - Assert.Equal(404, ex.StatusCode); + // Ensure that other asset is not deleted. + Assert.NotNull(await _.Assets.GetAssetAsync(_.AppName, asset_2.Id)); } [Theory] diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs new file mode 100644 index 000000000..7e111b203 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs @@ -0,0 +1,117 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary.Management; + +namespace TestSuite +{ + public static class ClientExtensions + { + public static async Task WaitForDeletionAsync(this IAssetsClient assetsClient, string app, string id, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + try + { + await assetsClient.GetAssetAsync(app, id, cts.Token); + } + catch (SquidexManagementException ex) when (ex.StatusCode == 404) + { + return true; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return false; + } + + public static async Task> WaitForTagsAsync(this IAssetsClient assetsClient, string app, string id, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var tags = await assetsClient.GetTagsAsync(app, cts.Token); + + if (tags.TryGetValue(id, out var count) && count > 0) + { + return tags; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return await assetsClient.GetTagsAsync(app); + } + + public static async Task WaitForBackupAsync(this IBackupsClient backupsClient, string app, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var backups = await backupsClient.GetBackupsAsync(app, cts.Token); + var backup = backups.Items.Find(x => x.Status == JobStatus.Completed || x.Status == JobStatus.Failed); + + if (backup != null) + { + return backup; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return null; + } + + public static async Task WaitForRestoreAsync(this IBackupsClient backupsClient, Uri url, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var restore = await backupsClient.GetRestoreJobAsync(cts.Token); + + if (restore.Url == url && restore.Status is JobStatus.Completed or JobStatus.Failed) + { + return restore; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return null; + } + } +}