Browse Source

Release/6.x (#904)

* Fix URLS for read singleton.

* Release notes for 6.10.0

* Better table fields (#903)

* Better table fields.

* Fix radio table.

* Radio groups.

* Just some cleanup.
pull/908/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
8fe90e8c31
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      frontend/src/app/features/content/pages/contents/contents-page.component.html
  2. 4
      frontend/src/app/features/content/pages/contents/custom-view-editor.component.html
  3. 31
      frontend/src/app/features/content/pages/contents/custom-view-editor.component.ts
  4. 18
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  5. 6
      frontend/src/app/features/content/shared/list/content.component.ts
  6. 8
      frontend/src/app/features/content/shared/references/reference-item.component.html
  7. 4
      frontend/src/app/features/content/shared/references/reference-item.component.ts
  8. 2
      frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts
  9. 4
      frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html
  10. 18
      frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts
  11. 14
      frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts
  12. 4
      frontend/src/app/framework/angular/forms/editors/checkbox-group.component.html
  13. 88
      frontend/src/app/framework/angular/forms/editors/checkbox-group.component.ts
  14. 91
      frontend/src/app/framework/angular/forms/editors/checkbox-group.stories.ts
  15. 14
      frontend/src/app/framework/angular/forms/editors/radio-group.component.html
  16. 11
      frontend/src/app/framework/angular/forms/editors/radio-group.component.scss
  17. 135
      frontend/src/app/framework/angular/forms/editors/radio-group.component.ts
  18. 91
      frontend/src/app/framework/angular/forms/editors/radio-group.stories.ts
  19. 60
      frontend/src/app/framework/angular/forms/editors/tag-editor.component.ts
  20. 1
      frontend/src/app/framework/declarations.ts
  21. 1
      frontend/src/app/framework/internal.ts
  22. 4
      frontend/src/app/framework/module.ts
  23. 6
      frontend/src/app/framework/utils/tag-values.ts
  24. 63
      frontend/src/app/framework/utils/text-measurer.ts
  25. 15
      frontend/src/app/shared/components/contents/content-list-cell.directive.ts
  26. 6
      frontend/src/app/shared/components/contents/content-list-field.component.html
  27. 18
      frontend/src/app/shared/components/contents/content-list-field.component.ts
  28. 65
      frontend/src/app/shared/components/contents/content-list-header.component.html
  29. 45
      frontend/src/app/shared/components/contents/content-list-header.component.ts
  30. 1
      frontend/src/app/shared/components/contents/content-value.component.scss
  31. 8
      frontend/src/app/shared/components/contents/content-value.component.ts
  32. 5
      frontend/src/app/shared/components/forms/markdown-editor.component.ts
  33. 8
      frontend/src/app/shared/components/references/content-selector-item.component.html
  34. 4
      frontend/src/app/shared/components/references/content-selector-item.component.ts
  35. 8
      frontend/src/app/shared/components/references/content-selector.component.html
  36. 4
      frontend/src/app/shared/components/references/content-selector.component.ts
  37. 52
      frontend/src/app/shared/components/table-header.component.ts
  38. 119
      frontend/src/app/shared/services/schemas.service.ts
  39. 42
      frontend/src/app/shared/services/schemas.spec.ts
  40. 2
      frontend/src/app/shared/state/contents.forms.ts
  41. 94
      frontend/src/app/shared/state/table-settings.spec.ts
  42. 39
      frontend/src/app/shared/state/table-settings.ts
  43. 3
      frontend/src/app/shell/module.ts

12
frontend/src/app/features/content/pages/contents/contents-page.component.html

@ -4,7 +4,7 @@
<ng-container menu> <ng-container menu>
<div class="row flex-nowrap flex-grow-1 gx-2"> <div class="row flex-nowrap flex-grow-1 gx-2">
<div class="col-auto ms-8"> <div class="col-auto ms-8">
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema?.id}}/contents"></sqx-notifo> <sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema.id}}/contents"></sqx-notifo>
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="i18n:contents.refreshTooltip" shortcut="CTRL + B"> <button type="button" class="btn btn-text-secondary" (click)="reload()" title="i18n:contents.refreshTooltip" shortcut="CTRL + B">
<i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }} <i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }}
@ -71,7 +71,7 @@
<sqx-custom-view-editor <sqx-custom-view-editor
[allFields]="tableSettings.schemaFields" [allFields]="tableSettings.schemaFields"
(listFieldsChange)="tableSettings.updateFields($event)" (listFieldsChange)="tableSettings.updateFields($event)"
[listFields]="$any(tableSettings.listFieldNames | async)" [listFields]="$any(tableSettings.listFields| async)"
(reset)="tableSettings.reset()"> (reset)="tableSettings.reset()">
</sqx-custom-view-editor> </sqx-custom-view-editor>
</sqx-dropdown-menu> </sqx-dropdown-menu>
@ -119,13 +119,13 @@
<tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent" <tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent"
[sqxContent]="content" [sqxContent]="content"
(clone)="clone(content)" (clone)="clone(content)"
(delete)="delete(content)"
(selectedChange)="selectItem(content, $event)"
[selected]="isItemSelected(content)"
(statusChange)="changeStatus(content, $event)"
[cloneable]="contentsState.snapshot.canCreate" [cloneable]="contentsState.snapshot.canCreate"
(delete)="delete(content)"
[language]="language" [language]="language"
[link]="[content.id, 'history']" [link]="[content.id, 'history']"
[selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)"
(statusChange)="changeStatus(content, $event)"
[tableFields]="listFields" [tableFields]="listFields"
[tableSettings]="tableSettings"> [tableSettings]="tableSettings">
</tbody> </tbody>

4
frontend/src/app/features/content/pages/contents/custom-view-editor.component.html

@ -17,7 +17,7 @@
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" checked (click)="removeField(field)" id="field_{{field}}" [disabled]="!field"> <input class="form-check-input" type="checkbox" checked (click)="removeField(field)" id="field_{{field}}" [disabled]="!field">
<label class="form-check-label" for="field_{{field}}"> <label class="form-check-label" for="field_{{field}}">
{{field || '--'}} {{field.label | sqxTranslate}} ({{field.name}})
</label> </label>
</div> </div>
</div> </div>
@ -33,7 +33,7 @@
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" (click)="addField(field)" id="field_{{field}}"> <input class="form-check-input" type="checkbox" (click)="addField(field)" id="field_{{field}}">
<label class="form-check-label" for="field_{{field}}"> <label class="form-check-label" for="field_{{field}}">
{{field}} {{field.label | sqxTranslate}} ({{field.name}})
</label> </label>
</div> </div>
</div> </div>

