Browse Source

Feature/custom views (#472)

* Unify pipe.

* Custom views.

* Using model for table view.

* Custom view.

* Better separation.

* Refactoring.

* Added some tests.

* Style and code style improvements.

* Names unified.
pull/473/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
a8c9785b55
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  2. 3
      frontend/app/features/administration/pages/event-consumers/event-consumer.component.ts
  3. 3
      frontend/app/features/administration/pages/users/user.component.ts
  4. 1
      frontend/app/features/content/declarations.ts
  5. 4
      frontend/app/features/content/module.ts
  6. 6
      frontend/app/features/content/pages/content/content-page.component.html
  7. 3
      frontend/app/features/content/pages/content/field-languages.component.ts
  8. 150
      frontend/app/features/content/pages/contents/contents-page.component.html
  9. 14
      frontend/app/features/content/pages/contents/contents-page.component.scss
  10. 17
      frontend/app/features/content/pages/contents/contents-page.component.ts
  11. 41
      frontend/app/features/content/pages/contents/custom-view-editor.component.html
  12. 23
      frontend/app/features/content/pages/contents/custom-view-editor.component.scss
  13. 60
      frontend/app/features/content/pages/contents/custom-view-editor.component.ts
  14. 19
      frontend/app/features/content/shared/content-list-cell.directive.ts
  15. 3
      frontend/app/features/content/shared/content-list-field.component.ts
  16. 3
      frontend/app/features/content/shared/content-list-header.component.ts
  17. 4
      frontend/app/features/content/shared/content-selector-item.component.ts
  18. 3
      frontend/app/features/content/shared/content-value-editor.component.ts
  19. 3
      frontend/app/features/content/shared/content-value.component.ts
  20. 2
      frontend/app/features/content/shared/content.component.html
  21. 7
      frontend/app/features/content/shared/content.component.ts
  22. 6
      frontend/app/features/content/shared/contents-selector.component.html
  23. 2
      frontend/app/features/content/shared/references-editor.component.html
  24. 7
      frontend/app/features/rules/pages/rules/rule-icon.component.ts
  25. 4
      frontend/app/features/schemas/pages/schema/field-list.component.ts
  26. 3
      frontend/app/features/schemas/pages/schema/forms/field-form-ui.component.ts
  27. 3
      frontend/app/features/schemas/pages/schema/forms/field-form-validation.component.ts
  28. 3
      frontend/app/features/settings/pages/backups/backup.component.ts
  29. 3
      frontend/app/features/settings/pages/clients/client-add-form.component.ts
  30. 3
      frontend/app/features/settings/pages/contributors/contributor.component.ts
  31. 3
      frontend/app/features/settings/pages/languages/language-add-form.component.ts
  32. 3
      frontend/app/features/settings/pages/roles/role-add-form.component.ts
  33. 1
      frontend/app/framework/angular/forms/dropdown.component.scss
  34. 9
      frontend/app/framework/angular/forms/form-alert.component.ts
  35. 3
      frontend/app/framework/angular/forms/form-error.component.ts
  36. 3
      frontend/app/framework/angular/forms/form-hint.component.ts
  37. 3
      frontend/app/framework/angular/modals/root-view.component.ts
  38. 4
      frontend/app/shared/components/asset-path.component.ts
  39. 16
      frontend/app/shared/components/queries/filter-comparison.component.html
  40. 3
      frontend/app/shared/components/queries/filter-node.component.ts
  41. 43
      frontend/app/shared/components/queries/query-path.component.ts
  42. 40
      frontend/app/shared/components/queries/query.component.ts
  43. 19
      frontend/app/shared/components/queries/sorting.component.ts
  44. 9
      frontend/app/shared/components/references-dropdown.component.ts
  45. 9
      frontend/app/shared/components/references-tags.component.ts
  46. 2
      frontend/app/shared/components/search-form.component.html
  47. 3
      frontend/app/shared/components/table-header.component.ts
  48. 1
      frontend/app/shared/declarations.ts
  49. 8
      frontend/app/shared/interceptors/auth.interceptor.spec.ts
  50. 1
      frontend/app/shared/internal.ts
  51. 2
      frontend/app/shared/module.ts
  52. 17
      frontend/app/shared/services/schemas.service.ts
  53. 12
      frontend/app/shared/state/contents.forms.spec.ts
  54. 12
      frontend/app/shared/state/contents.forms.ts
  55. 22
      frontend/app/shared/state/query.ts
  56. 123
      frontend/app/shared/state/table-fields.spec.ts
  57. 72
      frontend/app/shared/state/table-fields.ts
  58. 2
      frontend/app/theme/_forms.scss

14
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs

@ -102,23 +102,13 @@ namespace Squidex.Domain.Apps.Core.Schemas
public static IEnumerable<IField<ReferencesFieldProperties>> ResolvingReferences(this Schema schema)
{
return schema.Fields.OfType<IField<ReferencesFieldProperties>>()
.Where(x =>
x.Properties.ResolveReference &&
x.Properties.MaxItems == 1 &&
x.IsListField(schema));
.Where(x => x.Properties.ResolveReference && x.Properties.MaxItems == 1);
}
public static IEnumerable<IField<AssetsFieldProperties>> ResolvingAssets(this Schema schema)
{
return schema.Fields.OfType<IField<AssetsFieldProperties>>()
.Where(x =>
x.Properties.ResolveImage &&
x.IsListField(schema));
}
private static bool IsListField(this IField field, Schema schema)
{
return schema.FieldsInLists.Contains(field.Name) || schema.Fields.Count == 1 || (schema.FieldsInLists.Count == 0 && field == schema.Fields.FirstOrDefault());
.Where(x => x.Properties.ResolveImage);
}
}
}

3
frontend/app/features/administration/pages/event-consumers/event-consumer.component.ts

