From e0383fcf2c18d70295e4e67e24e5363d2194ccb3 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 5 Nov 2019 19:25:40 +0100 Subject: [PATCH] Feature/UI Fields (#438) * Better UI to manage list and reference fields. --- .../Squidex.SamplePlugin.csproj | 20 --- .../Schemas/FieldNames.cs | 48 +++++ .../Schemas/FieldProperties.cs | 4 - .../Schemas/Json/JsonSchemaModel.cs | 20 ++- .../Schemas/Schema.cs | 62 ++++++- .../Schemas/SchemaExtensions.cs | 6 +- .../Schemas/SchemaScripts.cs | 7 + .../SchemaSynchronizer.cs | 27 +-- .../ContentReferencesExtensions.cs | 7 +- .../Templates/Builders/AssetFieldBuilder.cs | 4 +- .../Templates/Builders/BooleanFieldBuilder.cs | 4 +- .../Builders/DateTimeFieldBuilder.cs | 4 +- .../Apps/Templates/Builders/FieldBuilder.cs | 18 +- .../Templates/Builders/JsonFieldBuilder.cs | 4 +- .../Templates/Builders/NumberFieldBuilder.cs | 4 +- .../Apps/Templates/Builders/SchemaBuilder.cs | 14 +- .../Templates/Builders/StringFieldBuilder.cs | 4 +- .../Templates/Builders/TagsFieldBuilder.cs | 4 +- .../Schemas/Commands/ConfigureUIFields.cs} | 16 +- .../Schemas/Commands/UpsertCommand.cs | 18 +- .../Guards/FieldPropertiesValidator.cs | 10 -- .../Schemas/Guards/GuardSchema.cs | 128 +++++++++++-- .../Schemas/Guards/GuardSchemaField.cs | 2 +- .../Schemas/SchemaGrain.cs | 15 ++ .../Schemas/State/SchemaState.cs | 15 ++ .../Schemas/SchemaUIFieldsConfigured.cs | 20 +++ .../CollectionExtensions.cs | 20 +++ .../Squidex.Infrastructure/Validation/Not.cs | 6 + .../ApiModelValidationAttribute.cs | 28 ++- .../Schemas/Models/ConfigureUIFieldsDto.cs | 31 ++++ .../Schemas/Models/FieldPropertiesDto.cs | 10 -- .../Schemas/Models/SchemaDetailsDto.cs | 15 ++ .../Controllers/Schemas/Models/SchemaDto.cs | 5 +- .../Schemas/Models/UpsertSchemaDto.cs | 2 +- .../Schemas/SchemaFieldsController.cs | 29 ++- .../Config/Domain/SerializationServices.cs | 2 + .../Model/Schemas/SchemaTests.cs | 34 ++++ .../SchemaSynchronizerTests.cs | 36 ++++ .../ReferenceFormattingTests.cs | 7 +- .../Queries/ContentEnricherAssetsTests.cs | 9 +- .../Queries/ContentEnricherReferencesTests.cs | 10 +- .../Schemas/Guards/GuardSchemaFieldTests.cs | 29 +-- .../Schemas/Guards/GuardSchemaTests.cs | 170 +++++++++++++++--- .../Schemas/SchemaGrainTests.cs | 66 +++++-- frontend/app/_theme.html | 2 +- .../apps/pages/apps-page.component.html | 2 +- .../pages/rules/rules-page.component.html | 2 +- frontend/app/features/schemas/declarations.ts | 2 + frontend/app/features/schemas/module.ts | 59 +----- .../pages/schema/field-list.component.html | 32 ++++ .../pages/schema/field-list.component.scss | 34 ++++ .../pages/schema/field-list.component.ts | 57 ++++++ .../schemas/pages/schema/field.component.ts | 8 +- .../forms/field-form-common.component.ts | 30 ---- .../pages/schema/schema-fields.component.ts | 2 - .../pages/schema/schema-page.component.html | 17 +- .../pages/schema/schema-page.component.ts | 33 +--- .../schema/schema-ui-form.component.html | 26 +++ .../schema/schema-ui-form.component.scss | 21 +++ .../pages/schema/schema-ui-form.component.ts | 74 ++++++++ .../pages/backups/backups-page.component.html | 2 +- .../pages/clients/clients-page.component.html | 2 +- .../contributors-page.component.html | 2 +- .../languages/languages-page.component.html | 2 +- .../patterns/patterns-page.component.html | 2 +- .../pages/roles/roles-page.component.html | 2 +- .../workflow-transition.component.scss | 1 - .../workflows/workflows-page.component.html | 2 +- .../angular/forms/tag-editor.component.scss | 2 +- .../components/asset-uploader.component.scss | 3 +- .../app/shared/components/help.component.html | 2 +- .../components/search-form.component.html | 2 +- .../shared/services/schemas.service.spec.ts | 31 ++++ .../app/shared/services/schemas.service.ts | 43 ++++- frontend/app/shared/services/schemas.types.ts | 2 - frontend/app/shared/state/clients.state.ts | 2 - .../app/shared/state/contents.forms.spec.ts | 48 +++-- frontend/app/shared/state/schemas.forms.ts | 4 +- .../app/shared/state/schemas.state.spec.ts | 28 ++- frontend/app/shared/state/schemas.state.ts | 11 +- frontend/app/shared/state/workflows.state.ts | 2 - frontend/app/shell/declarations.ts | 1 + frontend/app/shell/module.ts | 2 + .../internal/internal-area.component.html | 22 +-- .../internal/internal-area.component.scss | 54 ------ .../shell/pages/internal/logo.component.ts | 27 +++ .../internal/profile-menu.component.html | 5 + .../internal/profile-menu.component.scss | 8 + frontend/app/theme/_bootstrap-vars.scss | 2 +- frontend/app/theme/_vars.scss | 1 + frontend/app/theme/icomoon/demo.html | 16 +- frontend/app/theme/icomoon/fonts/icomoon.eot | Bin 30252 -> 30456 bytes frontend/app/theme/icomoon/fonts/icomoon.svg | 1 + frontend/app/theme/icomoon/fonts/icomoon.ttf | Bin 30088 -> 30292 bytes frontend/app/theme/icomoon/fonts/icomoon.woff | Bin 30164 -> 30368 bytes frontend/app/theme/icomoon/selection.json | 2 +- frontend/app/theme/icomoon/style.css | 13 +- 97 files changed, 1289 insertions(+), 455 deletions(-) delete mode 100644 backend/extensions/Squidex.SamplePlugin/Squidex.SamplePlugin.csproj create mode 100644 backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldNames.cs rename backend/{extensions/Squidex.SamplePlugin/SamplePlugin.cs => src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs} (50%) create mode 100644 backend/src/Squidex.Domain.Apps.Events/Schemas/SchemaUIFieldsConfigured.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs create mode 100644 frontend/app/features/schemas/pages/schema/field-list.component.html create mode 100644 frontend/app/features/schemas/pages/schema/field-list.component.scss create mode 100644 frontend/app/features/schemas/pages/schema/field-list.component.ts create mode 100644 frontend/app/features/schemas/pages/schema/schema-ui-form.component.html create mode 100644 frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss create mode 100644 frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts create mode 100644 frontend/app/shell/pages/internal/logo.component.ts diff --git a/backend/extensions/Squidex.SamplePlugin/Squidex.SamplePlugin.csproj b/backend/extensions/Squidex.SamplePlugin/Squidex.SamplePlugin.csproj deleted file mode 100644 index fd84a73aa..000000000 --- a/backend/extensions/Squidex.SamplePlugin/Squidex.SamplePlugin.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - netcoreapp3.0 - 8.0 - - - - - - - - - - - ..\..\Squidex.ruleset - - - - - diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldNames.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldNames.cs new file mode 100644 index 000000000..1df458c5e --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldNames.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public sealed class FieldNames : ReadOnlyCollection + { + private static readonly List EmptyNames = new List(); + + public static readonly FieldNames Empty = new FieldNames(new List()); + + public FieldNames(params string[] fields) + : base(fields?.ToList() ?? EmptyNames) + { + } + + public FieldNames(IList list) + : base(list ?? EmptyNames) + { + } + + public FieldNames Add(string field) + { + var list = this.ToList(); + + list.Add(field); + + return new FieldNames(list); + } + + public FieldNames Remove(string field) + { + var list = this.ToList(); + + list.Remove(field); + + return new FieldNames(list); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs index a0578f698..ae03e8cd4 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs @@ -13,10 +13,6 @@ namespace Squidex.Domain.Apps.Core.Schemas { public bool IsRequired { get; set; } - public bool IsListField { get; set; } - - public bool IsReferenceField { get; set; } - public string? Placeholder { get; set; } public string? EditorUrl { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs index 7f747da01..596d9dc4d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs @@ -32,13 +32,19 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json public SchemaProperties Properties { get; set; } [JsonProperty] - public SchemaScripts Scripts { get; set; } + public SchemaScripts? Scripts { get; set; } + + [JsonProperty] + public FieldNames? FieldsInLists { get; set; } + + [JsonProperty] + public FieldNames? FieldsInReferences { get; set; } [JsonProperty] public JsonFieldModel[] Fields { get; set; } [JsonProperty] - public Dictionary PreviewUrls { get; set; } + public Dictionary? PreviewUrls { get; set; } public JsonSchemaModel() { @@ -100,6 +106,16 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json schema = schema.ConfigureScripts(Scripts); } + if (FieldsInLists?.Count > 0) + { + schema = schema.ConfigureFieldsInLists(FieldsInLists); + } + + if (FieldsInReferences?.Count > 0) + { + schema = schema.ConfigureFieldsInReferences(FieldsInReferences); + } + if (PreviewUrls?.Count > 0) { schema = schema.ConfigurePreviewUrls(PreviewUrls); diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs index 2777eb65d..4c8a4813f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs @@ -18,9 +18,11 @@ namespace Squidex.Domain.Apps.Core.Schemas private readonly string name; private readonly bool isSingleton; private string category; + private FieldNames fieldsInLists = FieldNames.Empty; + private FieldNames fieldsInReferences = FieldNames.Empty; private FieldCollection fields = FieldCollection.Empty; private IReadOnlyDictionary previewUrls = EmptyPreviewUrls; - private SchemaScripts scripts = new SchemaScripts(); + private SchemaScripts scripts = SchemaScripts.Empty; private SchemaProperties properties; private bool isPublished; @@ -69,6 +71,16 @@ namespace Squidex.Domain.Apps.Core.Schemas get { return fields; } } + public FieldNames FieldsInLists + { + get { return fieldsInLists; } + } + + public FieldNames FieldsInReferences + { + get { return fieldsInReferences; } + } + public SchemaScripts Scripts { get { return scripts; } @@ -123,6 +135,42 @@ namespace Squidex.Domain.Apps.Core.Schemas }); } + [Pure] + public Schema ConfigureFieldsInLists(FieldNames names) + { + return Clone(clone => + { + clone.fieldsInLists = names ?? FieldNames.Empty; + }); + } + + [Pure] + public Schema ConfigureFieldsInLists(params string[] names) + { + return Clone(clone => + { + clone.fieldsInLists = new FieldNames(names); + }); + } + + [Pure] + public Schema ConfigureFieldsInReferences(FieldNames names) + { + return Clone(clone => + { + clone.fieldsInReferences = names ?? FieldNames.Empty; + }); + } + + [Pure] + public Schema ConfigureFieldsInReferences(params string[] names) + { + return Clone(clone => + { + clone.fieldsInReferences = new FieldNames(names); + }); + } + [Pure] public Schema Publish() { @@ -162,7 +210,17 @@ namespace Squidex.Domain.Apps.Core.Schemas [Pure] public Schema DeleteField(long fieldId) { - return UpdateFields(f => f.Remove(fieldId)); + if (!FieldsById.TryGetValue(fieldId, out var field)) + { + return this; + } + + return Clone(clone => + { + clone.fields = fields.Remove(fieldId); + clone.fieldsInLists = fieldsInLists.Remove(field.Name); + clone.fieldsInReferences = fieldsInReferences.Remove(field.Name); + }); } [Pure] diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs index 7e9b92636..60b5e918e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs @@ -66,14 +66,14 @@ namespace Squidex.Domain.Apps.Core.Schemas public static IEnumerable ReferenceFields(this Schema schema) { - var references = schema.Fields.Where(x => x.RawProperties.IsReferenceField); + var references = schema.FieldsInReferences.Select(x => schema.FieldsByName.GetOrDefault(x)).Where(x => x != null).ToList(); if (references.Any()) { return references; } - references = schema.Fields.Where(x => x.RawProperties.IsListField); + references = schema.FieldsInLists.Select(x => schema.FieldsByName.GetOrDefault(x)).Where(x => x != null).ToList(); if (references.Any()) { @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Core.Schemas private static bool IsListField(this IField field, Schema schema) { - return field.RawProperties.IsListField || schema.Fields.Count == 1; + return schema.FieldsInLists.Contains(field.Name) || schema.Fields.Count == 1; } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaScripts.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaScripts.cs index ae3990180..6894f6f86 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaScripts.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaScripts.cs @@ -9,6 +9,13 @@ namespace Squidex.Domain.Apps.Core.Schemas { public sealed class SchemaScripts : Freezable { + public static readonly SchemaScripts Empty = new SchemaScripts(); + + static SchemaScripts() + { + Empty.Freeze(); + } + public string Change { get; set; } public string Create { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/EventSynchronization/SchemaSynchronizer.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/EventSynchronization/SchemaSynchronizer.cs index fa7b81d77..5d67798e5 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/EventSynchronization/SchemaSynchronizer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/EventSynchronization/SchemaSynchronizer.cs @@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization if (!source.PreviewUrls.EqualsDictionary(target.PreviewUrls)) { - yield return E(new SchemaPreviewUrlsConfigured { PreviewUrls = target.PreviewUrls.ToDictionary(x => x.Key, x => x.Value) }); + yield return E(new SchemaPreviewUrlsConfigured { PreviewUrls = target.PreviewUrls.ToDictionary() }); } if (source.IsPublished != target.IsPublished) @@ -72,6 +72,16 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization { yield return E(@event); } + + if (!source.FieldsInLists.SequenceEqual(target.FieldsInLists)) + { + yield return E(new SchemaUIFieldsConfigured { FieldsInLists = target.FieldsInLists }); + } + + if (!source.FieldsInReferences.SequenceEqual(target.FieldsInReferences)) + { + yield return E(new SchemaUIFieldsConfigured { FieldsInReferences = target.FieldsInReferences }); + } } } @@ -90,8 +100,7 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization return @event; } - var sourceIds = new List>(source.Ordered.Select(x => x.NamedId())); - var sourceNames = sourceIds.Select(x => x.Name).ToList(); + var sourceIds = source.Ordered.Select(x => x.NamedId()).ToList(); if (!options.NoFieldDeletion) { @@ -102,7 +111,6 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization var id = sourceField.NamedId(); sourceIds.Remove(id); - sourceNames.Remove(id.Name); yield return E(new FieldDeleted { FieldId = id }); } @@ -133,7 +141,6 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization canCreateField = true; sourceIds.Remove(id); - sourceNames.Remove(id.Name); yield return E(new FieldDeleted { FieldId = id }); } @@ -160,7 +167,6 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization }; sourceIds.Add(id); - sourceNames.Add(id.Name); } if (id != null && (sourceField == null || CanUpdate(sourceField, targetField))) @@ -198,13 +204,14 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization } } - if (sourceNames.Count > 1) + if (sourceIds.Count > 1) { - var targetNames = target.Ordered.Select(x => x.Name); + var sourceNames = sourceIds.Select(x => x.Name).ToList(); + var targetNames = target.Ordered.Select(x => x.Name).ToList(); - if (sourceNames.Intersect(targetNames).Count() == target.Ordered.Count && !sourceNames.SequenceEqual(targetNames)) + if (sourceNames.SetEquals(targetNames) && !sourceNames.SequenceEqual(targetNames)) { - var fieldIds = targetNames.Select(x => sourceIds.FirstOrDefault(y => y.Name == x).Id).ToList(); + var fieldIds = targetNames.Select(x => sourceIds.Find(y => y.Name == x)!.Id).ToList(); yield return new SchemaFieldsReordered { FieldIds = fieldIds, ParentFieldId = parentId }; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs index 080340380..a5cafe930 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs @@ -122,12 +122,7 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds sb.Append(value); } - var referenceFields = schema.Fields.Where(x => x.RawProperties.IsReferenceField); - - if (!referenceFields.Any()) - { - referenceFields = schema.Fields.Take(1); - } + var referenceFields = schema.ReferenceFields(); foreach (var referenceField in referenceFields) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs index 12da92a89..1e0b1f582 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs @@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class AssetFieldBuilder : FieldBuilder { - public AssetFieldBuilder(UpsertSchemaField field) - : base(field) + public AssetFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs index 49aae2545..a6ee63839 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs @@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class BooleanFieldBuilder : FieldBuilder { - public BooleanFieldBuilder(UpsertSchemaField field) - : base(field) + public BooleanFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs index 72fc74810..de18458f2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs @@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class DateTimeFieldBuilder : FieldBuilder { - public DateTimeFieldBuilder(UpsertSchemaField field) - : base(field) + public DateTimeFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs index 31fdbf82a..8521e6ce1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs @@ -14,15 +14,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders public abstract class FieldBuilder { private readonly UpsertSchemaField field; + private readonly UpsertCommand schema; protected T Properties() where T : FieldProperties { return (T)field.Properties; } - protected FieldBuilder(UpsertSchemaField field) + protected FieldBuilder(UpsertSchemaField field, UpsertCommand schema) { this.field = field; + this.schema = schema; } public FieldBuilder Label(string? label) @@ -62,14 +64,24 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders public FieldBuilder ShowInList() { - field.Properties.IsListField = true; + if (schema.FieldsInReferences == null) + { + schema.FieldsInReferences = new FieldNames(); + } + + schema.FieldsInReferences.Add(field.Name); return this; } public FieldBuilder ShowInReferences() { - field.Properties.IsReferenceField = true; + if (schema.FieldsInLists == null) + { + schema.FieldsInLists = new FieldNames(); + } + + schema.FieldsInLists.Add(field.Name); return this; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs index bdf4f3ed7..7a488d0a9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs @@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class JsonFieldBuilder : FieldBuilder { - public JsonFieldBuilder(UpsertSchemaField field) - : base(field) + public JsonFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs index 67245aa34..211a1807f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs @@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class NumberFieldBuilder : FieldBuilder { - public NumberFieldBuilder(UpsertSchemaField field) - : base(field) + public NumberFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs index 8ee0eea85..f163d357f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs @@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new AssetFieldBuilder(field)); + configure(new AssetFieldBuilder(field, command)); return this; } @@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new BooleanFieldBuilder(field)); + configure(new BooleanFieldBuilder(field, command)); return this; } @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new DateTimeFieldBuilder(field)); + configure(new DateTimeFieldBuilder(field, command)); return this; } @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new JsonFieldBuilder(field)); + configure(new JsonFieldBuilder(field, command)); return this; } @@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new NumberFieldBuilder(field)); + configure(new NumberFieldBuilder(field, command)); return this; } @@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new StringFieldBuilder(field)); + configure(new StringFieldBuilder(field, command)); return this; } @@ -119,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { var field = AddField(name); - configure(new TagsFieldBuilder(field)); + configure(new TagsFieldBuilder(field, command)); return this; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs index 75ce75746..703d96f46 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs @@ -13,8 +13,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class StringFieldBuilder : FieldBuilder { - public StringFieldBuilder(UpsertSchemaField field) - : base(field) + public StringFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs index 93b4d6611..67a34d62c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs @@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders { public class TagsFieldBuilder : FieldBuilder { - public TagsFieldBuilder(UpsertSchemaField field) - : base(field) + public TagsFieldBuilder(UpsertSchemaField field, UpsertCommand schema) + : base(field, schema) { } } diff --git a/backend/extensions/Squidex.SamplePlugin/SamplePlugin.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs similarity index 50% rename from backend/extensions/Squidex.SamplePlugin/SamplePlugin.cs rename to backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs index c12b5ce22..9655cdab0 100644 --- a/backend/extensions/Squidex.SamplePlugin/SamplePlugin.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs @@ -5,18 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Squidex.Infrastructure.Plugins; +using Squidex.Domain.Apps.Core.Schemas; -namespace Squidex.SamplePlugin +namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class SamplePlugin : IPlugin + public sealed class ConfigureUIFields : SchemaCommand { - public void ConfigureServices(IServiceCollection services, IConfiguration config) - { - throw new NotImplementedException(); - } + public FieldNames? FieldsInLists { get; set; } + + public FieldNames? FieldsInReferences { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpsertCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpsertCommand.cs index 9b9e6bbe9..f0d823b11 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpsertCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpsertCommand.cs @@ -20,11 +20,15 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands public SchemaFields Fields { get; set; } - public SchemaScripts Scripts { get; set; } + public FieldNames? FieldsInReferences { get; set; } + + public FieldNames? FieldsInLists { get; set; } + + public SchemaScripts? Scripts { get; set; } public SchemaProperties Properties { get; set; } - public Dictionary PreviewUrls { get; set; } + public Dictionary? PreviewUrls { get; set; } public Schema ToSchema(string name, bool isSingleton) { @@ -45,6 +49,16 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands schema = schema.ConfigurePreviewUrls(PreviewUrls); } + if (FieldsInLists != null) + { + schema = schema.ConfigureFieldsInLists(FieldsInLists); + } + + if (FieldsInReferences != null) + { + schema = schema.ConfigureFieldsInLists(FieldsInReferences); + } + if (!string.IsNullOrWhiteSpace(Category)) { schema = schema.ChangeCategory(Category); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs index 650360535..bbc7279af 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs @@ -23,16 +23,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { if (properties != null) { - if (!properties.IsForApi() && properties.IsListField) - { - yield return new ValidationError("UI field cannot be a list field.", nameof(properties.IsListField)); - } - - if (!properties.IsForApi() && properties.IsReferenceField) - { - yield return new ValidationError("UI field cannot be a reference field.", nameof(properties.IsReferenceField)); - } - foreach (var error in properties.Accept(Instance)) { yield return error; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs index a009957f8..90316fe87 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -55,20 +55,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false); } - Validate.It(() => "Cannot reorder schema fields.", error => + Validate.It(() => "Cannot reorder schema fields.", e => { if (command.FieldIds == null) { - error("Field ids is required.", nameof(command.FieldIds)); + e(Not.Defined("Field ids"), nameof(command.FieldIds)); } if (arrayField == null) { - ValidateFieldIds(error, command, schema.FieldsById); + ValidateFieldIds(command, schema.FieldsById, e); } else { - ValidateFieldIds(error, command, arrayField.FieldsById); + ValidateFieldIds(command, arrayField.FieldsById, e); } }); } @@ -77,11 +77,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command); - Validate.It(() => "Cannot configure preview urls.", error => + Validate.It(() => "Cannot configure preview urls.", e => { if (command.PreviewUrls == null) { - error("Preview Urls is required.", nameof(command.PreviewUrls)); + e(Not.Defined("Preview Urls"), nameof(command.PreviewUrls)); } }); } @@ -106,6 +106,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards } } + public static void CanConfigureUIFields(Schema schema, ConfigureUIFields command) + { + Guard.NotNull(command); + + Validate.It(() => "Cannot configure UI fields.", e => + { + ValidateFieldNames(schema, command.FieldsInLists, nameof(command.FieldsInLists), e); + ValidateFieldNames(schema, command.FieldsInReferences, nameof(command.FieldsInReferences), e); + }); + } + public static void CanUpdate(Schema schema, UpdateSchema command) { Guard.NotNull(command); @@ -138,17 +149,23 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards fieldIndex++; fieldPrefix = $"Fields[{fieldIndex}]"; - ValidateRootField(field, fieldPrefix, e); + ValidateRootField(field, command, fieldPrefix, e); } - if (command.Fields.Select(x => x?.Name).Distinct().Count() != command.Fields.Count) + foreach (var fieldName in command.Fields.Duplicates(x => x.Name)) { - e("Fields cannot have duplicate names.", nameof(command.Fields)); + if (fieldName.IsPropertyName()) + { + e($"Field '{fieldName}' has been added twice.", nameof(command.Fields)); + } } } + + ValidateFieldNames(command, command.FieldsInLists, nameof(command.FieldsInLists), e); + ValidateFieldNames(command, command.FieldsInReferences, nameof(command.FieldsInReferences), e); } - private static void ValidateRootField(UpsertSchemaField field, string prefix, AddValidation e) + private static void ValidateRootField(UpsertSchemaField field, UpsertCommand command, string prefix, AddValidation e) { if (field == null) { @@ -175,7 +192,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards nestedIndex++; nestedPrefix = $"{prefix}.Nested[{nestedIndex}]"; - ValidateNestedField(nestedField, nestedPrefix, e); + ValidateNestedField(nestedField, field.Nested, nestedPrefix, e); } } else if (field.Nested.Count > 0) @@ -183,15 +200,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards e("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}"); } - if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) + foreach (var fieldName in field.Nested.Duplicates(x => x.Name)) { - e("Fields cannot have duplicate names.", $"{prefix}.Nested"); + if (fieldName.IsPropertyName()) + { + e($"Field '{fieldName}' has been added twice.", $"{prefix}.Nested"); + } } } } } - private static void ValidateNestedField(UpsertSchemaNestedField nestedField, string prefix, AddValidation e) + private static void ValidateNestedField(UpsertSchemaNestedField nestedField, IEnumerable fields, string prefix, AddValidation e) { if (nestedField == null) { @@ -212,7 +232,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { if (!field.Name.IsPropertyName()) { - e("Field name must be a valid javascript property name.", $"{prefix}.{nameof(field.Name)}"); + e(Not.ValidPropertyName("Field name"), $"{prefix}.{nameof(field.Name)}"); } if (field.Properties == null) @@ -240,11 +260,85 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards } } - private static void ValidateFieldIds(AddValidation error, ReorderFields c, IReadOnlyDictionary fields) + private static void ValidateFieldNames(Schema schema, FieldNames? fields, string path, AddValidation e) + { + if (fields != null) + { + var fieldIndex = 0; + var fieldPrefix = string.Empty; + + foreach (var fieldName in fields) + { + fieldIndex++; + fieldPrefix = $"{path}[{fieldIndex}]"; + + if (string.IsNullOrWhiteSpace(fieldName)) + { + e(Not.Defined("Field"), fieldPrefix); + } + else if (!schema.FieldsByName.TryGetValue(fieldName, out var field)) + { + e($"Field is not part of the schema.", fieldPrefix); + } + else if (!field.IsForApi()) + { + e($"Field cannot be an UI field.", fieldPrefix); + } + } + + foreach (var duplicate in fields.Duplicates()) + { + if (!string.IsNullOrWhiteSpace(duplicate)) + { + e($"Field '{duplicate}' has been added twice.", path); + } + } + } + } + + private static void ValidateFieldNames(UpsertCommand command, FieldNames? fields, string path, AddValidation e) + { + if (fields != null) + { + var fieldIndex = 0; + var fieldPrefix = string.Empty; + + foreach (var fieldName in fields) + { + fieldIndex++; + fieldPrefix = $"{path}[{fieldIndex}]"; + + var field = command?.Fields?.Find(x => x.Name == fieldName); + + if (string.IsNullOrWhiteSpace(fieldName)) + { + e(Not.Defined("Field"), fieldPrefix); + } + else if (field == null) + { + e($"Field is not part of the schema.", fieldPrefix); + } + else if (field?.Properties.IsForApi() != true) + { + e($"Field cannot be an UI field.", fieldPrefix); + } + } + + foreach (var duplicate in fields.Duplicates()) + { + if (!string.IsNullOrWhiteSpace(duplicate)) + { + e($"Field '{duplicate}' has been added twice.", path); + } + } + } + } + + private static void ValidateFieldIds(ReorderFields c, IReadOnlyDictionary fields, AddValidation e) { if (c.FieldIds != null && (c.FieldIds.Count != fields.Count || c.FieldIds.Any(x => !fields.ContainsKey(x)))) { - error("Field ids do not cover all fields.", nameof(c.FieldIds)); + e("Field ids do not cover all fields.", nameof(c.FieldIds)); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs index f99ffd53d..791943053 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs @@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { if (!command.Name.IsPropertyName()) { - e("Name must be a valid javascript property name.", nameof(command.Name)); + e(Not.ValidPropertyName("Name"), nameof(command.Name)); } if (command.Properties == null) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index f4fecddfc..a06717cdb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs @@ -224,6 +224,16 @@ namespace Squidex.Domain.Apps.Entities.Schemas return Snapshot; }); + case ConfigureUIFields configureUIFields: + return UpdateReturn(configureUIFields, c => + { + GuardSchema.CanConfigureUIFields(Snapshot.SchemaDef, c); + + ConfigureUIFields(c); + + return Snapshot; + }); + case DeleteSchema deleteSchema: return Update(deleteSchema, c => { @@ -331,6 +341,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas RaiseEvent(command, new SchemaPreviewUrlsConfigured()); } + public void ConfigureUIFields(ConfigureUIFields command) + { + RaiseEvent(command, new SchemaUIFieldsConfigured()); + } + public void Update(UpdateSchema command) { RaiseEvent(command, new SchemaUpdated()); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs index 1451c769a..31d3e58c6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs @@ -70,6 +70,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State break; } + case SchemaUIFieldsConfigured e: + { + if (e.FieldsInLists != null) + { + SchemaDef = SchemaDef.ConfigureFieldsInLists(e.FieldsInLists); + } + + if (e.FieldsInReferences != null) + { + SchemaDef = SchemaDef.ConfigureFieldsInReferences(e.FieldsInReferences); + } + + break; + } + case SchemaCategoryChanged e: { SchemaDef = SchemaDef.ChangeCategory(e.Name); diff --git a/backend/src/Squidex.Domain.Apps.Events/Schemas/SchemaUIFieldsConfigured.cs b/backend/src/Squidex.Domain.Apps.Events/Schemas/SchemaUIFieldsConfigured.cs new file mode 100644 index 000000000..232748aa7 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Schemas/SchemaUIFieldsConfigured.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Schemas +{ + [EventType(nameof(SchemaUIFieldsConfigured))] + public sealed class SchemaUIFieldsConfigured : SchemaEvent + { + public FieldNames FieldsInLists { get; set; } + + public FieldNames FieldsInReferences { get; set; } + } +} diff --git a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs index 379a65969..a714fdb38 100644 --- a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -13,6 +13,11 @@ namespace Squidex.Infrastructure { public static class CollectionExtensions { + public static bool SetEquals(this ICollection source, ICollection other) + { + return source.Intersect(other).Count() == other.Count; + } + public static IResultList SortSet(this IResultList input, Func idProvider, IReadOnlyList ids) where T : class { return ResultList.Create(input.Total, SortList(input, idProvider, ids)); @@ -23,6 +28,16 @@ namespace Squidex.Infrastructure return ids.Select(id => input.FirstOrDefault(x => Equals(idProvider(x), id))).Where(x => x != null); } + public static IEnumerable Duplicates(this IEnumerable input) + { + return input.GroupBy(x => x).Where(x => x.Count() > 1).Select(x => x.Key); + } + + public static IEnumerable Duplicates(this IEnumerable input, Func selector) + { + return input.GroupBy(selector).Where(x => x.Count() > 1).Select(x => x.Key); + } + public static void AddRange(this ICollection target, IEnumerable source) { foreach (var value in source) @@ -135,6 +150,11 @@ namespace Squidex.Infrastructure return other != null && dictionary.Count == other.Count && !dictionary.Except(other, comparer).Any(); } + public static Dictionary ToDictionary(this IReadOnlyDictionary dictionary) where TKey : notnull + { + return dictionary.ToDictionary(x => x.Key, x => x.Value); + } + public static TValue GetOrDefault(this IReadOnlyDictionary dictionary, TKey key) where TKey : notnull { return dictionary.GetOrCreate(key, _ => default!); diff --git a/backend/src/Squidex.Infrastructure/Validation/Not.cs b/backend/src/Squidex.Infrastructure/Validation/Not.cs index a82078c7a..2f5ee4094 100644 --- a/backend/src/Squidex.Infrastructure/Validation/Not.cs +++ b/backend/src/Squidex.Infrastructure/Validation/Not.cs @@ -29,6 +29,12 @@ namespace Squidex.Infrastructure.Validation return $"{Upper(property)} is not a valid slug."; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ValidPropertyName(string property) + { + return $"{Upper(property)} is not a Javascript property name."; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string GreaterThan(string property, string other) { diff --git a/backend/src/Squidex.Web/ApiModelValidationAttribute.cs b/backend/src/Squidex.Web/ApiModelValidationAttribute.cs index 8b22e36bc..f3bc81b79 100644 --- a/backend/src/Squidex.Web/ApiModelValidationAttribute.cs +++ b/backend/src/Squidex.Web/ApiModelValidationAttribute.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json; using Squidex.Infrastructure.Validation; @@ -27,17 +28,27 @@ namespace Squidex.Web { var errors = new List(); - foreach (var m in context.ModelState) + foreach (var (key, value) in context.ModelState) { - foreach (var e in m.Value.Errors) + if (value.ValidationState == ModelValidationState.Invalid) { - if (!string.IsNullOrWhiteSpace(e.ErrorMessage) && (allErrors || e.Exception is JsonException)) + if (string.IsNullOrWhiteSpace(key)) { - errors.Add(new ValidationError(e.ErrorMessage, m.Key)); + errors.Add(new ValidationError("Request body has an invalid format.")); } - else if (e.Exception is JsonException jsonException) + else { - errors.Add(new ValidationError(jsonException.Message)); + foreach (var error in value.Errors) + { + if (!string.IsNullOrWhiteSpace(error.ErrorMessage) && ShouldExpose(error)) + { + errors.Add(new ValidationError(error.ErrorMessage, key)); + } + else if (error.Exception is JsonException jsonException) + { + errors.Add(new ValidationError(jsonException.Message)); + } + } } } } @@ -48,5 +59,10 @@ namespace Squidex.Web } } } + + private bool ShouldExpose(ModelError error) + { + return allErrors || error.Exception is JsonException; + } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs new file mode 100644 index 000000000..2fd9bf8f2 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class ConfigureUIFieldsDto + { + /// + /// The name of fields that are used in content lists. + /// + public FieldNames? FieldsInLists { get; set; } + + /// + /// The name of fields that are used in content references. + /// + public FieldNames? FieldsInReferences { get; set; } + + public ConfigureUIFields ToCommand() + { + return SimpleMapper.Map(this, new ConfigureUIFields()); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs index 0c127730b..5ee49c811 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs @@ -43,16 +43,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public bool IsRequired { get; set; } - /// - /// Determines if the field should be displayed in lists. - /// - public bool IsListField { get; set; } - - /// - /// Determines if the field should be displayed in reference lists. - /// - public bool IsReferenceField { get; set; } - /// /// Optional url to the editor. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index d2b1402a3..a1d9eca59 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure.Reflection; using Squidex.Shared; @@ -21,13 +22,27 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// /// The scripts. /// + [Required] public SchemaScriptsDto Scripts { get; set; } = new SchemaScriptsDto(); /// /// The preview Urls. /// + [Required] public Dictionary PreviewUrls { get; set; } = EmptyPreviewUrls; + /// + /// The name of fields that are used in content lists. + /// + [Required] + public FieldNames FieldsInLists { get; set; } + + /// + /// The name of fields that are used in content references. + /// + [Required] + public FieldNames FieldsInReferences { get; set; } + /// /// The list of fields. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index 4b349216c..4ea8e3b9c 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -119,14 +119,15 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models if (allowUpdate) { + AddPostLink("fields/add", controller.Url(x => nameof(x.PostField), values)); + AddPutLink("fields/order", controller.Url(x => nameof(x.PutSchemaFieldOrdering), values)); + AddPutLink("fields/ui", controller.Url(x => nameof(x.PutSchemaUIFields), values)); AddPutLink("update", controller.Url(x => nameof(x.PutSchema), values)); AddPutLink("update/category", controller.Url(x => nameof(x.PutCategory), values)); AddPutLink("update/sync", controller.Url(x => nameof(x.PutSchemaSync), values)); AddPutLink("update/urls", controller.Url(x => nameof(x.PutPreviewUrls), values)); - - AddPostLink("fields/add", controller.Url(x => nameof(x.PostField), values)); } if (controller.HasPermission(Permissions.AppSchemasScripts, app, Name)) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs index be7290131..307f74e81 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs @@ -27,7 +27,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// /// Optional fields. /// - public List Fields { get; set; } + public List? Fields { get; set; } /// /// The optional preview urls. diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs index ec7617cf2..6671acc21 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs @@ -50,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas var response = await InvokeCommandAsync(app, command); - return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name = request.Name }, response); + return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name }, response); } /// @@ -77,7 +77,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas var response = await InvokeCommandAsync(app, command); - return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name = request.Name }, response); + return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name }, response); + } + + /// + /// Configure UI fields. + /// + /// The name of the app. + /// The name of the schema. + /// The request that contains the field names. + /// + /// 200 => Schema UI fields defined. + /// 400 => Schema field contains invalid field names. + /// 404 => Schema or app not found. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/ui/")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] + [ApiPermission(Permissions.AppSchemasUpdate)] + [ApiCosts(1)] + public async Task PutSchemaUIFields(string app, string name, [FromBody] ConfigureUIFieldsDto request) + { + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// diff --git a/backend/src/Squidex/Config/Domain/SerializationServices.cs b/backend/src/Squidex/Config/Domain/SerializationServices.cs index dbd3e7c0d..45d8b5727 100644 --- a/backend/src/Squidex/Config/Domain/SerializationServices.cs +++ b/backend/src/Squidex/Config/Domain/SerializationServices.cs @@ -115,6 +115,8 @@ namespace Squidex.Config.Domain { mvc.AddNewtonsoftJson(options => { + options.AllowInputFormatterExceptionMessages = false; + ConfigureJson(options.SerializerSettings, TypeNameHandling.None); }); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs index cba4b06ea..031cfc7c1 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs @@ -209,6 +209,22 @@ namespace Squidex.Domain.Apps.Core.Model.Schemas Assert.Empty(schema_2.FieldsById); } + [Fact] + public void Should_also_remove_deleted_fields_from_lists() + { + var field = CreateField(1); + + var schema_1 = schema_0 + .AddField(field) + .ConfigureFieldsInLists(field.Name) + .ConfigureFieldsInReferences(field.Name); + var schema_2 = schema_1.DeleteField(1); + + Assert.Empty(schema_2.FieldsById); + Assert.Empty(schema_2.FieldsInLists); + Assert.Empty(schema_2.FieldsInReferences); + } + [Fact] public void Should_return_same_schema_if_field_to_delete_does_not_exist() { @@ -283,6 +299,22 @@ namespace Squidex.Domain.Apps.Core.Model.Schemas Assert.Equal("Category", schema_1.Category); } + [Fact] + public void Should_set_list_fields() + { + var schema_1 = schema_0.ConfigureFieldsInLists("1"); + + Assert.Equal(new[] { "1" }, schema_1.FieldsInLists); + } + + [Fact] + public void Should_set_reference_fields() + { + var schema_1 = schema_0.ConfigureFieldsInReferences("2"); + + Assert.Equal(new[] { "2" }, schema_1.FieldsInReferences); + } + [Fact] public void Should_configure_scripts() { @@ -319,6 +351,8 @@ namespace Squidex.Domain.Apps.Core.Model.Schemas var schemaSource = TestUtils.MixedSchema(true) .ChangeCategory("Category") + .ConfigureFieldsInLists("field2") + .ConfigureFieldsInReferences("field1") .ConfigurePreviewUrls(new Dictionary { ["web"] = "Url" diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs index 491fdf39d..b83b86ffe 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs @@ -140,6 +140,42 @@ namespace Squidex.Domain.Apps.Core.Operations.EventSynchronization ); } + [Fact] + public void Should_create_events_if_list_fields_changed() + { + var sourceSchema = + new Schema("source") + .ConfigureFieldsInLists("1", "2"); + + var targetSchema = + new Schema("target") + .ConfigureFieldsInLists("2", "1"); + + var events = sourceSchema.Synchronize(targetSchema, jsonSerializer, idGenerator); + + events.ShouldHaveSameEvents( + new SchemaUIFieldsConfigured { FieldsInLists = new FieldNames("2", "1") } + ); + } + + [Fact] + public void Should_create_events_if_reference_fields_changed() + { + var sourceSchema = + new Schema("source") + .ConfigureFieldsInReferences("1", "2"); + + var targetSchema = + new Schema("target") + .ConfigureFieldsInReferences("2", "1"); + + var events = sourceSchema.Synchronize(targetSchema, jsonSerializer, idGenerator); + + events.ShouldHaveSameEvents( + new SchemaUIFieldsConfigured { FieldsInReferences = new FieldNames("2", "1") } + ); + } + [Fact] public void Should_create_events_if_nested_field_deleted() { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs index bfe371b33..15b4d3777 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs @@ -27,10 +27,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds var schema = new Schema("my-schema") .AddString(1, "ref1", Partitioning.Invariant, - new StringFieldProperties { IsReferenceField = true }) + new StringFieldProperties()) .AddString(2, "ref2", Partitioning.Invariant, - new StringFieldProperties { IsReferenceField = true }) - .AddString(3, "non-ref", Partitioning.Invariant); + new StringFieldProperties()) + .AddString(3, "non-ref", Partitioning.Invariant) + .ConfigureFieldsInReferences("ref1", "ref2"); var formatted = data.FormatReferences(schema, languages); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs index 11dc7d9c0..f51336474 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs @@ -42,16 +42,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { ResolveImage = true, MinItems = 2, - MaxItems = 3, - IsListField = true + MaxItems = 3 }) .AddAssets(2, "asset2", Partitioning.Language, new AssetsFieldProperties { ResolveImage = true, MinItems = 1, - MaxItems = 1, - IsListField = true, - }); + MaxItems = 1 + }) + .ConfigureFieldsInLists("asset1", "asset2"); A.CallTo(() => assetUrlGenerator.GenerateUrl(A.Ignored)) .ReturnsLazily(new Func(id => $"url/to/{id}")); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs index bec6e01a1..5817b98cd 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs @@ -42,16 +42,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries var refSchemaDef = new Schema("my-ref") .AddString(1, "name", Partitioning.Invariant, - new StringFieldProperties { IsReferenceField = true }) + new StringFieldProperties()) .AddNumber(2, "number", Partitioning.Invariant, - new NumberFieldProperties { IsReferenceField = true }); + new NumberFieldProperties()) + .ConfigureFieldsInReferences("name", "number"); var schemaDef = new Schema(schemaId.Name) .AddReferences(1, "ref1", Partitioning.Invariant, new ReferencesFieldProperties { ResolveReference = true, - IsListField = true, MinItems = 1, MaxItems = 1, SchemaId = refSchemaId1.Id @@ -59,11 +59,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .AddReferences(2, "ref2", Partitioning.Invariant, new ReferencesFieldProperties { ResolveReference = true, - IsListField = true, MinItems = 1, MaxItems = 1, SchemaId = refSchemaId2.Id - }); + }) + .ConfigureFieldsInLists("ref1", "ref2"); void SetupSchema(NamedId id, Schema def) { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs index 73c01dddc..d79c61ba8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs @@ -253,24 +253,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards GuardSchemaField.CanUpdate(schema_0, command); } - [Fact] - public void CanUpdate_should_throw_exception_if_marking_a_ui_field_as_list_field() - { - var command = new UpdateField { FieldId = 4, Properties = new UIFieldProperties { IsListField = true } }; - - ValidationAssert.Throws(() => GuardSchemaField.CanUpdate(schema_0, command), - new ValidationError("UI field cannot be a list field.", "Properties.IsListField")); - } - - [Fact] - public void CanUpdate_should_throw_exception_if_marking_a_ui_field_as_reference_field() - { - var command = new UpdateField { FieldId = 4, Properties = new UIFieldProperties { IsReferenceField = true } }; - - ValidationAssert.Throws(() => GuardSchemaField.CanUpdate(schema_0, command), - new ValidationError("UI field cannot be a reference field.", "Properties.IsReferenceField")); - } - [Fact] public void CanUpdate_should_throw_exception_if_properties_null() { @@ -313,7 +295,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards var command = new AddField { Name = "INVALID_NAME", Properties = validProperties }; ValidationAssert.Throws(() => GuardSchemaField.CanAdd(schema_0, command), - new ValidationError("Name must be a valid javascript property name.", "Name")); + new ValidationError("Name is not a Javascript property name.", "Name")); } [Fact] @@ -343,15 +325,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards new ValidationError("Partitioning is not a valid value.", "Partitioning")); } - [Fact] - public void CanAdd_should_throw_exception_if_creating_a_ui_field_as_list_field() - { - var command = new AddField { Name = "field5", Properties = new UIFieldProperties { IsListField = true } }; - - ValidationAssert.Throws(() => GuardSchemaField.CanAdd(schema_0, command), - new ValidationError("UI field cannot be a list field.", "Properties.IsListField")); - } - [Fact] public void CanAdd_should_throw_exception_if_parent_not_exists() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs index 3e7deb2d8..7413ea9af 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs @@ -29,7 +29,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards schema_0 = new Schema("my-schema") .AddString(1, "field1", Partitioning.Invariant) - .AddString(2, "field2", Partitioning.Invariant); + .AddString(2, "field2", Partitioning.Invariant) + .AddUI(4, "field4", Partitioning.Invariant); } [Fact] @@ -60,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards }; ValidationAssert.Throws(() => GuardSchema.CanCreate(command), - new ValidationError("Field name must be a valid javascript property name.", + new ValidationError("Field name is not a Javascript property name.", "Fields[1].Name")); } @@ -159,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards }; ValidationAssert.Throws(() => GuardSchema.CanCreate(command), - new ValidationError("Fields cannot have duplicate names.", + new ValidationError("Field 'field1' has been added twice.", "Fields")); } @@ -190,7 +191,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards }; ValidationAssert.Throws(() => GuardSchema.CanCreate(command), - new ValidationError("Field name must be a valid javascript property name.", + new ValidationError("Field name is not a Javascript property name.", "Fields[1].Nested[1].Name")); } @@ -320,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards }; ValidationAssert.Throws(() => GuardSchema.CanCreate(command), - new ValidationError("Fields cannot have duplicate names.", + new ValidationError("Field 'nested1' has been added twice.", "Fields[1].Nested")); } @@ -335,28 +336,102 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards new UpsertSchemaField { Name = "field1", - Properties = new UIFieldProperties - { - IsListField = true, - IsReferenceField = true - }, + Properties = new UIFieldProperties(), IsHidden = true, IsDisabled = true, Partitioning = Partitioning.Invariant.Key } }, + FieldsInLists = new FieldNames("field1"), + FieldsInReferences = new FieldNames("field1"), Name = "new-schema" }; ValidationAssert.Throws(() => GuardSchema.CanCreate(command), - new ValidationError("UI field cannot be a list field.", - "Fields[1].Properties.IsListField"), - new ValidationError("UI field cannot be a reference field.", - "Fields[1].Properties.IsReferenceField"), new ValidationError("UI field cannot be hidden.", "Fields[1].IsHidden"), new ValidationError("UI field cannot be disabled.", - "Fields[1].IsDisabled")); + "Fields[1].IsDisabled"), + new ValidationError("Field cannot be an UI field.", + "FieldsInLists[1]"), + new ValidationError("Field cannot be an UI field.", + "FieldsInReferences[1]")); + } + + [Fact] + public void CanCreate_should_throw_exception_if_invalid_lists_field_are_used() + { + var command = new CreateSchema + { + Fields = new List + { + new UpsertSchemaField + { + Name = "field1", + Properties = new StringFieldProperties(), + Partitioning = Partitioning.Invariant.Key + }, + new UpsertSchemaField + { + Name = "field4", + Properties = new UIFieldProperties(), + Partitioning = Partitioning.Invariant.Key + } + }, + FieldsInLists = new FieldNames(null!, null!, "field3", "field1", "field1", "field4"), + FieldsInReferences = null, + Name = "new-schema" + }; + + ValidationAssert.Throws(() => GuardSchema.CanCreate(command), + new ValidationError("Field is required.", + "FieldsInLists[1]"), + new ValidationError("Field is required.", + "FieldsInLists[2]"), + new ValidationError("Field is not part of the schema.", + "FieldsInLists[3]"), + new ValidationError("Field cannot be an UI field.", + "FieldsInLists[6]"), + new ValidationError("Field 'field1' has been added twice.", + "FieldsInLists")); + } + + [Fact] + public void CanCreate_should_throw_exception_if_invalid_references_field_are_used() + { + var command = new CreateSchema + { + Fields = new List + { + new UpsertSchemaField + { + Name = "field1", + Properties = new StringFieldProperties(), + Partitioning = Partitioning.Invariant.Key + }, + new UpsertSchemaField + { + Name = "field4", + Properties = new UIFieldProperties(), + Partitioning = Partitioning.Invariant.Key + } + }, + FieldsInLists = null, + FieldsInReferences = new FieldNames(null!, null!, "field3", "field1", "field1", "field4"), + Name = "new-schema" + }; + + ValidationAssert.Throws(() => GuardSchema.CanCreate(command), + new ValidationError("Field is required.", + "FieldsInReferences[1]"), + new ValidationError("Field is required.", + "FieldsInReferences[2]"), + new ValidationError("Field is not part of the schema.", + "FieldsInReferences[3]"), + new ValidationError("Field cannot be an UI field.", + "FieldsInReferences[6]"), + new ValidationError("Field 'field1' has been added twice.", + "FieldsInReferences")); } [Fact] @@ -370,10 +445,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards new UpsertSchemaField { Name = "field1", - Properties = new StringFieldProperties - { - IsListField = true - }, + Properties = new StringFieldProperties(), IsHidden = true, IsDisabled = true, Partitioning = Partitioning.Invariant.Key @@ -404,12 +476,70 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards } } }, + FieldsInLists = new FieldNames("field1"), + FieldsInReferences = new FieldNames("field1"), Name = "new-schema" }; GuardSchema.CanCreate(command); } + [Fact] + public void CanConfigureUIFields_should_throw_exception_if_invalid_lists_field_are_used() + { + var command = new ConfigureUIFields + { + FieldsInLists = new FieldNames(null!, null!, "field3", "field1", "field1", "field4"), + FieldsInReferences = null + }; + + ValidationAssert.Throws(() => GuardSchema.CanConfigureUIFields(schema_0, command), + new ValidationError("Field is required.", + "FieldsInLists[1]"), + new ValidationError("Field is required.", + "FieldsInLists[2]"), + new ValidationError("Field is not part of the schema.", + "FieldsInLists[3]"), + new ValidationError("Field cannot be an UI field.", + "FieldsInLists[6]"), + new ValidationError("Field 'field1' has been added twice.", + "FieldsInLists")); + } + + [Fact] + public void CanConfigureUIFields_should_throw_exception_if_invalid_references_field_are_used() + { + var command = new ConfigureUIFields + { + FieldsInLists = null, + FieldsInReferences = new FieldNames(null!, null!, "field3", "field1", "field1", "field4") + }; + + ValidationAssert.Throws(() => GuardSchema.CanConfigureUIFields(schema_0, command), + new ValidationError("Field is required.", + "FieldsInReferences[1]"), + new ValidationError("Field is required.", + "FieldsInReferences[2]"), + new ValidationError("Field is not part of the schema.", + "FieldsInReferences[3]"), + new ValidationError("Field cannot be an UI field.", + "FieldsInReferences[6]"), + new ValidationError("Field 'field1' has been added twice.", + "FieldsInReferences")); + } + + [Fact] + public void CanConfigureUIFields_should_not_throw_exception_if_command_is_valid() + { + var command = new ConfigureUIFields + { + FieldsInLists = new FieldNames("field1"), + FieldsInReferences = new FieldNames("field2") + }; + + GuardSchema.CanConfigureUIFields(schema_0, command); + } + [Fact] public void CanPublish_should_throw_exception_if_already_published() { @@ -484,7 +614,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards [Fact] public void CanReorder_should_not_throw_exception_if_field_ids_are_valid() { - var command = new ReorderFields { FieldIds = new List { 1, 2 } }; + var command = new ReorderFields { FieldIds = new List { 1, 2, 4 } }; GuardSchema.CanReorder(schema_0, command); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs index 7d7acabc0..8bc8fecbf 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs @@ -154,6 +154,52 @@ namespace Squidex.Domain.Apps.Entities.Schemas ); } + [Fact] + public async Task ConfigureUIFields_should_create_events_for_list_fields() + { + var command = new ConfigureUIFields + { + FieldsInLists = new FieldNames(fieldName) + }; + + await ExecuteCreateAsync(); + await ExecuteAddFieldAsync(fieldName); + + var result = await sut.ExecuteAsync(CreateCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(command.FieldsInLists, sut.Snapshot.SchemaDef.FieldsInLists); + + LastEvents + .ShouldHaveSameEvents( + CreateEvent(new SchemaUIFieldsConfigured { FieldsInLists = command.FieldsInLists }) + ); + } + + [Fact] + public async Task ConfigureUIFields_should_create_events_for_reference_fields() + { + var command = new ConfigureUIFields + { + FieldsInReferences = new FieldNames(fieldName) + }; + + await ExecuteCreateAsync(); + await ExecuteAddFieldAsync(fieldName); + + var result = await sut.ExecuteAsync(CreateCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(command.FieldsInReferences, sut.Snapshot.SchemaDef.FieldsInReferences); + + LastEvents + .ShouldHaveSameEvents( + CreateEvent(new SchemaUIFieldsConfigured { FieldsInReferences = command.FieldsInReferences }) + ); + } + [Fact] public async Task Publish_should_create_events_and_update_state() { @@ -630,18 +676,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas { var command = new SynchronizeSchema { - Scripts = new SchemaScripts - { - Query = " - { - ["Web"] = "web-url" - }, - Fields = new List - { - new UpsertSchemaField { Name = fieldId.Name, Properties = ValidProperties() } - }, Category = "My-Category" }; @@ -651,17 +685,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas result.ShouldBeEquivalent(sut.Snapshot); - Assert.NotNull(GetField(1)); Assert.Equal(command.Category, sut.Snapshot.SchemaDef.Category); - Assert.Equal(command.Scripts, sut.Snapshot.SchemaDef.Scripts); - Assert.Equal(command.PreviewUrls, sut.Snapshot.SchemaDef.PreviewUrls); LastEvents .ShouldHaveSameEvents( - CreateEvent(new SchemaCategoryChanged { Name = command.Category }), - CreateEvent(new SchemaScriptsConfigured { Scripts = command.Scripts }), - CreateEvent(new SchemaPreviewUrlsConfigured { PreviewUrls = command.PreviewUrls }), - CreateEvent(new FieldAdded { FieldId = fieldId, Name = fieldId.Name, Properties = command.Fields[0].Properties, Partitioning = Partitioning.Invariant.Key }) + CreateEvent(new SchemaCategoryChanged { Name = command.Category }) ); } diff --git a/frontend/app/_theme.html b/frontend/app/_theme.html index 11ca22d7a..e9678a8bc 100644 --- a/frontend/app/_theme.html +++ b/frontend/app/_theme.html @@ -1285,7 +1285,7 @@ diff --git a/frontend/app/features/apps/pages/apps-page.component.html b/frontend/app/features/apps/pages/apps-page.component.html index e993078b0..9c5a6eae4 100644 --- a/frontend/app/features/apps/pages/apps-page.component.html +++ b/frontend/app/features/apps/pages/apps-page.component.html @@ -34,7 +34,7 @@ -
+
{{app.description}}
diff --git a/frontend/app/features/rules/pages/rules/rules-page.component.html b/frontend/app/features/rules/pages/rules/rules-page.component.html index d885b700e..f7ab095ad 100644 --- a/frontend/app/features/rules/pages/rules/rules-page.component.html +++ b/frontend/app/features/rules/pages/rules/rules-page.component.html @@ -65,7 +65,7 @@ - + diff --git a/frontend/app/features/schemas/declarations.ts b/frontend/app/features/schemas/declarations.ts index a7edb1979..084450674 100644 --- a/frontend/app/features/schemas/declarations.ts +++ b/frontend/app/features/schemas/declarations.ts @@ -30,6 +30,7 @@ export * from './pages/schema/forms/field-form-ui.component'; export * from './pages/schema/forms/field-form-validation.component'; export * from './pages/schema/forms/field-form.component'; +export * from './pages/schema/field-list.component'; export * from './pages/schema/field-wizard.component'; export * from './pages/schema/field.component'; export * from './pages/schema/schema-edit-form.component'; @@ -38,6 +39,7 @@ export * from './pages/schema/schema-fields.component'; export * from './pages/schema/schema-page.component'; export * from './pages/schema/schema-preview-urls-form.component'; export * from './pages/schema/schema-scripts-form.component'; +export * from './pages/schema/schema-ui-form.component'; export * from './pages/schemas/schema-form.component'; export * from './pages/schemas/schemas-page.component'; \ No newline at end of file diff --git a/frontend/app/features/schemas/module.ts b/frontend/app/features/schemas/module.ts index 827398242..9bd44f027 100644 --- a/frontend/app/features/schemas/module.ts +++ b/frontend/app/features/schemas/module.ts @@ -28,6 +28,7 @@ import { FieldFormComponent, FieldFormUIComponent, FieldFormValidationComponent, + FieldListComponent, FieldWizardComponent, GeolocationUIComponent, GeolocationValidationComponent, @@ -45,6 +46,7 @@ import { SchemaPreviewUrlsFormComponent, SchemaScriptsFormComponent, SchemasPageComponent, + SchemaUIFormComponent, StringUIComponent, StringValidationComponent, TagsUIComponent, @@ -62,56 +64,11 @@ const routes: Routes = [ component: SchemaPageComponent, children: [ { - path: '', - redirectTo: 'fields' - }, - { - path: 'fields', - children: [ - { - path: 'help', - component: HelpComponent, - data: { - helpPage: '05-integrated/schemas' - } - } - ] - }, - { - path: 'scripts', - children: [ - { - path: 'help', - component: HelpComponent, - data: { - helpPage: '05-integrated/scripts' - } - } - ] - }, - { - path: 'json', - children: [ - { - path: 'help', - component: HelpComponent, - data: { - helpPage: '05-integrated/schema-json' - } - } - ] - }, - { - path: 'more', - children: [ - { - path: 'help', - component: HelpComponent, - data: { - helpPage: '05-integrated/preview' - } - } - ] + path: 'help', + component: HelpComponent, + data: { + helpPage: '05-integrated/schemas' + } } ] } @@ -141,6 +98,7 @@ const routes: Routes = [ FieldFormComponent, FieldFormUIComponent, FieldFormValidationComponent, + FieldListComponent, FieldWizardComponent, GeolocationUIComponent, GeolocationValidationComponent, @@ -158,6 +116,7 @@ const routes: Routes = [ SchemaPreviewUrlsFormComponent, SchemaScriptsFormComponent, SchemasPageComponent, + SchemaUIFormComponent, StringUIComponent, StringValidationComponent, TagsUIComponent, diff --git a/frontend/app/features/schemas/pages/schema/field-list.component.html b/frontend/app/features/schemas/pages/schema/field-list.component.html new file mode 100644 index 000000000..c7b3825f4 --- /dev/null +++ b/frontend/app/features/schemas/pages/schema/field-list.component.html @@ -0,0 +1,32 @@ +
+ + + +
+ {{emptyText}} +
+ +
+ {{field.displayName}} +
+
+ +
+ + + +
+ {{field.displayName}} +
+
\ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/field-list.component.scss b/frontend/app/features/schemas/pages/schema/field-list.component.scss new file mode 100644 index 000000000..c304a0964 --- /dev/null +++ b/frontend/app/features/schemas/pages/schema/field-list.component.scss @@ -0,0 +1,34 @@ +@import '_vars'; +@import '_mixins'; + +.field-list { + & { + overflow-x: hidden; + overflow-y: auto; + padding: $panel-padding; + padding-top: 1rem; + } + + &-assigned { + @include absolute(0, 50%, 0, 0); + border-right: 1px solid $color-border; + } + + &-available { + @include absolute(0, 0, 0, 50%); + } +} + +.cdk-drop-list-dragging { + .empty-hint { + display: none; + } +} + +.drag-handle { + margin-right: 1rem; +} + +label { + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/field-list.component.ts b/frontend/app/features/schemas/pages/schema/field-list.component.ts new file mode 100644 index 000000000..944069489 --- /dev/null +++ b/frontend/app/features/schemas/pages/schema/field-list.component.ts @@ -0,0 +1,57 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +// tslint:disable: readonly-array + +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'; + +@Component({ + selector: 'sqx-field-list', + styleUrls: ['./field-list.component.scss'], + templateUrl: './field-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FieldListComponent implements OnChanges { + @Input() + public emptyText = ''; + + @Input() + public schema: SchemaDetailsDto; + + @Input() + public fieldNames: ReadonlyArray; + + @Output() + public fieldNamesChange = new EventEmitter>(); + + public fieldsAdded: FieldDto[]; + public fieldsNotAdded: FieldDto[]; + + 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); + } + + public drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + transferArrayItem( + event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex); + } + + const newNames = this.fieldsAdded.map(x => x.name); + + this.fieldNamesChange.emit(newNames); + } +} \ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/field.component.ts b/frontend/app/features/schemas/pages/schema/field.component.ts index c53a2ebc3..528edd8d0 100644 --- a/frontend/app/features/schemas/pages/schema/field.component.ts +++ b/frontend/app/features/schemas/pages/schema/field.component.ts @@ -5,6 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { FormBuilder } from '@angular/forms'; @@ -19,7 +20,8 @@ import { PatternDto, RootFieldDto, SchemaDetailsDto, - SchemasState + SchemasState, + sorted } from '@app/shared'; @Component({ @@ -98,8 +100,8 @@ export class FieldComponent implements OnChanges { this.schemasState.hideField(this.schema, this.field); } - public sortFields(fields: ReadonlyArray) { - this.schemasState.orderFields(this.schema, fields, this.field).subscribe(); + public sortFields(event: CdkDragDrop>) { + this.schemasState.orderFields(this.schema, sorted(event), this.field).subscribe(); } public lockField() { diff --git a/frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts b/frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts index b6b010547..e7d204317 100644 --- a/frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts +++ b/frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts @@ -54,36 +54,6 @@ import { FieldDto } from '@app/shared'; -
-
-
- - -
- - - List fields are shown as a column in the content list.
When no list field is defined, the first field is used. -
-
-
- -
-
-
- - -
- - - Reference fields are shown as a column in the content list when referenced by another content.
When no reference field is defined, the first field is used. -
-
-
-
diff --git a/frontend/app/features/schemas/pages/schema/schema-fields.component.ts b/frontend/app/features/schemas/pages/schema/schema-fields.component.ts index 2d844b9af..9c5338863 100644 --- a/frontend/app/features/schemas/pages/schema/schema-fields.component.ts +++ b/frontend/app/features/schemas/pages/schema/schema-fields.component.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:no-shadowed-variable - import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { Component, Input, OnInit } from '@angular/core'; diff --git a/frontend/app/features/schemas/pages/schema/schema-page.component.html b/frontend/app/features/schemas/pages/schema/schema-page.component.html index 680bd2ce5..888a7863f 100644 --- a/frontend/app/features/schemas/pages/schema/schema-page.component.html +++ b/frontend/app/features/schemas/pages/schema/schema-page.component.html @@ -4,7 +4,7 @@ @@ -60,16 +60,19 @@ - + - + + + + - + - +
@@ -79,8 +82,8 @@ -
- + diff --git a/frontend/app/features/schemas/pages/schema/schema-page.component.ts b/frontend/app/features/schemas/pages/schema/schema-page.component.ts index dd4ef9743..3ad257bf1 100644 --- a/frontend/app/features/schemas/pages/schema/schema-page.component.ts +++ b/frontend/app/features/schemas/pages/schema/schema-page.component.ts @@ -5,10 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:no-shadowed-variable - import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { fadeAnimation, @@ -16,8 +14,7 @@ import { ModalModel, ResourceOwner, SchemaDetailsDto, - SchemasState, - Types + SchemasState } from '@app/shared'; import { @@ -33,12 +30,14 @@ import { ] }) export class SchemaPageComponent extends ResourceOwner implements OnInit { + public readonly exact = { exact: true }; + public schema: SchemaDetailsDto; - public editOptionsDropdown = new ModalModel(); + public selectableTabs: ReadonlyArray = ['Fields', 'UI', 'Scripts', 'Json', 'More']; + public selectedTab = this.selectableTabs[0]; - public selectedTab = 'fields'; - public selectableTabs: ReadonlyArray = ['Fields', 'Scripts', 'Json', 'More']; + public editOptionsDropdown = new ModalModel(); constructor( public readonly schemasState: SchemasState, @@ -50,16 +49,6 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit { } public ngOnInit() { - this.updateTab(); - - this.own( - this.router.events - .subscribe(event => { - if (Types.is(event, NavigationEnd)) { - this.updateTab(); - } - })); - this.own( this.schemasState.selectedSchema .subscribe(schema => { @@ -67,8 +56,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit { })); } - private updateTab() { - this.selectedTab = this.route.firstChild!.snapshot.routeConfig!.path!; + public cloneSchema() { + this.messageBus.emit(new SchemaCloning(this.schema.export())); } public publish() { @@ -86,10 +75,6 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit { }); } - public cloneSchema() { - this.messageBus.emit(new SchemaCloning(this.schema.export())); - } - public selectTab(tab: string) { this.selectedTab = tab; } diff --git a/frontend/app/features/schemas/pages/schema/schema-ui-form.component.html b/frontend/app/features/schemas/pages/schema/schema-ui-form.component.html new file mode 100644 index 000000000..a0e669b9f --- /dev/null +++ b/frontend/app/features/schemas/pages/schema/schema-ui-form.component.html @@ -0,0 +1,26 @@ +
+
+ + + +
+ +
+ + + + +
+
\ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss b/frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss new file mode 100644 index 000000000..d28bf56d4 --- /dev/null +++ b/frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss @@ -0,0 +1,21 @@ +@import '_vars'; +@import '_mixins'; + +:host, +.inner-form, +.inner-main { + @include flex-box; + @include flex-grow(1); + @include flex-direction(column); +} + +.inner-header, +.inner-main { + position: relative; +} + +.inner-header { + background: $color-theme-secondary; + border-bottom: 1px solid $color-border; + padding: 1rem $panel-padding; +} \ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts b/frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts new file mode 100644 index 000000000..0e8be33ca --- /dev/null +++ b/frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts @@ -0,0 +1,74 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, Input, OnChanges } from '@angular/core'; + +import { + DialogService, + SchemaDetailsDto, + SchemasState +} from '@app/shared'; + +type State = { fieldsInLists: ReadonlyArray, fieldsInReferences: ReadonlyArray }; + +@Component({ + selector: 'sqx-schema-ui-form', + styleUrls: ['./schema-ui-form.component.scss'], + templateUrl: './schema-ui-form.component.html' +}) +export class SchemaUIFormComponent implements OnChanges { + @Input() + public schema: SchemaDetailsDto; + + public selectableTabs: ReadonlyArray = ['List Fields', 'Reference Fields']; + public selectedTab = this.selectableTabs[0]; + + public isEditable = false; + + public state: State = { + fieldsInLists: [], + fieldsInReferences: [] + }; + + constructor( + private readonly dialogs: DialogService, + private readonly schemasState: SchemasState + ) { + } + + public ngOnChanges() { + this.isEditable = this.schema.canUpdate; + + this.state = { + fieldsInLists: this.schema.fieldsInLists, + fieldsInReferences: this.schema.fieldsInReferences + }; + } + + public setFieldsInLists(names: ReadonlyArray) { + this.state.fieldsInLists = names; + } + + public setFieldsInReferences(names: ReadonlyArray) { + this.state.fieldsInReferences = names; + } + + public selectTab(tab: string) { + this.selectedTab = tab; + } + + public saveSchema() { + if (!this.isEditable) { + return; + } + + this.schemasState.configureUIFields(this.schema, this.state) + .subscribe(() => { + this.dialogs.notifyInfo('UI fields updated successfully.'); + }); + } +} \ No newline at end of file diff --git a/frontend/app/features/settings/pages/backups/backups-page.component.html b/frontend/app/features/settings/pages/backups/backups-page.component.html index 60632ea47..b0c33d067 100644 --- a/frontend/app/features/settings/pages/backups/backups-page.component.html +++ b/frontend/app/features/settings/pages/backups/backups-page.component.html @@ -40,7 +40,7 @@ diff --git a/frontend/app/features/settings/pages/clients/clients-page.component.html b/frontend/app/features/settings/pages/clients/clients-page.component.html index 344e4efb2..66c811cbb 100644 --- a/frontend/app/features/settings/pages/clients/clients-page.component.html +++ b/frontend/app/features/settings/pages/clients/clients-page.component.html @@ -37,7 +37,7 @@ - +
diff --git a/frontend/app/features/settings/pages/contributors/contributors-page.component.html b/frontend/app/features/settings/pages/contributors/contributors-page.component.html index cb6956abe..dec7792ac 100644 --- a/frontend/app/features/settings/pages/contributors/contributors-page.component.html +++ b/frontend/app/features/settings/pages/contributors/contributors-page.component.html @@ -73,7 +73,7 @@ - +
diff --git a/frontend/app/features/settings/pages/languages/languages-page.component.html b/frontend/app/features/settings/pages/languages/languages-page.component.html index c3c67f976..094c01250 100644 --- a/frontend/app/features/settings/pages/languages/languages-page.component.html +++ b/frontend/app/features/settings/pages/languages/languages-page.component.html @@ -34,7 +34,7 @@ - +
diff --git a/frontend/app/features/settings/pages/patterns/patterns-page.component.html b/frontend/app/features/settings/pages/patterns/patterns-page.component.html index 714657e68..bdb21c3b4 100644 --- a/frontend/app/features/settings/pages/patterns/patterns-page.component.html +++ b/frontend/app/features/settings/pages/patterns/patterns-page.component.html @@ -34,7 +34,7 @@ - + diff --git a/frontend/app/features/settings/pages/roles/roles-page.component.html b/frontend/app/features/settings/pages/roles/roles-page.component.html index dc5d2342e..831933202 100644 --- a/frontend/app/features/settings/pages/roles/roles-page.component.html +++ b/frontend/app/features/settings/pages/roles/roles-page.component.html @@ -28,7 +28,7 @@ - + diff --git a/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss b/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss index 8e49fbd32..b1f919540 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss +++ b/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss @@ -2,7 +2,6 @@ @import '_mixins'; .dashed { - @include placeholder-color($color-theme-secondary); border-style: dashed; border-width: 1px; } diff --git a/frontend/app/features/settings/pages/workflows/workflows-page.component.html b/frontend/app/features/settings/pages/workflows/workflows-page.component.html index d4527fe71..51a04106a 100644 --- a/frontend/app/features/settings/pages/workflows/workflows-page.component.html +++ b/frontend/app/features/settings/pages/workflows/workflows-page.component.html @@ -43,7 +43,7 @@ diff --git a/frontend/app/framework/angular/forms/tag-editor.component.scss b/frontend/app/framework/angular/forms/tag-editor.component.scss index f36d8cf97..8070aa5e3 100644 --- a/frontend/app/framework/angular/forms/tag-editor.component.scss +++ b/frontend/app/framework/angular/forms/tag-editor.component.scss @@ -33,7 +33,6 @@ $focus-color: #b3d3ff; } &.dashed { - @include placeholder-color($color-theme-secondary); border-style: dashed; } } @@ -49,6 +48,7 @@ div { .blank { & { + @include placeholder-color($color-input-placeholder); padding: 0; border: 0; background: transparent; diff --git a/frontend/app/shared/components/asset-uploader.component.scss b/frontend/app/shared/components/asset-uploader.component.scss index 8a794dfef..4142a837f 100644 --- a/frontend/app/shared/components/asset-uploader.component.scss +++ b/frontend/app/shared/components/asset-uploader.component.scss @@ -3,8 +3,9 @@ .nav { & { - padding-right: 2rem; + padding-right: .5rem; } + .nav-item { & { line-height: 2rem; diff --git a/frontend/app/shared/components/help.component.html b/frontend/app/shared/components/help.component.html index 4beff0d69..719d2dc27 100644 --- a/frontend/app/shared/components/help.component.html +++ b/frontend/app/shared/components/help.component.html @@ -1,4 +1,4 @@ - + Help diff --git a/frontend/app/shared/components/search-form.component.html b/frontend/app/shared/components/search-form.component.html index ad15a5ca0..aba0e473e 100644 --- a/frontend/app/shared/components/search-form.component.html +++ b/frontend/app/shared/components/search-form.component.html @@ -62,7 +62,7 @@ - diff --git a/frontend/app/shared/services/schemas.service.spec.ts b/frontend/app/shared/services/schemas.service.spec.ts index ddc7b88c5..f6c6b9ebe 100644 --- a/frontend/app/shared/services/schemas.service.spec.ts +++ b/frontend/app/shared/services/schemas.service.spec.ts @@ -366,6 +366,33 @@ describe('SchemasService', () => { expect(schema!).toEqual(createSchemaDetails(12)); })); + it('should make put request to update ui fields', + inject([SchemasService, HttpTestingController], (schemasService: SchemasService, httpMock: HttpTestingController) => { + + const dto = { fieldsInReferences: ['field1'] }; + + const resource: Resource = { + _links: { + ['fields/ui']: { method: 'PUT', href: '/api/apps/my-app/schemas/my-schema/fields/ui' } + } + }; + + let schema: SchemaDetailsDto; + + schemasService.putUIFields('my-app', resource, dto, version).subscribe(result => { + schema = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/schemas/my-schema/fields/ui'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBe(version.value); + + req.flush(schemaDetailsResponse(12)); + + expect(schema!).toEqual(createSchemaDetails(12)); + })); + it('should make put request to update field ordering', inject([SchemasService, HttpTestingController], (schemasService: SchemasService, httpMock: HttpTestingController) => { @@ -751,6 +778,8 @@ describe('SchemasService', () => { _links: {} } ], + fieldsInLists: ['field1'], + fieldsInReferences: ['field1'], scripts: { query: '', create: '', @@ -812,6 +841,8 @@ export function createSchemaDetails(id: number, suffix = '') { new RootFieldDto({}, 19, 'field19', createProperties('String'), 'language', true, true, true), new RootFieldDto({}, 20, 'field20', createProperties('Tags'), 'language', true, true, true) ], + ['field1'], + ['field1'], { query: '', create: '', diff --git a/frontend/app/shared/services/schemas.service.ts b/frontend/app/shared/services/schemas.service.ts index 5349b3d04..c8a597f3f 100644 --- a/frontend/app/shared/services/schemas.service.ts +++ b/frontend/app/shared/services/schemas.service.ts @@ -33,6 +33,8 @@ export type SchemasDto = { readonly canCreate: boolean; } & Resource; +type FieldNames = ReadonlyArray; + export class SchemaDto { public readonly _links: ResourceLinks; @@ -46,6 +48,7 @@ export class SchemaDto { public readonly canUpdate: boolean; public readonly canUpdateCategory: boolean; public readonly canUpdateScripts: boolean; + public readonly canUpdateUIFields: boolean; public readonly canUpdateUrls: boolean; public readonly displayName: string; @@ -75,6 +78,7 @@ export class SchemaDto { this.canUpdate = hasAnyLink(links, 'update'); this.canUpdateCategory = hasAnyLink(links, 'update/category'); this.canUpdateScripts = hasAnyLink(links, 'update/scripts'); + this.canUpdateUIFields = hasAnyLink(links, 'fields/ui'); this.canUpdateUrls = hasAnyLink(links, 'update/urls'); this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name); @@ -82,6 +86,7 @@ export class SchemaDto { } export class SchemaDetailsDto extends SchemaDto { + public readonly contentFields: ReadonlyArray; public readonly listFields: ReadonlyArray; public readonly listFieldsEditable: ReadonlyArray; public readonly referenceFields: ReadonlyArray; @@ -96,13 +101,17 @@ export class SchemaDetailsDto extends SchemaDto { lastModifiedBy: string, version: Version, public readonly fields: ReadonlyArray = [], + public readonly fieldsInLists: FieldNames = [], + public readonly fieldsInReferences: FieldNames = [], public readonly scripts = {}, public readonly previewUrls = {} ) { super(links, id, name, category, properties, isSingleton, isPublished, created, createdBy, lastModified, lastModifiedBy, version); if (fields) { - this.listFields = this.fields.filter(x => x.properties.isListField && x.properties.isContentField); + this.contentFields = fields.filter(x => x.properties.isContentField); + + this.listFields = findFields(fieldsInLists, this.contentFields); if (this.listFields.length === 0 && this.fields.length > 0) { this.listFields = [this.fields[0]]; @@ -114,7 +123,7 @@ export class SchemaDetailsDto extends SchemaDto { this.listFieldsEditable = this.listFields.filter(x => x.isInlineEditable); - this.referenceFields = this.fields.filter(x => x.properties.isReferenceField && x.properties.isContentField); + this.referenceFields = findFields(fieldsInReferences, this.contentFields); if (this.referenceFields.length === 0) { this.referenceFields = this.listFields; @@ -170,6 +179,10 @@ export class SchemaDetailsDto extends SchemaDto { } } +function findFields(names: ReadonlyArray, fields: ReadonlyArray) { + return names.map(x => fields.find(f => f.name === x)!).filter(x => !!x); +} + export class FieldDto { public readonly _links: ResourceLinks; @@ -274,6 +287,11 @@ export interface AddFieldDto { readonly properties: FieldPropertiesDto; } +export interface UpdateUIFields { + readonly fieldsInLists?: FieldNames; + readonly fieldsInReferences?: FieldNames; +} + export interface CreateSchemaDto { readonly name: string; readonly fields?: ReadonlyArray; @@ -290,8 +308,8 @@ export interface UpdateFieldDto { } export interface SynchronizeSchemaDto { - noFieldDeletiong?: boolean; - noFieldRecreation?: boolean; + readonly noFieldDeletiong?: boolean; + readonly noFieldRecreation?: boolean; } export interface UpdateSchemaDto { @@ -461,6 +479,21 @@ export class SchemasService { pretifyError('Failed to add field. Please reload.')); } + public putUIFields(appName: string, resource: Resource, dto: UpdateUIFields, version: Version): Observable { + const link = resource._links['fields/ui']; + + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( + map(({ payload }) => { + return parseSchemaWithDetails(payload.body); + }), + tap(() => { + this.analytics.trackEvent('Schema', 'UIFieldsConfigured', appName); + }), + pretifyError('Failed to update UI fields. Please reload.')); + } + public putFieldOrdering(appName: string, resource: Resource, dto: ReadonlyArray, version: Version): Observable { const link = resource._links['fields/order']; @@ -630,6 +663,8 @@ function parseSchemaWithDetails(response: any) { DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy, new Version(response.version.toString()), fields, + response.fieldsInLists, + response.fieldsInReferences, response.scripts || {}, response.previewUrls || {}); } diff --git a/frontend/app/shared/services/schemas.types.ts b/frontend/app/shared/services/schemas.types.ts index f2d667d9a..4f46d5819 100644 --- a/frontend/app/shared/services/schemas.types.ts +++ b/frontend/app/shared/services/schemas.types.ts @@ -134,8 +134,6 @@ export abstract class FieldPropertiesDto { public readonly editorUrl?: string; public readonly hints?: string; - public readonly isListField: boolean = false; - public readonly isReferenceField: boolean = false; public readonly isRequired: boolean = false; public readonly label?: string; public readonly placeholder?: string; diff --git a/frontend/app/shared/state/clients.state.ts b/frontend/app/shared/state/clients.state.ts index 3e43590ed..5395ee3e7 100644 --- a/frontend/app/shared/state/clients.state.ts +++ b/frontend/app/shared/state/clients.state.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable: no-shadowed-variable - import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; diff --git a/frontend/app/shared/state/contents.forms.spec.ts b/frontend/app/shared/state/contents.forms.spec.ts index 1962b3baf..cd4066e23 100644 --- a/frontend/app/shared/state/contents.forms.spec.ts +++ b/frontend/app/shared/state/contents.forms.spec.ts @@ -37,6 +37,10 @@ const { } = TestValues; describe('SchemaDetailsDto', () => { + const field1 = createField({ properties: createProperties('Array'), id: 1 }); + const field2 = createField({ properties: createProperties('Array'), id: 2 }); + const field3 = createField({ properties: createProperties('Array'), id: 3 }); + it('should return label as display name', () => { const schema = createSchema({ properties: new SchemaPropertiesDto('Label') }); @@ -55,24 +59,34 @@ describe('SchemaDetailsDto', () => { expect(schema.displayName).toBe('schema1'); }); - it('should return configured fields as list fields if no schema field are declared', () => { - const field1 = createField({ properties: createProperties('Array', { isListField: true }) }); - const field2 = createField({ properties: createProperties('Array', { isListField: false }), id: 2 }); - const field3 = createField({ properties: createProperties('Array', { isListField: true }), id: 3 }); + it('should return configured fields as list fields if fields are declared', () => { + const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInLists: ['field1', 'field3'] }); + + expect(schema.listFields).toEqual([field1, field3]); + }); + 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, field3]); + expect(schema.listFields).toEqual([field1]); }); - it('should return first fields as list fields if no schema field is declared', () => { - const field1 = createField({ properties: createProperties('Array') }); - const field2 = createField({ properties: createProperties('Array'), id: 2 }); - const field3 = createField({ properties: createProperties('Array'), id: 3 }); + it('should return configured fields as references fields if fields are declared', () => { + const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInReferences: ['field1', 'field3'] }); + expect(schema.referenceFields).toEqual([field1, field3]); + }); + + 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'] }); + + expect(schema.referenceFields).toEqual([field2, field3]); + }); + + it('should return first field as reference fields if no field is declared', () => { const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] }); - expect(schema.listFields).toEqual([field1]); + expect(schema.referenceFields).toEqual([field1]); }); it('should return empty list fields if fields is empty', () => { @@ -814,9 +828,15 @@ describe('ContentForm', () => { } }); -type SchemaValues = { properties?: SchemaPropertiesDto; id?: number; fields?: ReadonlyArray; }; +type SchemaValues = { + id?: number; + fields?: ReadonlyArray; + fieldsInLists?: ReadonlyArray; + fieldsInReferences?: ReadonlyArray; + properties?: SchemaPropertiesDto; +}; -function createSchema({ properties, id, fields }: SchemaValues = {}) { +function createSchema({ properties, id, fields, fieldsInLists, fieldsInReferences }: SchemaValues = {}) { id = id || 1; return new SchemaDetailsDto({}, @@ -829,7 +849,9 @@ function createSchema({ properties, id, fields }: SchemaValues = {}) { modified, modifier, new Version('1'), - fields); + fields, + fieldsInLists || [], + fieldsInReferences || []); } type FieldValues = { properties: FieldPropertiesDto; id?: number; partitioning?: string; isDisabled?: boolean, nested?: ReadonlyArray }; diff --git a/frontend/app/shared/state/schemas.forms.ts b/frontend/app/shared/state/schemas.forms.ts index 4ed38c367..218416524 100644 --- a/frontend/app/shared/state/schemas.forms.ts +++ b/frontend/app/shared/state/schemas.forms.ts @@ -149,7 +149,7 @@ export class EditScriptsForm extends Form { +export class EditFieldForm extends Form { constructor(formBuilder: FormBuilder) { super(formBuilder.group({ label: ['', @@ -169,8 +169,6 @@ export class EditFieldForm extends Form { expect(schemasState.snapshot.selectedSchema).toEqual(updated); }); + it('should update schema and selected schema when ui fields configured', () => { + const request = { fieldsInLists: [schema.fields[1].name] }; + + const updated = createSchemaDetails(1, '_new'); + + schemasService.setup(x => x.putUIFields(app, schema1, request, version)) + .returns(() => of(updated)).verifiable(); + + schemasState.configureUIFields(schema1, request).subscribe(); + + const schema1New = schemasState.snapshot.schemas[0]; + + expect(schema1New).toEqual(updated); + expect(schemasState.snapshot.selectedSchema).toEqual(updated); + }); + it('should update schema and selected schema when fields sorted', () => { + const request = [schema.fields[1], schema.fields[2]]; + const updated = createSchemaDetails(1, '_new'); - schemasService.setup(x => x.putFieldOrdering(app, schema1, [schema.fields[1].fieldId, schema.fields[2].fieldId], version)) + schemasService.setup(x => x.putFieldOrdering(app, schema1, request.map(f => f.fieldId), version)) .returns(() => of(updated)).verifiable(); - schemasState.orderFields(schema1, [schema.fields[1], schema.fields[2]]).subscribe(); + schemasState.orderFields(schema1, request).subscribe(); const schema1New = schemasState.snapshot.schemas[0]; @@ -488,12 +506,14 @@ describe('SchemasState', () => { }); it('should update schema and selected schema when nested fields sorted', () => { + const request = [schema.fields[1], schema.fields[2]]; + const updated = createSchemaDetails(1, '_new'); - schemasService.setup(x => x.putFieldOrdering(app, schema.fields[0], [schema.fields[1].fieldId, schema.fields[2].fieldId], version)) + schemasService.setup(x => x.putFieldOrdering(app, schema.fields[0], request.map(f => f.fieldId), version)) .returns(() => of(updated)).verifiable(); - schemasState.orderFields(schema1, [schema.fields[1], schema.fields[2]], schema.fields[0]).subscribe(); + schemasState.orderFields(schema1, request, schema.fields[0]).subscribe(); const schema1New = schemasState.snapshot.schemas[0]; diff --git a/frontend/app/shared/state/schemas.state.ts b/frontend/app/shared/state/schemas.state.ts index 91e872826..ffe89fa9d 100644 --- a/frontend/app/shared/state/schemas.state.ts +++ b/frontend/app/shared/state/schemas.state.ts @@ -31,7 +31,8 @@ import { SchemaDto, SchemasService, UpdateFieldDto, - UpdateSchemaDto + UpdateSchemaDto, + UpdateUIFields } from './../services/schemas.service'; type AnyFieldDto = NestedFieldDto | RootFieldDto; @@ -268,6 +269,14 @@ export class SchemasState extends State { shareMapSubscribed(this.dialogs, x => getField(x, request, parent), { silent: true })); } + public configureUIFields(schema: SchemaDto, request: UpdateUIFields): Observable { + return this.schemasService.putUIFields(this.appName, schema, request, schema.version).pipe( + tap(updated => { + this.replaceSchema(updated); + }), + shareSubscribed(this.dialogs)); + } + public orderFields(schema: SchemaDto, fields: ReadonlyArray, parent?: RootFieldDto): Observable { return this.schemasService.putFieldOrdering(this.appName, parent || schema, fields.map(t => t.fieldId), schema.version).pipe( tap(updated => { diff --git a/frontend/app/shared/state/workflows.state.ts b/frontend/app/shared/state/workflows.state.ts index e485869a0..1dd7f0ed8 100644 --- a/frontend/app/shared/state/workflows.state.ts +++ b/frontend/app/shared/state/workflows.state.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable: no-shadowed-variable - import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; diff --git a/frontend/app/shell/declarations.ts b/frontend/app/shell/declarations.ts index 0224944b6..c4e0d8633 100644 --- a/frontend/app/shell/declarations.ts +++ b/frontend/app/shell/declarations.ts @@ -11,6 +11,7 @@ export * from './pages/forbidden/forbidden-page.component'; export * from './pages/home/home-page.component'; export * from './pages/internal/apps-menu.component'; export * from './pages/internal/internal-area.component'; +export * from './pages/internal/logo.component'; export * from './pages/internal/profile-menu.component'; export * from './pages/login/login-page.component'; export * from './pages/logout/logout-page.component'; diff --git a/frontend/app/shell/module.ts b/frontend/app/shell/module.ts index a3ccf1762..d2af1a856 100644 --- a/frontend/app/shell/module.ts +++ b/frontend/app/shell/module.ts @@ -17,6 +17,7 @@ import { InternalAreaComponent, LeftMenuComponent, LoginPageComponent, + LogoComponent, LogoutPageComponent, NotFoundPageComponent, ProfileMenuComponent @@ -42,6 +43,7 @@ import { InternalAreaComponent, LeftMenuComponent, LoginPageComponent, + LogoComponent, LogoutPageComponent, NotFoundPageComponent, ProfileMenuComponent diff --git a/frontend/app/shell/pages/internal/internal-area.component.html b/frontend/app/shell/pages/internal/internal-area.component.html index 74e738c30..a4d0b2887 100644 --- a/frontend/app/shell/pages/internal/internal-area.component.html +++ b/frontend/app/shell/pages/internal/internal-area.component.html @@ -1,12 +1,6 @@ -