Browse Source

Better table fields (#905)

* Better table fields.

* Fix radio table.

* Radio groups.

* Just some cleanup.
release/6.x
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
fb68f04501
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. 33
      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>
<div class="row flex-nowrap flex-grow-1 gx-2">
<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">
<i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }}
@ -71,7 +71,7 @@
<sqx-custom-view-editor
[allFields]="tableSettings.schemaFields"
(listFieldsChange)="tableSettings.updateFields($event)"
[listFields]="$any(tableSettings.listFieldNames | async)"
[listFields]="$any(tableSettings.listFields| async)"
(reset)="tableSettings.reset()">
</sqx-custom-view-editor>
</sqx-dropdown-menu>
@ -119,13 +119,13 @@
<tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent"
[sqxContent]="content"
(clone)="clone(content)"
(delete)="delete(content)"
(selectedChange)="selectItem(content, $event)"
[selected]="isItemSelected(content)"
(statusChange)="changeStatus(content, $event)"
[cloneable]="contentsState.snapshot.canCreate"
(delete)="delete(content)"
[language]="language"
[link]="[content.id, 'history']"
[selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)"
(statusChange)="changeStatus(content, $event)"
[tableFields]="listFields"
[tableSettings]="tableSettings">
</tbody>

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

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

33
frontend/src/app/features/content/pages/contents/custom-view-editor.component.ts

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

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

