Browse Source

Feature/meta fields (#445)

* Meta fields
pull/447/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
4d26ebfcfc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs
  2. 7
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs
  3. 10
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs
  4. 46
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/MetaFields.cs
  5. 32
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs
  8. 20
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs
  9. 33
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs
  10. 3
      frontend/app/features/content/declarations.ts
  11. 10
      frontend/app/features/content/module.ts
  12. 26
      frontend/app/features/content/pages/contents/contents-page.component.html
  13. 4
      frontend/app/features/content/pages/contents/contents-page.component.ts
  14. 109
      frontend/app/features/content/shared/content-list-cell.directive.ts
  15. 132
      frontend/app/features/content/shared/content-list-field.component.ts
  16. 115
      frontend/app/features/content/shared/content-list-header.component.ts
  17. 49
      frontend/app/features/content/shared/content-selector-item.component.ts
  18. 64
      frontend/app/features/content/shared/content.component.html
  19. 17
      frontend/app/features/content/shared/content.component.scss
  20. 64
      frontend/app/features/content/shared/content.component.ts
  21. 26
      frontend/app/features/content/shared/contents-selector.component.html
  22. 4
      frontend/app/features/content/shared/contents-selector.component.ts
  23. 2
      frontend/app/features/content/shared/reference-item.component.scss
  24. 4
      frontend/app/features/content/shared/reference-item.component.ts
  25. 4
      frontend/app/features/schemas/pages/schema/field-list.component.html
  26. 25
      frontend/app/features/schemas/pages/schema/field-list.component.ts
  27. 3
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.html
  28. 52
      frontend/app/shared/components/table-header.component.ts
  29. 66
      frontend/app/shared/services/schemas.service.ts
  30. 19
      frontend/app/shared/state/contents.forms.spec.ts
  31. 64
      frontend/app/shared/utils/array-extensions.spec.ts
  32. 73
      frontend/app/shared/utils/array-extensions.ts
  33. 10
      frontend/app/shared/utils/array-helper.ts

11
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs

@ -31,12 +31,17 @@ namespace Squidex.Domain.Apps.Core.Schemas
public static bool IsForApi<T>(this T field, bool withHidden = false) where T : IField
{
return (withHidden || !field.IsHidden) && field.RawProperties.IsForApi();
return (withHidden || !field.IsHidden) && !field.RawProperties.IsUIProperty();
}
public static bool IsForApi<T>(this T properties) where T : FieldProperties
public static bool IsUI<T>(this T field) where T : IField
{
return !(properties is UIFieldProperties);
return field.RawProperties is UIFieldProperties;
}
public static bool IsUIProperty<T>(this T properties) where T : FieldProperties
{
return properties is UIFieldProperties;
}
public static Schema ReorderFields(this Schema schema, List<long> ids, long? parentId = null)

7
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs

@ -5,19 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.ObjectModel;
namespace Squidex.Domain.Apps.Core.Schemas
{
public abstract class FieldProperties : NamedElementPropertiesBase
{
[Obsolete]
public bool IsListField { get; set; }
[Obsolete]
public bool IsReferenceField { get; set; }
public bool IsRequired { get; set; }
public string? Placeholder { get; set; }

10
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs

@ -108,21 +108,11 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
schema = schema.ConfigureScripts(Scripts);
}
if (FieldsInLists == null)
{
FieldsInLists = new FieldNames(Fields.Where(x => x.Properties.IsListField).Select(x => x.Name).ToArray());
}
if (FieldsInLists?.Count > 0)
{
schema = schema.ConfigureFieldsInLists(FieldsInLists);
}
if (FieldsInReferences == null)
{
FieldsInLists = new FieldNames(Fields.Where(x => x.Properties.IsReferenceField).Select(x => x.Name).ToArray());
}
if (FieldsInReferences?.Count > 0)
{
schema = schema.ConfigureFieldsInReferences(FieldsInReferences);

46
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/MetaFields.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Reflection;
namespace Squidex.Domain.Apps.Core.Schemas
{
public static class MetaFields
{
private static readonly HashSet<string> AllList = new HashSet<string>();
public static ISet<string> All
{
get { return AllList; }
}
public static readonly string Id = "meta.id";
public static readonly string Created = "meta.created";
public static readonly string CreatedByAvatar = "meta.createdBy.avatar";
public static readonly string CreatedByName = "meta.createdBy.name";
public static readonly string LastModified = "meta.lastModified";
public static readonly string LastModifiedByAvatar = "meta.lastModifiedBy.avatar";
public static readonly string LastModifiedByName = "meta.lastModifiedBy.name";
public static readonly string Status = "meta.status";
public static readonly string StatusColor = "meta.status.color";
public static readonly string Version = "meta.version";
static MetaFields()
{
foreach (var field in typeof(MetaFields).GetFields(BindingFlags.Public | BindingFlags.Static))
{
if (field.FieldType == typeof(string))
{
var value = field.GetValue(null) as string;
AllList.Add(value!);
}
}
}
}
}

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

@ -64,23 +64,39 @@ namespace Squidex.Domain.Apps.Core.Schemas
return properties.SchemaIds?.Count == 1 ? properties.SchemaIds[0] : Guid.Empty;
}
public static IEnumerable<RootField> ReferenceFields(this Schema schema)
public static IEnumerable<RootField> ReferencesFields(this Schema schema)
{
var references = schema.FieldsInReferences.Select(x => schema.FieldsByName.GetOrDefault(x)).Where(x => x != null).ToList();
return schema.RootFields(schema.FieldsInReferences);
}
if (references.Any())
public static IEnumerable<RootField> ListsFields(this Schema schema)
{
return references;
return schema.RootFields(schema.FieldsInLists);
}
references = schema.FieldsInLists.Select(x => schema.FieldsByName.GetOrDefault(x)).Where(x => x != null).ToList();
public static IEnumerable<RootField> RootFields(this Schema schema, FieldNames names)
{
var hasField = false;
if (references.Any())
foreach (var name in names)
{
if (schema.FieldsByName.TryGetValue(name, out var field))
{
return references;
hasField = true;
yield return field;
}
}
return schema.Fields.Take(1);
if (!hasField)
{
var first = schema.Fields.FirstOrDefault(x => !x.IsUI());
if (first != null)
{
yield return first;
}
}
}
public static IEnumerable<IField<ReferencesFieldProperties>> ResolvingReferences(this Schema schema)

2
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs

@ -121,7 +121,7 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
sb.Append(value);
}
var referenceFields = schema.ReferenceFields();
var referenceFields = schema.ReferencesFields();
foreach (var referenceField in referenceFields)
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs

@ -104,7 +104,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
if (ShouldEnrichWithSchema(context))
{
var referenceFields = schema.SchemaDef.ReferenceFields().ToArray();
var referenceFields = schema.SchemaDef.ReferencesFields().ToArray();
var schemaName = schema.SchemaDef.Name;
var schemaDisplayName = schema.SchemaDef.DisplayNameUnchanged();

20
backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs

@ -113,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
Validate.It(() => "Cannot configure UI fields.", e =>
{
ValidateFieldNames(schema, command.FieldsInLists, nameof(command.FieldsInLists), e);
ValidateFieldNames(schema, command.FieldsInLists, nameof(command.FieldsInLists), e, true);
ValidateFieldNames(schema, command.FieldsInReferences, nameof(command.FieldsInReferences), e);
});
}
@ -162,7 +162,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
ValidateFieldNames(command, command.FieldsInLists, nameof(command.FieldsInLists), e);
ValidateFieldNames(command, command.FieldsInLists, nameof(command.FieldsInLists), e, true);
ValidateFieldNames(command, command.FieldsInReferences, nameof(command.FieldsInReferences), e);
}
@ -242,7 +242,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
else
{
if (!field.Properties.IsForApi())
if (field.Properties.IsUIProperty())
{
if (field.IsHidden)
{
@ -261,7 +261,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
private static void ValidateFieldNames(Schema schema, FieldNames? fields, string path, AddValidation e)
private static void ValidateFieldNames(Schema schema, FieldNames? fields, string path, AddValidation e, bool withMeta = false)
{
if (fields != null)
{
@ -273,15 +273,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
fieldIndex++;
fieldPrefix = $"{path}[{fieldIndex}]";
var field = schema.FieldsByName.GetOrDefault(fieldName ?? string.Empty);
if (string.IsNullOrWhiteSpace(fieldName))
{
e(Not.Defined("Field"), fieldPrefix);
}
else if (!schema.FieldsByName.TryGetValue(fieldName, out var field))
else if (field == null && (!withMeta || !MetaFields.All.Contains(fieldName)))
{
e($"Field is not part of the schema.", fieldPrefix);
}
else if (!field.IsForApi())
else if (field?.IsUI() == true)
{
e($"Field cannot be an UI field.", fieldPrefix);
}
@ -297,7 +299,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
private static void ValidateFieldNames(UpsertCommand command, FieldNames? fields, string path, AddValidation e)
private static void ValidateFieldNames(UpsertCommand command, FieldNames? fields, string path, AddValidation e, bool withMeta = false)
{
if (fields != null)
{
@ -315,11 +317,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
e(Not.Defined("Field"), fieldPrefix);
}
else if (field == null)
else if (field == null && (!withMeta || !MetaFields.All.Contains(fieldName)))
{
e($"Field is not part of the schema.", fieldPrefix);
}
else if (field?.Properties.IsForApi() != true)
else if (field?.Properties?.IsUIProperty() == true)
{
e($"Field cannot be an UI field.", fieldPrefix);
}

33
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs

@ -434,6 +434,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
"FieldsInReferences"));
}
[Fact]
public void CanCreate_should_throw_exception_if_references_contains_meta_field()
{
var command = new CreateSchema
{
FieldsInLists = null,
FieldsInReferences = new FieldNames("meta.id"),
Name = "new-schema"
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Field is not part of the schema.",
"FieldsInReferences[1]"));
}
[Fact]
public void CanCreate_should_not_throw_exception_if_command_is_valid()
{
@ -476,7 +491,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
},
FieldsInLists = new FieldNames("field1"),
FieldsInLists = new FieldNames("field1", "meta.id"),
FieldsInReferences = new FieldNames("field1"),
Name = "new-schema"
};
@ -528,12 +543,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
"FieldsInReferences"));
}
[Fact]
public void CanConfigureUIFields_should_throw_exception_if_references_contains_meta_field()
{
var command = new ConfigureUIFields
{
FieldsInLists = null,
FieldsInReferences = new FieldNames("meta.id")
};
ValidationAssert.Throws(() => GuardSchema.CanConfigureUIFields(schema_0, command),
new ValidationError("Field is not part of the schema.",
"FieldsInReferences[1]"));
}
[Fact]
public void CanConfigureUIFields_should_not_throw_exception_if_command_is_valid()
{
var command = new ConfigureUIFields
{
FieldsInLists = new FieldNames("field1"),
FieldsInLists = new FieldNames("field1", "meta.id"),
FieldsInReferences = new FieldNames("field2")
};

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

@ -17,6 +17,9 @@ export * from './pages/schemas/schemas-page.component';
export * from './shared/array-editor.component';
export * from './shared/array-item.component';
export * from './shared/assets-editor.component';
export * from './shared/content-list-cell.directive';
export * from './shared/content-list-field.component';
export * from './shared/content-list-header.component';
export * from './shared/content.component';
export * from './shared/content-status.component';
export * from './shared/content-value.component';

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

@ -27,7 +27,12 @@ import {
ContentComponent,
ContentFieldComponent,
ContentHistoryPageComponent,
ContentListCellDirective,
ContentListFieldComponent,
ContentListHeaderComponent,
ContentListWidthPipe,
ContentPageComponent,
ContentReferencesWidthPipe,
ContentSelectorItemComponent,
ContentsFiltersPageComponent,
ContentsPageComponent,
@ -112,6 +117,11 @@ const routes: Routes = [
CommentsPageComponent,
ContentComponent,
ContentFieldComponent,
ContentListCellDirective,
ContentReferencesWidthPipe,
ContentListWidthPipe,
ContentListFieldComponent,
ContentListHeaderComponent,
ContentHistoryPageComponent,
ContentPageComponent,
ContentSelectorItemComponent,

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

@ -39,7 +39,7 @@
<ng-container content>
<div class="grid-header">
<table class="table table-items table-fixed" [style.minWidth]="minWidth" #header>
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentListWidth" #header>
<thead>
<tr>
<th class="cell-select">
@ -48,26 +48,13 @@
<th class="cell-actions cell-actions-left">
Actions
</th>
<th class="cell-user">
<sqx-table-header text="By"></sqx-table-header>
</th>
<th class="cell-auto cell-content" *ngFor="let field of schema.listFields">
<sqx-table-header [text]="field.displayName"
[sortable]="field.properties.isSortable"
<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-table-header>
</th>
<th class="cell-time">
<sqx-table-header text="Updated"
[sortable]="true"
[field]="'lastModified'"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</sqx-content-list-header>
</th>
</tr>
</thead>
@ -96,7 +83,7 @@
<div class="grid-content" [sqxSyncScrolling]="header">
<div class="table-container" sqxIgnoreScrollbar>
<table class="table table-items table-fixed" [style.minWidth]="minWidth">
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentListWidth">
<tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent"
[sqxContent]="content"
(delete)="delete(content)"
@ -107,8 +94,7 @@
[link]="[content.id]"
[language]="language"
[canClone]="contentsState.snapshot.canCreate"
[schema]="schema"
[schemaFields]="schema.listFields">
[schema]="schema">
</tbody>
</table>
</div>

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

@ -50,8 +50,6 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public queryModel: QueryModel;
public queries: Queries;
public minWidth: string;
@ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent;
@ -72,8 +70,6 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.schema = schema;
this.minWidth = `${300 + (200 * this.schema.listFields.length)}px`;
this.contentsState.load();
this.updateQueries();

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

@ -0,0 +1,109 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Directive, ElementRef, Input, OnChanges, Pipe, PipeTransform, Renderer2 } from '@angular/core';
import {
MetaFields,
RootFieldDto,
SchemaDetailsDto,
TableField,
Types
} from '@app/shared';
export function getTableWidth(fields: ReadonlyArray<TableField>) {
let result = 0;
for (let field of fields) {
result += getCellWidth(field);
}
return result;
}
export function getCellWidth(field: TableField) {
if (Types.is(field, RootFieldDto)) {
return 220;
} else {
switch (field) {
case MetaFields.id:
return 280;
case MetaFields.created:
return 150;
case MetaFields.createdByAvatar:
return 55;
case MetaFields.createdByName:
return 150;
case MetaFields.lastModified:
return 150;
case MetaFields.lastModifiedByAvatar:
return 55;
case MetaFields.lastModifiedByName:
return 150;
case MetaFields.status:
return 160;
case MetaFields.statusColor:
return 50;
case MetaFields.version:
return 80;
}
return 0;
}
}
@Pipe({
name: 'sqxContentListWidth',
pure: true
})
export class ContentListWidthPipe implements PipeTransform {
public transform(value: SchemaDetailsDto) {
if (!value) {
return 0;
}
return `${getTableWidth(value.referenceFields) + 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`;
}
}
@Directive({
selector: '[sqxContentListCell]'
})
export class ContentListCellDirective implements OnChanges {
@Input('sqxContentListCell')
public field: TableField;
constructor(
private readonly element: ElementRef,
private readonly renderer: Renderer2
) {
}
public ngOnChanges() {
if (Types.isString(this.field) && this.field) {
const width = `${getCellWidth(this.field)}px`;
this.renderer.setStyle(this.element.nativeElement, 'min-width', width);
this.renderer.setStyle(this.element.nativeElement, 'max-width', width);
this.renderer.setStyle(this.element.nativeElement, 'width', width);
}
}
}

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

@ -0,0 +1,132 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
ContentDto,
getContentValue,
LanguageDto,
MetaFields,
RootFieldDto,
TableField,
Types
} from '@app/shared';
@Component({
selector: 'sqx-content-list-field',
template: `
<ng-container [ngSwitch]="fieldName">
<ng-container *ngSwitchCase="metaFields.id">
<small class="truncate">{{content.id}}</small>
</ng-container>
<ng-container *ngSwitchCase="metaFields.created">
<small class="truncate">{{content.created | sqxFromNow}}</small>
</ng-container>
<ng-container *ngSwitchCase="metaFields.createdByAvatar">
<img class="user-picture" title="{{content.createdBy | sqxUserNameRef}}" [attr.src]="content.createdBy | sqxUserPictureRef" />
</ng-container>
<ng-container *ngSwitchCase="metaFields.createdByName">
<small class="truncate">{{content.createdBy | sqxUserNameRef}}</small>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModified">
<small class="truncate">{{content.lastModified | sqxFromNow}}</small>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModifiedByAvatar">
<img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" />
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModifiedByName">
<small class="truncate">{{content.lastModifiedBy | sqxUserNameRef}}</small>
</ng-container>
<ng-container *ngSwitchCase="metaFields.status">
<span class="truncate">
<sqx-content-status
[status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending">
</sqx-content-status>
{{content.status}}
</span>
</ng-container>
<ng-container *ngSwitchCase="metaFields.statusColor">
<sqx-content-status
[status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending">
</sqx-content-status>
</ng-container>
<ng-container *ngSwitchCase="metaFields.version">
<small class="truncate">{{content.version.value}}</small>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-container *ngIf="isInlineEditable && patchAllowed; else displayTemplate">
<sqx-content-value-editor [form]="patchForm" [field]="field"></sqx-content-value-editor>
</ng-container>
<ng-template #displayTemplate>
<sqx-content-value [value]="value"></sqx-content-value>
</ng-template>
</ng-container>
</ng-container>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentListFieldComponent implements OnChanges {
@Input()
public field: TableField;
@Input()
public content: ContentDto;
@Input()
public patchAllowed: boolean;
@Input()
public patchForm: FormGroup;
@Input()
public language: LanguageDto;
public value: any;
public ngOnChanges() {
this.reset();
}
public reset() {
if (Types.is(this.field, RootFieldDto)) {
const { value, formatted } = getContentValue(this.content, this.language, this.field);
if (this.patchForm) {
const formControl = this.patchForm.controls[this.field.name];
if (formControl) {
formControl.setValue(value);
}
}
this.value = formatted;
}
}
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;
}
}

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

@ -0,0 +1,115 @@
/*
* Squidex Headless CMS
*
* @license
* 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';
@Component({
selector: 'sqx-content-list-header',
template: `
<ng-container [ngSwitch]="fieldName">
<ng-container *ngSwitchCase="metaFields.id">
<sqx-table-header text="Id"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.created">
<sqx-table-header text="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="By"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.createdByName">
<sqx-table-header text="Created By"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModified">
<sqx-table-header text="Updated"
[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="By"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.lastModifiedByName">
<sqx-table-header text="Modified By"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.status">
<sqx-table-header text="Status"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.statusColor">
<sqx-table-header text="Status"></sqx-table-header>
</ng-container>
<ng-container *ngSwitchCase="metaFields.version">
<sqx-table-header text="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>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentListHeaderComponent {
@Input()
public field: TableField;
@Output()
public queryChange = new EventEmitter<Query>();
@Input()
public query: Query;
@Input()
public language: LanguageDto;
public get metaFields() {
return MetaFields;
}
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 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}`;
} else {
return `data.${this.field.name}.iv`;
}
}
}

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

@ -5,13 +5,12 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {
ContentDto,
getContentValue,
LanguageDto,
RootFieldDto
SchemaDetailsDto
} from '@app/shared';
/* tslint:disable:component-selector */
@ -27,31 +26,19 @@ import {
(ngModelChange)="emitSelectedChange($event)" />
</td>
<td class="cell-user">
<img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" />
<td sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field>
</td>
<td class="cell-auto cell-content" *ngFor="let value of values">
<sqx-content-value [value]="value"></sqx-content-value>
</td>
<td class="cell-time">
<sqx-content-status
[status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending">
</sqx-content-status>
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small>
<td *ngFor="let field of schema.referenceFields" [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 implements OnChanges {
export class ContentSelectorItemComponent {
@Output()
public selectedChange = new EventEmitter<boolean>();
@ -65,19 +52,11 @@ export class ContentSelectorItemComponent implements OnChanges {
public language: LanguageDto;
@Input()
public fields: ReadonlyArray<RootFieldDto>;
public schema: SchemaDetailsDto;
@Input('sqxContentSelectorItem')
public content: ContentDto;
public values: ReadonlyArray<any> = [];
public ngOnChanges(changes: SimpleChanges) {
if (changes['content'] || changes['language']) {
this.updateValues();
}
}
public toggle() {
if (this.selectable) {
this.emitSelectedChange(!this.selected);
@ -87,16 +66,4 @@ export class ContentSelectorItemComponent implements OnChanges {
public emitSelectedChange(isSelected: boolean) {
this.selectedChange.emit(isSelected);
}
private updateValues() {
const values = [];
for (const field of this.fields) {
const { formatted } = getContentValue(this.content, this.language, field);
values.push(formatted);
}
this.values = values;
}
}

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

@ -1,12 +1,24 @@
<tr [routerLink]="link">
<td class="cell-select" sqxStopClick>
<td class="cell-select inline-edit" sqxStopClick>
<input type="checkbox" class="form-check"
[ngModel]="selected"
(ngModelChange)="emitSelectedChange($event)" />
<ng-container *ngIf="isDirty">
<div class="edit-menu">
<button type="button" class="btn btn-text-secondary btn-cancel mr-2" (click)="cancel()" sqxStopClick>
<i class="icon-close"></i>
</button>
<button type="button" class="btn btn-success" (click)="save()" sqxStopClick>
<i class="icon-checkmark"></i>
</button>
</div>
</ng-container>
</td>
<td class="cell-actions cell-actions-left" *ngIf="!isDirty" sqxStopClick>
<div class="dropdown dropdown-options" *ngIf="content">
<td class="cell-actions cell-actions-left" sqxStopClick>
<div class="dropdown dropdown-options inline-edit" *ngIf="content">
<button type="button" class="btn btn-text-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<i class="icon-dots"></i>
</button>
@ -40,44 +52,14 @@
</div>
</td>
<ng-container *ngIf="isDirty">
<td class="cell-actions" >
<button type="button" class="btn btn-text-secondary btn-cancel" (click)="cancel()" sqxStopClick>
<i class="icon-close"></i>
</button>
</td>
<td class="cell-user" >
<button type="button" class="btn btn-success" (click)="save()" sqxStopClick>
<i class="icon-checkmark"></i>
</button>
</td>
</ng-container>
<td class="cell-user" *ngIf="!isCompact && !isDirty" [sqxStopClick]="isDirty">
<img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" />
</td>
<td class="cell-auto cell-content" *ngFor="let field of schemaFields; let i = index; trackBy: trackByFieldFn" [sqxStopClick]="isDirty || (field.isInlineEditable && patchAllowed)">
<ng-container *ngIf="field.isInlineEditable && patchAllowed; else displayTemplate">
<sqx-content-value-editor [form]="patchForm.form" [field]="field"></sqx-content-value-editor>
</ng-container>
<ng-template #displayTemplate>
<sqx-content-value [value]="values[i]"></sqx-content-value>
</ng-template>
</td>
<td class="cell-time" *ngIf="!isCompact" [sqxStopClick]="isDirty">
<sqx-content-status
[status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending">
</sqx-content-status>
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small>
<td *ngFor="let field of schema.listFields" [sqxContentListCell]="field" [sqxStopClick]="shouldStop(field)">
<sqx-content-list-field
[field]="field"
[patchForm]="patchForm.form"
[patchAllowed]="patchAllowed"
[content]="content"
[language]="language">
</sqx-content-list-field>
</td>
</tr>
<tr class="spacer"></tr>

17
frontend/app/features/content/shared/content.component.scss

@ -1,2 +1,19 @@
@import '_vars';
@import '_mixins';
.inline-edit {
& {
position: relative;
}
.edit-menu {
@include absolute(.75rem, auto, auto, .25rem);
border-right: 1px solid $color-border;
min-height: 2.4rem;
max-height: 2.4rem;
padding-right: 1rem;
white-space: nowrap;
background: $color-table-background;
z-index: 100;
}
}

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

@ -5,21 +5,23 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import {
AppLanguageDto,
ContentDto,
ContentsState,
fadeAnimation,
FieldDto,
getContentValue,
ModalModel,
PatchContentForm,
RootFieldDto,
SchemaDetailsDto
SchemaDetailsDto,
TableField,
Types
} from '@app/shared';
import { ContentListFieldComponent } from './content-list-field.component';
/* tslint:disable:component-selector */
@Component({
@ -53,30 +55,23 @@ export class ContentComponent implements OnChanges {
@Input()
public schema: SchemaDetailsDto;
@Input()
public schemaFields: ReadonlyArray<RootFieldDto>;
@Input()
public canClone: boolean;
@Input()
public isCompact = false;
@Input()
public link: any = null;
@Input('sqxContent')
public content: ContentDto;
public trackByFieldFn: (index: number, field: FieldDto) => any;
@ViewChildren(ContentListFieldComponent)
public fields: QueryList<ContentListFieldComponent>;
public patchForm: PatchContentForm;
public patchAllowed = false;
public dropdown = new ModalModel();
public values: ReadonlyArray<any> = [];
public get isDirty() {
return this.patchForm && this.patchForm.form.dirty;
}
@ -85,7 +80,6 @@ export class ContentComponent implements OnChanges {
private readonly changeDetector: ChangeDetectorRef,
private readonly contentsState: ContentsState
) {
this.trackByFieldFn = this.trackByField.bind(this);
}
public ngOnChanges(changes: SimpleChanges) {
@ -93,17 +87,11 @@ export class ContentComponent implements OnChanges {
this.patchAllowed = this.content.canUpdate;
}
if (changes['schema'] || changes['language']) {
if (this.patchAllowed) {
if (this.patchAllowed && (changes['schema'] || changes['language'])) {
this.patchForm = new PatchContentForm(this.schema, this.language);
}
}
if (changes['content'] || changes['language']) {
this.updateValues();
}
}
public save() {
if (!this.content.canUpdate) {
return;
@ -125,10 +113,18 @@ export class ContentComponent implements OnChanges {
}
}
public shouldStop(field: TableField) {
if (Types.is(field, RootFieldDto)) {
return this.isDirty || (field.isInlineEditable && this.patchAllowed);
} else {
return this.isDirty;
}
}
public cancel() {
this.patchForm.submitCompleted();
this.updateValues();
this.fields.forEach(x => x.reset());
}
public emitSelectedChange(isSelected: boolean) {
@ -146,28 +142,4 @@ export class ContentComponent implements OnChanges {
public emitClone() {
this.clone.emit();
}
private updateValues() {
const values = [];
for (const field of this.schemaFields) {
const { value, formatted } = getContentValue(this.content, this.language, field);
values.push(formatted);
if (this.patchForm) {
const formControl = this.patchForm.form.controls[field.name];
if (formControl) {
formControl.setValue(value);
}
}
}
this.values = values;
}
public trackByField(index: number, field: FieldDto) {
return field.fieldId + this.schema.id;
}
}

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

@ -39,32 +39,22 @@
<ng-container content>
<ng-container *ngIf="schema">
<div class="grid-header">
<table class="table table-items table-fixed" [style.minWidth]="minWidth" #header>
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentReferencesWidth" #header>
<thead>
<tr>
<th class="cell-select">
<input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" />
</th>
<th class="cell-user">
<sqx-table-header text="By"></sqx-table-header>
<th sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-content-list-header field="meta.lastModifiedBy.avatar"></sqx-content-list-header>
</th>
<th class="cell-content" *ngFor="let field of schema.referenceFields">
<sqx-table-header [text]="field.displayName"
[sortable]="field.properties.isSortable"
<th *ngFor="let field of schema.referenceFields" [sqxContentListCell]="field">
<sqx-content-list-header
[field]="field"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</th>
<th class="cell-time">
<sqx-table-header text="Updated"
[sortable]="true"
[field]="'lastModified'"
[query]="contentsState.contentsQuery | async"
(queryChange)="search($event)"
[language]="language">
</sqx-table-header>
</sqx-content-list-header>
</th>
</tr>
</thead>
@ -73,10 +63,10 @@
<div class="grid-content" [sqxSyncScrolling]="header">
<div class="table-container" sqxIgnoreScrollbar>
<table class="table table-items table-fixed" [style.minWidth]="minWidth" *ngIf="contentsState.contents | async; let contents">
<table class="table table-items table-fixed" [style.minWidth]="schema | sqxContentReferencesWidth" *ngIf="contentsState.contents | async; let contents">
<tbody *ngFor="let content of contents; trackBy: trackByContent"
[sqxContentSelectorItem]="content"
[fields]="schema.referenceFields"
[schema]="schema"
[selectable]="!isItemAlreadySelected(content)"
[selected]="isItemSelected(content)"
(selectedChange)="selectContent(content)"

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

@ -57,8 +57,6 @@ export class ContentsSelectorComponent extends ResourceOwner implements OnInit {
public selectionCount = 0;
public selectedAll = false;
public minWidth: string;
constructor(
public readonly contentsState: ManualContentsState,
public readonly schemasState: SchemasState,
@ -95,8 +93,6 @@ export class ContentsSelectorComponent extends ResourceOwner implements OnInit {
if (schema) {
this.schema = schema;
this.minWidth = `${200 + (200 * schema.referenceFields.length)}px`;
this.contentsState.schema = schema;
this.contentsState.load();

2
frontend/app/features/content/shared/reference-item.component.scss

@ -14,9 +14,11 @@
.reference-menu {
@include absolute(0, -.25rem, auto, auto);
border-left: 1px solid $color-border;
display: none;
min-height: 2.4rem;
max-height: 2.4rem;
padding-left: 1rem;
white-space: nowrap;
background: $color-table-background;
}

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

@ -24,8 +24,8 @@ import {
<ng-content></ng-content>
</td>
<td class="cell-user" *ngIf="!isCompact">
<img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" />
<td sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field>
</td>
<td class="cell-auto cell-content" *ngFor="let value of values">

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

@ -12,7 +12,7 @@
</div>
<div *ngFor="let field of fieldsAdded" class="table-items-row" cdkDrag>
<i class="icon-drag2 drag-handle"></i> <span>{{field.displayName}}</span>
<i class="icon-drag2 drag-handle"></i> <span>{{field}}</span>
</div>
</div>
@ -27,6 +27,6 @@
<label>Unassigned fields</label>
<div *ngFor="let field of fieldsNotAdded" class="table-items-row" cdkDrag>
<i class="icon-drag2 drag-handle"></i> <span>{{field.displayName}}</span>
<i class="icon-drag2 drag-handle"></i> <span>{{field}}</span>
</div>
</div>

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

@ -10,7 +10,9 @@
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { FieldDto, SchemaDetailsDto } from '@app/shared';
import { MetaFields, SchemaDetailsDto } from '@app/shared';
const MetaFieldNames = Object.values(MetaFields);
@Component({
selector: 'sqx-field-list',
@ -28,18 +30,27 @@ export class FieldListComponent implements OnChanges {
@Input()
public fieldNames: ReadonlyArray<string>;
@Input()
public withMetaFields = false;
@Output()
public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>();
public fieldsAdded: FieldDto[];
public fieldsNotAdded: FieldDto[];
public fieldsAdded: string[];
public fieldsNotAdded: string[];
public ngOnChanges() {
this.fieldsAdded = this.fieldNames.map(n => this.schema.contentFields.find(y => y.name === n)!).filter(x => !!x);
this.fieldsNotAdded = this.schema.contentFields.filter(n => this.fieldNames.indexOf(n.name) < 0);
let allFields = this.schema.contentFields.map(x => x.name);
if (this.withMetaFields) {
allFields = [...allFields, ...MetaFieldNames];
}
this.fieldsAdded = this.fieldNames.filter(n => allFields.indexOf(n) >= 0);
this.fieldsNotAdded = allFields.filter(n => this.fieldNames.indexOf(n) < 0);
}
public drop(event: CdkDragDrop<FieldDto[]>) {
public drop(event: CdkDragDrop<string[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
@ -50,7 +61,7 @@ export class FieldListComponent implements OnChanges {
event.currentIndex);
}
const newNames = this.fieldsAdded.map(x => x.name);
const newNames = this.fieldsAdded;
this.fieldNamesChange.emit(newNames);
}

3
frontend/app/features/schemas/pages/schema/schema-ui-form.component.html

@ -14,7 +14,8 @@
emptyText="Drop field here or reorder them to show the fields in the content list. When no list field is defined, the first field is used."
[schema]="schema"
[fieldNames]="state.fieldsInLists"
(fieldNamesChange)="setFieldsInLists($event)">
(fieldNamesChange)="setFieldsInLists($event)"
[withMetaFields]="true">
</sqx-field-list>
<sqx-field-list [class.hidden]="selectedTab !== 'Reference Fields'"
emptyText="Drop field here or reorder them to show the fields when referenced by another content. When no reference field is defined, the list fields are used instead."

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

@ -10,39 +10,38 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Out
import {
LanguageDto,
Query,
RootFieldDto,
SortMode,
Types
SortMode
} from '@app/shared/internal';
type Field = string | RootFieldDto;
@Component({
selector: 'sqx-table-header',
template: `
<a *ngIf="sortable; else notSortable" (click)="sort()" class="pointer truncate">
<span class="truncate">
<i *ngIf="order === 'ascending'" class="icon-caret-down"></i>
<i *ngIf="order === 'descending'" class="icon-caret-up"></i>
{{text}}
</span>
</a>
<ng-template #notSortable>
{{text}}
<span class="truncate">{{text}}</span>
</ng-template>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TableHeaderComponent implements OnChanges {
private fieldPath: string;
@Output()
public queryChange = new EventEmitter<Query>();
@Input()
public query: Query;
@Input()
public text: string;
@Input()
public field: Field;
public fieldPath: string;
@Input()
public language: LanguageDto;
@ -50,19 +49,20 @@ export class TableHeaderComponent implements OnChanges {
@Input()
public sortable = false;
@Input()
public query: Query;
public order: SortMode | null;
public ngOnChanges(changes: SimpleChanges) {
if (this.sortable) {
if (changes['language'] || changes['field']) {
this.fieldPath = getFieldPath(this.language, this.field);
if (changes['query'] || changes['fieldPath']) {
if (this.fieldPath &&
this.query &&
this.query.sort &&
this.query.sort.length === 1 &&
this.query.sort[0].path === this.fieldPath) {
this.order = this.query.sort[0].order;
} else {
this.order = null;
}
if (changes['query'] && this.fieldPath) {
this.order = getSortMode(this.query, this.fieldPath);
}
}
}
@ -83,21 +83,3 @@ export class TableHeaderComponent implements OnChanges {
return {...this.query, sort: [{ path: this.fieldPath, order: this.order! }] };
}
}
function getSortMode(query: Query, path: string) {
if (path && query && query.sort && query.sort.length === 1 && query.sort[0].path === path) {
return query.sort[0].order;
}
return null;
}
function getFieldPath(language: LanguageDto | undefined, field: Field) {
if (Types.isString(field)) {
return field;
} else if (field.isLocalizable && language) {
return `data.${field.name}.${language.iso2Code}`;
} else {
return `data.${field.name}.iv`;
}
}

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

@ -5,6 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: readonly-array
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@ -35,6 +37,19 @@ export type SchemasDto = {
type FieldNames = ReadonlyArray<string>;
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',
version: 'meta.version'
};
export class SchemaDto {
public readonly _links: ResourceLinks;
@ -85,11 +100,13 @@ export class SchemaDto {
}
}
export type TableField = RootFieldDto | string;
export class SchemaDetailsDto extends SchemaDto {
public readonly contentFields: ReadonlyArray<RootFieldDto>;
public readonly listFields: ReadonlyArray<RootFieldDto>;
public readonly listFields: ReadonlyArray<TableField>;
public readonly listFieldsEditable: ReadonlyArray<RootFieldDto>;
public readonly referenceFields: ReadonlyArray<RootFieldDto>;
public readonly referenceFields: ReadonlyArray<TableField>;
constructor(links: ResourceLinks, id: string, name: string, category: string,
properties: SchemaPropertiesDto,
@ -111,22 +128,32 @@ export class SchemaDetailsDto extends SchemaDto {
if (fields) {
this.contentFields = fields.filter(x => x.properties.isContentField);
this.listFields = findFields(fieldsInLists, this.contentFields);
const listFields = findFields(fieldsInLists, this.contentFields);
if (listFields.length === 0) {
listFields.push(MetaFields.lastModifiedByAvatar);
if (this.listFields.length === 0 && this.fields.length > 0) {
this.listFields = [this.fields[0]];
if (fields.length > 0) {
listFields.push(this.fields[0]);
} else {
listFields.push('');
}
if (this.listFields.length === 0) {
this.listFields = NONE_FIELDS;
listFields.push(MetaFields.statusColor);
listFields.push(MetaFields.lastModified);
}
this.listFieldsEditable = this.listFields.filter(x => x.isInlineEditable);
this.listFields = listFields;
this.listFieldsEditable = <any>this.listFields.filter(x => Types.is(x, RootFieldDto) && x.isInlineEditable);
this.referenceFields = findFields(fieldsInReferences, this.contentFields);
if (this.referenceFields.length === 0) {
this.referenceFields = this.listFields;
if (fields.length > 0) {
this.referenceFields = [fields[0]];
} else {
this.referenceFields = [''];
}
}
}
}
@ -179,8 +206,22 @@ export class SchemaDetailsDto extends SchemaDto {
}
}
function findFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>) {
return names.map(x => fields.find(f => f.name === x)!).filter(x => !!x);
function findFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): TableField[] {
let result: TableField[] = [];
for (let name of names) {
if (name.startsWith('meta.')) {
result.push(name);
} else {
const field = fields.find(x => x.name === name);
if (field) {
result.push(field);
}
}
}
return result;
}
export class FieldDto {
@ -258,9 +299,6 @@ export class RootFieldDto extends FieldDto {
}
}
const NONE_FIELD = new RootFieldDto({}, -1, '', createProperties('String'), 'invariant');
const NONE_FIELDS: ReadonlyArray<any> = [NONE_FIELD];
export class NestedFieldDto extends FieldDto {
constructor(links: ResourceLinks, fieldId: number, name: string, properties: FieldPropertiesDto,
public readonly parentId: number,

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

@ -19,6 +19,7 @@ import {
getContentValue,
HtmlValue,
LanguageDto,
MetaFields,
NestedFieldDto,
PartitionConfig,
RootFieldDto,
@ -68,19 +69,19 @@ describe('SchemaDetailsDto', () => {
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([field1]);
expect(schema.listFields).toEqual([MetaFields.lastModifiedByAvatar, field1, 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'] });
it('should return preset with empty content field as list fields if fields is empty', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.referenceFields).toEqual([field1, field3]);
expect(schema.listFields).toEqual([MetaFields.lastModifiedByAvatar, '', MetaFields.statusColor, MetaFields.lastModified]);
});
it('should return lists fields as reference fields if no field is declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInLists: ['field2', 'field3'] });
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([field2, field3]);
expect(schema.referenceFields).toEqual([field1, field3]);
});
it('should return first field as reference fields if no field is declared', () => {
@ -89,10 +90,10 @@ describe('SchemaDetailsDto', () => {
expect(schema.referenceFields).toEqual([field1]);
});
it('should return empty list fields if fields is empty', () => {
it('should return noop field as reference field if list is empty', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.listFields[0].fieldId).toEqual(-1);
expect(schema.referenceFields).toEqual(['']);
});
});

64
frontend/app/shared/utils/array-extensions.spec.ts

@ -0,0 +1,64 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
describe('ArrayExtensions', () => {
it('should return same array when replaying by property with null value', () => {
const array_0 = [{ id: 1 }, { id: 2 }];
const array_1 = array_0.replaceBy('id', null!);
expect(array_1).toBe(array_0);
});
it('should return new array when replaying by property', () => {
const array_0 = [{ id: 1, v: 10 }, { id: 2, v: 20 }];
const array_1 = array_0.replaceBy('id', { id: 1, v: 30 });
expect(array_1).toEqual([{ id: 1, v: 30 }, { id: 2, v: 20 }]);
});
it('should return same array when removing by property with null value', () => {
const array_0 = [{ id: 1 }, { id: 2 }];
const array_1 = array_0.removeBy('id', null!);
expect(array_1).toBe(array_0);
});
it('should return new array when removing by property', () => {
const array_0 = [{ id: 1 }, { id: 2 }];
const array_1 = array_0.removeBy('id', { id: 1 });
expect(array_1).toEqual([{ id: 2 }]);
});
it('should return same array when removing with null value', () => {
const array_0 = [1, 2, 3];
const array_1 = array_0.removed(null!);
expect(array_1).toBe(array_0);
});
it('should return new array when removing', () => {
const array_0 = [1, 2, 3];
const array_1 = array_0.removed(2);
expect(array_1).toEqual([1, 3]);
});
it('should sort by value', () => {
const array_0 = [3, 1, 2];
const array_1 = array_0.sorted();
expect(array_1).toEqual([1, 2, 3]);
});
it('should sort by property', () => {
const array_0 = [{ id: 'C' }, { id: 'b' }, { id: 'A' }];
const array_1 = array_0.sortedByString(x => x.id);
expect(array_1).toEqual([{ id: 'A' }, { id: 'b' }, { id: 'C' }]);
});
});

73
frontend/app/shared/utils/array-extensions.ts

@ -0,0 +1,73 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: readonly-array
interface ReadonlyArray<T> {
replaceBy(field: string, value: T): ReadonlyArray<T>;
removeBy(field: string, value: T): ReadonlyArray<T>;
removed(value?: T): ReadonlyArray<T>;
sorted(): ReadonlyArray<T>;
sortedByString(selector: (value: T) => string): ReadonlyArray<T>;
}
interface Array<T> {
replaceBy(field: string, value: T): Array<T>;
removeBy(field: string, value: T): Array<T>;
removed(value?: T): Array<T>;
sorted(): Array<T>;
sortedByString(selector: (value: T) => string): Array<T>;
}
Array.prototype.replaceBy = function<T>(field: string, value: T) {
if (!value) {
return this;
}
return this.map((v: T) => v[field] === value[field] ? value : v);
};
Array.prototype.removeBy = function<T>(field: string, value: T) {
if (!value) {
return this;
}
return this.filter((v: T) => v[field] !== value[field]);
};
Array.prototype.removed = function<T>(value?: T) {
if (!value) {
return this;
}
return this.filter((v: T) => v !== value);
};
Array.prototype.sorted = function() {
const copy = [...this];
copy.sort();
return copy;
};
Array.prototype.sortedByString = function<T>(selector: (value: T) => string) {
const copy = [...this];
copy.sort((a, b) => selector(a).localeCompare(selector(b), undefined, { sensitivity: 'base' }));
return copy;
};

10
frontend/app/shared/utils/array-helper.ts

@ -0,0 +1,10 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export function compareStrings(a: string, b: string) {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
}
Loading…
Cancel
Save