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. 36
      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. 66
      frontend/app/features/content/shared/content.component.html
  19. 19
      frontend/app/features/content/shared/content.component.scss
  20. 66
      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. 60
      frontend/app/shared/components/table-header.component.ts
  29. 68
      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 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) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace Squidex.Domain.Apps.Core.Schemas namespace Squidex.Domain.Apps.Core.Schemas
{ {
public abstract class FieldProperties : NamedElementPropertiesBase public abstract class FieldProperties : NamedElementPropertiesBase
{ {
[Obsolete]
public bool IsListField { get; set; }
[Obsolete]
public bool IsReferenceField { get; set; }
public bool IsRequired { get; set; } public bool IsRequired { get; set; }
public string? Placeholder { 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); 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) if (FieldsInLists?.Count > 0)
{ {
schema = schema.ConfigureFieldsInLists(FieldsInLists); 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) if (FieldsInReferences?.Count > 0)
{ {
schema = schema.ConfigureFieldsInReferences(FieldsInReferences); 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!);
}
}
}
}
}

36
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; 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)
{ {
return references; if (schema.FieldsByName.TryGetValue(name, out var field))
{
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) 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); sb.Append(value);
} }
var referenceFields = schema.ReferenceFields(); var referenceFields = schema.ReferencesFields();
foreach (var referenceField in referenceFields) 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)) if (ShouldEnrichWithSchema(context))
{ {
var referenceFields = schema.SchemaDef.ReferenceFields().ToArray(); var referenceFields = schema.SchemaDef.ReferencesFields().ToArray();
var schemaName = schema.SchemaDef.Name; var schemaName = schema.SchemaDef.Name;
var schemaDisplayName = schema.SchemaDef.DisplayNameUnchanged(); 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 => 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); 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); ValidateFieldNames(command, command.FieldsInReferences, nameof(command.FieldsInReferences), e);
} }
@ -242,7 +242,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
} }
else else
{ {
if (!field.Properties.IsForApi()) if (field.Properties.IsUIProperty())
{ {
if (field.IsHidden) 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) if (fields != null)
{ {
@ -273,15 +273,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
fieldIndex++; fieldIndex++;
fieldPrefix = $"{path}[{fieldIndex}]"; fieldPrefix = $"{path}[{fieldIndex}]";
var field = schema.FieldsByName.GetOrDefault(fieldName ?? string.Empty);
if (string.IsNullOrWhiteSpace(fieldName)) if (string.IsNullOrWhiteSpace(fieldName))
{ {
e(Not.Defined("Field"), fieldPrefix); 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); 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); 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) if (fields != null)
{ {
@ -315,11 +317,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{ {
e(Not.Defined("Field"), fieldPrefix); 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); 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); 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")); "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] [Fact]
public void CanCreate_should_not_throw_exception_if_command_is_valid() 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"), FieldsInReferences = new FieldNames("field1"),
Name = "new-schema" Name = "new-schema"
}; };
@ -528,12 +543,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
"FieldsInReferences")); "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] [Fact]
public void CanConfigureUIFields_should_not_throw_exception_if_command_is_valid() public void CanConfigureUIFields_should_not_throw_exception_if_command_is_valid()
{ {
var command = new ConfigureUIFields var command = new ConfigureUIFields
{ {
FieldsInLists = new FieldNames("field1"), FieldsInLists = new FieldNames("field1", "meta.id"),
FieldsInReferences = new FieldNames("field2") 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-editor.component';
export * from './shared/array-item.component'; export * from './shared/array-item.component';
export * from './shared/assets-editor.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.component';
export * from './shared/content-status.component'; export * from './shared/content-status.component';
export * from './shared/content-value.component'; export * from './shared/content-value.component';

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

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

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

@ -39,7 +39,7 @@
<ng-container content> <ng-container content>
<div class="grid-header"> <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> <thead>
<tr> <tr>
<th class="cell-select"> <th class="cell-select">
@ -48,26 +48,13 @@
<th class="cell-actions cell-actions-left"> <th class="cell-actions cell-actions-left">
Actions Actions
</th> </th>
<th class="cell-user"> <th *ngFor="let field of schema.listFields" [sqxContentListCell]="field">
<sqx-table-header text="By"></sqx-table-header> <sqx-content-list-header
</th>
<th class="cell-auto cell-content" *ngFor="let field of schema.listFields">
<sqx-table-header [text]="field.displayName"
[sortable]="field.properties.isSortable"
[field]="field" [field]="field"
[query]="contentsState.contentsQuery | async" [query]="contentsState.contentsQuery | async"
(queryChange)="search($event)" (queryChange)="search($event)"
[language]="language"> [language]="language">
</sqx-table-header> </sqx-content-list-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>
</th> </th>
</tr> </tr>
</thead> </thead>
@ -96,7 +83,7 @@
<div class="grid-content" [sqxSyncScrolling]="header"> <div class="grid-content" [sqxSyncScrolling]="header">
<div class="table-container" sqxIgnoreScrollbar> <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" <tbody *ngFor="let content of contentsState.contents | async; trackBy: trackByContent"
[sqxContent]="content" [sqxContent]="content"
(delete)="delete(content)" (delete)="delete(content)"
@ -107,8 +94,7 @@
[link]="[content.id]" [link]="[content.id]"
[language]="language" [language]="language"
[canClone]="contentsState.snapshot.canCreate" [canClone]="contentsState.snapshot.canCreate"
[schema]="schema" [schema]="schema">
[schemaFields]="schema.listFields">
</tbody> </tbody>
</table> </table>
</div> </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 queryModel: QueryModel;
public queries: Queries; public queries: Queries;
public minWidth: string;
@ViewChild('dueTimeSelector', { static: false }) @ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent; public dueTimeSelector: DueTimeSelectorComponent;
@ -72,8 +70,6 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.schema = schema; this.schema = schema;
this.minWidth = `${300 + (200 * this.schema.listFields.length)}px`;
this.contentsState.load(); this.contentsState.load();
this.updateQueries(); 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. * 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 { import {
ContentDto, ContentDto,
getContentValue,
LanguageDto, LanguageDto,
RootFieldDto SchemaDetailsDto
} from '@app/shared'; } from '@app/shared';
/* tslint:disable:component-selector */ /* tslint:disable:component-selector */
@ -27,31 +26,19 @@ import {
(ngModelChange)="emitSelectedChange($event)" /> (ngModelChange)="emitSelectedChange($event)" />
</td> </td>
<td class="cell-user"> <td sqxContentListCell="meta.lastModifiedBy.avatar">
<img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" /> <sqx-content-list-field field="meta.lastModifiedBy.avatar" [content]="content" [language]="language"></sqx-content-list-field>
</td> </td>
<td class="cell-auto cell-content" *ngFor="let value of values"> <td *ngFor="let field of schema.referenceFields" [sqxContentListCell]="field">
<sqx-content-value [value]="value"></sqx-content-value> <sqx-content-list-field [field]="field" [content]="content" [language]="language"></sqx-content-list-field>
</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> </td>
</tr> </tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContentSelectorItemComponent implements OnChanges { export class ContentSelectorItemComponent {
@Output() @Output()
public selectedChange = new EventEmitter<boolean>(); public selectedChange = new EventEmitter<boolean>();
@ -65,19 +52,11 @@ export class ContentSelectorItemComponent implements OnChanges {
public language: LanguageDto; public language: LanguageDto;
@Input() @Input()
public fields: ReadonlyArray<RootFieldDto>; public schema: SchemaDetailsDto;
@Input('sqxContentSelectorItem') @Input('sqxContentSelectorItem')
public content: ContentDto; public content: ContentDto;
public values: ReadonlyArray<any> = [];
public ngOnChanges(changes: SimpleChanges) {
if (changes['content'] || changes['language']) {
this.updateValues();
}
}
public toggle() { public toggle() {
if (this.selectable) { if (this.selectable) {
this.emitSelectedChange(!this.selected); this.emitSelectedChange(!this.selected);
@ -87,16 +66,4 @@ export class ContentSelectorItemComponent implements OnChanges {
public emitSelectedChange(isSelected: boolean) { public emitSelectedChange(isSelected: boolean) {
this.selectedChange.emit(isSelected); 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;
}
} }

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

@ -1,12 +1,24 @@
<tr [routerLink]="link"> <tr [routerLink]="link">
<td class="cell-select" sqxStopClick> <td class="cell-select inline-edit" sqxStopClick>
<input type="checkbox" class="form-check" <input type="checkbox" class="form-check"
[ngModel]="selected" [ngModel]="selected"
(ngModelChange)="emitSelectedChange($event)" /> (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>
<td class="cell-actions cell-actions-left" *ngIf="!isDirty" sqxStopClick> <td class="cell-actions cell-actions-left" sqxStopClick>
<div class="dropdown dropdown-options" *ngIf="content"> <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> <button type="button" class="btn btn-text-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
@ -39,45 +51,15 @@
</ng-container> </ng-container>
</div> </div>
</td> </td>
<ng-container *ngIf="isDirty"> <td *ngFor="let field of schema.listFields" [sqxContentListCell]="field" [sqxStopClick]="shouldStop(field)">
<td class="cell-actions" > <sqx-content-list-field
<button type="button" class="btn btn-text-secondary btn-cancel" (click)="cancel()" sqxStopClick> [field]="field"
<i class="icon-close"></i> [patchForm]="patchForm.form"
</button> [patchAllowed]="patchAllowed"
</td> [content]="content"
[language]="language">
<td class="cell-user" > </sqx-content-list-field>
<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> </td>
</tr> </tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>

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

@ -1,2 +1,19 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @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;
}
}

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

@ -5,21 +5,23 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * 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 { import {
AppLanguageDto, AppLanguageDto,
ContentDto, ContentDto,
ContentsState, ContentsState,
fadeAnimation, fadeAnimation,
FieldDto,
getContentValue,
ModalModel, ModalModel,
PatchContentForm, PatchContentForm,
RootFieldDto, RootFieldDto,
SchemaDetailsDto SchemaDetailsDto,
TableField,
Types
} from '@app/shared'; } from '@app/shared';
import { ContentListFieldComponent } from './content-list-field.component';
/* tslint:disable:component-selector */ /* tslint:disable:component-selector */
@Component({ @Component({
@ -53,30 +55,23 @@ export class ContentComponent implements OnChanges {
@Input() @Input()
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
@Input()
public schemaFields: ReadonlyArray<RootFieldDto>;
@Input() @Input()
public canClone: boolean; public canClone: boolean;
@Input()
public isCompact = false;
@Input() @Input()
public link: any = null; public link: any = null;
@Input('sqxContent') @Input('sqxContent')
public content: ContentDto; public content: ContentDto;
public trackByFieldFn: (index: number, field: FieldDto) => any; @ViewChildren(ContentListFieldComponent)
public fields: QueryList<ContentListFieldComponent>;
public patchForm: PatchContentForm; public patchForm: PatchContentForm;
public patchAllowed = false; public patchAllowed = false;
public dropdown = new ModalModel(); public dropdown = new ModalModel();
public values: ReadonlyArray<any> = [];
public get isDirty() { public get isDirty() {
return this.patchForm && this.patchForm.form.dirty; return this.patchForm && this.patchForm.form.dirty;
} }
@ -85,7 +80,6 @@ export class ContentComponent implements OnChanges {
private readonly changeDetector: ChangeDetectorRef, private readonly changeDetector: ChangeDetectorRef,
private readonly contentsState: ContentsState private readonly contentsState: ContentsState
) { ) {
this.trackByFieldFn = this.trackByField.bind(this);
} }
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {
@ -93,14 +87,8 @@ export class ContentComponent implements OnChanges {
this.patchAllowed = this.content.canUpdate; this.patchAllowed = this.content.canUpdate;
} }
if (changes['schema'] || changes['language']) { if (this.patchAllowed && (changes['schema'] || changes['language'])) {
if (this.patchAllowed) { this.patchForm = new PatchContentForm(this.schema, this.language);
this.patchForm = new PatchContentForm(this.schema, this.language);
}
}
if (changes['content'] || changes['language']) {
this.updateValues();
} }
} }
@ -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() { public cancel() {
this.patchForm.submitCompleted(); this.patchForm.submitCompleted();
this.updateValues(); this.fields.forEach(x => x.reset());
} }
public emitSelectedChange(isSelected: boolean) { public emitSelectedChange(isSelected: boolean) {
@ -146,28 +142,4 @@ export class ContentComponent implements OnChanges {
public emitClone() { public emitClone() {
this.clone.emit(); 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 content>
<ng-container *ngIf="schema"> <ng-container *ngIf="schema">
<div class="grid-header"> <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> <thead>
<tr> <tr>
<th class="cell-select"> <th class="cell-select">
<input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" /> <input type="checkbox" class="form-check" [ngModel]="selectedAll" (ngModelChange)="selectAll($event)" />
</th> </th>
<th class="cell-user"> <th sqxContentListCell="meta.lastModifiedBy.avatar">
<sqx-table-header text="By"></sqx-table-header> <sqx-content-list-header field="meta.lastModifiedBy.avatar"></sqx-content-list-header>
</th> </th>
<th class="cell-content" *ngFor="let field of schema.referenceFields"> <th *ngFor="let field of schema.referenceFields" [sqxContentListCell]="field">
<sqx-table-header [text]="field.displayName" <sqx-content-list-header
[sortable]="field.properties.isSortable"
[field]="field" [field]="field"
[query]="contentsState.contentsQuery | async" [query]="contentsState.contentsQuery | async"
(queryChange)="search($event)" (queryChange)="search($event)"
[language]="language"> [language]="language">
</sqx-table-header> </sqx-content-list-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>
</th> </th>
</tr> </tr>
</thead> </thead>
@ -73,10 +63,10 @@
<div class="grid-content" [sqxSyncScrolling]="header"> <div class="grid-content" [sqxSyncScrolling]="header">
<div class="table-container" sqxIgnoreScrollbar> <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" <tbody *ngFor="let content of contents; trackBy: trackByContent"
[sqxContentSelectorItem]="content" [sqxContentSelectorItem]="content"
[fields]="schema.referenceFields" [schema]="schema"
[selectable]="!isItemAlreadySelected(content)" [selectable]="!isItemAlreadySelected(content)"
[selected]="isItemSelected(content)" [selected]="isItemSelected(content)"
(selectedChange)="selectContent(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 selectionCount = 0;
public selectedAll = false; public selectedAll = false;
public minWidth: string;
constructor( constructor(
public readonly contentsState: ManualContentsState, public readonly contentsState: ManualContentsState,
public readonly schemasState: SchemasState, public readonly schemasState: SchemasState,
@ -95,8 +93,6 @@ export class ContentsSelectorComponent extends ResourceOwner implements OnInit {
if (schema) { if (schema) {
this.schema = schema; this.schema = schema;
this.minWidth = `${200 + (200 * schema.referenceFields.length)}px`;
this.contentsState.schema = schema; this.contentsState.schema = schema;
this.contentsState.load(); this.contentsState.load();

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

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

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

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

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

@ -12,7 +12,7 @@
</div> </div>
<div *ngFor="let field of fieldsAdded" class="table-items-row" cdkDrag> <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>
</div> </div>
@ -27,6 +27,6 @@
<label>Unassigned fields</label> <label>Unassigned fields</label>
<div *ngFor="let field of fieldsNotAdded" class="table-items-row" cdkDrag> <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>
</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 { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { FieldDto, SchemaDetailsDto } from '@app/shared'; import { MetaFields, SchemaDetailsDto } from '@app/shared';
const MetaFieldNames = Object.values(MetaFields);
@Component({ @Component({
selector: 'sqx-field-list', selector: 'sqx-field-list',
@ -28,18 +30,27 @@ export class FieldListComponent implements OnChanges {
@Input() @Input()
public fieldNames: ReadonlyArray<string>; public fieldNames: ReadonlyArray<string>;
@Input()
public withMetaFields = false;
@Output() @Output()
public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>(); public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>();
public fieldsAdded: FieldDto[]; public fieldsAdded: string[];
public fieldsNotAdded: FieldDto[]; public fieldsNotAdded: string[];
public ngOnChanges() { public ngOnChanges() {
this.fieldsAdded = this.fieldNames.map(n => this.schema.contentFields.find(y => y.name === n)!).filter(x => !!x); let allFields = this.schema.contentFields.map(x => x.name);
this.fieldsNotAdded = this.schema.contentFields.filter(n => this.fieldNames.indexOf(n.name) < 0);
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) { if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else { } else {
@ -50,7 +61,7 @@ export class FieldListComponent implements OnChanges {
event.currentIndex); event.currentIndex);
} }
const newNames = this.fieldsAdded.map(x => x.name); const newNames = this.fieldsAdded;
this.fieldNamesChange.emit(newNames); 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." 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" [schema]="schema"
[fieldNames]="state.fieldsInLists" [fieldNames]="state.fieldsInLists"
(fieldNamesChange)="setFieldsInLists($event)"> (fieldNamesChange)="setFieldsInLists($event)"
[withMetaFields]="true">
</sqx-field-list> </sqx-field-list>
<sqx-field-list [class.hidden]="selectedTab !== 'Reference Fields'" <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." 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."

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

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

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

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

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

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