@ -109,20 +109,15 @@
<ng-container *ngSwitchCase="'Stars'">
<sqx-stars [formControl]="$any(fieldForm)" [maximumStars]="field.rawProperties.maxValue"></sqx-stars>
</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'">
<select class="form-select" [formControl]="$any(fieldForm)">
<option [ngValue]="null"></option>
<option *ngFor="let value of field.rawProperties.allowedValues" [ngValue]="value">{{value}}</option>
</select>
</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 *ngSwitchCase="'References'">
@ -212,12 +207,7 @@
</select>
</ng-container>
<ng-container *ngSwitchCase="'Radio'">
<div class="form-check custom-control-inline" *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>
<sqx-radio-group [formControl]="$any(fieldForm)" [values]="field.rawProperties.allowedValues" [unsorted]="true"></sqx-radio-group>
</ng-container>
<ng-container *ngSwitchCase="'Color'">
<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 { AppLanguageDto, ContentDto, ContentListFieldComponent, ContentsState, ModalModel, PatchContentForm, RootFieldDto, TableField, TableSettings, Types } from '@app/shared';
import { AppLanguageDto, ContentDto, ContentListFieldComponent, ContentsState, ModalModel, PatchContentForm, TableField, TableSettings } from '@app/shared';
/* tslint:disable: component-selector */
@ -103,8 +103,8 @@ export class ContentComponent implements OnChanges {
}
public shouldStop(field: TableField) {
if (Types.is(field, RootFieldDto)) {
return this.isDirty || (field.isInlineEditable && this.patchAllowed);
if (field.rootField) {
return this.isDirty || (field.rootField.isInlineEditable && this.patchAllowed);
} else {
return this.isDirty;
}

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

@ -3,8 +3,8 @@
<ng-content></ng-content>
</td>
<td class="content-field" sqxContentListCell field="meta.lastModifiedBy.avatar">
<sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field>
<td class="content-field" sqxContentListCell [field]="metaFields.lastModifiedByAvatar">
<sqx-content-list-field [field]="metaFields.lastModifiedByAvatar" [content]="content" [language]="language"></sqx-content-list-field>
</td>
<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>
</td>
<td sqxContentListCell field="meta.status.color">
<sqx-content-list-field [language]="language" field="meta.status.color" [content]="content"></sqx-content-list-field>
<td sqxContentListCell [field]="metaFields.statusColor">
<sqx-content-list-field [language]="language" [field]="metaFields.statusColor" [content]="content"></sqx-content-list-field>
</td>
<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 */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { AppLanguageDto, ContentDto, getContentValue } from '@app/shared';
import { AppLanguageDto, ContentDto, getContentValue, MetaFields } from '@app/shared';
@Component({
selector: '[sqxReferenceItem][language]',
@ -17,6 +17,8 @@ import { AppLanguageDto, ContentDto, getContentValue } from '@app/shared';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReferenceItemComponent implements OnChanges {
public readonly metaFields = MetaFields;
@Output()
public delete = new EventEmitter();

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 { FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import { FieldDto, ResourceOwner, STRING_FIELD_EDITORS, StringFieldPropertiesDto, valueProjection$, SchemaTagSource } from '@app/shared';
import { FieldDto, ResourceOwner, SchemaTagSource, STRING_FIELD_EDITORS, StringFieldPropertiesDto, valueProjection$ } from '@app/shared';
@Component({
selector: 'sqx-string-ui[field][fieldForm][properties]',

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

@ -10,7 +10,7 @@
</div>
<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>
@ -23,6 +23,6 @@
<label>{{ 'schemas.ui.unassignedFields' | sqxTranslate }}</label>
<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>

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

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

@ -86,13 +86,6 @@ Icon.args = {
icon: 'user',
};
export const IconLoading = Template.bind({});
IconLoading.args = {
source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000),
icon: 'user',
};
export const StyleEmpty = Template.bind({});
StyleEmpty.args = {
@ -103,4 +96,11 @@ export const StyleUnderlined = Template.bind({});
StyleUnderlined.args = {
inputStyle: 'underlined',
};
export const IconLoading = Template.bind({});
IconLoading.args = {
source: new Source(['Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing'], 4000),
icon: 'user',
};

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

@ -1,5 +1,5 @@
<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-inline]="snapshot.isSingleline">
<input class="form-check-input" type="checkbox" id="{{controlId}}_{{value}}"
@ -8,6 +8,6 @@
[checked]="isChecked(value)"
[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>

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 { NG_VALUE_ACCESSOR } from '@angular/forms';
import { getTagValues, MathHelper, StatefulControlComponent, TagValue, Types } from '@app/framework/internal';
import { getTagValues, MathHelper, StatefulControlComponent, TagValue, TextMeasurer, Types } from '@app/framework/internal';
export const SQX_CHECKBOX_GROUP_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxGroupComponent), multi: true,
};
let CACHED_FONT: string;
interface State {
// The checked values.
checkedValues: ReadonlyArray<TagValue>;
@ -33,6 +31,7 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckboxGroupComponent extends StatefulControlComponent<State, string[]> implements AfterViewInit, AfterViewChecked, OnChanges {
private readonly textMeasurer: TextMeasurer;
private childrenWidth = 0;
private checkedValuesRaw: any;
private containerWidth = 0;
@ -44,7 +43,10 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
public containerElement!: ElementRef<HTMLDivElement>;
@Input()
public layout: 'Auto' | 'Singletine' | 'Multiline' = 'Auto';
public layout: 'Auto' | 'Singleline' | 'Multiline' = 'Auto';
@Input()
public unsorted = true;
@Input()
public set disabled(value: boolean | undefined | null) {
@ -53,17 +55,23 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
@Input()
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);
}
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) {
super(changeDetector, {
checkedValues: [],
});
super(changeDetector, { checkedValues: [] });
this.textMeasurer = new TextMeasurer(() => this.containerElement);
}
public ngAfterViewInit() {
@ -87,50 +95,33 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
}
private calculateWidth() {
this.calculateStyle();
if (this.labelsMeasured) {
this.calculateSingleLine();
return;
}
if (!CACHED_FONT ||
!this.containerElement ||
!this.containerElement.nativeElement) {
return;
}
let width = 0;
if (!canvas) {
canvas = document.createElement('canvas');
for (const value of this.tagValuesUnsorted) {
width += 40;
width += this.textMeasurer.getTextSize(value.name);
}
if (canvas) {
const ctx = canvas.getContext('2d');
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;
if (width < 0) {
return;
}
this.calculateSingleLine();
this.childrenWidth = width;
this.calculateSingleLine();
this.labelsMeasured = true;
}
}
this.labelsMeasured = true;
}
private calculateSingleLine() {
let isSingleline = false;
if (this.layout !== 'Auto') {
isSingleline = this.layout === 'Singletine';
isSingleline = this.layout === 'Singleline';
} else {
isSingleline = this.childrenWidth < this.containerWidth;
}
@ -138,32 +129,13 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
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) {
this.checkedValuesRaw = obj;
let checkedValues: TagValue[] = [];
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 });
@ -191,5 +163,3 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
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 { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import { getTagValues, Keys, ModalModel, StatefulControlComponent, StringConverter, TagValue, Types } from '@app/framework/internal';
import { getTagValues, Keys, ModalModel, StatefulControlComponent, StringConverter, TagValue, TextMeasurer, Types } from '@app/framework/internal';
export const SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagEditorComponent), multi: true,
};
let CACHED_FONT: string;
interface State {
// True, when the item has the focus.
hasFocus: boolean;
@ -40,6 +38,7 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TagEditorComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements AfterViewInit, OnChanges, OnInit {
private readonly textMeasurer: TextMeasurer;
private latestValue: any;
private latestInput?: string;
@ -136,6 +135,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
suggestedIndex: 0,
items: [],
});
this.textMeasurer = new TextMeasurer(() => this.inputElement);
}
public ngAfterViewInit() {
@ -243,35 +244,19 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
}
public resetSize() {
this.calculateStyle();
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 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 widthPlaceholder = ctx.measureText(this.placeholder).width;
const width = Math.max(widthText, widthPlaceholder);
const width = Math.max(widthText, widthPlaceholder);
this.inputElement.nativeElement.style.width = `${width + 5}px`;
}
if (width < 0) {
return;
}
this.inputElement.nativeElement.style.width = `${width + 5}px`;
if (this.singleLine) {
setTimeout(() => {
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) {
if (Keys.isComma(event)) {
return !this.selectValue(this.addInput.value);
@ -496,5 +462,3 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
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/dropdown.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/tag-editor.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/string-helper';
export * from './utils/tag-values';
export * from './utils/text-measurer';
export * from './utils/types';
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 { RouterModule } from '@angular/router';
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({
imports: [
@ -81,6 +81,7 @@ import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterc
PagerComponent,
ParentLinkDirective,
ProgressBarComponent,
RadioGroupComponent,
ResizedDirective,
RootViewComponent,
SafeHtmlPipe,
@ -169,6 +170,7 @@ import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterc
PagerComponent,
ParentLinkDirective,
ProgressBarComponent,
RadioGroupComponent,
ReactiveFormsModule,
ResizedDirective,
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)) {
return [];
}
@ -138,5 +138,9 @@ export function getTagValues(values: ReadonlyArray<string | TagValue> | undefine
}
}
if (!sorted) {
return result;
}
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';
export function getCellWidth(field: TableField, sizes: FieldSizes | undefined | null) {
if (Types.is(field, RootFieldDto)) {
field = field.name;
}
const size = sizes?.[field] || 0;
const size = sizes?.[field.name] || 0;
if (size > 0) {
return size;
@ -125,12 +121,9 @@ export class ContentListWidthDirective extends ResourceOwner implements OnChange
export class ContentListCellDirective extends ResourceOwner implements OnChanges {
private sizes?: FieldSizes;
private size = -1;
private fieldName?: string;
@Input()
public set field(value: TableField) {
this.fieldName = Types.is(value, RootFieldDto) ? value.name : value;
}
public field!: TableField;
@Input('fields')
public set tableFields(value: TableSettings | undefined | null) {
@ -155,11 +148,11 @@ export class ContentListCellDirective extends ResourceOwner implements OnChanges
}
private updateSize() {
if (!this.fieldName) {
if (!this.field.name) {
return;
}
const size = getCellWidth(this.fieldName, this.sizes);
const size = getCellWidth(this.field, this.sizes);
if (size === this.size) {
return;

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">
<small class="truncate">{{content.id}}</small>
</ng-container>
@ -94,8 +94,8 @@
<small class="truncate">{{content.version.value}}</small>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-container *ngIf="isInlineEditable && patchAllowed && patchForm; else displayTemplate">
<sqx-content-value-editor [form]="patchForm" [field]="$any(field)"></sqx-content-value-editor>
<ng-container *ngIf="field.rootField && isInlineEditable && patchAllowed && patchForm; else displayTemplate">
<sqx-content-value-editor [form]="patchForm" [field]="field.rootField"></sqx-content-value-editor>
</ng-container>
<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 { FormGroup } from '@angular/forms';
import { ContentDto, FieldValue, getContentValue, LanguageDto, MetaFields, RootFieldDto, StatefulComponent, TableField, TableSettings, Types } from '@app/shared/internal';
import { ContentDto, FieldValue, getContentValue, LanguageDto, MetaFields, StatefulComponent, TableField, TableSettings } from '@app/shared/internal';
interface State {
// The formatted value.
@ -21,6 +21,8 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContentListFieldComponent extends StatefulComponent<State> implements OnChanges {
public readonly metaFields = MetaFields;
@Input()
public field!: TableField;
@ -39,16 +41,8 @@ export class ContentListFieldComponent extends StatefulComponent<State> implemen
@Input()
public language!: LanguageDto;
public get metaFields() {
return MetaFields;
}
public get isInlineEditable() {
return Types.is(this.field, RootFieldDto) ? this.field.isInlineEditable : false;
}
public get fieldName() {
return Types.is(this.field, RootFieldDto) ? this.field.name : this.field;
return this.field.rootField?.isInlineEditable === true;
}
constructor(changeDetector: ChangeDetectorRef) {
@ -62,8 +56,8 @@ export class ContentListFieldComponent extends StatefulComponent<State> implemen
}
public reset() {
if (Types.is(this.field, RootFieldDto)) {
const { value, formatted } = getContentValue(this.content, this.language, this.field);
if (this.field.rootField) {
const { value, formatted } = getContentValue(this.content, this.language, this.field.rootField);
if (this.patchForm) {
const formControl = this.patchForm.controls[this.field.name];

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

@ -1,56 +1,9 @@
<ng-container [ngSwitch]="fieldName">
<ng-container *ngSwitchCase="metaFields.id">
<sqx-table-header text="i18n:contents.tableHeaders.id"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.created">
<sqx-table-header text="i18n:contents.tableHeaders.created"
[sortable]="true"
[fieldPath]="'created'"
[query]="query"
(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>
<sqx-table-header
[language]="language"
[query]="query"
(queryChange)="queryChange.emit($event)"
[sortable]="!!sortPath"
[sortDefault]="sortDefault"
[sortPath]="sortPath"
[text]="field.label">
</sqx-table-header>

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

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

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

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

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

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

@ -7,16 +7,16 @@
/>
</td>
<td sqxContentListCell field="meta.lastModifiedBy.avatar">
<sqx-content-list-field [language]="language" field="meta.lastModifiedBy.avatar" [content]="content"></sqx-content-list-field>
<td sqxContentListCell [field]="metaFields.lastModifiedByAvatar">
<sqx-content-list-field [language]="language" [field]="metaFields.lastModifiedByAvatar" [content]="content"></sqx-content-list-field>
</td>
<td *ngFor="let field of schema.defaultReferenceFields">
<sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field>
</td>
<td sqxContentListCell field="meta.status.color">
<sqx-content-list-field [language]="language" field="meta.status.color" [content]="content"></sqx-content-list-field>
<td sqxContentListCell [field]="metaFields.statusColor">
<sqx-content-list-field [language]="language" [field]="metaFields.statusColor" [content]="content"></sqx-content-list-field>
</td>
</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 */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ContentDto, LanguageDto, SchemaDto } from '@app/shared/internal';
import { ContentDto, LanguageDto, MetaFields, SchemaDto } from '@app/shared/internal';
@Component({
selector: '[sqxContentSelectorItem][language][schema]',
@ -17,6 +17,8 @@ import { ContentDto, LanguageDto, SchemaDto } from '@app/shared/internal';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContentSelectorItemComponent {
public readonly metaFields = MetaFields;
@Output()
public selectedChange = new EventEmitter<boolean>();

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

@ -49,8 +49,8 @@
[ngModel]="selectedAll"
(ngModelChange)="selectAll($event)">
</th>
<th sqxContentListCell field="meta.lastModifiedBy.avatar">
<sqx-content-list-header [language]="language" field="meta.lastModifiedBy.avatar"></sqx-content-list-header>
<th sqxContentListCell [field]="metaFields.lastModifiedByAvatar">
<sqx-content-list-header [language]="language" [field]="metaFields.lastModifiedByAvatar"></sqx-content-list-header>
</th>
<th *ngFor="let field of schema.defaultReferenceFields">
<sqx-content-list-header
@ -60,8 +60,8 @@
[language]="language">
</sqx-content-list-header>
</th>
<th sqxContentListCell field="meta.status.color">
<sqx-content-list-header [language]="language" field="meta.status.color"></sqx-content-list-header>
<th sqxContentListCell [field]="metaFields.statusColor">
<sqx-content-list-header [language]="language" [field]="metaFields.statusColor"></sqx-content-list-header>
</th>
</tr>
</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 { BehaviorSubject, of } from 'rxjs';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDto, Query, ResourceOwner, SchemaDto, SchemasService, SchemasState } from '@app/shared/internal';
import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDto, MetaFields, Query, ResourceOwner, SchemaDto, SchemasService, SchemasState } from '@app/shared/internal';
@Component({
selector: 'sqx-content-selector[language][languages]',
@ -19,6 +19,8 @@ import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDt
],
})
export class ContentSelectorComponent extends ResourceOwner implements OnInit {
public readonly metaFields = MetaFields;
@Output()
public select = new EventEmitter<ReadonlyArray<ContentDto>>();

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

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

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

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

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

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

@ -42,7 +42,7 @@ export class PatchContentForm extends Form<ExtendedFormGroup, any> {
) {
super(new ExtendedFormGroup({}));
this.editableFields = this.listFields.filter(x => Types.is(x, RootFieldDto) && x.isInlineEditable) as any;
this.editableFields = this.listFields.filter(x => x.rootField?.isInlineEditable).map(x => x.rootField!);
for (const field of this.editableFields) {
const validators = FieldsValidators.create(field, this.language.isOptional);

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

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

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

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

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

Loading…
Cancel
Save