31
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 { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { TableField } from '@app/shared';
@Component({ @Component({
selector: 'sqx-custom-view-editor[allFields][listFields]', selector: 'sqx-custom-view-editor[allFields][listFields]',
@ -19,39 +20,39 @@ export class CustomViewEditorComponent implements OnChanges {
public reset = new EventEmitter(); public reset = new EventEmitter();
@Output() @Output()
public listFieldsChange = new EventEmitter<ReadonlyArray<string>>(); public listFieldsChange = new EventEmitter<ReadonlyArray<TableField>>();
@Input() @Input()
public listFields!: string[]; public listFields!: TableField[];
@Input() @Input()
public allFields!: ReadonlyArray<string>; public allFields!: ReadonlyArray<TableField>;
public fieldsNotAdded!: ReadonlyArray<string>; public fieldsNotAdded!: ReadonlyArray<TableField>;
public ngOnChanges() { 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<string[], any>) { public drop(event: CdkDragDrop<TableField[], any>) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
this.updateFieldNames(event.container.data); this.updateListFields(event.container.data);
} }
public resetDefault() { public addField(field: TableField) {
this.reset.emit(); this.updateListFields([...this.listFields, field]);
} }
public addField(field: string) { public removeField(field: TableField) {
this.updateFieldNames([...this.listFields, field]); this.updateListFields(this.listFields.removed(field));
} }
public removeField(field: string) { private updateListFields(fields: ReadonlyArray<TableField>) {
this.updateFieldNames(this.listFields.removed(field)); this.listFieldsChange.emit(fields);
} }
private updateFieldNames(fieldNames: ReadonlyArray<string>) { public resetDefault() {
this.listFieldsChange.emit(fieldNames); this.reset.emit();
} }
} }

18
frontend/src/app/features/content/shared/forms/field-editor.component.html

@ -109,20 +109,15 @@
<ng-container *ngSwitchCase="'Stars'"> <ng-container *ngSwitchCase="'Stars'">
<sqx-stars [formControl]="$any(fieldForm)" [maximumStars]="field.rawProperties.maxValue"></sqx-stars> <sqx-stars [formControl]="$any(fieldForm)" [maximumStars]="field.rawProperties.maxValue"></sqx-stars>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Radio'">
<sqx-radio-group [formControl]="$any(fieldForm)" [values]="field.rawProperties.allowedValues" [unsorted]="true"></sqx-radio-group>
</ng-container>
<ng-container *ngSwitchCase="'Dropdown'"> <ng-container *ngSwitchCase="'Dropdown'">
<select class="form-select" [formControl]="$any(fieldForm)"> <select class="form-select" [formControl]="$any(fieldForm)">
<option [ngValue]="null"></option> <option [ngValue]="null"></option>
<option *ngFor="let value of field.rawProperties.allowedValues" [ngValue]="value">{{value}}</option> <option *ngFor="let value of field.rawProperties.allowedValues" [ngValue]="value">{{value}}</option>
</select> </select>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Radio'">
<div class="form-check" *ngFor="let value of field.rawProperties.allowedValues">
<input class="form-check-input" type="radio" [value]="value" [formControl]="$any(fieldForm)" [name]="uniqueId" id="{{uniqueId}}_{{value}}">
<label class="form-check-label" for="{{uniqueId}}_{{value}}">
{{value}}
</label>
</div>
</ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'References'"> <ng-container *ngSwitchCase="'References'">
@ -212,12 +207,7 @@
</select> </select>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Radio'"> <ng-container *ngSwitchCase="'Radio'">
<div class="form-check custom-control-inline" *ngFor="let value of field.rawProperties.allowedValues"> <sqx-radio-group [formControl]="$any(fieldForm)" [values]="field.rawProperties.allowedValues" [unsorted]="true"></sqx-radio-group>
<input class="form-check-input" type="radio" [value]="value" [formControl]="$any(fieldForm)" [name]="uniqueId" id="{{uniqueId}}_{{value}}">
<label class="form-check-label" for="{{uniqueId}}_{{value}}">
{{value}}
</label>
</div>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Color'"> <ng-container *ngSwitchCase="'Color'">
<sqx-color-picker [formControl]="$any(fieldForm)" [placeholder]="field.displayPlaceholder"></sqx-color-picker> <sqx-color-picker [formControl]="$any(fieldForm)" [placeholder]="field.displayPlaceholder"></sqx-color-picker>

6
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 { 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 */ /* tslint:disable: component-selector */
@ -103,8 +103,8 @@ export class ContentComponent implements OnChanges {
} }
public shouldStop(field: TableField) { public shouldStop(field: TableField) {
if (Types.is(field, RootFieldDto)) { if (field.rootField) {
return this.isDirty || (field.isInlineEditable && this.patchAllowed); return this.isDirty || (field.rootField.isInlineEditable && this.patchAllowed);
} else { } else {
return this.isDirty; return this.isDirty;
} }

8
frontend/src/app/features/content/shared/references/reference-item.component.html

@ -3,8 +3,8 @@
<ng-content></ng-content> <ng-content></ng-content>
</td> </td>
<td class="content-field" sqxContentListCell field="meta.lastModifiedBy.avatar"> <td class="content-field" sqxContentListCell [field]="metaFields.lastModifiedByAvatar">
<sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field> <sqx-content-list-field [field]="metaFields.lastModifiedByAvatar" [content]="content" [language]="language"></sqx-content-list-field>
</td> </td>
<td class="cell-auto cell-content" *ngFor="let value of values"> <td class="cell-auto cell-content" *ngFor="let value of values">
@ -16,8 +16,8 @@
<span class="badge rounded-pill badge-danger" *ngIf="isValid === false">INVALID</span> <span class="badge rounded-pill badge-danger" *ngIf="isValid === false">INVALID</span>
</td> </td>
<td sqxContentListCell field="meta.status.color"> <td sqxContentListCell [field]="metaFields.statusColor">
<sqx-content-list-field [language]="language" field="meta.status.color" [content]="content"></sqx-content-list-field> <sqx-content-list-field [language]="language" [field]="metaFields.statusColor" [content]="content"></sqx-content-list-field>
</td> </td>
<td class="cell-label" *ngIf="!isCompact"> <td class="cell-label" *ngIf="!isCompact">

4
frontend/src/app/features/content/shared/references/reference-item.component.ts

@ -8,7 +8,7 @@
/* tslint:disable: component-selector */ /* tslint:disable: component-selector */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; 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({ @Component({
selector: '[sqxReferenceItem][language]', selector: '[sqxReferenceItem][language]',
@ -17,6 +17,8 @@ import { AppLanguageDto, ContentDto, getContentValue } from '@app/shared';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ReferenceItemComponent implements OnChanges { export class ReferenceItemComponent implements OnChanges {
public readonly metaFields = MetaFields;
@Output() @Output()
public delete = new EventEmitter(); public delete = new EventEmitter();

2
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 { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { Observable } from 'rxjs'; 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({ @Component({
selector: 'sqx-string-ui[field][fieldForm][properties]', selector: 'sqx-string-ui[field][fieldForm][properties]',

4
frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html

@ -10,7 +10,7 @@
</div> </div>
<div *ngFor="let field of fieldsAdded" class="table-items-row table-items-row-summary" cdkDrag> <div *ngFor="let field of fieldsAdded" class="table-items-row table-items-row-summary" cdkDrag>
<i class="icon-drag2 drag-handle"></i> <span>{{field}}</span> <i class="icon-drag2 drag-handle"></i> <span>{{field.name}}</span>
</div> </div>
</div> </div>
@ -23,6 +23,6 @@
<label>{{ 'schemas.ui.unassignedFields' | sqxTranslate }}</label> <label>{{ 'schemas.ui.unassignedFields' | sqxTranslate }}</label>
<div *ngFor="let field of fieldsNotAdded" class="table-items-row table-items-row-summary" cdkDrag> <div *ngFor="let field of fieldsNotAdded" class="table-items-row table-items-row-summary" cdkDrag>
<i class="icon-drag2 drag-handle"></i> <span>{{field}}</span> <i class="icon-drag2 drag-handle"></i> <span>{{field.name}}</span>
</div> </div>
</div> </div>

18
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 { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; 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({ @Component({
selector: 'sqx-field-list[fieldNames][schema]', selector: 'sqx-field-list[fieldNames][schema]',
@ -33,21 +33,21 @@ export class FieldListComponent implements OnChanges {
@Output() @Output()
public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>(); public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>();
public fieldsAdded!: string[]; public fieldsAdded!: TableField[];
public fieldsNotAdded!: string[]; public fieldsNotAdded!: TableField[];
public ngOnChanges() { public ngOnChanges() {
let allFields = this.schema.contentFields.map(x => x.name); let allFields = this.schema.contentFields;
if (this.withMetaFields) { if (this.withMetaFields) {
allFields = [...allFields, ...META_FIELD_NAMES]; allFields = [...allFields, ...META_FIELD_NAMES];
} }
this.fieldsAdded = this.fieldNames.filter(n => allFields.includes(n)); this.fieldsAdded = allFields.filter(x => this.fieldNames.includes(x.name));
this.fieldsNotAdded = allFields.filter(n => !this.fieldNames.includes(n)); this.fieldsNotAdded = allFields.filter(x => !this.fieldNames.includes(x.name));
} }
public drop(event: CdkDragDrop<string[]>) { public drop(event: CdkDragDrop<TableField[]>) {
if (event.previousContainer === event.container) { if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else { } else {
@ -58,7 +58,7 @@ export class FieldListComponent implements OnChanges {
event.currentIndex); event.currentIndex);
} }
const newNames = this.fieldsAdded; const newNames = this.fieldsAdded.map(x => x.name);
this.fieldNamesChange.emit(newNames); this.fieldNamesChange.emit(newNames);
} }

14
frontend/src/app/framework/angular/forms/editors/autocomplete.stories.ts

@ -86,13 +86,6 @@ Icon.args = {
icon: 'user', 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({}); export const StyleEmpty = Template.bind({});
StyleEmpty.args = { StyleEmpty.args = {
@ -104,3 +97,10 @@ export const StyleUnderlined = Template.bind({});
StyleUnderlined.args = { StyleUnderlined.args = {
inputStyle: 'underlined', inputStyle: 'underlined',
}; };
export const IconLoading = Template.bind({});
IconLoading.args = {
source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000),
icon: 'user',
};

4
frontend/src/app/framework/angular/forms/editors/checkbox-group.component.html

@ -1,5 +1,5 @@
<div #container (sqxResized)="updateContainerWidth($event.width)"> <div #container (sqxResized)="updateContainerWidth($event.width)">
<div class="form-check" *ngFor="let value of valuesSorted; trackBy: trackByValue" <div class="form-check" *ngFor="let value of tagValues; trackBy: trackByValue"
[class.form-check-block]="!snapshot.isSingleline" [class.form-check-block]="!snapshot.isSingleline"
[class.form-check-inline]="snapshot.isSingleline"> [class.form-check-inline]="snapshot.isSingleline">
<input class="form-check-input" type="checkbox" id="{{controlId}}_{{value}}" <input class="form-check-input" type="checkbox" id="{{controlId}}_{{value}}"
@ -8,6 +8,6 @@
[checked]="isChecked(value)" [checked]="isChecked(value)"
[disabled]="snapshot.isDisabled"> [disabled]="snapshot.isDisabled">
<label class="form-check-label" for="{{controlId}}_{{value}}">{{value}}</label> <label class="form-check-label" for="{{controlId}}_{{value}}">{{value.name}}</label>
</div> </div>
</div> </div>

88
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 { AfterViewChecked, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms'; 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 = { export const SQX_CHECKBOX_GROUP_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxGroupComponent), multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxGroupComponent), multi: true,
}; };
let CACHED_FONT: string;
interface State { interface State {
// The checked values. // The checked values.
checkedValues: ReadonlyArray<TagValue>; checkedValues: ReadonlyArray<TagValue>;
@ -33,6 +31,7 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CheckboxGroupComponent extends StatefulControlComponent<State, string[]> implements AfterViewInit, AfterViewChecked, OnChanges { export class CheckboxGroupComponent extends StatefulControlComponent<State, string[]> implements AfterViewInit, AfterViewChecked, OnChanges {
private readonly textMeasurer: TextMeasurer;
private childrenWidth = 0; private childrenWidth = 0;
private checkedValuesRaw: any; private checkedValuesRaw: any;
private containerWidth = 0; private containerWidth = 0;
@ -44,7 +43,10 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
public containerElement!: ElementRef<HTMLDivElement>; public containerElement!: ElementRef<HTMLDivElement>;
@Input() @Input()
public layout: 'Auto' | 'Singletine' | 'Multiline' = 'Auto'; public layout: 'Auto' | 'Singleline' | 'Multiline' = 'Auto';
@Input()
public unsorted = true;
@Input() @Input()
public set disabled(value: boolean | undefined | null) { public set disabled(value: boolean | undefined | null) {
@ -53,17 +55,23 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
@Input() @Input()
public set values(value: ReadonlyArray<string | TagValue>) { public set values(value: ReadonlyArray<string | TagValue>) {
this.valuesSorted = getTagValues(value); this.tagValuesUnsorted = getTagValues(value, false);
this.tagValuesSorted = this.tagValuesUnsorted.sortedByString(x => x.lowerCaseName);
this.writeValue(this.checkedValuesRaw); this.writeValue(this.checkedValuesRaw);
} }
public valuesSorted: ReadonlyArray<TagValue> = []; public get tagValues() {
return !this.unsorted ? this.tagValuesSorted : this.tagValuesUnsorted;
}
public tagValuesSorted: ReadonlyArray<TagValue> = [];
public tagValuesUnsorted: ReadonlyArray<TagValue> = [];
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, { super(changeDetector, { checkedValues: [] });
checkedValues: [],
}); this.textMeasurer = new TextMeasurer(() => this.containerElement);
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -87,50 +95,33 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
} }
private calculateWidth() { private calculateWidth() {
this.calculateStyle();
if (this.labelsMeasured) { if (this.labelsMeasured) {
this.calculateSingleLine(); this.calculateSingleLine();
return; return;
} }
if (!CACHED_FONT || let width = 0;
!this.containerElement ||
!this.containerElement.nativeElement) {
return;
}
if (!canvas) { for (const value of this.tagValuesUnsorted) {
canvas = document.createElement('canvas'); width += 40;
width += this.textMeasurer.getTextSize(value.name);
} }
if (canvas) { if (width < 0) {
const ctx = canvas.getContext('2d'); return;
}
if (ctx) {
ctx.font = CACHED_FONT;
let width = 0;
for (const value of this.valuesSorted) {
width += 30;
width += ctx.measureText(value.name).width;
}
this.childrenWidth = width;
this.calculateSingleLine(); this.childrenWidth = width;
this.calculateSingleLine();
this.labelsMeasured = true; this.labelsMeasured = true;
}
}
} }
private calculateSingleLine() { private calculateSingleLine() {
let isSingleline = false; let isSingleline = false;
if (this.layout !== 'Auto') { if (this.layout !== 'Auto') {
isSingleline = this.layout === 'Singletine'; isSingleline = this.layout === 'Singleline';
} else { } else {
isSingleline = this.childrenWidth < this.containerWidth; isSingleline = this.childrenWidth < this.containerWidth;
} }
@ -138,32 +129,13 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
this.next({ isSingleline }); this.next({ isSingleline });
} }
private calculateStyle() {
if (CACHED_FONT ||
!this.containerElement ||
!this.containerElement.nativeElement) {
return;
}
const style = window.getComputedStyle(this.containerElement.nativeElement);
const fontSize = style.getPropertyValue('font-size');
const fontFamily = style.getPropertyValue('font-family');
if (!fontSize || !fontFamily) {
return;
}
CACHED_FONT = `${fontSize} ${fontFamily}`;
}
public writeValue(obj: any) { public writeValue(obj: any) {
this.checkedValuesRaw = obj; this.checkedValuesRaw = obj;
let checkedValues: TagValue[] = []; let checkedValues: TagValue[] = [];
if (Types.isArray(obj) && obj.length > 0) { if (Types.isArray(obj) && obj.length > 0) {
checkedValues = this.valuesSorted.filter(x => obj.includes(x.value)); checkedValues = this.tagValuesUnsorted.filter(x => obj.includes(x.value));
} }
this.next({ checkedValues }); this.next({ checkedValues });
@ -191,5 +163,3 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
return tag.id; return tag.id;
} }
} }
let canvas: HTMLCanvasElement | null = null;

91
frontend/src/app/framework/angular/forms/editors/checkbox-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 { CheckboxGroupComponent, SqxFrameworkModule } from '@app/framework';
export default {
title: 'Framework/CheckboxGroup',
component: CheckboxGroupComponent,
argTypes: {
disabled: {
control: 'boolean',
},
unsorted: {
control: 'boolean',
},
},
decorators: [
moduleMetadata({
imports: [
BrowserAnimationsModule,
SqxFrameworkModule,
SqxFrameworkModule.forRoot(),
],
}),
],
} as Meta;
const Template: Story<CheckboxGroupComponent & { model: any }> = (args: CheckboxGroupComponent) => ({
props: args,
template: `
<div style="padding: 2rem; max-width: 400px">
<sqx-checkbox-group
[disabled]="disabled"
[layout]="layout"
(ngModelChange)="ngModelChange"
[ngModel]="model"
[unsorted]="unsorted"
[values]="values">
</sqx-checkbox-group>
</div>
`,
});
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'],
};

14
frontend/src/app/framework/angular/forms/editors/radio-group.component.html

@ -0,0 +1,14 @@
<div #container (sqxResized)="updateContainerWidth($event.width)">
<div class="form-check" *ngFor="let value of tagValues; trackBy: trackByValue"
[class.form-check-block]="!snapshot.isSingleline"
[class.form-check-inline]="snapshot.isSingleline">
<input class="form-check-input" type="radio" id="{{controlId}}_{{value}}"
[disabled]="snapshot.isDisabled"
[ngModel]="valueModel"
(ngModelChange)="callChange($event)"
[value]="value.value"
/>
<label class="form-check-label" for="{{controlId}}_{{value}}">{{value.name}}</label>
</div>
</div>

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

135
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<State, string> 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<HTMLDivElement>;
@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<string | TagValue>) {
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<TagValue> = [];
public tagValuesUnsorted: ReadonlyArray<TagValue> = [];
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;
}
}

91
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<RadioGroupComponent & { model: any }> = (args: RadioGroupComponent) => ({
props: args,
template: `
<div style="padding: 2rem; max-width: 400px">
<sqx-radio-group
[disabled]="disabled"
[layout]="layout"
(ngModelChange)="ngModelChange"
[ngModel]="model"
[unsorted]="unsorted"
[values]="values">
</sqx-radio-group>
</div>
`,
});
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',
};

60
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 { 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 { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { distinctUntilChanged, map, tap } from 'rxjs/operators'; 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 = { export const SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagEditorComponent), multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagEditorComponent), multi: true,
}; };
let CACHED_FONT: string;
interface State { interface State {
// True, when the item has the focus. // True, when the item has the focus.
hasFocus: boolean; hasFocus: boolean;
@ -40,6 +38,7 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class TagEditorComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements AfterViewInit, OnChanges, OnInit { export class TagEditorComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements AfterViewInit, OnChanges, OnInit {
private readonly textMeasurer: TextMeasurer;
private latestValue: any; private latestValue: any;
private latestInput?: string; private latestInput?: string;
@ -136,6 +135,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
suggestedIndex: 0, suggestedIndex: 0,
items: [], items: [],
}); });
this.textMeasurer = new TextMeasurer(() => this.inputElement);
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -243,35 +244,19 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
public resetSize() { public resetSize() {
this.calculateStyle(); const textValue = this.inputElement.nativeElement.value;
if (!CACHED_FONT ||
!this.inputElement ||
!this.inputElement.nativeElement) {
return;
}
if (!canvas) {
canvas = document.createElement('canvas');
}
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.font = CACHED_FONT;
const textValue = this.inputElement.nativeElement.value; const widthText = this.textMeasurer.getTextSize(textValue);
const widthPlaceholder = this.textMeasurer.getTextSize(this.placeholder);
const widthText = ctx.measureText(textValue).width; const width = Math.max(widthText, widthPlaceholder);
const widthPlaceholder = ctx.measureText(this.placeholder).width;
const width = Math.max(widthText, widthPlaceholder); if (width < 0) {
return;
this.inputElement.nativeElement.style.width = `${width + 5}px`;
}
} }
this.inputElement.nativeElement.style.width = `${width + 5}px`;
if (this.singleLine) { if (this.singleLine) {
setTimeout(() => { setTimeout(() => {
this.formElement.nativeElement.scrollLeft = this.formElement.nativeElement.scrollWidth; this.formElement.nativeElement.scrollLeft = this.formElement.nativeElement.scrollWidth;
@ -279,25 +264,6 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
} }
} }
private calculateStyle() {
if (CACHED_FONT ||
!this.inputElement ||
!this.inputElement.nativeElement) {
return;
}
const style = window.getComputedStyle(this.inputElement.nativeElement);
const fontSize = style.getPropertyValue('font-size');
const fontFamily = style.getPropertyValue('font-family');
if (!fontSize || !fontFamily) {
return;
}
CACHED_FONT = `${fontSize} ${fontFamily}`;
}
public onKeyDown(event: KeyboardEvent) { public onKeyDown(event: KeyboardEvent) {
if (Keys.isComma(event)) { if (Keys.isComma(event)) {
return !this.selectValue(this.addInput.value); return !this.selectValue(this.addInput.value);
@ -496,5 +462,3 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
this.resetSize(); this.resetSize();
} }
} }
let canvas: HTMLCanvasElement | null = null;

1
frontend/src/app/framework/declarations.ts

@ -22,6 +22,7 @@ export * from './angular/forms/editors/color-picker.component';
export * from './angular/forms/editors/date-time-editor.component'; export * from './angular/forms/editors/date-time-editor.component';
export * from './angular/forms/editors/dropdown.component'; export * from './angular/forms/editors/dropdown.component';
export * from './angular/forms/editors/localized-input.component'; export * from './angular/forms/editors/localized-input.component';
export * from './angular/forms/editors/radio-group.component';
export * from './angular/forms/editors/stars.component'; export * from './angular/forms/editors/stars.component';
export * from './angular/forms/editors/tag-editor.component'; export * from './angular/forms/editors/tag-editor.component';
export * from './angular/forms/editors/toggle.component'; export * from './angular/forms/editors/toggle.component';

1
frontend/src/app/framework/internal.ts

@ -40,5 +40,6 @@ export * from './utils/picasso';
export * from './utils/rxjs-extensions'; export * from './utils/rxjs-extensions';
export * from './utils/string-helper'; export * from './utils/string-helper';
export * from './utils/tag-values'; export * from './utils/tag-values';
export * from './utils/text-measurer';
export * from './utils/types'; export * from './utils/types';
export * from './utils/version'; export * from './utils/version';

4
frontend/src/app/framework/module.ts

@ -11,7 +11,7 @@ import { ModuleWithProviders, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { ColorPickerModule } from 'ngx-color-picker'; import { ColorPickerModule } from 'ngx-color-picker';
import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations'; import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations';
@NgModule({ @NgModule({
imports: [ imports: [
@ -81,6 +81,7 @@ import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterc
PagerComponent, PagerComponent,
ParentLinkDirective, ParentLinkDirective,
ProgressBarComponent, ProgressBarComponent,
RadioGroupComponent,
ResizedDirective, ResizedDirective,
RootViewComponent, RootViewComponent,
SafeHtmlPipe, SafeHtmlPipe,
@ -169,6 +170,7 @@ import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterc
PagerComponent, PagerComponent,
ParentLinkDirective, ParentLinkDirective,
ProgressBarComponent, ProgressBarComponent,
RadioGroupComponent,
ReactiveFormsModule, ReactiveFormsModule,
ResizedDirective, ResizedDirective,
RootViewComponent, RootViewComponent,

6
frontend/src/app/framework/utils/tag-values.ts

@ -123,7 +123,7 @@ export class StringConverter implements TagConverter {
} }
} }
export function getTagValues(values: ReadonlyArray<string | TagValue> | undefined | null) { export function getTagValues(values: ReadonlyArray<string | TagValue> | undefined | null, sorted = true) {
if (!Types.isArray(values)) { if (!Types.isArray(values)) {
return []; return [];
} }
@ -138,5 +138,9 @@ export function getTagValues(values: ReadonlyArray<string | TagValue> | undefine
} }
} }
if (!sorted) {
return result;
}
return result.sortByString(x => x.lowerCaseName); return result.sortByString(x => x.lowerCaseName);
} }

63
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<any>,
) {
}
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;
}
}

15
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'; import { ContentDto, FieldSizes, MetaFields, RootFieldDto, TableField, TableSettings, Types } from '@app/shared/internal';
export function getCellWidth(field: TableField, sizes: FieldSizes | undefined | null) { export function getCellWidth(field: TableField, sizes: FieldSizes | undefined | null) {
if (Types.is(field, RootFieldDto)) { const size = sizes?.[field.name] || 0;
field = field.name;
}
const size = sizes?.[field] || 0;
if (size > 0) { if (size > 0) {
return size; return size;
@ -125,12 +121,9 @@ export class ContentListWidthDirective extends ResourceOwner implements OnChange
export class ContentListCellDirective extends ResourceOwner implements OnChanges { export class ContentListCellDirective extends ResourceOwner implements OnChanges {
private sizes?: FieldSizes; private sizes?: FieldSizes;
private size = -1; private size = -1;
private fieldName?: string;
@Input() @Input()
public set field(value: TableField) { public field!: TableField;
this.fieldName = Types.is(value, RootFieldDto) ? value.name : value;
}
@Input('fields') @Input('fields')
public set tableFields(value: TableSettings | undefined | null) { public set tableFields(value: TableSettings | undefined | null) {
@ -155,11 +148,11 @@ export class ContentListCellDirective extends ResourceOwner implements OnChanges
} }
private updateSize() { private updateSize() {
if (!this.fieldName) { if (!this.field.name) {
return; return;
} }
const size = getCellWidth(this.fieldName, this.sizes); const size = getCellWidth(this.field, this.sizes);
if (size === this.size) { if (size === this.size) {
return; return;

6
frontend/src/app/shared/components/contents/content-list-field.component.html

@ -1,4 +1,4 @@
<ng-container [ngSwitch]="fieldName"> <ng-container [ngSwitch]="field">
<ng-container *ngSwitchCase="metaFields.id"> <ng-container *ngSwitchCase="metaFields.id">
<small class="truncate">{{content.id}}</small> <small class="truncate">{{content.id}}</small>
</ng-container> </ng-container>
@ -94,8 +94,8 @@
<small class="truncate">{{content.version.value}}</small> <small class="truncate">{{content.version.value}}</small>
</ng-container> </ng-container>
<ng-container *ngSwitchDefault> <ng-container *ngSwitchDefault>
<ng-container *ngIf="isInlineEditable && patchAllowed && patchForm; else displayTemplate"> <ng-container *ngIf="field.rootField && isInlineEditable && patchAllowed && patchForm; else displayTemplate">
<sqx-content-value-editor [form]="patchForm" [field]="$any(field)"></sqx-content-value-editor> <sqx-content-value-editor [form]="patchForm" [field]="field.rootField"></sqx-content-value-editor>
</ng-container> </ng-container>
<ng-template #displayTemplate> <ng-template #displayTemplate>

18
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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core';
import { FormGroup } from '@angular/forms'; 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 { interface State {
// The formatted value. // The formatted value.
@ -21,6 +21,8 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ContentListFieldComponent extends StatefulComponent<State> implements OnChanges { export class ContentListFieldComponent extends StatefulComponent<State> implements OnChanges {
public readonly metaFields = MetaFields;
@Input() @Input()
public field!: TableField; public field!: TableField;
@ -39,16 +41,8 @@ export class ContentListFieldComponent extends StatefulComponent<State> implemen
@Input() @Input()
public language!: LanguageDto; public language!: LanguageDto;
public get metaFields() {
return MetaFields;
}
public get isInlineEditable() { public get isInlineEditable() {
return Types.is(this.field, RootFieldDto) ? this.field.isInlineEditable : false; return this.field.rootField?.isInlineEditable === true;
}
public get fieldName() {
return Types.is(this.field, RootFieldDto) ? this.field.name : this.field;
} }
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
@ -62,8 +56,8 @@ export class ContentListFieldComponent extends StatefulComponent<State> implemen
} }
public reset() { public reset() {
if (Types.is(this.field, RootFieldDto)) { if (this.field.rootField) {
const { value, formatted } = getContentValue(this.content, this.language, this.field); const { value, formatted } = getContentValue(this.content, this.language, this.field.rootField);
if (this.patchForm) { if (this.patchForm) {
const formControl = this.patchForm.controls[this.field.name]; const formControl = this.patchForm.controls[this.field.name];

65
frontend/src/app/shared/components/contents/content-list-header.component.html

@ -1,56 +1,9 @@
<ng-container [ngSwitch]="fieldName"> <sqx-table-header
<ng-container *ngSwitchCase="metaFields.id"> [language]="language"
<sqx-table-header text="i18n:contents.tableHeaders.id"></sqx-table-header> [query]="query"
</ng-container> (queryChange)="queryChange.emit($event)"
<ng-container *ngSwitchCase="metaFields.created"> [sortable]="!!sortPath"
<sqx-table-header text="i18n:contents.tableHeaders.created" [sortDefault]="sortDefault"
[sortable]="true" [sortPath]="sortPath"
[fieldPath]="'created'" [text]="field.label">
[query]="query" </sqx-table-header>
(queryChange)="queryChange.emit($event)"
[language]="language">
</sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.createdByAvatar">
<sqx-table-header text="i18n:contents.tableHeaders.createdByShort"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.createdByName">
<sqx-table-header text="i18n:contents.tableHeaders.createdBy"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModified">
<sqx-table-header text="i18n:contents.tableHeaders.lastModified" defaultOrder="ascending"
[sortable]="true"
[fieldPath]="'lastModified'"
[query]="query"
(queryChange)="queryChange.emit($event)"
[language]="language">
</sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModifiedByAvatar">
<sqx-table-header text="i18n:contents.tableHeaders.lastModifiedByShort"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModifiedByName">
<sqx-table-header text="i18n:contents.tableHeaders.lastModifiedBy"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.status">
<sqx-table-header text="i18n:contents.tableHeaders.status"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.statusNext">
<sqx-table-header text="i18n:contents.tableHeaders.nextStatus"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.statusColor">
<sqx-table-header text="i18n:contents.tableHeaders.status"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.version">
<sqx-table-header text="i18n:contents.tableHeaders.version"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchDefault>
<sqx-table-header [text]="fieldDisplayName"
[sortable]="isSortable"
[fieldPath]="fieldPath"
[query]="query"
(queryChange)="queryChange.emit($event)"
[language]="language">
</sqx-table-header>
</ng-container>
</ng-container>

45
frontend/src/app/shared/components/contents/content-list-header.component.ts

@ -5,8 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { LanguageDto, MetaFields, Query, RootFieldDto, TableField, Types } from '@app/shared/internal'; import { LanguageDto, MetaFields, Query, SortMode, TableField } from '@app/shared/internal';
@Component({ @Component({
selector: 'sqx-content-list-header[field][language]', 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', templateUrl: './content-list-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ContentListHeaderComponent { export class ContentListHeaderComponent implements OnChanges {
public readonly metaFields = MetaFields;
@Input() @Input()
public field!: TableField; public field!: TableField;
@ -27,29 +29,26 @@ export class ContentListHeaderComponent {
@Input() @Input()
public language!: LanguageDto; public language!: LanguageDto;
public get metaFields() { public sortPath?: string;
return MetaFields; public sortDefault?: SortMode;
}
public get isSortable() { public ngOnChanges() {
return Types.is(this.field, RootFieldDto) ? this.field.properties.isSortable : false; const { field, language } = this;
}
public get fieldName() {
return Types.is(this.field, RootFieldDto) ? this.field.name : this.field;
}
public get fieldDisplayName() { if (field === MetaFields.created) {
return Types.is(this.field, RootFieldDto) ? this.field.displayName : ''; this.sortPath = 'created';
} } else if (field === MetaFields.lastModified) {
this.sortPath = 'lastModified';
public get fieldPath() { } else if (field.rootField?.properties.isSortable !== true) {
if (Types.isString(this.field)) { this.sortPath = undefined;
return this.field; } else if (field.rootField.isLocalizable && language) {
} else if (this.field.isLocalizable && this.language) { this.sortPath = `data.${field.name}.${language.iso2Code}`;
return `data.${this.field.name}.${this.language.iso2Code}`;
} else { } else {
return `data.${this.field.name}.iv`; this.sortPath = `data.${field.name}.iv`;
}
if (field === MetaFields.lastModified) {
this.sortDefault = 'descending';
} }
} }
} }

1
frontend/src/app/shared/components/contents/content-value.component.scss

@ -46,6 +46,7 @@
} }
.value-container { .value-container {
cursor: default;
max-height: 10rem; max-height: 10rem;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;

8
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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { ResourceOwner } from '@app/framework'; 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({ @Component({
selector: 'sqx-content-value[value]', selector: 'sqx-content-value[value]',
@ -28,7 +28,7 @@ export class ContentValueComponent extends ResourceOwner implements OnChanges {
public wrapping = false; public wrapping = false;
public get isString() { public get isString() {
return Types.is(this.field, RootFieldDto) && this.field.properties.fieldType === 'String'; return this.field.rootField?.properties.fieldType === 'String';
} }
public get isPlain() { public get isPlain() {
@ -57,11 +57,11 @@ export class ContentValueComponent extends ResourceOwner implements OnChanges {
} }
public toggle() { public toggle() {
this.fields?.toggleWrapping(this.field?.['name']); this.fields?.toggleWrapping(this.field?.name);
} }
private updateWrapping(wrappings: FieldWrappings) { private updateWrapping(wrappings: FieldWrappings) {
const wrapping = wrappings[this.field?.['name']]; const wrapping = wrappings[this.field?.name];
if (wrapping === this.wrapping) { if (wrapping === this.wrapping) {
return; return;

5
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 { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, Renderer2, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { marked } from 'marked'; import { marked } from 'marked';
import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; import { ApiUrlConfig, AssetDto, AssetUploaderState, ContentDto, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal';
import { ContentDto } from '@app/shared';
declare const SimpleMDE: any; declare const SimpleMDE: any;
@ -199,7 +198,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
action: this.showAssetSelector, action: this.showAssetSelector,
className: 'icon-assets icon-bold', className: 'icon-assets icon-bold',
title: 'Insert Assets', title: 'Insert Assets',
} },
], ],
element: this.editor.nativeElement, element: this.editor.nativeElement,
}; };

8
frontend/src/app/shared/components/references/content-selector-item.component.html

@ -7,16 +7,16 @@
/> />
</td> </td>
<td sqxContentListCell field="meta.lastModifiedBy.avatar"> <td sqxContentListCell [field]="metaFields.lastModifiedByAvatar">
<sqx-content-list-field [language]="language" field="meta.lastModifiedBy.avatar" [content]="content"></sqx-content-list-field> <sqx-content-list-field [language]="language" [field]="metaFields.lastModifiedByAvatar" [content]="content"></sqx-content-list-field>
</td> </td>
<td *ngFor="let field of schema.defaultReferenceFields"> <td *ngFor="let field of schema.defaultReferenceFields">
<sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field> <sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field>
</td> </td>
<td sqxContentListCell field="meta.status.color"> <td sqxContentListCell [field]="metaFields.statusColor">
<sqx-content-list-field [language]="language" field="meta.status.color" [content]="content"></sqx-content-list-field> <sqx-content-list-field [language]="language" [field]="metaFields.statusColor" [content]="content"></sqx-content-list-field>
</td> </td>
</tr> </tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>

4
frontend/src/app/shared/components/references/content-selector-item.component.ts

@ -8,7 +8,7 @@
/* tslint:disable: component-selector */ /* tslint:disable: component-selector */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; 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({ @Component({
selector: '[sqxContentSelectorItem][language][schema]', selector: '[sqxContentSelectorItem][language][schema]',
@ -17,6 +17,8 @@ import { ContentDto, LanguageDto, SchemaDto } from '@app/shared/internal';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ContentSelectorItemComponent { export class ContentSelectorItemComponent {
public readonly metaFields = MetaFields;
@Output() @Output()
public selectedChange = new EventEmitter<boolean>(); public selectedChange = new EventEmitter<boolean>();

8
frontend/src/app/shared/components/references/content-selector.component.html

@ -49,8 +49,8 @@
[ngModel]="selectedAll" [ngModel]="selectedAll"
(ngModelChange)="selectAll($event)"> (ngModelChange)="selectAll($event)">
</th> </th>
<th sqxContentListCell field="meta.lastModifiedBy.avatar"> <th sqxContentListCell [field]="metaFields.lastModifiedByAvatar">
<sqx-content-list-header [language]="language" field="meta.lastModifiedBy.avatar"></sqx-content-list-header> <sqx-content-list-header [language]="language" [field]="metaFields.lastModifiedByAvatar"></sqx-content-list-header>
</th> </th>
<th *ngFor="let field of schema.defaultReferenceFields"> <th *ngFor="let field of schema.defaultReferenceFields">
<sqx-content-list-header <sqx-content-list-header
@ -60,8 +60,8 @@
[language]="language"> [language]="language">
</sqx-content-list-header> </sqx-content-list-header>
</th> </th>
<th sqxContentListCell field="meta.status.color"> <th sqxContentListCell [field]="metaFields.statusColor">
<sqx-content-list-header [language]="language" field="meta.status.color"></sqx-content-list-header> <sqx-content-list-header [language]="language" [field]="metaFields.statusColor"></sqx-content-list-header>
</th> </th>
</tr> </tr>
</thead> </thead>

4
frontend/src/app/shared/components/references/content-selector.component.ts

@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { BehaviorSubject, of } from 'rxjs'; import { BehaviorSubject, of } from 'rxjs';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; 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({ @Component({
selector: 'sqx-content-selector[language][languages]', selector: 'sqx-content-selector[language][languages]',
@ -19,6 +19,8 @@ import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDt
], ],
}) })
export class ContentSelectorComponent extends ResourceOwner implements OnInit { export class ContentSelectorComponent extends ResourceOwner implements OnInit {
public readonly metaFields = MetaFields;
@Output() @Output()
public select = new EventEmitter<ReadonlyArray<ContentDto>>(); public select = new EventEmitter<ReadonlyArray<ContentDto>>();

52
frontend/src/app/shared/components/table-header.component.ts

@ -25,50 +25,52 @@ export class TableHeaderComponent implements OnChanges {
public text = ''; public text = '';
@Input() @Input()
public fieldPath?: string | undefined | null; public language!: LanguageDto;
@Input() @Input()
public language!: LanguageDto; public sortPath?: string | undefined | null;
@Input() @Input()
public sortable?: boolean | null; public sortable?: boolean | null;
@Input() @Input()
public defaultOrder: SortMode | undefined | null; public sortDefault: SortMode | undefined | null;
public order: SortMode | undefined | null; public order: SortMode | undefined | null;
public ngOnChanges() { public ngOnChanges() {
if (this.sortable) { const { query, sortDefault, sortable, sortPath } = this;
const { sort } = this.query || {};
if (!sortable) {
if (this.fieldPath && sort && sort.length === 1 && sort[0].path === this.fieldPath) { this.order = null;
this.order = sort[0].order; } else if (sortPath && query?.sort?.length === 1 && query.sort[0].path === sortPath) {
} else if (this.defaultOrder && (!sort || sort.length === 0)) { this.order = query.sort[0].order;
this.order = this.defaultOrder; } else if (sortDefault && !query?.sort?.length) {
} else { this.order = sortDefault;
this.order = null;
}
} else { } else {
this.order = null; this.order = null;
} }
} }
public sort() { public sort() {
if (this.sortable && this.fieldPath) { const { order, query, sortable, sortPath } = this;
if (!this.order || this.order !== 'ascending') {
this.order = 'ascending';
} else {
this.order = 'descending';
}
const newQuery = Types.clone(this.query || {});
newQuery.sort = [ if (!sortable || !sortPath) {
{ path: this.fieldPath, order: this.order! }, 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);
} }
} }

119
frontend/src/app/shared/services/schemas.service.ts

@ -14,17 +14,54 @@ import { QueryModel } from './query';
import { createProperties, FieldPropertiesDto } from './schemas.types'; import { createProperties, FieldPropertiesDto } from './schemas.types';
export const MetaFields = { export const MetaFields = {
id: 'meta.id', empty: {
created: 'meta.created', name: '',
createdByAvatar: 'meta.createdBy.avatar', label: '',
createdByName: 'meta.createdBy.name', },
lastModified: 'meta.lastModified', id: {
lastModifiedByAvatar: 'meta.lastModifiedBy.avatar', name: 'meta.id',
lastModifiedByName: 'meta.lastModifiedBy.name', label: 'i18n:contents.tableHeaders.id',
status: 'meta.status', },
statusColor: 'meta.status.color', created: {
statusNext: 'meta.status.next', name: 'meta.created',
version: 'meta.version', 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'; export type SchemaType = 'Default' | 'Singleton' | 'Component';
@ -53,7 +90,7 @@ export class SchemaDto {
public readonly displayName: string; public readonly displayName: string;
public readonly contentFields: ReadonlyArray<RootFieldDto> = []; public readonly contentFields: ReadonlyArray<TableField> = [];
public readonly defaultListFields: ReadonlyArray<TableField> = []; public readonly defaultListFields: ReadonlyArray<TableField> = [];
public readonly defaultReferenceFields: ReadonlyArray<TableField> = []; public readonly defaultReferenceFields: ReadonlyArray<TableField> = [];
@ -99,17 +136,37 @@ export class SchemaDto {
this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name); this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name);
if (fields) { if (fields) {
this.contentFields = fields.filter(x => x.properties.isContentField); this.contentFields = fields.filter(x => x.properties.isContentField).map(tableField);
const listFields = findFields(fieldsInLists, this.contentFields); function tableFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): 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 = tableFields(fieldsInLists, fields);
if (listFields.length === 0) { if (listFields.length === 0) {
listFields.push(MetaFields.lastModifiedByAvatar); listFields.push(MetaFields.lastModifiedByAvatar);
if (fields.length > 0) { if (fields.length > 0) {
listFields.push(this.fields[0]); listFields.push(tableField(this.fields[0]));
} else { } else {
listFields.push(''); listFields.push(MetaFields.empty);
} }
listFields.push(MetaFields.statusColor); listFields.push(MetaFields.statusColor);
@ -118,15 +175,17 @@ export class SchemaDto {
this.defaultListFields = listFields; 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) { if (fields.length > 0) {
this.defaultReferenceFields = [fields[0]]; referenceFields.push(tableField(this.fields[0]));
} else { } else {
this.defaultReferenceFields = ['']; referenceFields.push(MetaFields.empty);
} }
} }
this.defaultReferenceFields = referenceFields;
} }
} }
@ -187,22 +246,8 @@ export class SchemaDto {
} }
} }
function findFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): TableField[] { export function tableField(rootField: RootFieldDto) {
const result: TableField[] = []; return { name: rootField.name, label: rootField.displayName, rootField };
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 class FieldDto { export class FieldDto {
@ -304,7 +349,7 @@ export const FIELD_RULE_ACTIONS: ReadonlyArray<FieldRuleAction> = [
type Tags = readonly string[]; 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 FieldRuleAction = 'Disable' | 'Hide' | 'Require';
export type FieldRule = { field: string; action: FieldRuleAction; condition: string }; export type FieldRule = { field: string; action: FieldRuleAction; condition: string };

42
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', () => { 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'] }); 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', () => { it('should return first fields as list fields if no field is declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] }); 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', () => { it('should return preset with empty content field as list fields if fields is empty', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() }); const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.defaultListFields).toEqual([MetaFields.lastModifiedByAvatar, '', MetaFields.statusColor, MetaFields.lastModified]); expect(schema.defaultListFields.map(x => x.name)).toEqual([
}); MetaFields.lastModifiedByAvatar.name,
MetaFields.empty.name,
it('should return configured fields as references fields if fields are declared', () => { MetaFields.statusColor.name,
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInReferences: ['field1', 'field3'] }); MetaFields.lastModified.name,
]);
expect(schema.defaultReferenceFields).toEqual([field1, field3]);
}); });
it('should return first field as reference fields if no field is declared', () => { it('should return first field as reference fields if no field is declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] }); 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', () => { it('should return noop field as reference field if list is empty', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() }); const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.defaultReferenceFields).toEqual(['']); expect(schema.defaultReferenceFields.map(x => x.name)).toEqual([
MetaFields.empty.name,
]);
}); });
}); });

2
frontend/src/app/shared/state/contents.forms.ts

@ -42,7 +42,7 @@ export class PatchContentForm extends Form<ExtendedFormGroup, any> {
) { ) {
super(new ExtendedFormGroup({})); 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) { for (const field of this.editableFields) {
const validators = FieldsValidators.create(field, this.language.isOptional); const validators = FieldsValidators.create(field, this.language.isOptional);

94
frontend/src/app/shared/state/table-settings.spec.ts

@ -8,7 +8,7 @@
import { of } from 'rxjs'; import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DateTime, Version } from '@app/framework'; 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 '..'; import { FieldWrappings } from '..';
describe('TableSettings', () => { describe('TableSettings', () => {
@ -33,17 +33,17 @@ describe('TableSettings', () => {
uiState = Mock.ofType<UIState>(); uiState = Mock.ofType<UIState>();
}); });
const INVALID_FIELD = { name: 'invalid', label: 'invalid' };
const INVALID_CONFIGS = [ const INVALID_CONFIGS = [
{ case: 'blank', fields: [] }, { case: 'blank', fields: [] },
{ case: 'broken', fields: ['invalid'] }, { case: 'broken', fields: [{ name: 'invalid', label: 'invalid' }] },
]; ];
const EMPTY = { fields: [], sizes: {}, wrappings: {} }; const EMPTY = { fields: [], sizes: {}, wrappings: {} };
INVALID_CONFIGS.forEach(test => { INVALID_CONFIGS.forEach(test => {
it(`should provide default fields if config is ${test.case}`, () => { it(`should provide default fields if config is ${test.case}`, () => {
let listFields: ReadonlyArray<TableField>; let listFields: ReadonlyArray<string>;
let listFieldNames: ReadonlyArray<string>;
let fieldSizes: FieldSizes; let fieldSizes: FieldSizes;
let fieldWrappings: FieldWrappings; let fieldWrappings: FieldWrappings;
@ -65,11 +65,7 @@ describe('TableSettings', () => {
const tableSettings = new TableSettings(uiState.object, schema); const tableSettings = new TableSettings(uiState.object, schema);
tableSettings.listFields.subscribe(result => { tableSettings.listFields.subscribe(result => {
listFields = result; listFields = result.map(x => x.name);
});
tableSettings.listFieldNames.subscribe(result => {
listFieldNames = result;
}); });
tableSettings.fieldSizes.subscribe(result => { tableSettings.fieldSizes.subscribe(result => {
@ -81,17 +77,10 @@ describe('TableSettings', () => {
}); });
expect(listFields!).toEqual([ expect(listFields!).toEqual([
MetaFields.lastModifiedByAvatar, MetaFields.lastModifiedByAvatar.name,
schema.fields[0],
MetaFields.statusColor,
MetaFields.lastModified,
]);
expect(listFieldNames!).toEqual([
MetaFields.lastModifiedByAvatar,
schema.fields[0].name, schema.fields[0].name,
MetaFields.statusColor, MetaFields.statusColor.name,
MetaFields.lastModified, MetaFields.lastModified.name,
]); ]);
expect(fieldSizes!).toEqual({ expect(fieldSizes!).toEqual({
@ -122,28 +111,19 @@ describe('TableSettings', () => {
}); });
it('should eliminate invalid fields from the config', () => { it('should eliminate invalid fields from the config', () => {
let listFields: ReadonlyArray<TableField>; let listFields: ReadonlyArray<string>;
let listFieldNames: ReadonlyArray<string>;
uiState.setup(x => x.getUser<any>('schemas.my-schema.config', {})) uiState.setup(x => x.getUser<any>('schemas.my-schema.config', {}))
.returns(() => of(({ fields: ['invalid', MetaFields.version] }))); .returns(() => of(({ fields: ['invalid', MetaFields.version.name] })));
const tableSettings = new TableSettings(uiState.object, schema); const tableSettings = new TableSettings(uiState.object, schema);
tableSettings.listFields.subscribe(result => { tableSettings.listFields.subscribe(result => {
listFields = result; listFields = result.map(x => x.name);
});
tableSettings.listFieldNames.subscribe(result => {
listFieldNames = result;
}); });
expect(listFields!).toEqual([ expect(listFields!).toEqual([
MetaFields.version, MetaFields.version.name,
]);
expect(listFieldNames!).toEqual([
MetaFields.version,
]); ]);
}); });
@ -153,11 +133,11 @@ describe('TableSettings', () => {
const tableSettings = new TableSettings(uiState.object, schema); const tableSettings = new TableSettings(uiState.object, schema);
const config = ['invalid', MetaFields.version]; const config = [INVALID_FIELD, MetaFields.version];
tableSettings.updateFields(config, true); 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(); expect().nothing();
}); });
@ -181,7 +161,7 @@ describe('TableSettings', () => {
const tableSettings = new TableSettings(uiState.object, schema); const tableSettings = new TableSettings(uiState.object, schema);
const config = ['invalid', MetaFields.version]; const config = [INVALID_FIELD, MetaFields.version];
tableSettings.updateFields(config, false); tableSettings.updateFields(config, false);
@ -202,11 +182,11 @@ describe('TableSettings', () => {
fieldSizes = result; 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', () => { it('should update config if sizes are only updated', () => {
@ -221,11 +201,11 @@ describe('TableSettings', () => {
fieldSizes = result; 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()); 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', () => { it('should update config if wrapping is toggled', () => {
@ -240,11 +220,11 @@ describe('TableSettings', () => {
fieldWrappings = result; 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', () => { it('should update config if wrapping is toggled and only updated', () => {
@ -259,22 +239,21 @@ describe('TableSettings', () => {
fieldWrappings = result; 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()); 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', () => { it('should provide default fields if reset', () => {
let listFields: ReadonlyArray<TableField>; let listFields: ReadonlyArray<string>;
let listFieldNames: ReadonlyArray<string>;
let fieldSizes: FieldSizes; let fieldSizes: FieldSizes;
let fieldWrappings: FieldWrappings; let fieldWrappings: FieldWrappings;
const config = { const config = {
fields: [ fields: [
MetaFields.version, MetaFields.version.name,
], ],
sizes: { sizes: {
field1: 100, field1: 100,
@ -292,11 +271,7 @@ describe('TableSettings', () => {
const tableSettings = new TableSettings(uiState.object, schema); const tableSettings = new TableSettings(uiState.object, schema);
tableSettings.listFields.subscribe(result => { tableSettings.listFields.subscribe(result => {
listFields = result; listFields = result.map(x => x.name);
});
tableSettings.listFieldNames.subscribe(result => {
listFieldNames = result;
}); });
tableSettings.fieldSizes.subscribe(result => { tableSettings.fieldSizes.subscribe(result => {
@ -310,17 +285,10 @@ describe('TableSettings', () => {
tableSettings.reset(); tableSettings.reset();
expect(listFields!).toEqual([ expect(listFields!).toEqual([
MetaFields.lastModifiedByAvatar, MetaFields.lastModifiedByAvatar.name,
schema.fields[0],
MetaFields.statusColor,
MetaFields.lastModified,
]);
expect(listFieldNames!).toEqual([
MetaFields.lastModifiedByAvatar,
schema.fields[0].name, schema.fields[0].name,
MetaFields.statusColor, MetaFields.statusColor.name,
MetaFields.lastModified, MetaFields.lastModified.name,
]); ]);
expect(fieldSizes!).toEqual({}); expect(fieldSizes!).toEqual({});

39
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 { MetaFields, SchemaDto, TableField } from './../services/schemas.service';
import { UIState } from './ui.state'; 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 FieldSizes = { [name: string]: number };
export type FieldWrappings = { [name: string]: boolean }; export type FieldWrappings = { [name: string]: boolean };
interface Snapshot { interface Snapshot {
// The table fields in the right order. // The table fields in the right order.
fields: ReadonlyArray<string>; fields: ReadonlyArray<TableField>;
// The sizes of the columns if overriden. // The sizes of the columns if overriden.
sizes: FieldSizes; sizes: FieldSizes;
@ -29,8 +29,8 @@ interface Snapshot {
export class TableSettings extends State<Snapshot> { export class TableSettings extends State<Snapshot> {
private readonly settingsKey: string; private readonly settingsKey: string;
public readonly schemaFields: ReadonlyArray<string>; public readonly schemaFields: ReadonlyArray<TableField>;
public readonly schemaDefaults: ReadonlyArray<string>; public readonly schemaDefaults: ReadonlyArray<TableField>;
public fieldSizes = public fieldSizes =
this.project(x => x.sizes); this.project(x => x.sizes);
@ -41,11 +41,8 @@ export class TableSettings extends State<Snapshot> {
public fields = public fields =
this.project(x => x.fields); this.project(x => x.fields);
public listFieldNames =
this.projectFrom(this.fields, x => this.getListFieldNames(x));
public listFields = public listFields =
this.projectFrom(this.listFieldNames, x => this.getListFields(x)); this.projectFrom(this.fields, x => x.length > 0 ? x : this.schemaDefaults);
constructor( constructor(
private readonly uiState: UIState, private readonly uiState: UIState,
@ -53,8 +50,8 @@ export class TableSettings extends State<Snapshot> {
) { ) {
super({ fields: [], sizes: {}, wrappings: {} }); super({ fields: [], sizes: {}, wrappings: {} });
this.schemaFields = [...schema.contentFields.map(x => x.name), ...META_FIELD_NAMES].sort(); this.schemaFields = [...schema.contentFields, ...META_FIELD_NAMES];
this.schemaDefaults = schema.defaultListFields.map(x => x['name'] || x); this.schemaDefaults = schema.defaultListFields;
this.settingsKey = `schemas.${this.schema.name}.config`; this.settingsKey = `schemas.${this.schema.name}.config`;
@ -112,8 +109,8 @@ export class TableSettings extends State<Snapshot> {
} }
} }
public updateFields(fields: ReadonlyArray<string>, save = true) { public updateFields(fields: ReadonlyArray<TableField>, save = true) {
this.publishFields(fields); this.publishFields(fields.map(x => x.name));
if (save) { if (save) {
this.saveConfig(); this.saveConfig();
@ -128,8 +125,10 @@ export class TableSettings extends State<Snapshot> {
this.next({ wrappings }); this.next({ wrappings });
} }
private publishFields(fields: ReadonlyArray<string>) { private publishFields(names: ReadonlyArray<string>) {
this.next({ fields: fields.filter(x => this.schemaFields.includes(x)) }); const fields = names.map(n => this.schemaFields.find(f => f.name === n)).filter(x => !!x) as any;
this.next({ fields });
} }
private saveConfig() { private saveConfig() {
@ -138,15 +137,9 @@ export class TableSettings extends State<Snapshot> {
if (Object.keys(sizes).length === 0 && Object.keys(wrappings).length === 0 && fields.length === 0) { if (Object.keys(sizes).length === 0 && Object.keys(wrappings).length === 0 && fields.length === 0) {
this.uiState.removeUser(this.settingsKey); this.uiState.removeUser(this.settingsKey);
} else { } else {
this.uiState.set(this.settingsKey, this.snapshot, true); const update = { sizes, wrappings, fields: fields.map(x => x.name) };
}
}
private getListFields(names: ReadonlyArray<string>): ReadonlyArray<TableField> { this.uiState.set(this.settingsKey, update, true);
return names.map(n => this.schema.fields.find(f => f.name === n) || n); }
}
private getListFieldNames(names: ReadonlyArray<string>): ReadonlyArray<string> {
return names.length === 0 ? this.schemaDefaults : names;
} }
} }

3
frontend/src/app/shell/module.ts

@ -7,8 +7,7 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; import { SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { NotificationDropdownComponent } from '.'; import { AppAreaComponent, AppsMenuComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LeftMenuComponent, LoginPageComponent, LogoComponent, LogoutPageComponent, NotFoundPageComponent, NotificationDropdownComponent, NotificationsMenuComponent, ProfileMenuComponent, SearchMenuComponent } from './declarations';
import { AppAreaComponent, AppsMenuComponent, ForbiddenPageComponent, HomePageComponent, InternalAreaComponent, LeftMenuComponent, LoginPageComponent, LogoComponent, LogoutPageComponent, NotFoundPageComponent, NotificationsMenuComponent, ProfileMenuComponent, SearchMenuComponent } from './declarations';
@NgModule({ @NgModule({
imports: [ imports: [

Loading…
Cancel
Save