@ -37,7 +37,8 @@ import { EventConsumerDto, EventConsumersState } from '@app/features/administrat
</button>
</td>
</tr>
<tr class="spacer"></tr>`,
<tr class="spacer"></tr>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EventConsumerComponent {

3
frontend/app/features/administration/pages/users/user.component.ts

@ -33,7 +33,8 @@ import { UserDto, UsersState } from '@app/features/administration/internal';
</button>
</td>
</tr>
<tr class="spacer"></tr>`,
<tr class="spacer"></tr>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {

1
frontend/app/features/content/declarations.ts

@ -12,6 +12,7 @@ export * from './pages/content/content-page.component';
export * from './pages/content/field-languages.component';
export * from './pages/contents/contents-filters-page.component';
export * from './pages/contents/contents-page.component';
export * from './pages/contents/custom-view-editor.component';
export * from './pages/schemas/schemas-page.component';
export * from './shared/array-editor.component';

4
frontend/app/features/content/module.ts

@ -32,7 +32,6 @@ import {
ContentListHeaderComponent,
ContentListWidthPipe,
ContentPageComponent,
ContentReferencesWidthPipe,
ContentSelectorItemComponent,
ContentsFiltersPageComponent,
ContentsPageComponent,
@ -40,6 +39,7 @@ import {
ContentStatusComponent,
ContentValueComponent,
ContentValueEditorComponent,
CustomViewEditorComponent,
DueTimeSelectorComponent,
FieldEditorComponent,
FieldLanguagesComponent,
@ -119,7 +119,6 @@ const routes: Routes = [
ContentComponent,
ContentFieldComponent,
ContentListCellDirective,
ContentReferencesWidthPipe,
ContentListWidthPipe,
ContentListFieldComponent,
ContentListHeaderComponent,
@ -132,6 +131,7 @@ const routes: Routes = [
ContentStatusComponent,
ContentValueComponent,
ContentValueEditorComponent,
CustomViewEditorComponent,
DueTimeSelectorComponent,
FieldEditorComponent,
FieldLanguagesComponent,

6
frontend/app/features/content/pages/content/content-page.component.html

@ -20,7 +20,7 @@
</ng-container>
<ng-container menu>
<ng-container *ngIf="content; else noContent">
<ng-container *ngIf="content; let c; else noContent">
<div class="dropdown dropdown-options ml-1">
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
@ -100,12 +100,12 @@
<ng-container content>
<sqx-list-view>
<ng-container topHeader>
<div class="panel-alert panel-alert-danger" *ngIf="contentVersion; let version">
<div class="panel-alert panel-alert-danger" *ngIf="contentVersion">
<div class="float-right">
<a class="force" (click)="showLatest()">View latest</a>
</div>
Viewing <strong>version {{version.value}}</strong>.
Viewing <strong>version {{contentVersion.value}}</strong>.
</div>
</ng-container>

3
frontend/app/features/content/pages/content/field-languages.component.ts

@ -28,7 +28,8 @@ import { AppLanguageDto, RootFieldDto } from '@app/shared';
Please remember to check all languages when you see validation errors.
</sqx-onboarding-tooltip>
</ng-container>
</ng-container>`,
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FieldLanguagesComponent {

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

@ -16,10 +16,10 @@
</div>
<div class="col pl-1">
<sqx-search-form formClass="form" placeholder="Fulltext search"
(queryChange)="search($event)"
[query]="contentsState.contentsQuery | async"
[queries]="queries"
[queryModel]="queryModel"
(queryChange)="search($event)"
[language]="languageMaster"
enableShortcut="true">
</sqx-search-form>
@ -38,75 +38,93 @@
</ng-container>
<ng-container content>
<sqx-list-view [isLoading]="contentsState.isLoading | async" syncedHeader="true" table="true">
<ng-container topHeader>
<div class="selection" *ngIf="selectionCount > 0">
{{selectionCount}} items selected&nbsp;&nbsp;
<button type="button" class="btn btn-outline-secondary btn-status mr-1" *ngFor="let status of nextStatuses | sqxKeys" (click)="changeSelectedStatus(status)">
<sqx-content-status
[status]="status"
[statusColor]="nextStatuses[status]"
showLabel="true"
small="true">
</sqx-content-status>
</button>
<button type="button" class="btn btn-danger" *ngIf="selectionCanDelete"
(sqxConfirmClick)="deleteSelected()"
confirmTitle="Delete content"
confirmText="Do you really want to delete the selected content items?">
Delete
</button>
</div>
</ng-container>
<ng-container *ngIf="tableView.listFields | async; let listFields">
<sqx-list-view [isLoading]="contentsState.isLoading | async" syncedHeader="true" table="true">
<ng-container topHeader>
<div class="selection" *ngIf="selectionCount > 0">
{{selectionCount}} items selected&nbsp;&nbsp;
<button type="button" class="btn btn-outline-secondary btn-status mr-1" *ngFor="let status of nextStatuses | sqxKeys" (click)="changeSelectedStatus(status)">
<sqx-content-status
[status]="status"
[statusColor]="nextStatuses[status]"
showLabel="true"
small="true">
</sqx-content-status>
</button>
<button type="button" class="btn btn-danger" *ngIf="selectionCanDelete"
(sqxConfirmClick)="deleteSelected()"
confirmTitle="Delete content"
confirmText="Do you really want to delete the selected content items?">
Delete
</button>
</div>
<ng-container syncedHeader>
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentListWidth" #header>
<thead>
<tr>
<th class="cell-select">
<input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" />
</th>
<th class="cell-actions cell-actions-left">
Actions
</th>
<th *ngFor="let field of schema.listFields" [sqxContentListCell]="field">
<sqx-content-list-header
[field]="field"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-content-list-header>
</th>
</tr>
</thead>
</table>
</ng-container>
<div class="settings-container">
<button type="button" class="btn btn-sm settings-button" (click)="tableViewModal.toggle()" #buttonSettings>
<i class="icon-settings"></i>
</button>
<ng-container syncedContent>
<div class="table-container">
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentListWidth" [sqxSyncWidth]="header">
<tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent"
[sqxContent]="content"
(delete)="delete(content)"
[selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)"
(statusChange)="changeStatus(content, $event)"
(clone)="clone(content)"
[link]="[content.id]"
[language]="language"
[canClone]="contentsState.snapshot.canCreate"
[schema]="schema">
</tbody>
<ng-container *sqxModal="tableViewModal">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonSettings" @fade position="bottom-right">
<sqx-custom-view-editor
[allFields]="tableView.allFields"
(fieldNamesChange)="tableView.updateFields($event)"
[fieldNames]="tableView.listFieldNames | async">
</sqx-custom-view-editor>
</div>
</ng-container>
</div>
</ng-container>
<ng-container syncedHeader>
<table class="table table-items table-fixed" [style.minWidth]="listFields | sqxContentListWidth" #header>
<thead>
<tr>
<th class="cell-select">
<input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" />
</th>
<th class="cell-actions cell-actions-left">
Actions
</th>
<th *ngFor="let field of listFields" [sqxContentListCell]="field">
<sqx-content-list-header
[field]="field"
(queryChange)="search($event)"
[query]="contentsState.contentsQuery | async"
[language]="language">
</sqx-content-list-header>
</th>
</tr>
</thead>
</table>
</div>
</ng-container>
</ng-container>
<ng-container syncedContent>
<div class="table-container">
<table class="table table-items table-fixed" [style.minWidth]="listFields | sqxContentListWidth" [sqxSyncWidth]="header">
<tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent"
[sqxContent]="content"
(delete)="delete(content)"
[selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)"
(statusChange)="changeStatus(content, $event)"
(clone)="clone(content)"
[canClone]="contentsState.snapshot.canCreate"
[language]="language"
[link]="[content.id]"
[listFields]="listFields">
</tbody>
</table>
</div>
</ng-container>
<ng-container footer>
<sqx-pager [pager]="contentsState.contentsPager | async" (pagerChange)="contentsState.setPager($event)"></sqx-pager>
</ng-container>
</sqx-list-view>
<ng-container footer>
<sqx-pager [pager]="contentsState.contentsPager | async" (pagerChange)="contentsState.setPager($event)"></sqx-pager>
</ng-container>
</sqx-list-view>
</ng-container>
</ng-container>
<ng-container sidebar>

14
frontend/app/features/content/pages/contents/contents-page.component.scss

@ -16,6 +16,20 @@
font-size: .8rem;
}
.hidden {
display: none !important;
}
.settings {
&-container {
position: relative;
}
&-button {
@include absolute(null, 1rem, -2.375rem);
}
}
.btn-status {
background: $color-dark-foreground;
}

17
frontend/app/features/content/pages/contents/contents-page.component.ts

@ -12,6 +12,7 @@ import {
AppLanguageDto,
ContentDto,
ContentsState,
fadeAnimation,
LanguagesState,
ModalModel,
Queries,
@ -21,6 +22,7 @@ import {
ResourceOwner,
SchemaDetailsDto,
SchemasState,
TableFields,
UIState
} from '@app/shared';
@ -29,11 +31,17 @@ import { DueTimeSelectorComponent } from './../../shared/due-time-selector.compo
@Component({
selector: 'sqx-contents-page',
styleUrls: ['./contents-page.component.scss'],
templateUrl: './contents-page.component.html'
templateUrl: './contents-page.component.html',
animations: [
fadeAnimation
]
})
export class ContentsPageComponent extends ResourceOwner implements OnInit {
public schema: SchemaDetailsDto;
public tableView: TableFields;
public tableViewModal = new ModalModel();
public searchModal = new ModalModel();
public selectedItems: { [id: string]: boolean; } = {};
@ -74,6 +82,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.updateQueries();
this.updateModel();
this.updateTable();
}));
this.own(
@ -218,6 +227,12 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
}
private updateTable() {
if (this.schema) {
this.tableView = new TableFields(this.uiState, this.schema);
}
}
private updateModel() {
if (this.schema && this.languages) {
this.queryModel = queryModelFromSchema(this.schema, this.languages, this.contentsState.snapshot.statuses);

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

@ -0,0 +1,41 @@
<div class="container">
<div class="header">
<button type="button" class="btn btn-secondary btn-sm" (click)="resetDefault()">
Reset Default View
</button>
</div>
<hr />
<div
cdkDropList
[cdkDropListData]="fieldNames"
(cdkDropListDropped)="drop($event)">
<div *ngFor="let field of fieldNames; trackBy: random" cdkDrag>
<i class="icon-drag2 drag-handle"></i>
<div class="form-check">
<input class="form-check-input" type="checkbox" checked (change)="removeField(field)" id="field_{{field}}">
<label class="form-check-label" for="field_{{field}}">
{{field}}
</label>
</div>
</div>
</div>
<hr />
<div>
<div *ngFor="let field of fieldsNotAdded">
<i class="icon-drag2 drag-handle invisible"></i>
<div class="form-check">
<input class="form-check-input" type="checkbox" (change)="addField(field)" id="field_{{field}}">
<label class="form-check-label" for="field_{{field}}">
{{field}}
</label>
</div>
</div>
</div>
</div>

23
frontend/app/features/content/pages/contents/custom-view-editor.component.scss

@ -0,0 +1,23 @@
@import '_vars';
@import '_mixins';
.container {
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
padding: .5rem 1rem;
}
.header {
text-align: right;
}
.invisible {
visibility: hidden;
}
.form-check {
display: inline-block;
padding-left: 2rem;
padding-right: .5rem;
}

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

@ -0,0 +1,60 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: readonly-array
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
@Component({
selector: 'sqx-custom-view-editor',
styleUrls: ['./custom-view-editor.component.scss'],
templateUrl: './custom-view-editor.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomViewEditorComponent implements OnChanges {
@Input()
public allFields: ReadonlyArray<string>;
@Input()
public fieldNames: ReadonlyArray<string>;
@Output()
public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>();
public fieldsNotAdded: ReadonlyArray<string>;
public ngOnChanges() {
this.fieldsNotAdded = this.allFields.filter(n => this.fieldNames.indexOf(n) < 0);
}
public random() {
return Math.random();
}
public drop(event: CdkDragDrop<string[]>) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
this.updateFields(event.container.data);
}
public updateFields(fieldNames: ReadonlyArray<string>) {
this.fieldNamesChange.emit(fieldNames);
}
public resetDefault() {
this.updateFields([]);
}
public addField(field: string) {
this.updateFields([...this.fieldNames, field]);
}
public removeField(field: string) {
this.updateFields(this.fieldNames.filter(x => x !== field));
}
}

19
frontend/app/features/content/shared/content-list-cell.directive.ts

@ -10,7 +10,6 @@ import { Directive, ElementRef, Input, OnChanges, Pipe, PipeTransform, Renderer2
import {
MetaFields,
RootFieldDto,
SchemaDetailsDto,
TableField,
Types
} from '@app/shared';
@ -63,26 +62,12 @@ export function getCellWidth(field: TableField) {
pure: true
})
export class ContentListWidthPipe implements PipeTransform {
public transform(value: SchemaDetailsDto) {
public transform(value: ReadonlyArray<TableField>) {
if (!value) {
return 0;
}
return `${getTableWidth(value.listFields) + 100}px`;
}
}
@Pipe({
name: 'sqxContentReferencesWidth',
pure: true
})
export class ContentReferencesWidthPipe implements PipeTransform {
public transform(value: SchemaDetailsDto) {
if (!value) {
return 0;
}
return `${getTableWidth(value.referenceFields) + 300}px`;
return `${getTableWidth(value) + 100}px`;
}
}

3
frontend/app/features/content/shared/content-list-field.component.ts

@ -82,7 +82,8 @@ import {
<sqx-content-value [value]="value"></sqx-content-value>
</ng-template>
</ng-container>
</ng-container>`,
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentListFieldComponent implements OnChanges {

3
frontend/app/features/content/shared/content-list-header.component.ts

@ -74,7 +74,8 @@ import {
[language]="language">
</sqx-table-header>
</ng-container>
</ng-container>`,
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentListHeaderComponent {

4
frontend/app/features/content/shared/content-selector-item.component.ts

@ -30,12 +30,12 @@ import {
<sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field>
</td>
<td *ngFor="let field of schema.referenceFields" [sqxContentListCell]="field">
<td *ngFor="let field of schema.defaultReferenceFields" [sqxContentListCell]="field">
<sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field>
</td>
</tr>
<tr class="spacer"></tr>
`,
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentSelectorItemComponent {

3
frontend/app/features/content/shared/content-value-editor.component.ts

@ -60,7 +60,8 @@ import { FieldDto } from '@app/shared';
</ng-container>
</ng-container>
</ng-container>
</div>`,
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentValueEditorComponent {

3
frontend/app/features/content/shared/content-value.component.ts

@ -17,7 +17,8 @@ import { HtmlValue, Types } from '@app/shared';
</ng-container>
<ng-template #html>
<span class="html-value" [innerHTML]="value.html"></span>
</ng-template>`,
</ng-template>
`,
styles: [`
.html-value {
position: relative;

2
frontend/app/features/content/shared/content.component.html

@ -52,7 +52,7 @@
</div>
</td>
<td *ngFor="let field of schema.listFields" [sqxContentListCell]="field" [sqxStopClick]="shouldStop(field)">
<td *ngFor="let field of listFields" [sqxContentListCell]="field" [sqxStopClick]="shouldStop(field)">
<sqx-content-list-field
[field]="field"
[patchForm]="patchForm?.form"

7
frontend/app/features/content/shared/content.component.ts

@ -15,7 +15,6 @@ import {
ModalModel,
PatchContentForm,
RootFieldDto,
SchemaDetailsDto,
TableField,
Types
} from '@app/shared';
@ -53,7 +52,7 @@ export class ContentComponent implements OnChanges {
public language: AppLanguageDto;
@Input()
public schema: SchemaDetailsDto;
public listFields: ReadonlyArray<TableField>;
@Input()
public canClone: boolean;
@ -87,8 +86,8 @@ export class ContentComponent implements OnChanges {
this.patchAllowed = this.content.canUpdate;
}
if (this.patchAllowed && (changes['schema'] || changes['language'])) {
this.patchForm = new PatchContentForm(this.schema, this.language);
if (this.patchAllowed && (changes['listFields'] || changes['language'])) {
this.patchForm = new PatchContentForm(this.listFields, this.language);
}
}

6
frontend/app/features/content/shared/contents-selector.component.html

@ -46,7 +46,7 @@
<ng-container *ngIf="schema">
<sqx-list-view [isLoading]="contentsState.isLoading | async" syncedHeader="true" table="true">
<ng-container syncedHeader>
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentReferencesWidth" #header>
<table class="table table-items table-fixed" [style.minWidth]="schema.defaultReferenceFields | sqxContentListWidth" #header>
<thead>
<tr>
<th class="cell-select">
@ -55,7 +55,7 @@
<th sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-content-list-header field="meta.lastModifiedBy.avatar"></sqx-content-list-header>
</th>
<th *ngFor="let field of schema.referenceFields" [sqxContentListCell]="field">
<th *ngFor="let field of schema.defaultReferenceFields" [sqxContentListCell]="field">
<sqx-content-list-header
[field]="field"
[query]="contentsState.contentsQuery | async"
@ -70,7 +70,7 @@
<ng-container syncedContent>
<div class="table-container">
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentReferencesWidth" *ngIf="contentsState.contents | async; let contents" [sqxSyncWidth]="header">
<table class="table table-items table-fixed" [style.minWidth]="schema.defaultReferenceFields | sqxContentListWidth" *ngIf="contentsState.contents | async; let contents" [sqxSyncWidth]="header">
<tbody *ngFor="let content of contents; trackBy: trackByContent"
[sqxContentSelectorItem]="content"
[schema]="schema"

2
frontend/app/features/content/shared/references-editor.component.html

@ -26,7 +26,7 @@
</ng-container>
</div>
<ng-container *sqxModal="selectorDialog;closeAuto:false">
<ng-container *sqxModal="selectorDialog">
<sqx-contents-selector
[allowDuplicates]="allowDuplicates"
[alreadySelected]="snapshot.contentItems"

7
frontend/app/features/rules/pages/rules/rule-icon.component.ts

@ -20,9 +20,10 @@ import { RuleElementDto } from '@app/shared';
<ng-template #svgIcon>
<i class="svg-icon" [innerHtml]="element.iconImage | sqxSafeHtml"></i>
</ng-template>
</span>`,
styles: [
`.svg-icon {
</span>
`,
styles: [`
.svg-icon {
display: inline-block;
}

4
frontend/app/features/schemas/pages/schema/field-list.component.ts

@ -12,7 +12,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Out
import { MetaFields, SchemaDetailsDto } from '@app/shared';
const MetaFieldNames = Object.values(MetaFields);
const META_FIELD_NAMES = Object.values(MetaFields);
@Component({
selector: 'sqx-field-list',
@ -43,7 +43,7 @@ export class FieldListComponent implements OnChanges {
let allFields = this.schema.contentFields.map(x => x.name);
if (this.withMetaFields) {
allFields = [...allFields, ...MetaFieldNames];
allFields = [...allFields, ...META_FIELD_NAMES];
}
this.fieldsAdded = this.fieldNames.filter(n => allFields.indexOf(n) >= 0);

3
frontend/app/features/schemas/pages/schema/forms/field-form-ui.component.ts

@ -41,7 +41,8 @@ import { FieldDto } from '@app/shared';
<ng-container *ngSwitchCase="'Tags'">
<sqx-tags-ui [editForm]="editForm" [field]="field" [properties]="field.rawProperties"></sqx-tags-ui>
</ng-container>
</ng-container>`
</ng-container>
`
})
export class FieldFormUIComponent {
@Input()

3
frontend/app/features/schemas/pages/schema/forms/field-form-validation.component.ts

@ -44,7 +44,8 @@ import { FieldDto, PatternDto } from '@app/shared';
<ng-container *ngSwitchCase="'Tags'">
<sqx-tags-validation [editForm]="editForm" [field]="field" [properties]="field.rawProperties"></sqx-tags-validation>
</ng-container>
</ng-container>`
</ng-container>
`
})
export class FieldFormValidationComponent {
@Input()

3
frontend/app/features/settings/pages/backups/backup.component.ts

@ -65,7 +65,8 @@ import {
</button>
</div>
</div>
</div>`,
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BackupComponent {

3
frontend/app/features/settings/pages/clients/client-add-form.component.ts

@ -29,7 +29,8 @@ import { AddClientForm, ClientsState } from '@app/shared';
</div>
</div>
</form>
</div>`,
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ClientAddFormComponent {

3
frontend/app/features/settings/pages/contributors/contributor.component.ts

@ -43,7 +43,8 @@ import {
</button>
</td>
</tr>
<tr class="spacer"></tr>`,
<tr class="spacer"></tr>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContributorComponent {

3
frontend/app/features/settings/pages/languages/language-add-form.component.ts

@ -30,7 +30,8 @@ import {
</div>
</div>
</form>
</div>`,
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LanguageAddFormComponent implements OnChanges {

3
frontend/app/features/settings/pages/roles/role-add-form.component.ts

@ -29,7 +29,8 @@ import { AddRoleForm, RolesState } from '@app/shared';
</div>
</div>
</form>
</div>`,
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RoleAddFormComponent {

1
frontend/app/framework/angular/forms/dropdown.component.scss

@ -29,6 +29,7 @@ $color-input-disabled: #eef1f4;
.control-dropdown-items {
max-height: 15rem;
overflow-x: hidden;
overflow-y: auto;
}

9
frontend/app/framework/angular/forms/form-alert.component.ts

@ -9,6 +9,11 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'sqx-form-alert',
template: `
<div class="alert alert-hint mt-{{marginTop}} mb-{{marginBottom}} {{class}}" [class.light]="light">
<i class="icon-info-outline"></i> <ng-content></ng-content>
</div>
`,
styles: [`
:host {
display: block;
@ -20,10 +25,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
background: #fcfeff;
}
`],
template: `
<div class="alert alert-hint mt-{{marginTop}} mb-{{marginBottom}} {{class}}" [class.light]="light">
<i class="icon-info-outline"></i> <ng-content></ng-content>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FormAlertComponent {

3
frontend/app/framework/angular/forms/form-error.component.ts

@ -21,7 +21,8 @@ import { ErrorDto } from '@app/framework/internal';
<div [innerHTML]="error?.displayMessage | sqxMarkdown"></div>
</div>
</div>
</ng-container>`,
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FormErrorComponent implements OnChanges {

3
frontend/app/framework/angular/forms/form-hint.component.ts

@ -12,7 +12,8 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
template: `
<small class="text-muted form-text mt-{{marginTop}} mb-{{marginBottom}} {{class}}">
<ng-content></ng-content>
</small>`,
</small>
`,
styles: [`
:host {
display: block;

3
frontend/app/framework/angular/modals/root-view.component.ts

@ -12,7 +12,8 @@ import { ChangeDetectionStrategy, Component, ViewChild, ViewContainerRef } from
template: `
<div #element></div>
<ng-content></ng-content>`,
<ng-content></ng-content>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RootViewComponent {

4
frontend/app/shared/components/asset-path.component.ts

@ -25,8 +25,8 @@ import { AssetPathItem } from '@app/shared/internal';
</ng-container>
</ng-template>
`,
styles: [
`i {
styles: [`
i {
vertical-align: middle;
}`
],

16
frontend/app/shared/components/queries/filter-comparison.component.html

@ -1,16 +1,10 @@
<div class="row no-gutters mb-1" *ngIf="fieldModel">
<div class="col-auto path">
<sqx-dropdown [items]="model.fields | sqxKeys" [ngModel]="filter.path" (ngModelChange)="changePath($event)" [canSearch]="false" separated="true">
<ng-template let-field="$implicit">
<div>{{model.fields[field].displayName}}</div>
<sqx-form-hint>{{model.fields[field].description}}</sqx-form-hint>
</ng-template>
<ng-template let-field="$implicit">
{{model.fields[field].displayName}}
</ng-template>
</sqx-dropdown>
<sqx-query-path
(pathChange)="changePath($event)"
[path]="filter.path"
[model]="model">
</sqx-query-path>
</div>
<div class="col-auto operator pl-1">
<select class="form-control" [ngModel]="filter.op" (ngModelChange)="changeOp($event)">

3
frontend/app/shared/components/queries/filter-node.component.ts

@ -28,7 +28,8 @@ import {
<sqx-filter-comparison [model]="model" [filter]="comparison" [language]="language"
(remove)="remove.emit()" (change)="change.emit()">
</sqx-filter-comparison>
</ng-container>`,
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterNodeComponent {

43
frontend/app/shared/components/queries/query-path.component.ts

@ -0,0 +1,43 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { QueryModel } from '@app/shared/internal';
@Component({
selector: 'sqx-query-path',
template: `
<sqx-dropdown [items]="model.fields | sqxKeys" [ngModel]="path" (ngModelChange)="pathChange.emit($event)" [canSearch]="false" separated="true">
<ng-template let-field="$implicit">
<div class="row">
<div class="col-auto">
<div class="badge badge-pill badge-primary">{{model.fields[field].displayName}}</div>
</div>
<div class="col text-right">
<small class="text-muted">{{model.fields[field].description}}</small>
</div>
</div>
</ng-template>
<ng-template let-field="$implicit">
{{model.fields[field].displayName}}
</ng-template>
</sqx-dropdown>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QueryPathComponent {
@Output()
public pathChange = new EventEmitter<string>();
@Input()
public path: string;
@Input()
public model: QueryModel;
}

40
frontend/app/shared/components/queries/query.component.ts

@ -1,3 +1,10 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {
@ -10,23 +17,22 @@ import {
@Component({
selector: 'sqx-query',
template: `
<div>
<sqx-filter-logical isRoot="true" [filter]="queryValue.filter" [model]="model" [language]="language"
(change)="emitQueryChange()">
</sqx-filter-logical>
<h4 class="mt-4">Sorting</h4>
<div class="mb-2" *ngFor="let sorting of queryValue.sort">
<sqx-sorting [sorting]="sorting" [model]="model"
(remove)="removeSorting(sorting)" (change)="emitQueryChange()">
</sqx-sorting>
</div>
<button class="btn btn-outline-success btn-sm mr-2" (click)="addSorting()">
Add Sorting
</button>
</div>`,
<sqx-filter-logical isRoot="true" [filter]="queryValue.filter" [model]="model" [language]="language"
(change)="emitQueryChange()">
</sqx-filter-logical>
<h4 class="mt-4">Sorting</h4>
<div class="mb-2" *ngFor="let sorting of queryValue.sort">
<sqx-sorting [sorting]="sorting" [model]="model"
(remove)="removeSorting(sorting)" (change)="emitQueryChange()">
</sqx-sorting>
</div>
<button class="btn btn-outline-success btn-sm mr-2" (click)="addSorting()">
Add Sorting
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QueryComponent {

19
frontend/app/shared/components/queries/sorting.component.ts

@ -15,17 +15,11 @@ import { QueryModel, QuerySorting } from '@app/shared/internal';
<div class="row">
<div class="col">
<div class="form-inline">
<sqx-dropdown [items]="model.fields | sqxKeys" [ngModel]="sorting.path" (ngModelChange)="changePath($event)" [canSearch]="false" separated="true">
<ng-template let-field="$implicit">
<div>{{model.fields[field].displayName}}</div>
<sqx-form-hint>{{model.fields[field].description}}</sqx-form-hint>
</ng-template>
<ng-template let-field="$implicit">
{{model.fields[field].displayName}}
</ng-template>
</sqx-dropdown>
<sqx-query-path
(pathChange)="changePath($event)"
[path]="sorting.path"
[model]="model">
</sqx-query-path>
<select class="form-control ml-1" [ngModel]="sorting.order" (ngModelChange)="changeOrder($event)">
<option>ascending</option>
@ -38,7 +32,8 @@ import { QueryModel, QuerySorting } from '@app/shared/internal';
<i class="icon-bin2"></i>
</button>
</div>
</div>`,
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SortingComponent {

9
frontend/app/shared/components/references-dropdown.component.ts

@ -41,9 +41,12 @@ const NO_EMIT = { emitEvent: false };
<ng-template let-content="$implicit" let-context="context">
<span class="truncate" [innerHTML]="content.name | sqxHighlight:context"></span>
</ng-template>
</sqx-dropdown>`,
styles: [
'.truncate { min-height: 1.5rem; }'
</sqx-dropdown>
`,
styles: [`
.truncate {
min-height: 1.5rem;
}`
],
providers: [SQX_REFERENCES_DROPDOWN_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush

9
frontend/app/shared/components/references-tags.component.ts

@ -74,9 +74,12 @@ interface State {
template: `
<sqx-tag-editor placeholder=", to add reference" [converter]="snapshot.converter" [formControl]="selectionControl"
[suggestedValues]="snapshot.converter.suggestions">
</sqx-tag-editor>`,
styles: [
'.truncate { min-height: 1.5rem; }'
</sqx-tag-editor>
`,
styles: [`
.truncate {
min-height: 1.5rem;
}`
],
providers: [SQX_REFERENCES_TAGS_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush

2
frontend/app/shared/components/search-form.component.html

@ -40,7 +40,7 @@
Search for content using full text search over all fields and languages!
</sqx-onboarding-tooltip>
<ng-container *sqxModal="searchDialog;closeAuto:false">
<ng-container *sqxModal="searchDialog">
<sqx-modal-dialog (close)="searchDialog.hide()" large="true">
<ng-container title>
Custom Query

3
frontend/app/shared/components/table-header.component.ts

@ -27,7 +27,8 @@ import {
<ng-template #notSortable>
<span class="truncate">{{text}}</span>
</ng-template>`,
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TableHeaderComponent implements OnChanges {

1
frontend/app/shared/declarations.ts

@ -35,6 +35,7 @@ export * from './components/table-header.component';
export * from './components/queries/filter-comparison.component';
export * from './components/queries/filter-logical.component';
export * from './components/queries/filter-node.component';
export * from './components/queries/query-path.component';
export * from './components/queries/query.component';
export * from './components/queries/sorting.component';

8
frontend/app/shared/interceptors/auth.interceptor.spec.ts

@ -107,7 +107,9 @@ describe('AuthInterceptor', () => {
authService.verify(x => x.logoutRedirect(), Times.once());
}));
[403].forEach(statusCode => {
const AUTH_ERRORS = [403];
AUTH_ERRORS.forEach(statusCode => {
it(`should redirect for ${statusCode} status code`,
inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => {
@ -125,7 +127,9 @@ describe('AuthInterceptor', () => {
}));
});
[500, 404, 405].forEach(statusCode => {
const SERVER_ERRORS = [500, 404, 405];
SERVER_ERRORS.forEach(statusCode => {
it(`should not logout for ${statusCode} status code`,
inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => {

1
frontend/app/shared/internal.ts

@ -65,6 +65,7 @@ export * from './state/rules.state';
export * from './state/schema-tag-source';
export * from './state/schemas.forms';
export * from './state/schemas.state';
export * from './state/table-fields';
export * from './state/ui.state';
export * from './state/workflows.forms';
export * from './state/workflows.state';

2
frontend/app/shared/module.ts

@ -75,6 +75,7 @@ import {
PlansService,
PlansState,
QueryComponent,
QueryPathComponent,
ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent,
@ -147,6 +148,7 @@ import {
LanguageSelectorComponent,
MarkdownEditorComponent,
QueryComponent,
QueryPathComponent,
ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent,

17
frontend/app/shared/services/schemas.service.ts

@ -105,9 +105,9 @@ export type TableField = RootFieldDto | string;
export class SchemaDetailsDto extends SchemaDto {
public readonly contentFields: ReadonlyArray<RootFieldDto>;
public readonly listFields: ReadonlyArray<TableField>;
public readonly listFieldsEditable: ReadonlyArray<RootFieldDto>;
public readonly referenceFields: ReadonlyArray<TableField>;
public readonly defaultListFields: ReadonlyArray<TableField>;
public readonly defaultReferenceFields: ReadonlyArray<TableField>;
constructor(links: ResourceLinks, id: string, name: string, category: string,
properties: SchemaPropertiesDto,
@ -144,16 +144,15 @@ export class SchemaDetailsDto extends SchemaDto {
listFields.push(MetaFields.lastModified);
}
this.listFields = listFields;
this.listFieldsEditable = <any>this.listFields.filter(x => Types.is(x, RootFieldDto) && x.isInlineEditable);
this.defaultListFields = listFields;
this.referenceFields = findFields(fieldsInReferences, this.contentFields);
this.defaultReferenceFields = findFields(fieldsInReferences, this.contentFields);
if (this.referenceFields.length === 0) {
if (this.defaultReferenceFields.length === 0) {
if (fields.length > 0) {
this.referenceFields = [fields[0]];
this.defaultReferenceFields = [fields[0]];
} else {
this.referenceFields = [''];
this.defaultReferenceFields = [''];
}
}
}

12
frontend/app/shared/state/contents.forms.spec.ts

@ -63,37 +63,37 @@ describe('SchemaDetailsDto', () => {
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.listFields).toEqual([field1, field3]);
expect(schema.defaultListFields).toEqual([field1, field3]);
});
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.listFields).toEqual([MetaFields.lastModifiedByAvatar, field1, MetaFields.statusColor, MetaFields.lastModified]);
expect(schema.defaultListFields).toEqual([MetaFields.lastModifiedByAvatar, field1, MetaFields.statusColor, MetaFields.lastModified]);
});
it('should return preset with empty content field as list fields if fields is empty', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.listFields).toEqual([MetaFields.lastModifiedByAvatar, '', MetaFields.statusColor, MetaFields.lastModified]);
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.referenceFields).toEqual([field1, field3]);
expect(schema.defaultReferenceFields).toEqual([field1, field3]);
});
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.referenceFields).toEqual([field1]);
expect(schema.defaultReferenceFields).toEqual([field1]);
});
it('should return noop field as reference field if list is empty', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.referenceFields).toEqual(['']);
expect(schema.defaultReferenceFields).toEqual(['']);
});
});

12
frontend/app/shared/state/contents.forms.ts

@ -21,7 +21,7 @@ import {
import { ContentDto, ContentReferencesValue } from '../services/contents.service';
import { LanguageDto } from '../services/languages.service';
import { AppLanguageDto } from './../services/app-languages.service';
import { FieldDto, RootFieldDto, SchemaDetailsDto } from './../services/schemas.service';
import { FieldDto, RootFieldDto, SchemaDetailsDto, TableField } from './../services/schemas.service';
import {
ArrayFieldPropertiesDto,
AssetsFieldPropertiesDto,
@ -659,13 +659,17 @@ export class EditContentForm extends Form<FormGroup, any> {
}
export class PatchContentForm extends Form<FormGroup, any> {
private readonly editableFields: ReadonlyArray<RootFieldDto>;
constructor(
private readonly schema: SchemaDetailsDto,
private readonly listFields: ReadonlyArray<TableField>,
private readonly language: AppLanguageDto
) {
super(new FormGroup({}));
for (const field of this.schema.listFieldsEditable) {
this.editableFields = <any>this.listFields.filter(x => Types.is(x, RootFieldDto) && x.isInlineEditable);
for (const field of this.editableFields) {
const validators = FieldsValidators.create(field, this.language.isOptional);
this.form.setControl(field.name, new FormControl(undefined, validators));
@ -678,7 +682,7 @@ export class PatchContentForm extends Form<FormGroup, any> {
if (result) {
const request = {};
for (const field of this.schema.listFieldsEditable) {
for (const field of this.editableFields) {
const value = result[field.name];
if (field.isLocalizable) {

22
frontend/app/shared/state/query.ts

@ -11,7 +11,7 @@ import { Types } from '@app/framework';
import { StatusInfo } from './../services/contents.service';
import { LanguageDto } from './../services/languages.service';
import { SchemaDetailsDto } from './../services/schemas.service';
import { MetaFields, SchemaDetailsDto } from './../services/schemas.service';
export type QueryValueType =
'boolean' |
@ -188,27 +188,27 @@ const TypeTags: QueryFieldModel = {
const DEFAULT_FIELDS: QueryModelFields = {
created: {
...TypeDateTime,
displayName: 'Created',
displayName: MetaFields.created,
description: 'The date time when the content item was created.'
},
createdBy: {
...TypeString,
displayName: 'Created by',
displayName: 'meta.createdBy',
description: 'The user who created the content item.'
},
lastModified: {
...TypeDateTime,
displayName: 'Updated',
description: 'The date time when the content item was updated the last time.'
displayName: MetaFields.lastModified,
description: 'The date time when the content item was modified the last time.'
},
lastModifiedBy: {
...TypeString,
displayName: 'Updated by',
description: 'The user who updated the content item the last time.'
displayName: 'meta.lastModifiedBy',
description: 'The user who modified the content item the last time.'
},
version: {
...TypeNumber,
displayName: 'Version',
displayName: MetaFields.version,
description: 'The version of the content item'
}
};
@ -223,7 +223,7 @@ export function queryModelFromSchema(schema: SchemaDetailsDto, languages: Readon
if (statuses) {
model.fields['status'] = {
...TypeStatus,
displayName: 'Status',
displayName: MetaFields.status,
description: 'The status of the content item.',
extra: statuses
};
@ -252,7 +252,7 @@ export function queryModelFromSchema(schema: SchemaDetailsDto, languages: Readon
if (field.isLocalizable) {
for (const code of languagesCodes) {
const infos = {
displayName: `${field.displayName} (${code})`,
displayName: `${field.name} (${code})`,
description: `The '${field.displayName}' field of the content item (localized).`
};
@ -260,7 +260,7 @@ export function queryModelFromSchema(schema: SchemaDetailsDto, languages: Readon
}
} else {
const infos = {
displayName: field.displayName,
displayName: field.name,
description: `The '${field.displayName}' field of the content item.`
};

123
frontend/app/shared/state/table-fields.spec.ts

@ -0,0 +1,123 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { DateTime, Version } from '@app/framework';
import {
createProperties,
MetaFields,
RootFieldDto,
SchemaDetailsDto,
TableField,
TableFields,
UIState
} from '@app/shared/internal';
describe('TableFielsd', () => {
let uiState: IMock<UIState>;
const schema =
new SchemaDetailsDto({}, '1', 'my-schema', '', {},
false,
false,
DateTime.now(), 'me',
DateTime.now(), 'me',
new Version('1'),
[
new RootFieldDto({}, 1, 'string', createProperties('String'), 'invariant')
]);
beforeEach(() => {
uiState = Mock.ofType<UIState>();
});
const INVALID_CONFIGS = [
{ case: 'empty', fields: [] },
{ case: 'invalid', fields: ['invalid'] }
];
INVALID_CONFIGS.forEach(test => {
it(`should provide default fields if config is ${test.case}`, () => {
let fields: ReadonlyArray<TableField>;
let fieldNames: ReadonlyArray<string>;
uiState.setup(x => x.getUser<string[]>('schemas.my-schema.view', []))
.returns(() => of(test.fields));
const tableFields = new TableFields(uiState.object, schema);
tableFields.listFields.subscribe(result => fields = result);
tableFields.listFieldNames.subscribe(result => fieldNames = result);
expect(fields!).toEqual([
MetaFields.lastModifiedByAvatar,
schema.fields[0],
MetaFields.statusColor,
MetaFields.lastModified
]);
expect(fieldNames!).toEqual([
MetaFields.lastModifiedByAvatar,
schema.fields[0].name,
MetaFields.statusColor,
MetaFields.lastModified
]);
});
});
INVALID_CONFIGS.forEach(test => {
it(`should remove ui state if config is ${test.case}`, () => {
uiState.setup(x => x.getUser<string[]>('schemas.my-schema.view', []))
.returns(() => of([]));
const tableFields = new TableFields(uiState.object, schema);
tableFields.updateFields(test.fields, true);
uiState.verify(x => x.removeUser('schemas.my-schema.view'), Times.once());
});
});
it('should eliminate invalid fields from the config', () => {
let fields: ReadonlyArray<TableField>;
let fieldNames: ReadonlyArray<string>;
const config = ['invalid', MetaFields.version];
uiState.setup(x => x.getUser<string[]>('schemas.my-schema.view', []))
.returns(() => of(config));
const tableFields = new TableFields(uiState.object, schema);
tableFields.listFields.subscribe(result => fields = result);
tableFields.listFieldNames.subscribe(result => fieldNames = result);
expect(fields!).toEqual([
MetaFields.version
]);
expect(fieldNames!).toEqual([
MetaFields.version
]);
});
it('should update config when fields are saved', () => {
uiState.setup(x => x.getUser<string[]>('schemas.my-schema.view', []))
.returns(() => of([]));
const tableFields = new TableFields(uiState.object, schema);
const config = ['invalid', MetaFields.version];
tableFields.updateFields(config, true);
uiState.verify(x => x.set('schemas.my-schema.view', [MetaFields.version], true), Times.once());
});
});

72
frontend/app/shared/state/table-fields.ts

@ -0,0 +1,72 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: readonly-array
import { BehaviorSubject, Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import {
MetaFields,
SchemaDetailsDto,
TableField
} from '../services/schemas.service';
import { UIState } from './ui.state';
const META_FIELD_NAMES = Object.values(MetaFields);
export class TableFields {
private readonly listField$ = new BehaviorSubject<ReadonlyArray<TableField>>([]);
private readonly listFieldName$ = new BehaviorSubject<ReadonlyArray<string>>([]);
private readonly settingsKey: string;
public readonly allFields: ReadonlyArray<string>;
public get listFields(): Observable<ReadonlyArray<TableField>> {
return this.listField$;
}
public get listFieldNames(): Observable<ReadonlyArray<string>> {
return this.listFieldName$;
}
constructor(
private readonly uiState: UIState,
private readonly schema: SchemaDetailsDto
) {
this.allFields = [...this.schema.contentFields.map(x => x.name), ...META_FIELD_NAMES].sorted();
this.settingsKey = `schemas.${this.schema.name}.view`;
this.uiState.getUser<string[]>(this.settingsKey, []).pipe(take(1))
.subscribe(fieldNames => {
this.updateFields(fieldNames, false);
});
}
public updateFields(fieldNames: string[], save = true) {
fieldNames = fieldNames.filter(x => this.allFields.indexOf(x) >= 0);
if (fieldNames.length === 0) {
fieldNames = this.schema.defaultListFields.map(x => x['name'] || x);
if (save) {
this.uiState.removeUser(this.settingsKey);
}
} else {
if (save) {
this.uiState.set(this.settingsKey, fieldNames, true);
}
}
const fields: ReadonlyArray<TableField> = fieldNames.map(n => this.schema.fields.find(f => f.name === n) || n);
this.listField$.next(fields);
this.listFieldName$.next(fieldNames);
}
}

2
frontend/app/theme/_forms.scss

@ -161,7 +161,7 @@
&:hover {
color: $color-dark-foreground;
& * {
.text-muted {
color: $color-dark-foreground !important;
}
}

Loading…
Cancel
Save