diff --git a/.gitignore b/.gitignore index 49682e0d7..3631589b0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,9 @@ node_modules/ # Scripts (should be copied from node_modules on build) **/wwwroot/scripts/**/*.* +/src/Squidex/appsettings.Development.json /src/Squidex/Assets +/src/Squidex/package-lock.json +/src/Squidex/Properties/launchSettings.json -/src/Squidex/appsettings.Development.json -/src/Squidex/Properties/launchSettings.json \ No newline at end of file +/global.json diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs index 0258998e9..2ebc1d054 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs @@ -25,8 +25,8 @@ namespace Squidex.Domain.Apps.Core.Contents { } - protected ContentData(IDictionary copy, IEqualityComparer comparer) - : base(copy, comparer) + protected ContentData(int capacity, IEqualityComparer comparer) + : base(capacity, comparer) { } @@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Core.Contents { foreach (var otherValue in source) { - var fieldValue = target.GetOrAdd(otherValue.Key, x => new ContentFieldData()); + var fieldValue = target.GetOrAddNew(otherValue.Key); foreach (var value in otherValue.Value) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs index 6398d66d4..0ca33663e 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs @@ -18,8 +18,8 @@ namespace Squidex.Domain.Apps.Core.Contents { } - public IdContentData(IdContentData copy) - : base(copy, EqualityComparer.Default) + public IdContentData(int capacity) + : base(capacity, EqualityComparer.Default) { } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs index b7e4187a5..faa409abb 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs @@ -18,8 +18,8 @@ namespace Squidex.Domain.Apps.Core.Contents { } - public NamedContentData(NamedContentData copy) - : base(copy, EqualityComparer.Default) + public NamedContentData(int capacity) + : base(capacity, EqualityComparer.Default) { } diff --git a/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs b/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs index f6600ede5..8190674f1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs @@ -45,5 +45,12 @@ namespace Squidex.Domain.Apps.Core { return Key; } + + public static Partitioning FromString(string value) + { + var isLanguage = string.Equals(value, Language.Key, StringComparison.OrdinalIgnoreCase); + + return isLanguage ? Language : Invariant; + } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs new file mode 100644 index 000000000..7a74a39f0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs @@ -0,0 +1,77 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public sealed class ArrayField : RootField, IArrayField + { + private FieldCollection fields = FieldCollection.Empty; + + public IReadOnlyList Fields + { + get { return fields.Ordered; } + } + + public IReadOnlyDictionary FieldsById + { + get { return fields.ById; } + } + + public IReadOnlyDictionary FieldsByName + { + get { return fields.ByName; } + } + + public ArrayField(long id, string name, Partitioning partitioning, ArrayFieldProperties properties) + : base(id, name, partitioning, properties) + { + } + + [Pure] + public ArrayField DeleteField(long fieldId) + { + return Updatefields(f => f.Remove(fieldId)); + } + + [Pure] + public ArrayField ReorderFields(List ids) + { + return Updatefields(f => f.Reorder(ids)); + } + + [Pure] + public ArrayField AddField(NestedField field) + { + return Updatefields(f => f.Add(field)); + } + + [Pure] + public ArrayField UpdateField(long fieldId, Func updater) + { + return Updatefields(f => f.Update(fieldId, updater)); + } + + private ArrayField Updatefields(Func, FieldCollection> updater) + { + var newFields = updater(fields); + + if (ReferenceEquals(newFields, fields)) + { + return this; + } + + return Clone(clone => + { + clone.fields = newFields; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs new file mode 100644 index 000000000..f3f6100d9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs @@ -0,0 +1,40 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + [TypeName("ArrayField")] + public sealed class ArrayFieldProperties : FieldProperties + { + public int? MinItems { get; set; } + + public int? MaxItems { get; set; } + + public override T Accept(IFieldPropertiesVisitor visitor) + { + return visitor.Visit(this); + } + + public override T Accept(IFieldVisitor visitor, IField field) + { + return visitor.Visit((IArrayField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Array(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs index 182030e50..62b3be7c1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs @@ -47,9 +47,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.Assets(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Assets(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs index 363a1a6fa..a4a0750a5 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs @@ -28,9 +28,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.Boolean(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Boolean(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs index 0a740490f..efbcad12b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs @@ -33,9 +33,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.DateTime(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.DateTime(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs new file mode 100644 index 000000000..d62ef5873 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -0,0 +1,161 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.Contracts; +using System.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public sealed class FieldCollection : Cloneable> where T : IField + { + public static readonly FieldCollection Empty = new FieldCollection(); + + private ImmutableArray fieldsOrdered = ImmutableArray.Empty; + private ImmutableDictionary fieldsById; + private ImmutableDictionary fieldsByName; + + public IReadOnlyList Ordered + { + get { return fieldsOrdered; } + } + + public IReadOnlyDictionary ById + { + get + { + if (fieldsById == null) + { + if (fieldsOrdered.Length == 0) + { + fieldsById = ImmutableDictionary.Empty; + } + else + { + fieldsById = fieldsOrdered.ToImmutableDictionary(x => x.Id); + } + } + + return fieldsById; + } + } + + public IReadOnlyDictionary ByName + { + get + { + if (fieldsByName == null) + { + if (fieldsOrdered.Length == 0) + { + fieldsByName = ImmutableDictionary.Empty; + } + else + { + fieldsByName = fieldsOrdered.ToImmutableDictionary(x => x.Name); + } + } + + return fieldsByName; + } + } + + private FieldCollection() + { + } + + public FieldCollection(T[] fields) + { + Guard.NotNull(fields, nameof(fields)); + + fieldsOrdered = ImmutableArray.Create(fields); + } + + protected override void OnCloned() + { + fieldsById = null; + fieldsByName = null; + } + + [Pure] + public FieldCollection Remove(long fieldId) + { + if (!ById.TryGetValue(fieldId, out var field)) + { + return this; + } + + return Clone(clone => + { + clone.fieldsOrdered = fieldsOrdered.Remove(field); + }); + } + + [Pure] + public FieldCollection Reorder(List ids) + { + Guard.NotNull(ids, nameof(ids)); + + if (ids.Count != fieldsOrdered.Length || ids.Any(x => !ById.ContainsKey(x))) + { + throw new ArgumentException("Ids must cover all fields.", nameof(ids)); + } + + return Clone(clone => + { + clone.fieldsOrdered = fieldsOrdered.OrderBy(f => ids.IndexOf(f.Id)).ToImmutableArray(); + }); + } + + [Pure] + public FieldCollection Add(T field) + { + Guard.NotNull(field, nameof(field)); + + if (ByName.ContainsKey(field.Name) || ById.ContainsKey(field.Id)) + { + throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); + } + + return Clone(clone => + { + clone.fieldsOrdered = clone.fieldsOrdered.Add(field); + }); + } + + [Pure] + public FieldCollection Update(long fieldId, Func updater) + { + Guard.NotNull(updater, nameof(updater)); + + if (!ById.TryGetValue(fieldId, out var field)) + { + return this; + } + + var newField = updater(field); + + if (ReferenceEquals(newField, field)) + { + return this; + } + + if (!(newField is T typedField)) + { + throw new InvalidOperationException($"Field must be of type {typeof(T)}"); + } + + return Clone(clone => + { + clone.fieldsOrdered = clone.fieldsOrdered.Replace(field, typedField); + }); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs new file mode 100644 index 000000000..bf34e0911 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs @@ -0,0 +1,158 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public static class FieldExtensions + { + public static Schema ReorderFields(this Schema schema, List ids, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.ReorderFields(ids); + } + + return f; + }); + } + + return schema.ReorderFields(ids); + } + + public static Schema DeleteField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.DeleteField(fieldId); + } + + return f; + }); + } + + return schema.DeleteField(fieldId); + } + + public static Schema LockField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Lock()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Lock()); + } + + public static Schema HideField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Hide()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Hide()); + } + + public static Schema ShowField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Show()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Show()); + } + + public static Schema EnableField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Enable()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Enable()); + } + + public static Schema DisableField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Disable()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Disable()); + } + + public static Schema UpdateField(this Schema schema, long fieldId, FieldProperties properties, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Update(properties)); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Update(properties)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs index 35da7cf8b..a9c8d0421 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs @@ -21,6 +21,8 @@ namespace Squidex.Domain.Apps.Core.Schemas public abstract T Accept(IFieldVisitor visitor, IField field); - public abstract Field CreateField(long id, string name, Partitioning partitioning); + public abstract RootField CreateRootField(long id, string name, Partitioning partitioning); + + public abstract NestedField CreateNestedField(long id, string name); } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs index f3488d75e..fe778009b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs @@ -14,10 +14,8 @@ namespace Squidex.Domain.Apps.Core.Schemas { public sealed class FieldRegistry { - private delegate Field FactoryFunction(long id, string name, Partitioning partitioning, FieldProperties properties); - private readonly TypeNameRegistry typeNameRegistry; - private readonly Dictionary fieldsByPropertyType = new Dictionary(); + private readonly HashSet supportedFields = new HashSet(); public FieldRegistry(TypeNameRegistry typeNameRegistry) { @@ -38,23 +36,34 @@ namespace Squidex.Domain.Apps.Core.Schemas private void RegisterField(Type type) { - typeNameRegistry.Map(type); + if (supportedFields.Add(type)) + { + typeNameRegistry.Map(type); + } + } + + public RootField CreateRootField(long id, string name, Partitioning partitioning, FieldProperties properties) + { + CheckProperties(properties); - fieldsByPropertyType[type] = (id, name, partitioning, properties) => properties.CreateField(id, name, partitioning); + return properties.CreateRootField(id, name, partitioning); } - public Field CreateField(long id, string name, Partitioning partitioning, FieldProperties properties) + public NestedField CreateNestedField(long id, string name, FieldProperties properties) { - Guard.NotNull(properties, nameof(properties)); + CheckProperties(properties); + + return properties.CreateNestedField(id, name); + } - var factory = fieldsByPropertyType.GetOrDefault(properties.GetType()); + private void CheckProperties(FieldProperties properties) + { + Guard.NotNull(properties, nameof(properties)); - if (factory == null) + if (!supportedFields.Contains(properties.GetType())) { throw new InvalidOperationException($"The field property '{properties.GetType()}' is not supported."); } - - return factory(id, name, partitioning, properties); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs index a74402b24..de6e49e28 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs @@ -5,53 +5,132 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; + namespace Squidex.Domain.Apps.Core.Schemas { public static class Fields { - public static Field Assets(long id, string name, Partitioning partitioning, AssetsFieldProperties properties = null) + public static RootField Array(long id, string name, Partitioning partitioning, params NestedField[] fields) + { + var result = new ArrayField(id, name, partitioning, new ArrayFieldProperties()); + + if (fields != null) + { + foreach (var field in fields) + { + result = result.AddField(field); + } + } + + return result; + } + + public static ArrayField Array(long id, string name, Partitioning partitioning, ArrayFieldProperties properties = null) + { + return new ArrayField(id, name, partitioning, properties ?? new ArrayFieldProperties()); + } + + public static RootField Assets(long id, string name, Partitioning partitioning, AssetsFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new AssetsFieldProperties()); + } + + public static RootField Boolean(long id, string name, Partitioning partitioning, BooleanFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new BooleanFieldProperties()); + } + + public static RootField DateTime(long id, string name, Partitioning partitioning, DateTimeFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new DateTimeFieldProperties()); + } + + public static RootField Geolocation(long id, string name, Partitioning partitioning, GeolocationFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new GeolocationFieldProperties()); + } + + public static RootField Json(long id, string name, Partitioning partitioning, JsonFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new JsonFieldProperties()); + } + + public static RootField Number(long id, string name, Partitioning partitioning, NumberFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new NumberFieldProperties()); + } + + public static RootField References(long id, string name, Partitioning partitioning, ReferencesFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new ReferencesFieldProperties()); + } + + public static RootField String(long id, string name, Partitioning partitioning, StringFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new StringFieldProperties()); + } + + public static RootField Tags(long id, string name, Partitioning partitioning, TagsFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new TagsFieldProperties()); + } + + public static NestedField Assets(long id, string name, AssetsFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new AssetsFieldProperties()); + return new NestedField(id, name, properties ?? new AssetsFieldProperties()); } - public static Field Boolean(long id, string name, Partitioning partitioning, BooleanFieldProperties properties = null) + public static NestedField Boolean(long id, string name, BooleanFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new BooleanFieldProperties()); + return new NestedField(id, name, properties ?? new BooleanFieldProperties()); } - public static Field DateTime(long id, string name, Partitioning partitioning, DateTimeFieldProperties properties = null) + public static NestedField DateTime(long id, string name, DateTimeFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new DateTimeFieldProperties()); + return new NestedField(id, name, properties ?? new DateTimeFieldProperties()); } - public static Field Geolocation(long id, string name, Partitioning partitioning, GeolocationFieldProperties properties = null) + public static NestedField Geolocation(long id, string name, GeolocationFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new GeolocationFieldProperties()); + return new NestedField(id, name, properties ?? new GeolocationFieldProperties()); } - public static Field Json(long id, string name, Partitioning partitioning, JsonFieldProperties properties = null) + public static NestedField Json(long id, string name, JsonFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new JsonFieldProperties()); + return new NestedField(id, name, properties ?? new JsonFieldProperties()); } - public static Field Number(long id, string name, Partitioning partitioning, NumberFieldProperties properties = null) + public static NestedField Number(long id, string name, NumberFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new NumberFieldProperties()); + return new NestedField(id, name, properties ?? new NumberFieldProperties()); } - public static Field References(long id, string name, Partitioning partitioning, ReferencesFieldProperties properties = null) + public static NestedField References(long id, string name, ReferencesFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new ReferencesFieldProperties()); + return new NestedField(id, name, properties ?? new ReferencesFieldProperties()); } - public static Field String(long id, string name, Partitioning partitioning, StringFieldProperties properties = null) + public static NestedField String(long id, string name, StringFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new StringFieldProperties()); + return new NestedField(id, name, properties ?? new StringFieldProperties()); } - public static Field Tags(long id, string name, Partitioning partitioning, TagsFieldProperties properties = null) + public static NestedField Tags(long id, string name, TagsFieldProperties properties = null) { - return new Field(id, name, partitioning, properties ?? new TagsFieldProperties()); + return new NestedField(id, name, properties ?? new TagsFieldProperties()); + } + + public static Schema AddArray(this Schema schema, long id, string name, Partitioning partitioning, Func handler, ArrayFieldProperties properties = null) + { + var field = Array(id, name, partitioning, properties); + + if (handler != null) + { + field = handler(field); + } + + return schema.AddField(field); } public static Schema AddAssets(this Schema schema, long id, string name, Partitioning partitioning, AssetsFieldProperties properties = null) @@ -98,5 +177,50 @@ namespace Squidex.Domain.Apps.Core.Schemas { return schema.AddField(Tags(id, name, partitioning, properties)); } + + public static ArrayField AddAssets(this ArrayField field, long id, string name, AssetsFieldProperties properties = null) + { + return field.AddField(Assets(id, name, properties)); + } + + public static ArrayField AddBoolean(this ArrayField field, long id, string name, BooleanFieldProperties properties = null) + { + return field.AddField(Boolean(id, name, properties)); + } + + public static ArrayField AddDateTime(this ArrayField field, long id, string name, DateTimeFieldProperties properties = null) + { + return field.AddField(DateTime(id, name, properties)); + } + + public static ArrayField AddGeolocation(this ArrayField field, long id, string name, GeolocationFieldProperties properties = null) + { + return field.AddField(Geolocation(id, name, properties)); + } + + public static ArrayField AddJson(this ArrayField field, long id, string name, JsonFieldProperties properties = null) + { + return field.AddField(Json(id, name, properties)); + } + + public static ArrayField AddNumber(this ArrayField field, long id, string name, NumberFieldProperties properties = null) + { + return field.AddField(Number(id, name, properties)); + } + + public static ArrayField AddReferences(this ArrayField field, long id, string name, ReferencesFieldProperties properties = null) + { + return field.AddField(References(id, name, properties)); + } + + public static ArrayField AddString(this ArrayField field, long id, string name, StringFieldProperties properties = null) + { + return field.AddField(String(id, name, properties)); + } + + public static ArrayField AddTags(this ArrayField field, long id, string name, TagsFieldProperties properties = null) + { + return field.AddField(Tags(id, name, properties)); + } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs index d5a48136c..9136b723c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs @@ -24,9 +24,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.Geolocation(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Geolocation(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IArrayField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IArrayField.cs new file mode 100644 index 000000000..0ea74cfbd --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IArrayField.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface IArrayField : IField + { + IReadOnlyList Fields { get; } + + IReadOnlyDictionary FieldsById { get; } + + IReadOnlyDictionary FieldsByName { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs index 552a24eda..6cc86239d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs @@ -13,12 +13,12 @@ namespace Squidex.Domain.Apps.Core.Schemas string Name { get; } + bool IsLocked { get; } + bool IsDisabled { get; } bool IsHidden { get; } - bool IsLocked { get; } - FieldProperties RawProperties { get; } T Accept(IFieldVisitor visitor); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs index 108269359..c4593a450 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Core.Schemas { public interface IFieldPropertiesVisitor { + T Visit(ArrayFieldProperties properties); + T Visit(AssetsFieldProperties properties); T Visit(BooleanFieldProperties properties); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs index 3bfad913a..67142acc4 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Core.Schemas { public interface IFieldVisitor { + T Visit(IArrayField field); + T Visit(IField field); T Visit(IField field); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/INestedField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/INestedField.cs new file mode 100644 index 000000000..5bacd00eb --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/INestedField.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface INestedField : IField + { + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IRootField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IRootField.cs new file mode 100644 index 000000000..31d9cd05f --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IRootField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface IRootField : IField + { + Partitioning Partitioning { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs index e4c04fdf8..952ef87f1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using Newtonsoft.Json; namespace Squidex.Domain.Apps.Core.Schemas.Json @@ -15,21 +16,24 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json public long Id { get; set; } [JsonProperty] - public bool IsHidden { get; set; } + public string Name { get; set; } [JsonProperty] - public bool IsLocked { get; set; } + public string Partitioning { get; set; } [JsonProperty] - public bool IsDisabled { get; set; } + public bool IsHidden { get; set; } [JsonProperty] - public string Name { get; set; } + public bool IsLocked { get; set; } [JsonProperty] - public string Partitioning { get; set; } + public bool IsDisabled { get; set; } [JsonProperty] public FieldProperties Properties { get; set; } + + [JsonProperty] + public List Children { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonNestedFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonNestedFieldModel.cs new file mode 100644 index 000000000..59b2035e7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonNestedFieldModel.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; + +namespace Squidex.Domain.Apps.Core.Schemas.Json +{ + public sealed class JsonNestedFieldModel + { + [JsonProperty] + public long Id { get; set; } + + [JsonProperty] + public string Name { get; set; } + + [JsonProperty] + public bool IsHidden { get; set; } + + [JsonProperty] + public bool IsDisabled { get; set; } + + [JsonProperty] + public FieldProperties Properties { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs index be5b64a66..d45620c9a 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs @@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json { public sealed class JsonSchemaModel { - private static readonly Field[] Empty = new Field[0]; + private static readonly RootField[] Empty = new RootField[0]; [JsonProperty] public string Name { get; set; } @@ -38,11 +38,12 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json Properties = schema.Properties; Fields = - schema.Fields?.Select(x => + schema.Fields.Select(x => new JsonFieldModel { Id = x.Id, Name = x.Name, + Children = CreateChildren(x), IsHidden = x.IsHidden, IsLocked = x.IsLocked, IsDisabled = x.IsDisabled, @@ -53,13 +54,31 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json IsPublished = schema.IsPublished; } - public Schema ToSchema(FieldRegistry fieldRegistry) + private static List CreateChildren(IField field) { - Field[] fields = Empty; + if (field is ArrayField arrayField) + { + return arrayField.Fields.Select(x => + new JsonNestedFieldModel + { + Id = x.Id, + Name = x.Name, + IsHidden = x.IsHidden, + IsDisabled = x.IsDisabled, + Properties = x.RawProperties + }).ToList(); + } + + return null; + } + + public Schema ToSchema(FieldRegistry registry) + { + RootField[] fields = Empty; if (Fields != null) { - fields = new Field[Fields.Count]; + fields = new RootField[Fields.Count]; for (var i = 0; i < fields.Length; i++) { @@ -67,7 +86,29 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json var parititonKey = new Partitioning(fieldModel.Partitioning); - var field = fieldRegistry.CreateField(fieldModel.Id, fieldModel.Name, parititonKey, fieldModel.Properties); + var field = registry.CreateRootField(fieldModel.Id, fieldModel.Name, parititonKey, fieldModel.Properties); + + if (field is ArrayField arrayField && fieldModel.Children?.Count > 0) + { + foreach (var nestedFieldModel in fieldModel.Children) + { + var nestedField = registry.CreateNestedField(nestedFieldModel.Id, nestedFieldModel.Name, nestedFieldModel.Properties); + + if (nestedFieldModel.IsHidden) + { + nestedField = nestedField.Hide(); + } + + if (nestedFieldModel.IsDisabled) + { + nestedField = nestedField.Disable(); + } + + arrayField = arrayField.AddField(nestedField); + } + + field = arrayField; + } if (fieldModel.IsDisabled) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs index b0801ef60..6edb4f80b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs @@ -22,9 +22,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.Json(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Json(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField.cs new file mode 100644 index 000000000..c958951ad --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics.Contracts; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public abstract class NestedField : Cloneable, INestedField + { + private readonly long fieldId; + private readonly string fieldName; + private bool isDisabled; + private bool isHidden; + private bool isLocked; + + public long Id + { + get { return fieldId; } + } + + public string Name + { + get { return fieldName; } + } + + public bool IsLocked + { + get { return isLocked; } + } + + public bool IsHidden + { + get { return isHidden; } + } + + public bool IsDisabled + { + get { return isDisabled; } + } + + public abstract FieldProperties RawProperties { get; } + + protected NestedField(long id, string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.GreaterThan(id, 0, nameof(id)); + + fieldId = id; + fieldName = name; + } + + [Pure] + public NestedField Lock() + { + return Clone(clone => + { + clone.isLocked = true; + }); + } + + [Pure] + public NestedField Hide() + { + return Clone(clone => + { + clone.isHidden = true; + }); + } + + [Pure] + public NestedField Show() + { + return Clone(clone => + { + clone.isHidden = false; + }); + } + + [Pure] + public NestedField Disable() + { + return Clone(clone => + { + clone.isDisabled = true; + }); + } + + [Pure] + public NestedField Enable() + { + return Clone(clone => + { + clone.isDisabled = false; + }); + } + + public abstract T Accept(IFieldVisitor visitor); + + public abstract NestedField Update(FieldProperties newProperties); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField{T}.cs new file mode 100644 index 000000000..7de914a4b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField{T}.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Diagnostics.Contracts; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public class NestedField : NestedField, IField where T : FieldProperties, new() + { + private T properties; + + public T Properties + { + get { return properties; } + } + + public override FieldProperties RawProperties + { + get { return properties; } + } + + public NestedField(long id, string name, T properties) + : base(id, name) + { + Guard.NotNull(properties, nameof(properties)); + + SetProperties(properties); + } + + [Pure] + public override NestedField Update(FieldProperties newProperties) + { + var typedProperties = ValidateProperties(newProperties); + + return Clone>(clone => + { + clone.SetProperties(typedProperties); + }); + } + + private void SetProperties(T newProperties) + { + properties = newProperties; + properties.Freeze(); + } + + private T ValidateProperties(FieldProperties newProperties) + { + Guard.NotNull(newProperties, nameof(newProperties)); + + if (!(newProperties is T typedProperties)) + { + throw new ArgumentException($"Properties must be of type '{typeof(T)}", nameof(newProperties)); + } + + return typedProperties; + } + + public override TResult Accept(IFieldVisitor visitor) + { + return properties.Accept(visitor, this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs index 4f08f1972..3238aff25 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs @@ -35,9 +35,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.Number(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Number(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs index 2bb64e682..98e4bb5ec 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs @@ -29,9 +29,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.References(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.References(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField.cs similarity index 84% rename from src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs rename to src/Squidex.Domain.Apps.Core.Model/Schemas/RootField.cs index b69071fb3..461f60365 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField.cs @@ -10,11 +10,11 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - public abstract class Field : Cloneable, IField + public abstract class RootField : Cloneable, IRootField { private readonly long fieldId; - private readonly Partitioning partitioning; private readonly string fieldName; + private readonly Partitioning partitioning; private bool isDisabled; private bool isHidden; private bool isLocked; @@ -51,11 +51,11 @@ namespace Squidex.Domain.Apps.Core.Schemas public abstract FieldProperties RawProperties { get; } - protected Field(long id, string name, Partitioning partitioning) + protected RootField(long id, string name, Partitioning partitioning) { Guard.NotNullOrEmpty(name, nameof(name)); - Guard.NotNull(partitioning, nameof(partitioning)); Guard.GreaterThan(id, 0, nameof(id)); + Guard.NotNull(partitioning, nameof(partitioning)); fieldId = id; fieldName = name; @@ -64,16 +64,16 @@ namespace Squidex.Domain.Apps.Core.Schemas } [Pure] - public Field Lock() + public RootField Lock() { - return Clone(clone => + return Clone(clone => { clone.isLocked = true; }); } [Pure] - public Field Hide() + public RootField Hide() { return Clone(clone => { @@ -82,7 +82,7 @@ namespace Squidex.Domain.Apps.Core.Schemas } [Pure] - public Field Show() + public RootField Show() { return Clone(clone => { @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Core.Schemas } [Pure] - public Field Disable() + public RootField Disable() { return Clone(clone => { @@ -100,7 +100,7 @@ namespace Squidex.Domain.Apps.Core.Schemas } [Pure] - public Field Enable() + public RootField Enable() { return Clone(clone => { @@ -108,8 +108,8 @@ namespace Squidex.Domain.Apps.Core.Schemas }); } - public abstract Field Update(FieldProperties newProperties); - public abstract T Accept(IFieldVisitor visitor); + + public abstract RootField Update(FieldProperties newProperties); } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField{T}.cs similarity index 71% rename from src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs rename to src/Squidex.Domain.Apps.Core.Model/Schemas/RootField{T}.cs index e1da04c62..90165643b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField{T}.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - public sealed class Field : Field, IField where T : FieldProperties, new() + public class RootField : RootField, IField where T : FieldProperties, new() { private T properties; @@ -25,27 +25,31 @@ namespace Squidex.Domain.Apps.Core.Schemas get { return properties; } } - public Field(long id, string name, Partitioning partitioning, T properties) + public RootField(long id, string name, Partitioning partitioning, T properties) : base(id, name, partitioning) { Guard.NotNull(properties, nameof(properties)); - this.properties = properties; - this.properties.Freeze(); + SetProperties(properties); } [Pure] - public override Field Update(FieldProperties newProperties) + public override RootField Update(FieldProperties newProperties) { var typedProperties = ValidateProperties(newProperties); - return Clone>(clone => + return Clone>(clone => { - clone.properties = typedProperties; - clone.properties.Freeze(); + clone.SetProperties(typedProperties); }); } + private void SetProperties(T newProperties) + { + properties = newProperties; + properties.Freeze(); + } + private T ValidateProperties(FieldProperties newProperties) { Guard.NotNull(newProperties, nameof(newProperties)); @@ -60,7 +64,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public override TResult Accept(IFieldVisitor visitor) { - return RawProperties.Accept(visitor, this); + return properties.Accept(visitor, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs index ec20d7e8a..c6e187087 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs @@ -7,9 +7,7 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics.Contracts; -using System.Linq; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas @@ -17,9 +15,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public sealed class Schema : Cloneable { private readonly string name; - private ImmutableArray fieldsOrdered = ImmutableArray.Empty; - private ImmutableDictionary fieldsById; - private ImmutableDictionary fieldsByName; + private FieldCollection fields = FieldCollection.Empty; private SchemaProperties properties; private bool isPublished; @@ -33,49 +29,19 @@ namespace Squidex.Domain.Apps.Core.Schemas get { return isPublished; } } - public IReadOnlyList Fields + public IReadOnlyList Fields { - get { return fieldsOrdered; } + get { return fields.Ordered; } } - public IReadOnlyDictionary FieldsById + public IReadOnlyDictionary FieldsById { - get - { - if (fieldsById == null) - { - if (fieldsOrdered.Length == 0) - { - fieldsById = ImmutableDictionary.Empty; - } - else - { - fieldsById = fieldsOrdered.ToImmutableDictionary(x => x.Id); - } - } - - return fieldsById; - } + get { return fields.ById; } } - public IReadOnlyDictionary FieldsByName + public IReadOnlyDictionary FieldsByName { - get - { - if (fieldsByName == null) - { - if (fieldsOrdered.Length == 0) - { - fieldsByName = ImmutableDictionary.Empty; - } - else - { - fieldsByName = fieldsOrdered.ToImmutableDictionary(x => x.Name); - } - } - - return fieldsByName; - } + get { return fields.ByName; } } public SchemaProperties Properties @@ -93,21 +59,14 @@ namespace Squidex.Domain.Apps.Core.Schemas this.properties.Freeze(); } - public Schema(string name, Field[] fields, SchemaProperties properties, bool isPublished) + public Schema(string name, RootField[] fields, SchemaProperties properties, bool isPublished) : this(name, properties) { - Guard.NotNullOrEmpty(name, nameof(name)); Guard.NotNull(fields, nameof(fields)); - this.isPublished = isPublished; - - fieldsOrdered = ImmutableArray.Create(fields); - } + this.fields = new FieldCollection(fields); - protected override void OnCloned() - { - fieldsById = null; - fieldsByName = null; + this.isPublished = isPublished; } [Pure] @@ -122,60 +81,6 @@ namespace Squidex.Domain.Apps.Core.Schemas }); } - [Pure] - public Schema UpdateField(long fieldId, FieldProperties newProperties) - { - return UpdateField(fieldId, field => - { - return field.Update(newProperties); - }); - } - - [Pure] - public Schema LockField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Lock(); - }); - } - - [Pure] - public Schema DisableField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Disable(); - }); - } - - [Pure] - public Schema EnableField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Enable(); - }); - } - - [Pure] - public Schema HideField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Hide(); - }); - } - - [Pure] - public Schema ShowField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Show(); - }); - } - [Pure] public Schema Publish() { @@ -197,62 +102,39 @@ namespace Squidex.Domain.Apps.Core.Schemas [Pure] public Schema DeleteField(long fieldId) { - if (!FieldsById.TryGetValue(fieldId, out var field)) - { - return this; - } - - return Clone(clone => - { - clone.fieldsOrdered = fieldsOrdered.Remove(field); - }); + return Updatefields(f => f.Remove(fieldId)); } [Pure] public Schema ReorderFields(List ids) { - Guard.NotNull(ids, nameof(ids)); - - if (ids.Count != fieldsOrdered.Length || ids.Any(x => !FieldsById.ContainsKey(x))) - { - throw new ArgumentException("Ids must cover all fields.", nameof(ids)); - } - - return Clone(clone => - { - clone.fieldsOrdered = fieldsOrdered.OrderBy(f => ids.IndexOf(f.Id)).ToImmutableArray(); - }); + return Updatefields(f => f.Reorder(ids)); } [Pure] - public Schema AddField(Field field) + public Schema AddField(RootField field) { - Guard.NotNull(field, nameof(field)); - - if (FieldsByName.ContainsKey(field.Name) || FieldsById.ContainsKey(field.Id)) - { - throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); - } - - return Clone(clone => - { - clone.fieldsOrdered = clone.fieldsOrdered.Add(field); - }); + return Updatefields(f => f.Add(field)); } [Pure] - public Schema UpdateField(long fieldId, Func updater) + public Schema UpdateField(long fieldId, Func updater) + { + return Updatefields(f => f.Update(fieldId, updater)); + } + + private Schema Updatefields(Func, FieldCollection> updater) { - Guard.NotNull(updater, nameof(updater)); + var newFields = updater(fields); - if (!FieldsById.TryGetValue(fieldId, out var field)) + if (ReferenceEquals(newFields, fields)) { return this; } return Clone(clone => { - clone.fieldsOrdered = clone.fieldsOrdered.Replace(field, updater(field)); + clone.fields = newFields; }); } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs index 288e36bf5..e9731480d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs @@ -39,9 +39,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.String(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.String(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs index 61f3298a2..31da100e6 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs @@ -26,9 +26,14 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit((IField)field); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override RootField CreateRootField(long id, string name, Partitioning partitioning) { - return new Field(id, name, partitioning, this); + return Fields.Tags(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Tags(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs index e450d470d..52284631d 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs @@ -5,128 +5,92 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.ConvertContent { - public delegate ContentFieldData FieldConverter(ContentFieldData data, Field field); - public static class ContentConverter { - public static NamedContentData ToNameModel(this IdContentData source, Schema schema, params FieldConverter[] converters) + private static readonly Func KeyNameResolver = f => f.Name; + private static readonly Func KeyIdResolver = f => f.Id; + + public static NamedContentData ConvertId2Name(this IdContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - var result = new NamedContentData(); - - foreach (var fieldValue in source) - { - if (!schema.FieldsById.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - var fieldData = Convert(fieldValue.Value, field, converters); - - if (fieldData != null) - { - result[field.Name] = fieldData; - } - } + var result = new NamedContentData(content.Count); - return result; + return ConvertInternal(content, result, schema.FieldsById, KeyNameResolver, converters); } - public static IdContentData ToIdModel(this NamedContentData content, Schema schema, params FieldConverter[] converters) + public static IdContentData ConvertId2Id(this IdContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - var result = new IdContentData(); + var result = new IdContentData(content.Count); - foreach (var fieldValue in content) - { - if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - var fieldData = Convert(fieldValue.Value, field, converters); - - if (fieldData != null) - { - result[field.Id] = fieldData; - } - } - - return result; + return ConvertInternal(content, result, schema.FieldsById, KeyIdResolver, converters); } - public static IdContentData Convert(this IdContentData content, Schema schema, params FieldConverter[] converters) + public static NamedContentData ConvertName2Name(this NamedContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - var result = new IdContentData(); - - foreach (var fieldValue in content) - { - if (!schema.FieldsById.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - var fieldData = Convert(fieldValue.Value, field, converters); - - if (fieldData != null) - { - result[field.Id] = fieldData; - } - } + var result = new NamedContentData(content.Count); - return result; + return ConvertInternal(content, result, schema.FieldsByName, KeyNameResolver, converters); } - public static NamedContentData Convert(this NamedContentData content, Schema schema, params FieldConverter[] converters) + public static IdContentData ConvertName2Id(this NamedContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - var result = new NamedContentData(); + var result = new IdContentData(content.Count); - foreach (var fieldValue in content) + return ConvertInternal(content, result, schema.FieldsByName, KeyIdResolver, converters); + } + + private static TDict2 ConvertInternal( + TDict1 source, + TDict2 target, + IReadOnlyDictionary fields, + Func targetKey, params FieldConverter[] converters) + where TDict1 : IDictionary + where TDict2 : IDictionary + { + foreach (var fieldKvp in source) { - if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field)) + if (!fields.TryGetValue(fieldKvp.Key, out var field)) { continue; } - var fieldData = Convert(fieldValue.Value, field, converters); + var newvalue = fieldKvp.Value; - if (fieldData != null) + if (converters != null) { - result[field.Name] = fieldData; - } - } + foreach (var converter in converters) + { + newvalue = converter(newvalue, field); - return result; - } + if (newvalue == null) + { + break; + } + } + } - private static ContentFieldData Convert(ContentFieldData fieldData, Field field, FieldConverter[] converters) - { - if (converters != null) - { - foreach (var converter in converters) + if (newvalue != null) { - fieldData = converter(fieldData, field); - - if (fieldData == null) - { - break; - } + target.Add(targetKey(field), newvalue); } } - return fieldData; + return target; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs index b842ef7a0..381a2d8aa 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs @@ -8,7 +8,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; @@ -21,8 +20,17 @@ using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.ConvertContent { + public delegate ContentFieldData FieldConverter(ContentFieldData data, IRootField field); + public static class FieldConverters { + private static readonly Func KeyNameResolver = f => f.Name; + private static readonly Func KeyIdResolver = f => f.Id.ToString(); + private static readonly Func FieldByIdResolver = + (f, k) => long.TryParse(k, out var id) ? f.FieldsById.GetOrDefault(id) : null; + private static readonly Func FieldByNameResolver = + (f, k) => f.FieldsByName.GetOrDefault(k); + public static FieldConverter ExcludeHidden() { return (data, field) => @@ -35,29 +43,23 @@ namespace Squidex.Domain.Apps.Core.ConvertContent { return (data, field) => { - var isValid = true; - foreach (var value in data.Values) { + if (value.IsNull()) + { + continue; + } + try { - if (!value.IsNull()) - { - JsonValueConverter.ConvertValue(field, value); - } + JsonValueConverter.ConvertValue(field, value); } catch { - isValid = false; - break; + return null; } } - if (!isValid) - { - return null; - } - return data; }; } @@ -173,12 +175,10 @@ namespace Squidex.Domain.Apps.Core.ConvertContent return (data, field) => data; } - var languageCodes = - new HashSet( - languages.Select(x => x.Iso2Code).Where(x => languagesConfig.Contains(x)), - StringComparer.OrdinalIgnoreCase); + var languageCodes = languages.Select(x => x.Iso2Code).Where(x => languagesConfig.Contains(x)); + var languageSet = new HashSet(languageCodes, StringComparer.OrdinalIgnoreCase); - if (languageCodes.Count == 0) + if (languageSet.Count == 0) { return (data, field) => data; } @@ -189,7 +189,7 @@ namespace Squidex.Domain.Apps.Core.ConvertContent { var result = new ContentFieldData(); - foreach (var languageCode in languageCodes) + foreach (var languageCode in languageSet) { if (data.TryGetValue(languageCode, out var value)) { @@ -204,26 +204,87 @@ namespace Squidex.Domain.Apps.Core.ConvertContent }; } - public static FieldConverter DecodeJson() + public static FieldConverter ForNestedName2Name(params ValueConverter[] converters) + { + return ForNested(FieldByNameResolver, KeyNameResolver, converters); + } + + public static FieldConverter ForNestedName2Id(params ValueConverter[] converters) + { + return ForNested(FieldByNameResolver, KeyIdResolver, converters); + } + + public static FieldConverter ForNestedId2Name(params ValueConverter[] converters) + { + return ForNested(FieldByIdResolver, KeyNameResolver, converters); + } + + public static FieldConverter ForNestedId2Id(params ValueConverter[] converters) + { + return ForNested(FieldByIdResolver, KeyIdResolver, converters); + } + + private static FieldConverter ForNested( + Func fieldResolver, + Func keyResolver, + params ValueConverter[] converters) { return (data, field) => { - if (field is IField) + if (field is IArrayField arrayField) { var result = new ContentFieldData(); - foreach (var partitionValue in data) + foreach (var partition in data) { - if (partitionValue.Value.IsNull()) + if (!(partition.Value is JArray jArray)) { - result[partitionValue.Key] = null; + continue; } - else + + var newArray = new JArray(); + + foreach (JObject item in jArray.OfType()) { - var value = Encoding.UTF8.GetString(Convert.FromBase64String(partitionValue.Value.ToString())); + var newItem = new JObject(); - result[partitionValue.Key] = JToken.Parse(value); + foreach (var kvp in item) + { + var nestedField = fieldResolver(arrayField, kvp.Key); + + if (nestedField == null) + { + continue; + } + + var newValue = kvp.Value; + + var isUnset = false; + + if (converters != null) + { + foreach (var converter in converters) + { + newValue = converter(newValue, nestedField); + + if (ReferenceEquals(newValue, Value.Unset)) + { + isUnset = true; + break; + } + } + } + + if (!isUnset) + { + newItem.Add(keyResolver(nestedField), newValue); + } + } + + newArray.Add(newItem); } + + result.Add(partition.Key, newArray); } return result; @@ -233,25 +294,37 @@ namespace Squidex.Domain.Apps.Core.ConvertContent }; } - public static FieldConverter EncodeJson() + public static FieldConverter ForValues(params ValueConverter[] converters) { return (data, field) => { - if (field is IField) + if (!(field is IArrayField)) { var result = new ContentFieldData(); - foreach (var partitionValue in data) + foreach (var partition in data) { - if (partitionValue.Value.IsNull()) + var newValue = partition.Value; + + var isUnset = false; + + if (converters != null) { - result[partitionValue.Key] = null; + foreach (var converter in converters) + { + newValue = converter(newValue, field); + + if (ReferenceEquals(newValue, Value.Unset)) + { + isUnset = true; + break; + } + } } - else - { - var value = Convert.ToBase64String(Encoding.UTF8.GetBytes(partitionValue.Value.ToString())); - result[partitionValue.Key] = value; + if (!isUnset) + { + result.Add(partition.Key, newValue); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/Value.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/Value.cs new file mode 100644 index 000000000..2229afdfa --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/Value.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Core.ConvertContent +{ + public static class Value + { + public static readonly JToken Unset = JValue.CreateUndefined(); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ValueConverters.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ValueConverters.cs new file mode 100644 index 000000000..b6b15ad30 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ValueConverters.cs @@ -0,0 +1,81 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Text; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ConvertContent +{ + public delegate JToken ValueConverter(JToken value, IField field); + + public static class ValueConverters + { + public static ValueConverter DecodeJson() + { + return (value, field) => + { + if (!value.IsNull() && field is IField) + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value.ToString())); + + return JToken.Parse(decoded); + } + + return value; + }; + } + + public static ValueConverter EncodeJson() + { + return (value, field) => + { + if (!value.IsNull() && field is IField) + { + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(value.ToString())); + + return encoded; + } + + return value; + }; + } + + public static ValueConverter ExcludeHidden() + { + return (value, field) => + { + return field.IsHidden ? Value.Unset : value; + }; + } + + public static ValueConverter ExcludeChangedTypes() + { + return (value, field) => + { + if (value.IsNull()) + { + return value; + } + + try + { + JsonValueConverter.ConvertValue(field, value); + } + catch + { + return Value.Unset; + } + + return value; + }; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs index 898a98431..a4f8be960 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.EnrichContent { - public sealed class DefaultValueFactory : IFieldPropertiesVisitor + public sealed class DefaultValueFactory : IFieldVisitor { private readonly Instant now; @@ -26,62 +26,67 @@ namespace Squidex.Domain.Apps.Core.EnrichContent { Guard.NotNull(field, nameof(field)); - return field.RawProperties.Accept(new DefaultValueFactory(now)); + return field.Accept(new DefaultValueFactory(now)); } - public JToken Visit(AssetsFieldProperties properties) + public JToken Visit(IArrayField field) { return new JArray(); } - public JToken Visit(BooleanFieldProperties properties) + public JToken Visit(IField field) { - return properties.DefaultValue; + return new JArray(); + } + + public JToken Visit(IField field) + { + return field.Properties.DefaultValue; } - public JToken Visit(GeolocationFieldProperties properties) + public JToken Visit(IField field) { return JValue.CreateNull(); } - public JToken Visit(JsonFieldProperties properties) + public JToken Visit(IField field) { return JValue.CreateNull(); } - public JToken Visit(NumberFieldProperties properties) + public JToken Visit(IField field) { - return properties.DefaultValue; + return field.Properties.DefaultValue; } - public JToken Visit(ReferencesFieldProperties properties) + public JToken Visit(IField field) { return new JArray(); } - public JToken Visit(StringFieldProperties properties) + public JToken Visit(IField field) { - return properties.DefaultValue; + return field.Properties.DefaultValue; } - public JToken Visit(TagsFieldProperties properties) + public JToken Visit(IField field) { return new JArray(); } - public JToken Visit(DateTimeFieldProperties properties) + public JToken Visit(IField field) { - if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now) + if (field.Properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now) { return now.ToString(); } - if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Today) + if (field.Properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Today) { return now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } - return properties.DefaultValue?.ToString(); + return field.Properties.DefaultValue?.ToString(); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs index 54f1e95fa..4c072e9ab 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs @@ -7,65 +7,95 @@ using System; using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { - public static class ReferencesCleaner + public sealed class ReferencesCleaner : IFieldVisitor { - private static readonly List EmptyIds = new List(); + private readonly JToken value; + private readonly ICollection oldReferences; - public static JToken CleanReferences(this IField field, JToken value, ISet oldReferences) + private ReferencesCleaner(JToken value, ICollection oldReferences) { - if ((field is IField || field is IField) && !value.IsNull()) - { - switch (field) - { - case IField assetsField: - return Visit(assetsField, value, oldReferences); - - case IField referencesField: - return Visit(referencesField, value, oldReferences); - } - } + this.value = value; - return value; + this.oldReferences = oldReferences; } - private static JToken Visit(IField field, JToken value, IEnumerable oldReferences) + public static JToken CleanReferences(IField field, JToken value, ICollection oldReferences) { - var oldIds = GetIds(value); - var newIds = oldIds.Except(oldReferences).ToList(); + return field.Accept(new ReferencesCleaner(value, oldReferences)); + } - return oldIds.Count != newIds.Count ? JToken.FromObject(newIds) : value; + public JToken Visit(IArrayField field) + { + return value; } - private static JToken Visit(IField field, JToken value, ICollection oldReferences) + public JToken Visit(IField field) + { + return CleanIds(); + } + + public JToken Visit(IField field) { if (oldReferences.Contains(field.Properties.SchemaId)) { return new JArray(); } - var oldIds = GetIds(value); - var newIds = oldIds.Except(oldReferences).ToList(); - - return oldIds.Count != newIds.Count ? JToken.FromObject(newIds) : value; + return CleanIds(); } - private static List GetIds(JToken value) + private JToken CleanIds() { - try - { - return value?.ToObject>() ?? EmptyIds; - } - catch + var ids = value.ToGuidSet(); + + var isRemoved = false; + + foreach (var oldReference in oldReferences) { - return EmptyIds; + isRemoved |= ids.Remove(oldReference); } + + return isRemoved ? ids.ToJToken() : value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs new file mode 100644 index 000000000..b7170f9aa --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ExtractReferenceIds +{ + public static class ReferencesExtensions + { + public static IEnumerable ExtractReferences(this IField field, JToken value) + { + return ReferencesExtractor.ExtractReferences(field, value); + } + + public static JToken CleanReferences(this IField field, JToken value, ICollection oldReferences) + { + if (value.IsNull()) + { + return value; + } + + return ReferencesCleaner.CleanReferences(field, value, oldReferences); + } + + public static JToken ToJToken(this HashSet ids) + { + var result = new JArray(); + + foreach (var id in ids) + { + result.Add(new JValue(id)); + } + + return result; + } + + public static HashSet ToGuidSet(this JToken value) + { + if (value is JArray ids) + { + var result = new HashSet(); + + foreach (var id in ids) + { + if (id.Type == JTokenType.Guid) + { + result.Add((Guid)id); + } + else if (id.Type == JTokenType.String && Guid.TryParse((string)id, out var guid)) + { + result.Add(guid); + } + } + + return result; + } + + return new HashSet(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs index 216172bc0..c39dc66fd 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs @@ -13,50 +13,93 @@ using Squidex.Domain.Apps.Core.Schemas; namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { - public static class ReferencesExtractor + public sealed class ReferencesExtractor : IFieldVisitor> { - public static IEnumerable ExtractReferences(this IField field, JToken value) - { - switch (field) - { - case IField assetsField: - return Visit(assetsField, value); + private readonly JToken value; - case IField referencesField: - return Visit(referencesField, value); - } + private ReferencesExtractor(JToken value) + { + this.value = value; + } - return Enumerable.Empty(); + public static IEnumerable ExtractReferences(IField field, JToken value) + { + return field.Accept(new ReferencesExtractor(value)); } - public static IEnumerable Visit(IField field, JToken value) + public IEnumerable Visit(IArrayField field) { - IEnumerable result; - try - { - result = value?.ToObject>(); - } - catch + var result = new List(); + + if (value is JArray items) { - result = null; + foreach (JObject item in value) + { + foreach (var nestedField in field.Fields) + { + if (item.TryGetValue(field.Name, out var value)) + { + result.AddRange(nestedField.Accept(new ReferencesExtractor(value))); + } + } + } } - return result ?? Enumerable.Empty(); + return result; } - private static IEnumerable Visit(IField field, JToken value) + public IEnumerable Visit(IField field) { - IEnumerable result; - try - { - result = value?.ToObject>() ?? Enumerable.Empty(); - } - catch + var ids = value.ToGuidSet(); + + return ids; + } + + public IEnumerable Visit(IField field) + { + var ids = value.ToGuidSet(); + + if (field.Properties.SchemaId != Guid.Empty) { - result = Enumerable.Empty(); + ids.Add(field.Properties.SchemaId); } - return result.Union(new[] { field.Properties.SchemaId }); + return ids; + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/FieldReferencesConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs similarity index 63% rename from src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/FieldReferencesConverter.cs rename to src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs index 8f1a751cc..e99459cc7 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/FieldReferencesConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs @@ -7,28 +7,25 @@ using System; using System.Collections.Generic; -using System.Linq; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { - public static class FieldReferencesConverter + public static class ValueReferencesConverter { - public static FieldConverter CleanReferences(IEnumerable deletedReferencedIds) + public static ValueConverter CleanReferences(IEnumerable deletedReferencedIds) { var ids = new HashSet(deletedReferencedIds); - return (data, field) => + return (value, field) => { - foreach (var partitionValue in data.Where(x => !x.Value.IsNull()).ToList()) + if (value.IsNull()) { - var newValue = field.CleanReferences(partitionValue.Value, ids); - - data[partitionValue.Key] = newValue; + return value; } - return data; + return field.CleanReferences(value, ids); }; } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs index 83d3b1a47..789da3081 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs @@ -23,6 +23,11 @@ namespace Squidex.Domain.Apps.Core.GenerateEdmSchema return field.Accept(Instance); } + public IEdmTypeReference Visit(IArrayField field) + { + return null; + } + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.String, field); diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs index baff80ccb..00ad5f60f 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs @@ -7,6 +7,7 @@ using System; using System.Collections.ObjectModel; +using System.Linq; using NJsonSchema; using Squidex.Domain.Apps.Core.Schemas; @@ -21,6 +22,30 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema this.schemaResolver = schemaResolver; } + public JsonProperty Visit(IArrayField field) + { + return CreateProperty(field, jsonProperty => + { + var itemSchema = new JsonSchema4 + { + Type = JsonObjectType.Object + }; + + foreach (var nestedField in field.Fields.Where(x => !x.IsHidden)) + { + var childProperty = nestedField.Accept(this); + + childProperty.Description = nestedField.RawProperties.Hints; + childProperty.IsRequired = nestedField.RawProperties.IsRequired; + + itemSchema.Properties.Add(nestedField.Name, childProperty); + } + + jsonProperty.Type = JsonObjectType.Object; + jsonProperty.Item = itemSchema; + }); + } + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs index 79b1e14c4..6a72f9508 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs @@ -95,14 +95,14 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper { EnsurePropertiesInitialized(); - fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this)).Value = value; + fieldProperties.GetOrAdd(propertyName, this, (k, c) => new ContentDataProperty(c)).Value = value; } public override PropertyDescriptor GetOwnProperty(string propertyName) { EnsurePropertiesInitialized(); - return fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this, new ContentFieldObject(this, new ContentFieldData(), false))); + return fieldProperties.GetOrAdd(propertyName, this, (k, c) => new ContentDataProperty(c, new ContentFieldObject(c, new ContentFieldData(), false))); } public override IEnumerable> GetOwnProperties() diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs index 5c0b24b81..38517df83 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs @@ -7,18 +7,22 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.ValidateContent.Validators; using Squidex.Infrastructure; -#pragma warning disable 168 +#pragma warning disable SA1028, IDE0004 // Code must not contain trailing whitespace namespace Squidex.Domain.Apps.Core.ValidateContent { public sealed class ContentValidator { + private static readonly ContentFieldData DefaultFieldData = new ContentFieldData(); + private static readonly JToken DefaultValue = JValue.CreateNull(); private readonly Schema schema; private readonly PartitionResolver partitionResolver; private readonly ValidationContext context; @@ -39,103 +43,60 @@ namespace Squidex.Domain.Apps.Core.ValidateContent this.partitionResolver = partitionResolver; } - public Task ValidatePartialAsync(NamedContentData data) + private void AddError(IEnumerable path, string message) { - Guard.NotNull(data, nameof(data)); - - var tasks = new List(); - - foreach (var fieldData in data) - { - var fieldName = fieldData.Key; - - if (!schema.FieldsByName.TryGetValue(fieldData.Key, out var field)) - { - errors.AddError(" is not a known field.", fieldName); - } - else - { - tasks.Add(ValidateFieldPartialAsync(field, fieldData.Value)); - } - } + var pathString = path.ToPathString(); - return Task.WhenAll(tasks); + errors.Add(new ValidationError($"{pathString}: {message}", pathString)); } - private Task ValidateFieldPartialAsync(Field field, ContentFieldData fieldData) + public Task ValidatePartialAsync(NamedContentData data) { - var partitioning = field.Partitioning; - var partition = partitionResolver(partitioning); - - var tasks = new List(); + Guard.NotNull(data, nameof(data)); - foreach (var partitionValues in fieldData) - { - if (partition.TryGetItem(partitionValues.Key, out var item)) - { - tasks.Add(field.ValidateAsync(partitionValues.Value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); - } - else - { - errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field); - } - } + var validator = CreateSchemaValidator(true); - return Task.WhenAll(tasks); + return validator.ValidateAsync(data, context, AddError); } public Task ValidateAsync(NamedContentData data) { Guard.NotNull(data, nameof(data)); - ValidateUnknownFields(data); - - var tasks = new List(); - - foreach (var field in schema.FieldsByName.Values) - { - var fieldData = data.GetOrCreate(field.Name, k => new ContentFieldData()); - - tasks.Add(ValidateFieldAsync(field, fieldData)); - } + var validator = CreateSchemaValidator(false); - return Task.WhenAll(tasks); + return validator.ValidateAsync(data, context, AddError); } - private void ValidateUnknownFields(NamedContentData data) + private IValidator CreateSchemaValidator(bool isPartial) { - foreach (var fieldData in data) + var fieldsValidators = new Dictionary(); + + foreach (var field in schema.FieldsByName) { - if (!schema.FieldsByName.ContainsKey(fieldData.Key)) - { - errors.AddError(" is not a known field.", fieldData.Key); - } + fieldsValidators[field.Key] = (!field.Value.RawProperties.IsRequired, CreateFieldValidator(field.Value, isPartial)); } + + return new ObjectValidator(fieldsValidators, isPartial, "field", DefaultFieldData); } - private Task ValidateFieldAsync(Field field, ContentFieldData fieldData) + private IValidator CreateFieldValidator(IRootField field, bool isPartial) { - var partitioning = field.Partitioning; - var partition = partitionResolver(partitioning); + var partitioning = partitionResolver(field.Partitioning); - var tasks = new List(); + var fieldValidator = new FieldValidator(ValidatorsFactory.CreateValidators(field).ToArray(), field); + var fieldsValidators = new Dictionary(); - foreach (var partitionValues in fieldData) + foreach (var partition in partitioning) { - if (!partition.TryGetItem(partitionValues.Key, out var _)) - { - errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field); - } + fieldsValidators[partition.Key] = (partition.IsOptional, fieldValidator); } - foreach (var item in partition) - { - var value = fieldData.GetOrCreate(item.Key, k => JValue.CreateNull()); + var isLanguage = field.Partitioning.Equals(Partitioning.Language); - tasks.Add(field.ValidateAsync(value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); - } + var type = isLanguage ? "language" : "invariant value"; - return Task.WhenAll(tasks); + return new ObjectValidator(fieldsValidators, isPartial, type, DefaultValue); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs deleted file mode 100644 index ac2c46ca3..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.ValidateContent -{ - public static class FieldExtensions - { - public static void AddError(this ConcurrentBag errors, string message, IField field, IFieldPartitionItem partitionItem = null) - { - AddError(errors, message, !string.IsNullOrWhiteSpace(field.RawProperties.Label) ? field.RawProperties.Label : field.Name, field.Name, partitionItem); - } - - public static void AddError(this ConcurrentBag errors, string message, string fieldName, IFieldPartitionItem partitionItem = null) - { - AddError(errors, message, fieldName, fieldName, partitionItem); - } - - public static void AddError(this ConcurrentBag errors, string message, string displayName, string fieldName, IFieldPartitionItem partitionItem = null) - { - if (partitionItem != null && partitionItem != InvariantPartitioning.Instance.Master) - { - displayName += $" ({partitionItem.Key})"; - } - - errors.Add(new ValidationError(message.Replace("", displayName), fieldName)); - } - - public static async Task ValidateAsync(this IField field, JToken value, ValidationContext context, Action addError) - { - try - { - var typedValue = value.IsNull() ? null : JsonValueConverter.ConvertValue(field, value); - - foreach (var validator in ValidatorsFactory.CreateValidators(field)) - { - await validator.ValidateAsync(typedValue, context, addError); - } - } - catch - { - addError(" is not a valid value."); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs index 9de8f0716..a43f8a095 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs @@ -16,11 +16,11 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { public sealed class JsonValueConverter : IFieldVisitor { - public JToken Value { get; } + private readonly JToken value; private JsonValueConverter(JToken value) { - Value = value; + this.value = value; } public static object ConvertValue(IField field, JToken json) @@ -28,21 +28,26 @@ namespace Squidex.Domain.Apps.Core.ValidateContent return field.Accept(new JsonValueConverter(json)); } + public object Visit(IArrayField field) + { + return value.ToObject>(); + } + public object Visit(IField field) { - return Value.ToObject>(); + return value.ToObject>(); } public object Visit(IField field) { - return (bool?)Value; + return (bool?)value; } public object Visit(IField field) { - if (Value.Type == JTokenType.String) + if (value.Type == JTokenType.String) { - var parseResult = InstantPattern.General.Parse(Value.ToString()); + var parseResult = InstantPattern.General.Parse(value.ToString()); if (!parseResult.Success) { @@ -57,7 +62,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent public object Visit(IField field) { - var geolocation = (JObject)Value; + var geolocation = (JObject)value; foreach (var property in geolocation.Properties()) { @@ -81,32 +86,32 @@ namespace Squidex.Domain.Apps.Core.ValidateContent throw new InvalidCastException("Longitude must be between -180 and 180."); } - return Value; + return value; } public object Visit(IField field) { - return Value; + return value; } public object Visit(IField field) { - return (double?)Value; + return (double?)value; } public object Visit(IField field) { - return Value.ToObject>(); + return value.ToObject>(); } public object Visit(IField field) { - return Value.ToString(); + return value.ToString(); } public object Visit(IField field) { - return Value.ToObject>(); + return value.ToObject>(); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs new file mode 100644 index 000000000..3b1c216cb --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Squidex.Domain.Apps.Core.ValidateContent +{ + public static class ObjectPath + { + public static string ToPathString(this IEnumerable path) + { + var sb = new StringBuilder(); + + var index = 0; + foreach (var property in path) + { + if (index == 0) + { + sb.Append(property); + } + else if (index == 1) + { + if (!property.Equals(InvariantPartitioning.Instance.Master.Key, StringComparison.OrdinalIgnoreCase)) + { + sb.Append("("); + sb.Append(property); + sb.Append(")"); + } + } + else + { + if (property[0] != '[') + { + sb.Append("."); + } + + sb.Append(property); + } + + index++; + } + + return sb.ToString(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs index 4a57b5fc8..5db995c34 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -16,24 +17,33 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { private readonly Func, Guid, Task>> checkContent; private readonly Func, Task>> checkAsset; + private readonly ImmutableQueue propertyPath; + + public ImmutableQueue Path + { + get { return propertyPath; } + } public bool IsOptional { get; } public ValidationContext( Func, Guid, Task>> checkContent, Func, Task>> checkAsset) - : this(checkContent, checkAsset, false) + : this(checkContent, checkAsset, ImmutableQueue.Empty, false) { } private ValidationContext( Func, Guid, Task>> checkContent, Func, Task>> checkAsset, + ImmutableQueue propertyPath, bool isOptional) { Guard.NotNull(checkAsset, nameof(checkAsset)); Guard.NotNull(checkContent, nameof(checkAsset)); + this.propertyPath = propertyPath; + this.checkContent = checkContent; this.checkAsset = checkAsset; @@ -42,7 +52,12 @@ namespace Squidex.Domain.Apps.Core.ValidateContent public ValidationContext Optional(bool isOptional) { - return isOptional == IsOptional ? this : new ValidationContext(checkContent, checkAsset, isOptional); + return isOptional == IsOptional ? this : new ValidationContext(checkContent, checkAsset, propertyPath, isOptional); + } + + public ValidationContext Nested(string property) + { + return new ValidationContext(checkContent, checkAsset, propertyPath.Enqueue(property), IsOptional); } public Task> GetInvalidContentIdsAsync(IEnumerable contentIds, Guid schemaId) diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs index 44aba8c60..9b1d6129d 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Linq; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -24,7 +23,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.allowedValues = allowedValues; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value == null) { @@ -35,7 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (!allowedValues.Contains(typedValue)) { - addError(" is not an allowed value."); + addError(context.Path, "Not an allowed value."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs index 2c6db10d7..f1a87c283 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs @@ -23,52 +23,49 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.properties = properties; } - public async Task ValidateAsync(object value, ValidationContext context, Action addError) + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (value is ICollection assetIds) + if (value is ICollection assetIds && assetIds.Count > 0) { var assets = await context.GetAssetInfosAsync(assetIds); - var i = 0; + var index = 0; foreach (var assetId in assetIds) { - i++; + index++; - var asset = assets.FirstOrDefault(x => x.AssetId == assetId); + var path = context.Path.Enqueue($"[{index}]"); - void Error(string message) - { - addError($" has invalid asset #{i}: {message}"); - } + var asset = assets.FirstOrDefault(x => x.AssetId == assetId); if (asset == null) { - Error($"Id '{assetId}' not found."); + addError(path, $"Id '{assetId}' not found."); continue; } if (properties.MinSize.HasValue && asset.FileSize < properties.MinSize) { - Error($"'{asset.FileSize.ToReadableSize()}' less than minimum of '{properties.MinSize.Value.ToReadableSize()}'."); + addError(path, $"'{asset.FileSize.ToReadableSize()}' less than minimum of '{properties.MinSize.Value.ToReadableSize()}'."); } if (properties.MaxSize.HasValue && asset.FileSize > properties.MaxSize) { - Error($"'{asset.FileSize.ToReadableSize()}' greater than maximum of '{properties.MaxSize.Value.ToReadableSize()}'."); + addError(path, $"'{asset.FileSize.ToReadableSize()}' greater than maximum of '{properties.MaxSize.Value.ToReadableSize()}'."); } if (properties.AllowedExtensions != null && properties.AllowedExtensions.Count > 0 && !properties.AllowedExtensions.Any(x => asset.FileName.EndsWith("." + x, StringComparison.OrdinalIgnoreCase))) { - Error("Invalid file extension."); + addError(path, "Invalid file extension."); } if (!asset.IsImage) { if (properties.MustBeImage) { - Error("Not an image."); + addError(path, "Not an image."); } continue; @@ -84,22 +81,22 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (properties.MinWidth.HasValue && w < properties.MinWidth) { - Error($"Width '{w}px' less than minimum of '{properties.MinWidth}px'."); + addError(path, $"Width '{w}px' less than minimum of '{properties.MinWidth}px'."); } if (properties.MaxWidth.HasValue && w > properties.MaxWidth) { - Error($"Width '{w}px' greater than maximum of '{properties.MaxWidth}px'."); + addError(path, $"Width '{w}px' greater than maximum of '{properties.MaxWidth}px'."); } if (properties.MinHeight.HasValue && h < properties.MinHeight) { - Error($"Height '{h}px' less than minimum of '{properties.MinHeight}px'."); + addError(path, $"Height '{h}px' less than minimum of '{properties.MinHeight}px'."); } if (properties.MaxHeight.HasValue && h > properties.MaxHeight) { - Error($"Height '{h}px' greater than maximum of '{properties.MaxHeight}px'."); + addError(path, $"Height '{h}px' greater than maximum of '{properties.MaxHeight}px'."); } if (properties.AspectHeight.HasValue && properties.AspectWidth.HasValue) @@ -108,7 +105,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (Math.Abs(expectedRatio - actualRatio) > double.Epsilon) { - Error($"Aspect ratio not '{properties.AspectWidth}:{properties.AspectHeight}'."); + addError(path, $"Aspect ratio not '{properties.AspectWidth}:{properties.AspectHeight}'."); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs index ea75cdd53..8e8efdd46 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs @@ -5,14 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { - public sealed class CollectionItemValidator : IValidator + public sealed class CollectionItemValidator : IValidator { private readonly IValidator[] itemValidators; @@ -24,23 +24,26 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.itemValidators = itemValidators; } - public async Task ValidateAsync(object value, ValidationContext context, Action addError) + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (value is ICollection items) + if (value is ICollection items && items.Count > 0) { - var innerContext = context.Optional(false); - + var innerTasks = new List(); var index = 1; foreach (var item in items) { + var innerContext = context.Nested($"[{index}]"); + foreach (var itemValidator in itemValidators) { - await itemValidator.ValidateAsync(item, innerContext, e => addError(e.Replace("", $" item #{index}"))); + innerTasks.Add(itemValidator.ValidateAsync(item, innerContext, addError)); } index++; } + + await Task.WhenAll(innerTasks); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs index a1699e802..820afe308 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs @@ -5,14 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Collections.Generic; +using System.Collections; using System.Threading.Tasks; using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { - public sealed class CollectionValidator : IValidator + public sealed class CollectionValidator : IValidator { private readonly bool isRequired; private readonly int? minItems; @@ -25,13 +24,13 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.maxItems = maxItems; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (!(value is ICollection items) || items.Count == 0) + if (!(value is ICollection items) || items.Count == 0) { if (isRequired && !context.IsOptional) { - addError(" is required."); + addError(context.Path, "Field is required."); } return TaskHelper.Done; @@ -39,12 +38,12 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (minItems.HasValue && items.Count < minItems.Value) { - addError($" must have at least {minItems} item(s)."); + addError(context.Path, $"Must have at least {minItems} item(s)."); } if (maxItems.HasValue && items.Count > maxItems.Value) { - addError($" must have not more than {maxItems} item(s)."); + addError(context.Path, $"Must have not more than {maxItems} item(s)."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs new file mode 100644 index 000000000..2c8e4d680 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ValidateContent.Validators +{ + public sealed class FieldValidator : IValidator + { + private readonly IValidator[] validators; + private readonly IField field; + + public FieldValidator(IValidator[] validators, IField field) + { + this.validators = validators; + this.field = field; + } + + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) + { + try + { + object typedValue = null; + + if (value is JToken jToken) + { + typedValue = jToken.IsNull() ? null : JsonValueConverter.ConvertValue(field, jToken); + } + + var tasks = new List(); + + foreach (var validator in ValidatorsFactory.CreateValidators(field)) + { + tasks.Add(validator.ValidateAsync(typedValue, context, addError)); + } + + await Task.WhenAll(tasks); + } + catch + { + addError(context.Path, "Not a valid value."); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs index e0d7c49f8..47592700f 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs @@ -5,13 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { + public delegate void AddError(IEnumerable path, string message); + public interface IValidator { - Task ValidateAsync(object value, ValidationContext context, Action addError); + Task ValidateAsync(object value, ValidationContext context, AddError addError); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs new file mode 100644 index 000000000..6dd8a9c28 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Core.ValidateContent.Validators +{ + public sealed class ObjectValidator : IValidator + { + private readonly IDictionary schema; + private readonly bool isPartial; + private readonly string fieldType; + private readonly TValue fieldDefault; + + public ObjectValidator(IDictionary schema, bool isPartial, string fieldType, TValue fieldDefault) + { + this.schema = schema; + this.fieldDefault = fieldDefault; + this.fieldType = fieldType; + this.isPartial = isPartial; + } + + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) + { + if (value is IDictionary values) + { + foreach (var fieldData in values) + { + var name = fieldData.Key; + + if (!schema.ContainsKey(name)) + { + addError(context.Path.Enqueue(name), $"Not a known {fieldType}."); + } + } + + var tasks = new List(); + + foreach (var field in schema) + { + var name = field.Key; + + if (!values.TryGetValue(name, out var fieldValue)) + { + if (isPartial) + { + continue; + } + + fieldValue = fieldDefault; + } + + var (isOptional, validator) = field.Value; + var fieldContext = context.Nested(name).Optional(isOptional); + + tasks.Add(validator.ValidateAsync(fieldValue, fieldContext, addError)); + } + + await Task.WhenAll(tasks); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs index ec799c0bc..c358c3bdf 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs @@ -25,7 +25,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators regex = new Regex("^" + pattern + "$", RegexOptions.None, Timeout); } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value is string stringValue) { @@ -37,17 +37,17 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { if (string.IsNullOrWhiteSpace(errorMessage)) { - addError(" is not valid."); + addError(context.Path, "Not valid."); } else { - addError(errorMessage); + addError(context.Path, errorMessage); } } } catch { - addError(" has a regex that is too slow."); + addError(context.Path, "Regex is too slow."); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs index 47507c696..5c26227fd 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.max = max; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value == null) { @@ -38,12 +38,12 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (min.HasValue && typedValue.CompareTo(min.Value) < 0) { - addError($" must be greater or equals than '{min}'."); + addError(context.Path, $"Must be greater or equals than '{min}'."); } if (max.HasValue && typedValue.CompareTo(max.Value) > 0) { - addError($" must be less or equals than '{max}'."); + addError(context.Path, $"Must be less or equals than '{max}'."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs index a47f0f279..9fd97bc02 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.schemaId = schemaId; } - public async Task ValidateAsync(object value, ValidationContext context, Action addError) + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value is ICollection contentIds) { @@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators foreach (var invalidId in invalidIds) { - addError($" contains invalid reference '{invalidId}'."); + addError(context.Path, $"Contains invalid reference '{invalidId}'."); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs index fd7a39ad2..eacbcdb79 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Squidex.Infrastructure.Tasks; @@ -20,7 +19,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.validateEmptyStrings = validateEmptyStrings; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (context.IsOptional || (value != null && !(value is string))) { @@ -31,7 +30,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (valueAsString == null || (validateEmptyStrings && string.IsNullOrWhiteSpace(valueAsString))) { - addError(" is required."); + addError(context.Path, "Field is required."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs index 4dfeb1849..6d2d308a6 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Squidex.Infrastructure.Tasks; @@ -13,11 +12,11 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { public class RequiredValidator : IValidator { - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value == null && !context.IsOptional) { - addError(" is required."); + addError(context.Path, "Field is required."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs index acc8e13e4..f89b1ed3e 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs @@ -27,18 +27,18 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.maxLength = maxLength; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value is string stringValue && !string.IsNullOrEmpty(stringValue)) { if (minLength.HasValue && stringValue.Length < minLength.Value) { - addError($" must have more than '{minLength}' characters."); + addError(context.Path, $"Must have more than '{minLength}' characters."); } if (maxLength.HasValue && stringValue.Length > maxLength.Value) { - addError($" must have less than '{maxLength}' characters."); + addError(context.Path, $"Must have less than '{maxLength}' characters."); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs index 330c3f1bb..eba15cb29 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json.Linq; using NodaTime; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.ValidateContent.Validators; @@ -15,7 +16,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.ValidateContent { - public sealed class ValidatorsFactory : IFieldPropertiesVisitor> + public sealed class ValidatorsFactory : IFieldVisitor> { private static readonly ValidatorsFactory Instance = new ValidatorsFactory(); @@ -27,118 +28,135 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { Guard.NotNull(field, nameof(field)); - return field.RawProperties.Accept(Instance); + return field.Accept(Instance); } - public IEnumerable Visit(AssetsFieldProperties properties) + public IEnumerable Visit(IArrayField field) { - if (properties.IsRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue) + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) { - yield return new CollectionValidator(properties.IsRequired, properties.MinItems, properties.MaxItems); + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - yield return new AssetsValidator(properties); + var nestedSchema = new Dictionary(); + + foreach (var nestedField in field.Fields) + { + nestedSchema[nestedField.Name] = (false, new FieldValidator(nestedField.Accept(this).ToArray(), nestedField)); + } + + yield return new CollectionItemValidator(new ObjectValidator(nestedSchema, false, "field", JValue.CreateNull())); + } + + public IEnumerable Visit(IField field) + { + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) + { + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); + } + + yield return new AssetsValidator(field.Properties); } - public IEnumerable Visit(BooleanFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } } - public IEnumerable Visit(DateTimeFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } - if (properties.MinValue.HasValue || properties.MaxValue.HasValue) + if (field.Properties.MinValue.HasValue || field.Properties.MaxValue.HasValue) { - yield return new RangeValidator(properties.MinValue, properties.MaxValue); + yield return new RangeValidator(field.Properties.MinValue, field.Properties.MaxValue); } } - public IEnumerable Visit(GeolocationFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } } - public IEnumerable Visit(JsonFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } } - public IEnumerable Visit(NumberFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } - if (properties.MinValue.HasValue || properties.MaxValue.HasValue) + if (field.Properties.MinValue.HasValue || field.Properties.MaxValue.HasValue) { - yield return new RangeValidator(properties.MinValue, properties.MaxValue); + yield return new RangeValidator(field.Properties.MinValue, field.Properties.MaxValue); } - if (properties.AllowedValues != null) + if (field.Properties.AllowedValues != null) { - yield return new AllowedValuesValidator(properties.AllowedValues.ToArray()); + yield return new AllowedValuesValidator(field.Properties.AllowedValues.ToArray()); } } - public IEnumerable Visit(ReferencesFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue) + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) { - yield return new CollectionValidator(properties.IsRequired, properties.MinItems, properties.MaxItems); + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - if (properties.SchemaId != Guid.Empty) + if (field.Properties.SchemaId != Guid.Empty) { - yield return new ReferencesValidator(properties.SchemaId); + yield return new ReferencesValidator(field.Properties.SchemaId); } } - public IEnumerable Visit(StringFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredStringValidator(); } - if (properties.MinLength.HasValue || properties.MaxLength.HasValue) + if (field.Properties.MinLength.HasValue || field.Properties.MaxLength.HasValue) { - yield return new StringLengthValidator(properties.MinLength, properties.MaxLength); + yield return new StringLengthValidator(field.Properties.MinLength, field.Properties.MaxLength); } - if (!string.IsNullOrWhiteSpace(properties.Pattern)) + if (!string.IsNullOrWhiteSpace(field.Properties.Pattern)) { - yield return new PatternValidator(properties.Pattern, properties.PatternMessage); + yield return new PatternValidator(field.Properties.Pattern, field.Properties.PatternMessage); } - if (properties.AllowedValues != null) + if (field.Properties.AllowedValues != null) { - yield return new AllowedValuesValidator(properties.AllowedValues.ToArray()); + yield return new AllowedValuesValidator(field.Properties.AllowedValues.ToArray()); } } - public IEnumerable Visit(TagsFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue) + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) { - yield return new CollectionValidator(properties.IsRequired, properties.MinItems, properties.MaxItems); + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - yield return new CollectionItemValidator(new RequiredStringValidator()); + yield return new CollectionItemValidator(new RequiredStringValidator()); } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs index b4917e217..ce085a1dc 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs @@ -28,12 +28,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public static NamedContentData FromMongoModel(this IdContentData result, Schema schema, List deletedIds) { - return result.ToNameModel(schema, FieldConverters.DecodeJson(), FieldReferencesConverter.CleanReferences(deletedIds)); + return result.ConvertId2Name(schema, + FieldConverters.ForValues( + ValueConverters.DecodeJson(), + ValueReferencesConverter.CleanReferences(deletedIds)), + FieldConverters.ForNestedId2Name( + ValueConverters.DecodeJson(), + ValueReferencesConverter.CleanReferences(deletedIds))); } public static IdContentData ToMongoModel(this NamedContentData result, Schema schema) { - return result.ToIdModel(schema, FieldConverters.EncodeJson()); + return result.ConvertName2Id(schema, + FieldConverters.ForValues( + ValueConverters.EncodeJson()), + FieldConverters.ForNestedName2Id( + ValueConverters.EncodeJson())); } public static string ToFullText(this ContentData data) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index e91e02385..8393c28c3 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -39,11 +39,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); var idData = value.Data.ToMongoModel(schema.SchemaDef); + var idDraftData = idData; + + if (!ReferenceEquals(value.Data, value.DataDraft)) + { + idDraftData = value.DataDraft?.ToMongoModel(schema.SchemaDef); + } var content = SimpleMapper.Map(value, new MongoContentEntity { DataByIds = idData, - DataDraftByIds = value.DataDraft?.ToMongoModel(schema.SchemaDef), + DataDraftByIds = idDraftData, IsDeleted = value.IsDeleted, IndexedAppId = value.AppId.Id, IndexedSchemaId = value.SchemaId.Id, diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 370ad85de..975b1cd36 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -200,7 +200,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public void Create(CreateApp command) { - var appId = new NamedId(command.AppId, command.Name); + var appId = NamedId.Of(command.AppId, command.Name); var events = new List { @@ -308,7 +308,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { if (@event.AppId == null) { - @event.AppId = new NamedId(Snapshot.Id, Snapshot.Name); + @event.AppId = NamedId.Of(Snapshot.Id, Snapshot.Name); } RaiseEvent(Envelope.Create(@event)); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs index 536a4cd42..f2d0bf83d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs @@ -32,7 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates { if (context.IsCompleted && context.Command is CreateApp createApp && IsRightTemplate(createApp)) { - var appId = new NamedId(createApp.AppId, createApp.Name); + var appId = NamedId.Of(createApp.AppId, createApp.Name); var publish = new Func(command => { @@ -156,7 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - var schemaId = new NamedId(command.SchemaId, command.Name); + var schemaId = NamedId.Of(command.SchemaId, command.Name); await publish(new ConfigureScripts { @@ -222,7 +222,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - var schemaId = new NamedId(command.SchemaId, command.Name); + var schemaId = NamedId.Of(command.SchemaId, command.Name); await publish(new ConfigureScripts { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs index 936b0a055..15f710fd2 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates { if (context.IsCompleted && context.Command is CreateApp createApp && IsRightTemplate(createApp)) { - var appId = new NamedId(createApp.AppId, createApp.Name); + var appId = NamedId.Of(createApp.AppId, createApp.Name); var publish = new Func(command => { @@ -233,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } private async Task> CreateProjectsSchemaAsync(Func publish) @@ -324,7 +324,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } private async Task> CreateExperienceSchemaAsync(Func publish) @@ -404,7 +404,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } private async Task> CreateEducationSchemaAsync(Func publish) @@ -484,7 +484,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } private async Task> CreatePublicationsSchemaAsync(Func publish) @@ -552,7 +552,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } private async Task> CreateSkillsSchemaAsync(Func publish) @@ -597,7 +597,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index 7e14b50e7..5ec1f122e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -154,12 +154,12 @@ namespace Squidex.Domain.Apps.Entities.Contents result.Data = scriptEngine.Transform(new ScriptContext { User = context.User, Data = content.Data, ContentId = content.Id }, scriptText); } - result.Data = result.Data.Convert(schema.SchemaDef, converters); + result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters); } if (result.DataDraft != null) { - result.DataDraft = result.DataDraft.Convert(schema.SchemaDef, converters); + result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters); } yield return result; @@ -172,11 +172,13 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!context.IsFrontendClient) { yield return FieldConverters.ExcludeHidden(); + yield return FieldConverters.ForNestedName2Name(ValueConverters.ExcludeHidden()); } if (checkType) { yield return FieldConverters.ExcludeChangedTypes(); + yield return FieldConverters.ForNestedName2Name(ValueConverters.ExcludeChangedTypes()); } yield return FieldConverters.ResolveInvariant(context.App.LanguagesConfig); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 56f8bf48c..68bad5840 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.Caching.Memory; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { @@ -20,7 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); private readonly IContentQueryService contentQuery; - private readonly ICommandBus commandBus; private readonly IGraphQLUrlGenerator urlGenerator; private readonly IAssetRepository assetRepository; private readonly IAppProvider appProvider; @@ -28,20 +26,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public CachingGraphQLService(IMemoryCache cache, IAppProvider appProvider, IAssetRepository assetRepository, - ICommandBus commandBus, IContentQueryService contentQuery, IGraphQLUrlGenerator urlGenerator) : base(cache) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(assetRepository, nameof(assetRepository)); - Guard.NotNull(commandBus, nameof(commandBus)); Guard.NotNull(contentQuery, nameof(contentQuery)); Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.appProvider = appProvider; this.assetRepository = assetRepository; - this.commandBus = commandBus; this.contentQuery = contentQuery; this.urlGenerator = urlGenerator; } @@ -58,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var modelContext = await GetModelAsync(context.App); - var ctx = new GraphQLExecutionContext(context, assetRepository, commandBus, contentQuery, urlGenerator); + var ctx = new GraphQLExecutionContext(context, assetRepository, contentQuery, urlGenerator); return await modelContext.ExecuteAsync(ctx, query); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index 8456884eb..31acd1523 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -11,25 +11,19 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Repositories; -using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class GraphQLExecutionContext : QueryExecutionContext { - public ICommandBus CommandBus { get; } - public IGraphQLUrlGenerator UrlGenerator { get; } public GraphQLExecutionContext(QueryContext context, IAssetRepository assetRepository, - ICommandBus commandBus, IContentQueryService contentQuery, IGraphQLUrlGenerator urlGenerator) : base(context, assetRepository, contentQuery) { - CommandBus = commandBus; - UrlGenerator = urlGenerator; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index 6546572fe..2b8daf4e6 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -12,12 +12,13 @@ using System.Threading.Tasks; using GraphQL; using GraphQL.Resolvers; using GraphQL.Types; +using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core; -using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using GraphQLSchema = GraphQL.Types.Schema; @@ -28,13 +29,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class GraphQLModel : IGraphModel { - private readonly QueryGraphTypeVisitor schemaTypes; private readonly Dictionary contentTypes = new Dictionary(); private readonly Dictionary contentDataTypes = new Dictionary(); private readonly Dictionary schemasById; private readonly PartitionResolver partitionResolver; private readonly IAppEntity app; private readonly IGraphType assetType; + private readonly IGraphType assetListType; private readonly GraphQLSchema graphQLSchema; public bool CanGenerateAssetSourceUrl { get; private set; } @@ -48,10 +49,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL CanGenerateAssetSourceUrl = urlGenerator.CanGenerateAssetSourceUrl; assetType = new AssetGraphType(this); + assetListType = new ListGraphType(new NonNullGraphType(assetType)); + schemasById = schemas.ToDictionary(x => x.Id); - schemaTypes = new QueryGraphTypeVisitor(GetContentType, new ListGraphType(new NonNullGraphType(assetType))); graphQLSchema = BuildSchema(this); + graphQLSchema.RegisterValueConverter(JsonConverter.Instance); InitializeContentTypes(); } @@ -60,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var schemas = model.schemasById.Values; - return new GraphQLSchema { Query = new AppQueriesGraphType(model, schemas), Mutation = new AppMutationsGraphType(model, schemas) }; + return new GraphQLSchema { Query = new AppQueriesGraphType(model, schemas) }; } private void InitializeContentTypes() @@ -78,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private static (IGraphType ResolveType, IFieldResolver Resolver) ResolveDefault(IGraphType type) { - return (type, new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName))); + return (type, new FuncFieldResolver, object>(c => c.Source.GetOrDefault(c.FieldName))); } public IFieldResolver ResolveAssetUrl() @@ -134,14 +137,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return partitionResolver(key); } - public (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field) { - return field.Accept(schemaTypes); - } - - public IGraphType GetInputGraphType(IField field) - { - return field.GetInputGraphType(); + return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType)); } public IGraphType GetAssetType() @@ -158,7 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return null; } - return schema != null ? contentDataTypes.GetOrAdd(schema, s => new ContentDataGraphType()) : null; + return schema != null ? contentDataTypes.GetOrAddNew(schema) : null; } public IGraphType GetContentType(Guid schemaId) @@ -170,6 +168,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return null; } + contentDataTypes.GetOrAdd(schema, s => new ContentDataGraphType()); + return contentTypes.GetOrAdd(schema, s => new ContentGraphType()); } @@ -177,13 +177,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { Guard.NotNull(context, nameof(context)); + var inputs = query.Variables?.ToInputs(); + var result = await new DocumentExecuter().ExecuteAsync(options => { - options.Inputs = query.Variables?.ToInputs() ?? new Inputs(); - options.Query = query.Query; options.OperationName = query.OperationName; - options.Schema = graphQLSchema; options.UserContext = context; + options.Schema = graphQLSchema; + options.Inputs = inputs; + options.Query = query.Query; }).ConfigureAwait(false); return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray()); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs index db45e3672..91d17ec15 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json.Linq; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public class GraphQLQuery + public sealed class GraphQLQuery { public string OperationName { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs index 91acea708..f44c36b58 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs @@ -10,6 +10,7 @@ using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL @@ -34,8 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL IGraphType GetContentDataType(Guid schemaId); - IGraphType GetInputGraphType(IField field); - - (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(IField field); + (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs index 00be09720..f9f25ad41 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs @@ -22,52 +22,46 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType Date = new DateGraphType(); + public static readonly IGraphType Json = new JsonGraphType(); + + public static readonly IGraphType Tags = new ListGraphType>(); + public static readonly IGraphType Float = new FloatGraphType(); + public static readonly IGraphType Status = new EnumerationGraphType(); + public static readonly IGraphType String = new StringGraphType(); public static readonly IGraphType Boolean = new BooleanGraphType(); - public static readonly IGraphType StatusType = new EnumerationGraphType(); + public static readonly IGraphType References = new ListGraphType>(); - public static readonly IGraphType NonNullInt = new NonNullGraphType(new IntGraphType()); + public static readonly IGraphType NonNullInt = new NonNullGraphType(Int); - public static readonly IGraphType NonNullGuid = new NonNullGraphType(new GuidGraphType()); + public static readonly IGraphType NonNullGuid = new NonNullGraphType(Guid); - public static readonly IGraphType NonNullDate = new NonNullGraphType(new DateGraphType()); + public static readonly IGraphType NonNullDate = new NonNullGraphType(Date); - public static readonly IGraphType NonNullFloat = new NonNullGraphType(new FloatGraphType()); + public static readonly IGraphType NonNullFloat = new NonNullGraphType(Float); - public static readonly IGraphType NonNullString = new NonNullGraphType(new StringGraphType()); + public static readonly IGraphType NonNullString = new NonNullGraphType(String); - public static readonly IGraphType NonNullBoolean = new NonNullGraphType(new BooleanGraphType()); + public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean); - public static readonly IGraphType NonNullStatusType = new NonNullGraphType(new EnumerationGraphType()); + public static readonly IGraphType NonNullStatusType = new NonNullGraphType(Status); - public static readonly IGraphType ListOfNonNullGuid = new ListGraphType(new NonNullGraphType(new GuidGraphType())); + public static readonly IGraphType NoopDate = new NoopGraphType(Date); - public static readonly IGraphType ListOfNonNullString = new ListGraphType(new NonNullGraphType(new StringGraphType())); + public static readonly IGraphType NoopJson = new NoopGraphType(Json); - public static readonly IGraphType NoopInt = new NoopGraphType("Int"); + public static readonly IGraphType NoopFloat = new NoopGraphType(Float); - public static readonly IGraphType NoopGuid = new NoopGraphType("Guid"); + public static readonly IGraphType NoopString = new NoopGraphType(String); - public static readonly IGraphType NoopDate = new NoopGraphType("Date"); - - public static readonly IGraphType NoopJson = new NoopGraphType("Json"); + public static readonly IGraphType NoopBoolean = new NoopGraphType(Boolean); public static readonly IGraphType NoopTags = new NoopGraphType("Tags"); - public static readonly IGraphType NoopFloat = new NoopGraphType("Float"); - - public static readonly IGraphType NoopString = new NoopGraphType("String"); - - public static readonly IGraphType NoopBoolean = new NoopGraphType("Boolean"); - public static readonly IGraphType NoopGeolocation = new NoopGraphType("Geolocation"); - - public static readonly IGraphType CommandVersion = new CommandVersionGraphType(); - - public static readonly IGraphType GeolocationInput = new GeolocationInputGraphType(); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs deleted file mode 100644 index 0da986ba6..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs +++ /dev/null @@ -1,344 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using GraphQL; -using GraphQL.Resolvers; -using GraphQL.Types; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class AppMutationsGraphType : ObjectGraphType - { - public AppMutationsGraphType(IGraphModel model, IEnumerable schemas) - { - foreach (var schema in schemas) - { - var schemaId = schema.NamedId(); - var schemaType = schema.TypeName(); - var schemaName = schema.DisplayName(); - - var contentType = model.GetContentType(schema.Id); - var contentDataType = model.GetContentDataType(schema.Id); - - var resultType = new ContentDataChangedResultGraphType(schemaType, schemaName, contentDataType); - - var inputType = new ContentDataGraphInputType(model, schema); - - AddContentCreate(schemaId, schemaType, schemaName, inputType, contentDataType, contentType); - AddContentUpdate(schemaType, schemaName, inputType, resultType); - AddContentPatch(schemaType, schemaName, inputType, resultType); - AddContentPublish(schemaType, schemaName); - AddContentUnpublish(schemaType, schemaName); - AddContentArchive(schemaType, schemaName); - AddContentRestore(schemaType, schemaName); - AddContentDelete(schemaType, schemaName); - } - - Description = "The app mutations."; - } - - private void AddContentCreate(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IGraphType contentDataType, IGraphType contentType) - { - AddField(new FieldType - { - Name = $"create{schemaType}Content", - Arguments = new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "data", - Description = $"The data for the {schemaName} content.", - DefaultValue = null, - ResolvedType = new NonNullGraphType(inputType), - }, - new QueryArgument(AllTypes.None) - { - Name = "publish", - Description = "Set to true to autopublish content.", - DefaultValue = false, - ResolvedType = AllTypes.Boolean - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }, - ResolvedType = new NonNullGraphType(contentType), - Resolver = ResolveAsync(async (c, publish) => - { - var argPublish = c.GetArgument("publish"); - - var contentData = GetContentData(c); - - var command = new CreateContent { SchemaId = schemaId, Data = contentData, Publish = argPublish }; - var commandContext = await publish(command); - - var result = commandContext.Result>(); - var response = ContentEntity.Create(command, result); - - return (IContentEntity)ContentEntity.Create(command, result); - }), - Description = $"Creates an {schemaName} content." - }); - } - - private void AddContentUpdate(string schemaType, string schemaName, ContentDataGraphInputType inputType, IGraphType resultType) - { - AddField(new FieldType - { - Name = $"update{schemaType}Content", - Arguments = new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "id", - Description = $"The id of the {schemaName} content (GUID)", - DefaultValue = string.Empty, - ResolvedType = AllTypes.NonNullGuid - }, - new QueryArgument(AllTypes.None) - { - Name = "data", - Description = $"The data for the {schemaName} content.", - DefaultValue = null, - ResolvedType = new NonNullGraphType(inputType), - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }, - ResolvedType = new NonNullGraphType(resultType), - Resolver = ResolveAsync(async (c, publish) => - { - var contentId = c.GetArgument("id"); - var contentData = GetContentData(c); - - var command = new UpdateContent { ContentId = contentId, Data = contentData }; - var commandContext = await publish(command); - - var result = commandContext.Result(); - - return result; - }), - Description = $"Update an {schemaName} content by id." - }); - } - - private void AddContentPatch(string schemaType, string schemaName, ContentDataGraphInputType inputType, IGraphType resultType) - { - AddField(new FieldType - { - Name = $"patch{schemaType}Content", - Arguments = new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "id", - Description = $"The id of the {schemaName} content (GUID)", - DefaultValue = string.Empty, - ResolvedType = AllTypes.NonNullGuid - }, - new QueryArgument(AllTypes.None) - { - Name = "data", - Description = $"The data for the {schemaName} content.", - DefaultValue = null, - ResolvedType = new NonNullGraphType(inputType), - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }, - ResolvedType = new NonNullGraphType(resultType), - Resolver = ResolveAsync(async (c, publish) => - { - var contentId = c.GetArgument("id"); - var contentData = GetContentData(c); - - var command = new PatchContent { ContentId = contentId, Data = contentData }; - var commandContext = await publish(command); - - var result = commandContext.Result(); - - return result; - }), - Description = $"Patch a {schemaName} content." - }); - } - - private void AddContentPublish(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"publish{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Published }; - - return publish(command); - }), - Description = $"Publish a {schemaName} content." - }); - } - - private void AddContentUnpublish(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"unpublish{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; - - return publish(command); - }), - Description = $"Unpublish a {schemaName} content." - }); - } - - private void AddContentArchive(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"archive{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Archived }; - - return publish(command); - }), - Description = $"Archive a {schemaName} content." - }); - } - - private void AddContentRestore(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"restore{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; - - return publish(command); - }), - Description = $"Restore a {schemaName} content." - }); - } - - private void AddContentDelete(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"delete{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new DeleteContent { ContentId = contentId }; - - return publish(command); - }), - Description = $"Delete an {schemaName} content." - }); - } - - private static QueryArguments CreateIdArguments(string schemaName) - { - return new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "id", - Description = $"The id of the {schemaName} content (GUID)", - DefaultValue = string.Empty, - ResolvedType = AllTypes.NonNullGuid - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }; - } - - private static IFieldResolver ResolveAsync(Func>, Task> action) - { - return new FuncFieldResolver>(async c => - { - var e = (GraphQLExecutionContext)c.UserContext; - - try - { - return await action(c, command => - { - command.ExpectedVersion = c.GetArgument("expectedVersion", EtagVersion.Any); - - return e.CommandBus.PublishAsync(command); - }); - } - catch (ValidationException ex) - { - c.Errors.Add(new ExecutionError(ex.Message)); - - throw; - } - catch (DomainException ex) - { - c.Errors.Add(new ExecutionError(ex.Message)); - - throw; - } - }); - } - - private static NamedContentData GetContentData(ResolveFieldContext c) - { - return JObject.FromObject(c.GetArgument("data")).ToObject(); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs deleted file mode 100644 index 9fdc792f2..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using GraphQL.Resolvers; -using GraphQL.Types; -using Squidex.Infrastructure.Commands; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class CommandVersionGraphType : ObjectGraphType - { - public CommandVersionGraphType() - { - Name = "CommandVersionDto"; - - AddField(new FieldType - { - Name = "version", - ResolvedType = AllTypes.Int, - Resolver = ResolveVersion(), - Description = "The new version of the item." - }); - - Description = "The result of a mutation"; - } - - private static IFieldResolver ResolveVersion() - { - return new FuncFieldResolver(x => - { - if (x.Source.Result() is EntitySavedResult result) - { - return (int)result.Version; - } - - return null; - }); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs deleted file mode 100644 index d050c3696..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using GraphQL.Resolvers; -using GraphQL.Types; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class ContentDataChangedResultGraphType : ObjectGraphType - { - public ContentDataChangedResultGraphType(string schemaType, string schemaName, IGraphType contentDataType) - { - Name = $"{schemaName}DataChangedResultDto"; - - AddField(new FieldType - { - Name = "version", - ResolvedType = AllTypes.Int, - Resolver = Resolve(x => x.Version), - Description = $"The new version of the {schemaName} content." - }); - - AddField(new FieldType - { - Name = "data", - ResolvedType = new NonNullGraphType(contentDataType), - Resolver = Resolve(x => x.Data), - Description = $"The new data of the {schemaName} content." - }); - - Description = $"The result of the {schemaName} mutation"; - } - - private static IFieldResolver Resolve(Func action) - { - return new FuncFieldResolver(c => action(c.Source)); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs deleted file mode 100644 index 42512c861..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs +++ /dev/null @@ -1,74 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Linq; -using GraphQL.Resolvers; -using GraphQL.Types; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class ContentDataGraphInputType : InputObjectGraphType - { - public ContentDataGraphInputType(IGraphModel model, ISchemaEntity schema) - { - var schemaType = schema.TypeName(); - var schemaName = schema.DisplayName(); - - Name = $"{schemaType}InputDto"; - - foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) - { - var inputType = model.GetInputGraphType(field); - - if (inputType != null) - { - if (field.RawProperties.IsRequired) - { - inputType = new NonNullGraphType(inputType); - } - - var fieldName = field.RawProperties.Label.WithFallback(field.Name); - - var fieldGraphType = new InputObjectGraphType - { - Name = $"{schemaType}Data{field.Name.ToPascalCase()}InputDto" - }; - - var partition = model.ResolvePartition(field.Partitioning); - - foreach (var partitionItem in partition) - { - fieldGraphType.AddField(new FieldType - { - Name = partitionItem.Key, - ResolvedType = inputType, - Resolver = null, - Description = field.RawProperties.Hints - }); - } - - fieldGraphType.Description = $"The input structure of the {fieldName} of a {schemaName} content type."; - - var fieldResolver = new FuncFieldResolver(c => c.Source.GetOrDefault(field.Name)); - - AddField(new FieldType - { - Name = field.Name.ToCamelCase(), - Resolver = fieldResolver, - ResolvedType = fieldGraphType, - Description = $"The {fieldName} field." - }); - } - } - - Description = $"The structure of a {schemaName} content type."; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs index d74c6e383..ab748e37a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -5,9 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Linq; using GraphQL.Resolvers; using GraphQL.Types; +using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; @@ -25,33 +27,49 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) { - var fieldInfo = model.GetGraphType(field); + var fieldInfo = model.GetGraphType(schema, field); if (fieldInfo.ResolveType != null) { - var fieldName = field.RawProperties.Label.WithFallback(field.Name); + var fieldType = field.TypeName(); + var fieldName = field.DisplayName(); var fieldGraphType = new ObjectGraphType { - Name = $"{schemaType}Data{field.Name.ToPascalCase()}Dto" + Name = $"{schemaType}Data{fieldType}Dto" }; var partition = model.ResolvePartition(field.Partitioning); foreach (var partitionItem in partition) { + var resolver = new FuncFieldResolver(c => + { + if (((ContentFieldData)c.Source).TryGetValue(c.FieldName, out var value)) + { + return fieldInfo.Resolver(value, c); + } + else + { + return fieldInfo; + } + }); + fieldGraphType.AddField(new FieldType { Name = partitionItem.Key, - Resolver = fieldInfo.Resolver, + Resolver = resolver, ResolvedType = fieldInfo.ResolveType, Description = field.RawProperties.Hints }); } - fieldGraphType.Description = $"The structure of the {fieldName} of a {schemaName} content type."; + fieldGraphType.Description = $"The structure of the {fieldName} field of the {schemaName} content type."; - var fieldResolver = new FuncFieldResolver(c => c.Source.GetOrDefault(field.Name)); + var fieldResolver = new FuncFieldResolver>(c => + { + return c.Source.GetOrDefault(field.Name); + }); AddField(new FieldType { @@ -63,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types } } - Description = $"The structure of a {schemaName} content type."; + Description = $"The structure of the {schemaName} content type."; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs deleted file mode 100644 index 73ba49b5c..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using GraphQL.Types; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class GeolocationInputGraphType : InputObjectGraphType - { - public GeolocationInputGraphType() - { - Name = "GeolocationInputDto"; - - AddField(new FieldType - { - Name = "latitude", - ResolvedType = AllTypes.NonNullFloat - }); - - AddField(new FieldType - { - Name = "longitude", - ResolvedType = AllTypes.NonNullFloat - }); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/InputFieldVisitor.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/InputFieldVisitor.cs deleted file mode 100644 index 80ea21c4c..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/InputFieldVisitor.cs +++ /dev/null @@ -1,66 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using GraphQL.Types; -using Squidex.Domain.Apps.Core.Schemas; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class InputFieldVisitor : IFieldVisitor - { - public static readonly InputFieldVisitor Default = new InputFieldVisitor(); - - private InputFieldVisitor() - { - } - - public IGraphType Visit(IField field) - { - return AllTypes.ListOfNonNullGuid; - } - - public IGraphType Visit(IField field) - { - return AllTypes.Boolean; - } - - public IGraphType Visit(IField field) - { - return AllTypes.Date; - } - - public IGraphType Visit(IField field) - { - return AllTypes.GeolocationInput; - } - - public IGraphType Visit(IField field) - { - return AllTypes.NoopJson; - } - - public IGraphType Visit(IField field) - { - return AllTypes.Float; - } - - public IGraphType Visit(IField field) - { - return AllTypes.ListOfNonNullGuid; - } - - public IGraphType Visit(IField field) - { - return AllTypes.String; - } - - public IGraphType Visit(IField field) - { - return AllTypes.ListOfNonNullString; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs new file mode 100644 index 000000000..5bdc36b5d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs @@ -0,0 +1,61 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class NestedGraphType : ObjectGraphType + { + public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field) + { + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + var fieldType = field.TypeName(); + var fieldName = field.DisplayName(); + + Name = $"{schemaType}{fieldName}ChildDto"; + + foreach (var nestedField in field.Fields.Where(x => !x.IsHidden)) + { + var fieldInfo = model.GetGraphType(schema, nestedField); + + if (fieldInfo.ResolveType != null) + { + var resolver = new FuncFieldResolver(c => + { + if (((JObject)c.Source).TryGetValue(nestedField.Name, out var value)) + { + return fieldInfo.Resolver(value, c); + } + else + { + return fieldInfo; + } + }); + + AddField(new FieldType + { + Name = nestedField.Name.ToCamelCase(), + Resolver = resolver, + ResolvedType = fieldInfo.ResolveType, + Description = $"The {fieldName}/{nestedField.DisplayName()} nested field." + }); + } + } + + Description = $"The structure of the {schemaName}.{fieldName} nested schema."; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs index 4ca4bae5a..f330b5844 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs @@ -6,89 +6,106 @@ // ========================================================================== using System; -using GraphQL.Resolvers; using GraphQL.Types; -using Squidex.Domain.Apps.Core.Contents; +using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; +using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class QueryGraphTypeVisitor : IFieldVisitor<(IGraphType ResolveType, IFieldResolver Resolver)> + public delegate object ValueResolver(JToken value, ResolveFieldContext context); + + public sealed class QueryGraphTypeVisitor : IFieldVisitor<(IGraphType ResolveType, ValueResolver Resolver)> { + private static readonly ValueResolver NoopResolver = new ValueResolver((value, c) => value); + private readonly ISchemaEntity schema; private readonly Func schemaResolver; + private readonly IGraphModel model; private readonly IGraphType assetListType; - public QueryGraphTypeVisitor(Func schemaResolver, IGraphType assetListType) + public QueryGraphTypeVisitor(ISchemaEntity schema, Func schemaResolver, IGraphModel model, IGraphType assetListType) { + this.model = model; this.assetListType = assetListType; + this.schema = schema; this.schemaResolver = schemaResolver; } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IArrayField field) + { + return ResolveNested(field); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { - return ResolveAssets(assetListType); + return ResolveAssets(); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopBoolean); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopDate); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopGeolocation); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopJson); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopFloat); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveReferences(field); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopString); } - public (IGraphType ResolveType, IFieldResolver Resolver) Visit(IField field) + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) { return ResolveDefault(AllTypes.NoopTags); } - private static (IGraphType ResolveType, IFieldResolver Resolver) ResolveDefault(IGraphType type) + private static (IGraphType ResolveType, ValueResolver Resolver) ResolveDefault(IGraphType type) { - return (type, new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName))); + return (type, NoopResolver); + } + + private (IGraphType ResolveType, ValueResolver Resolver) ResolveNested(IArrayField field) + { + var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field))); + + return (schemaFieldType, NoopResolver); } - private static ValueTuple ResolveAssets(IGraphType assetListType) + private (IGraphType ResolveType, ValueResolver Resolver) ResolveAssets() { - var resolver = new FuncFieldResolver(c => + var resolver = new ValueResolver((value, c) => { var context = (GraphQLExecutionContext)c.UserContext; - var contentIds = c.Source.GetOrDefault(c.FieldName); - return context.GetReferencedAssetsAsync(contentIds); + return context.GetReferencedAssetsAsync(value); }); return (assetListType, resolver); } - private ValueTuple ResolveReferences(IField field) + private (IGraphType ResolveType, ValueResolver Resolver) ResolveReferences(IField field) { var schemaId = ((ReferencesFieldProperties)field.RawProperties).SchemaId; @@ -99,12 +116,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return (null, null); } - var resolver = new FuncFieldResolver(c => + var resolver = new ValueResolver((value, c) => { var context = (GraphQLExecutionContext)c.UserContext; - var contentIds = c.Source.GetOrDefault(c.FieldName); - return context.GetReferencedContentsAsync(schemaId, contentIds); + return context.GetReferencedContentsAsync(schemaId, value); }); var schemaFieldType = new ListGraphType(new NonNullGraphType(contentType)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/GuidGraphType.cs similarity index 95% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/GuidGraphType.cs index 196c8ed44..bbfc8ea5b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/GuidGraphType.cs @@ -9,7 +9,7 @@ using System; using GraphQL.Language.AST; using GraphQL.Types; -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils { public sealed class GuidGraphType : ScalarGraphType { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonConverter.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonConverter.cs new file mode 100644 index 000000000..62bc939fb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonConverter.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using GraphQL.Types; +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class JsonConverter : IAstFromValueConverter + { + public static readonly JsonConverter Instance = new JsonConverter(); + + private JsonConverter() + { + } + + public IValue Convert(object value, IGraphType type) + { + return new JsonValue(value as JObject); + } + + public bool Matches(object value, IGraphType type) + { + return value is JObject; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonGraphType.cs new file mode 100644 index 000000000..3b99c8412 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonGraphType.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class JsonGraphType : ScalarGraphType + { + public JsonGraphType() + { + Name = "Json"; + + Description = "Unstructured Json object"; + } + + public override object Serialize(object value) + { + return value; + } + + public override object ParseValue(object value) + { + return value; + } + + public override object ParseLiteral(IValue value) + { + if (value is JsonValue jsonGraphType) + { + return jsonGraphType.Value; + } + + return value; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonValue.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonValue.cs new file mode 100644 index 000000000..4977e33d2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonValue.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class JsonValue : ValueNode + { + public JsonValue(JObject value) + { + Value = value; + } + + protected override bool Equals(ValueNode node) + { + return false; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs index 5be62090f..8b8c3ceea 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using GraphQL.Language.AST; using GraphQL.Types; @@ -18,6 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils Name = name; } + public NoopGraphType(IGraphType type) + : this(type.Name) + { + Description = type.Description; + } + public override object Serialize(object value) { return value; @@ -30,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils public override object ParseLiteral(IValue value) { - throw new NotSupportedException(); + return value.Value; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs index 8856821d6..09cc26f0a 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public sealed class AddField : SchemaCommand { + public long? ParentFieldId { get; set; } + public string Name { get; set; } public string Partitioning { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs index 82e8876eb..dd5dd16e1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Schemas; +using FieldNested = System.Collections.Generic.List; namespace Squidex.Domain.Apps.Entities.Schemas.Commands { @@ -21,6 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands public bool IsDisabled { get; set; } + public FieldNested Nested { get; set; } + public FieldProperties Properties { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs new file mode 100644 index 000000000..409d79f99 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class CreateSchemaNestedField + { + public string Name { get; set; } + + public bool IsHidden { get; set; } + + public bool IsDisabled { get; set; } + + public FieldProperties Properties { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs index 5ad93ddf1..6246ad94c 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public class FieldCommand : SchemaCommand { + public long? ParentFieldId { get; set; } + public long FieldId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs index 9afe0346c..4c452d2ff 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public sealed class ReorderFields : SchemaCommand { + public long? ParentFieldId { get; set; } + public List FieldIds { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs index e746580f9..8beb2b8f1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections.Generic; -using System.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; @@ -22,7 +21,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards public static IEnumerable Validate(FieldProperties properties) { - return properties?.Accept(Instance) ?? Enumerable.Empty(); + return properties?.Accept(Instance); + } + + public IEnumerable Visit(ArrayFieldProperties properties) + { + if (properties.MaxItems.HasValue && properties.MinItems.HasValue && properties.MinItems.Value >= properties.MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items.", + nameof(properties.MinItems), + nameof(properties.MaxItems)); + } } public IEnumerable Visit(AssetsFieldProperties properties) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs index 81684f6b0..2348c1f45 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core; @@ -32,12 +34,14 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards error(new ValidationError($"A schema with name '{command.Name}' already exists", nameof(command.Name))); } - if (command.Fields != null && command.Fields.Any()) + if (command.Fields?.Count > 0) { var index = 0; foreach (var field in command.Fields) { + index++; + var prefix = $"Fields.{index}"; if (!field.Partitioning.IsValidPartitioning()) @@ -54,12 +58,57 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { error(new ValidationError("Properties is required.", $"{prefix}.{nameof(field.Properties)}")); } + else + { + var errors = FieldPropertiesValidator.Validate(field.Properties); - var propertyErrors = FieldPropertiesValidator.Validate(field.Properties); + foreach (var e in errors) + { + error(e.WithPrefix(prefix)); + } + } - foreach (var propertyError in propertyErrors) + if (field.Nested?.Count > 0) { - error(propertyError); + if (!(field.Properties is ArrayFieldProperties)) + { + error(new ValidationError("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}")); + } + else + { + var nestedIndex = 0; + + foreach (var nestedField in field.Nested) + { + nestedIndex++; + + var nestedPrefix = $"Fields.{index}.Nested.{nestedIndex}"; + + if (!nestedField.Name.IsPropertyName()) + { + error(new ValidationError("Name must be a valid property name.", $"{prefix}.{nameof(nestedField.Name)}")); + } + + if (nestedField.Properties == null) + { + error(new ValidationError("Properties is required.", $"{prefix}.{nameof(nestedField.Properties)}")); + } + else + { + var errors = FieldPropertiesValidator.Validate(nestedField.Properties); + + foreach (var e in errors) + { + error(e.WithPrefix(nestedPrefix)); + } + } + } + } + + if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) + { + error(new ValidationError("Fields cannot have duplicate names.", $"{prefix}.Nested")); + } } } @@ -75,6 +124,22 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); + IArrayField arrayField = null; + + if (command.ParentFieldId.HasValue) + { + var parentId = command.ParentFieldId.Value; + + if (schema.FieldsById.TryGetValue(parentId, out var field) && field is IArrayField a) + { + arrayField = a; + } + else + { + throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema)); + } + } + Validate.It(() => "Cannot reorder schema fields.", error => { if (command.FieldIds == null) @@ -82,13 +147,25 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards error(new ValidationError("Field ids is required.", nameof(command.FieldIds))); } - if (command.FieldIds != null && (command.FieldIds.Count != schema.Fields.Count || command.FieldIds.Any(x => !schema.FieldsById.ContainsKey(x)))) + if (arrayField == null) { - error(new ValidationError("Ids must cover all fields.", nameof(command.FieldIds))); + CheckFields(error, command, schema.FieldsById); + } + else + { + CheckFields(error, command, arrayField.FieldsById); } }); } + private static void CheckFields(Action error, ReorderFields c, IReadOnlyDictionary fields) + { + if (c.FieldIds != null && (c.FieldIds.Count != fields.Count || c.FieldIds.Any(x => !fields.ContainsKey(x)))) + { + error(new ValidationError("Ids must cover all fields.", nameof(c.FieldIds))); + } + } + public static void CanPublish(Schema schema, PublishSchema command) { Guard.NotNull(command, nameof(command)); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs index 73463a710..1ccbbe505 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs @@ -20,11 +20,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards Validate.It(() => "Cannot add a new field.", error => { - if (!command.Partitioning.IsValidPartitioning()) - { - error(new ValidationError("Partitioning is not valid.", nameof(command.Partitioning))); - } - if (!command.Name.IsPropertyName()) { error(new ValidationError("Name must be a valid property name.", nameof(command.Name))); @@ -34,17 +29,40 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { error(new ValidationError("Properties is required.", nameof(command.Properties))); } + else + { + var errors = FieldPropertiesValidator.Validate(command.Properties); - var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); + foreach (var e in errors) + { + error(e.WithPrefix(nameof(command.Properties))); + } + } - foreach (var propertyError in propertyErrors) + if (command.ParentFieldId.HasValue) { - error(propertyError); + var parentId = command.ParentFieldId.Value; + + if (!schema.FieldsById.TryGetValue(parentId, out var rootField) || !(rootField is IArrayField arrayField)) + { + throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema)); + } + if (arrayField.FieldsByName.ContainsKey(command.Name)) + { + error(new ValidationError($"There is already a field with name '{command.Name}'", nameof(command.Name))); + } } - - if (schema.FieldsByName.ContainsKey(command.Name)) + else { - error(new ValidationError($"There is already a field with name '{command.Name}'", nameof(command.Name))); + if (command.ParentFieldId == null && !command.Partitioning.IsValidPartitioning()) + { + error(new ValidationError("Partitioning is not valid.", nameof(command.Partitioning))); + } + + if (schema.FieldsByName.ContainsKey(command.Name)) + { + error(new ValidationError($"There is already a field with name '{command.Name}'", nameof(command.Name))); + } } }); } @@ -53,80 +71,89 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); + + if (field.IsLocked) + { + throw new DomainException("Schema field is already locked."); + } + Validate.It(() => "Cannot update field.", error => { if (command.Properties == null) { error(new ValidationError("Properties is required.", nameof(command.Properties))); } - - var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); - - foreach (var propertyError in propertyErrors) + else { - error(propertyError); + var errors = FieldPropertiesValidator.Validate(command.Properties); + + foreach (var e in errors) + { + error(e.WithPrefix(nameof(command.Properties))); + } } }); - - var field = GetFieldOrThrow(schema, command.FieldId); - - if (field.IsLocked) - { - throw new DomainException("Schema field is already locked."); - } } - public static void CanDelete(Schema schema, DeleteField command) + public static void CanHide(Schema schema, HideField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); if (field.IsLocked) { throw new DomainException("Schema field is locked."); } + + if (field.IsHidden) + { + throw new DomainException("Schema field is already hidden."); + } } - public static void CanHide(Schema schema, HideField command) + public static void CanDisable(Schema schema, DisableField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - if (field.IsHidden) + if (field.IsDisabled) { - throw new DomainException("Schema field is already hidden."); + throw new DomainException("Schema field is already disabled."); } } - public static void CanShow(Schema schema, ShowField command) + public static void CanDelete(Schema schema, DeleteField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - if (!field.IsHidden) + if (field.IsLocked) { - throw new DomainException("Schema field is already visible."); + throw new DomainException("Schema field is locked."); } } - public static void CanDisable(Schema schema, DisableField command) + public static void CanShow(Schema schema, ShowField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - if (field.IsDisabled) + if (!field.IsHidden) { - throw new DomainException("Schema field is already disabled."); + throw new DomainException("Schema field is already visible."); } } public static void CanEnable(Schema schema, EnableField command) { - var field = GetFieldOrThrow(schema, command.FieldId); + Guard.NotNull(command, nameof(command)); + + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); if (!field.IsDisabled) { @@ -138,7 +165,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); if (field.IsLocked) { @@ -146,8 +173,23 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards } } - private static Field GetFieldOrThrow(Schema schema, long fieldId) + private static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId) { + if (parentId.HasValue) + { + if (!schema.FieldsById.TryGetValue(parentId.Value, out var rootField) || !(rootField is IArrayField arrayField)) + { + throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema)); + } + + if (!arrayField.FieldsById.TryGetValue(fieldId, out var nestedField)) + { + throw new DomainObjectNotFoundException(fieldId.ToString(), $"Fields[{parentId}].Fields", typeof(Schema)); + } + + return nestedField; + } + if (!schema.FieldsById.TryGetValue(fieldId, out var field)) { throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema)); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs index 5d86e5682..4d45ac893 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs @@ -18,11 +18,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas return new NamedId(schema.Id, schema.Name); } + public static string TypeName(this IField field) + { + return field.Name.ToPascalCase(); + } + public static string TypeName(this ISchemaEntity schema) { return schema.SchemaDef.Name.ToPascalCase(); } + public static string DisplayName(this IField field) + { + return field.RawProperties.Label.WithFallback(field.TypeName()); + } + public static string DisplayName(this ISchemaEntity schema) { return schema.SchemaDef.Properties.Label.WithFallback(schema.TypeName()); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index 62a30e545..e7eb3a28d 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; @@ -47,14 +46,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas switch (command) { - case CreateSchema createSchema: - return CreateAsync(createSchema, async c => - { - await GuardSchema.CanCreate(c, appProvider); - - Create(c); - }); - case AddField addField: return UpdateReturnAsync(addField, c => { @@ -62,7 +53,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas Add(c); - return EntityCreatedResult.Create(Snapshot.SchemaDef.FieldsById.Values.First(x => x.Name == addField.Name).Id, NewVersion); + var id = 0L; + + if (c.ParentFieldId == null) + { + id = Snapshot.SchemaDef.FieldsByName[c.Name].Id; + } + else + { + id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id; + } + + return EntityCreatedResult.Create(id, NewVersion); + }); + + case CreateSchema createSchema: + return CreateAsync(createSchema, async c => + { + await GuardSchema.CanCreate(c, appProvider); + + Create(c); }); case DeleteField deleteField: @@ -184,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas public void Create(CreateSchema command) { - var @event = SimpleMapper.Map(command, new SchemaCreated { SchemaId = new NamedId(command.SchemaId, command.Name) }); + var @event = SimpleMapper.Map(command, new SchemaCreated { SchemaId = NamedId.Of(command.SchemaId, command.Name) }); if (command.Fields != null) { @@ -195,6 +205,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas var eventField = SimpleMapper.Map(commandField, new SchemaCreatedField()); @event.Fields.Add(eventField); + + if (commandField.Nested != null) + { + eventField.Nested = new List(); + + foreach (var nestedField in commandField.Nested) + { + var eventNestedField = SimpleMapper.Map(nestedField, new SchemaCreatedNestedField()); + + eventField.Nested.Add(eventNestedField); + } + } } } @@ -203,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas public void Add(AddField command) { - RaiseEvent(SimpleMapper.Map(command, new FieldAdded { FieldId = new NamedId(Snapshot.TotalFields + 1, command.Name) })); + RaiseEvent(SimpleMapper.Map(command, new FieldAdded { ParentFieldId = GetFieldId(command.ParentFieldId), FieldId = CreateFieldId(command) })); } public void UpdateField(UpdateField command) @@ -243,7 +265,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas public void Reorder(ReorderFields command) { - RaiseEvent(SimpleMapper.Map(command, new SchemaFieldsReordered())); + RaiseEvent(SimpleMapper.Map(command, new SchemaFieldsReordered { ParentFieldId = GetFieldId(command.ParentFieldId) })); } public void Publish(PublishSchema command) @@ -280,19 +302,46 @@ namespace Squidex.Domain.Apps.Entities.Schemas { SimpleMapper.Map(fieldCommand, @event); - if (Snapshot.SchemaDef.FieldsById.TryGetValue(fieldCommand.FieldId, out var field)) + if (fieldCommand.ParentFieldId.HasValue) + { + if (Snapshot.SchemaDef.FieldsById.TryGetValue(fieldCommand.ParentFieldId.Value, out var field)) + { + @event.ParentFieldId = NamedId.Of(field.Id, field.Name); + + if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fieldCommand.FieldId, out var nestedField)) + { + @event.FieldId = NamedId.Of(nestedField.Id, nestedField.Name); + } + } + } + else { - @event.FieldId = new NamedId(field.Id, field.Name); + @event.FieldId = GetFieldId(fieldCommand.FieldId); } RaiseEvent(@event); } + private NamedId CreateFieldId(AddField command) + { + return NamedId.Of(Snapshot.TotalFields + 1L, command.Name); + } + + private NamedId GetFieldId(long? id) + { + if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field)) + { + return NamedId.Of(field.Id, field.Name); + } + + return null; + } + private void RaiseEvent(SchemaEvent @event) { if (@event.SchemaId == null) { - @event.SchemaId = new NamedId(Snapshot.Id, Snapshot.Name); + @event.SchemaId = NamedId.Of(Snapshot.Id, Snapshot.Name); } if (@event.AppId == null) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs index 256d42673..4a787d912 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs @@ -83,12 +83,33 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State { TotalFields++; - var partitioning = - string.Equals(eventField.Partitioning, Partitioning.Language.Key, StringComparison.OrdinalIgnoreCase) ? - Partitioning.Language : - Partitioning.Invariant; + var partitioning = Partitioning.FromString(eventField.Partitioning); - var field = registry.CreateField(TotalFields, eventField.Name, partitioning, eventField.Properties); + var field = registry.CreateRootField(TotalFields, eventField.Name, partitioning, eventField.Properties); + + if (field is ArrayField arrayField && eventField.Nested?.Count > 0) + { + foreach (var nestedEventField in eventField.Nested) + { + TotalFields++; + + var nestedField = registry.CreateNestedField(TotalFields, nestedEventField.Name, nestedEventField.Properties); + + if (nestedEventField.IsHidden) + { + nestedField = nestedField.Hide(); + } + + if (nestedEventField.IsDisabled) + { + nestedField = nestedField.Disable(); + } + + arrayField = arrayField.AddField(nestedField); + } + + field = arrayField; + } if (eventField.IsHidden) { @@ -116,15 +137,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State protected void On(FieldAdded @event, FieldRegistry registry) { - var partitioning = - string.Equals(@event.Partitioning, Partitioning.Language.Key, StringComparison.OrdinalIgnoreCase) ? - Partitioning.Language : - Partitioning.Invariant; + if (@event.ParentFieldId != null) + { + var field = registry.CreateNestedField(@event.FieldId.Id, @event.Name, @event.Properties); + + SchemaDef = SchemaDef.UpdateField(@event.ParentFieldId.Id, x => ((ArrayField)x).AddField(field)); + } + else + { + var partitioning = Partitioning.FromString(@event.Partitioning); - var field = registry.CreateField(@event.FieldId.Id, @event.Name, partitioning, @event.Properties); + var field = registry.CreateRootField(@event.FieldId.Id, @event.Name, partitioning, @event.Properties); - SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id); - SchemaDef = SchemaDef.AddField(field); + SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id); + SchemaDef = SchemaDef.AddField(field); + } TotalFields++; } @@ -151,42 +178,42 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State protected void On(SchemaFieldsReordered @event, FieldRegistry registry) { - SchemaDef = SchemaDef.ReorderFields(@event.FieldIds); + SchemaDef = SchemaDef.ReorderFields(@event.FieldIds, @event.ParentFieldId?.Id); } protected void On(FieldUpdated @event, FieldRegistry registry) { - SchemaDef = SchemaDef.UpdateField(@event.FieldId.Id, @event.Properties); + SchemaDef = SchemaDef.UpdateField(@event.FieldId.Id, @event.Properties, @event.ParentFieldId?.Id); } protected void On(FieldLocked @event, FieldRegistry registry) { - SchemaDef = SchemaDef.LockField(@event.FieldId.Id); + SchemaDef = SchemaDef.LockField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldDisabled @event, FieldRegistry registry) { - SchemaDef = SchemaDef.DisableField(@event.FieldId.Id); + SchemaDef = SchemaDef.DisableField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldEnabled @event, FieldRegistry registry) { - SchemaDef = SchemaDef.EnableField(@event.FieldId.Id); + SchemaDef = SchemaDef.EnableField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldHidden @event, FieldRegistry registry) { - SchemaDef = SchemaDef.HideField(@event.FieldId.Id); + SchemaDef = SchemaDef.HideField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldShown @event, FieldRegistry registry) { - SchemaDef = SchemaDef.ShowField(@event.FieldId.Id); + SchemaDef = SchemaDef.ShowField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldDeleted @event, FieldRegistry registry) { - SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id); + SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(SchemaDeleted @event, FieldRegistry registry) diff --git a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 82878f303..9dfc0d4a6 100644 --- a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs b/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs index 8887bf696..0c2a9690f 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs @@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Events.Schemas public abstract class FieldEvent : SchemaEvent { public NamedId FieldId { get; set; } + + public NamedId ParentFieldId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs index 3858eb897..ce040cba5 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Schemas; +using FieldNested = System.Collections.Generic.List; namespace Squidex.Domain.Apps.Events.Schemas { @@ -21,6 +22,8 @@ namespace Squidex.Domain.Apps.Events.Schemas public bool IsDisabled { get; set; } + public FieldNested Nested { get; set; } + public FieldProperties Properties { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/InputFieldExtensions.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs similarity index 57% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/InputFieldExtensions.cs rename to src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs index 84af9a8f0..7d8fb5878 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/InputFieldExtensions.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs @@ -5,16 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using GraphQL.Types; using Squidex.Domain.Apps.Core.Schemas; -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +namespace Squidex.Domain.Apps.Events.Schemas { - public static class InputFieldExtensions + public sealed class SchemaCreatedNestedField { - public static IGraphType GetInputGraphType(this IField field) - { - return field.Accept(InputFieldVisitor.Default); - } + public string Name { get; set; } + + public bool IsHidden { get; set; } + + public bool IsLocked { get; set; } + + public bool IsDisabled { get; set; } + + public FieldProperties Properties { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs index 9a34fd94e..50ad79ed8 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Schemas @@ -13,6 +14,8 @@ namespace Squidex.Domain.Apps.Events.Schemas [EventType(nameof(SchemaFieldsReordered))] public sealed class SchemaFieldsReordered : SchemaEvent { + public NamedId ParentFieldId { get; set; } + public List FieldIds { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index 72e45373d..bd03eadfe 100644 --- a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -11,7 +11,6 @@ - diff --git a/src/Squidex.Infrastructure.Redis/RedisPubSub.cs b/src/Squidex.Infrastructure.Redis/RedisPubSub.cs index 353f7f295..ce14d18fb 100644 --- a/src/Squidex.Infrastructure.Redis/RedisPubSub.cs +++ b/src/Squidex.Infrastructure.Redis/RedisPubSub.cs @@ -56,7 +56,7 @@ namespace Squidex.Infrastructure { var typeName = typeof(T).FullName; - return (RedisSubscription)subscriptions.GetOrAdd(typeName, c => new RedisSubscription(redisSubscriber.Value, c, log)); + return (RedisSubscription)subscriptions.GetOrAdd(typeName, this, (k, c) => new RedisSubscription(c.redisSubscriber.Value, k, c.log)); } } } diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index febc9654d..18bcc7ae9 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -172,6 +172,18 @@ namespace Squidex.Infrastructure return result; } + public static TValue GetOrAdd(this IDictionary dictionary, TKey key, TContext context, Func creator) + { + if (!dictionary.TryGetValue(key, out var result)) + { + result = creator(key, context); + + dictionary.Add(key, result); + } + + return result; + } + public static void Foreach(this IEnumerable collection, Action action) { foreach (var item in collection) diff --git a/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs b/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs index 32e113711..3076ef02c 100644 --- a/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs +++ b/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs @@ -32,7 +32,7 @@ namespace Squidex.Infrastructure.Json throw new JsonException("Named id must have more than 2 parts divided by colon."); } - return new NamedId(parts[0], string.Join(",", parts.Skip(1))); + return NamedId.Of(parts[0], string.Join(",", parts.Skip(1))); } } } diff --git a/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs b/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs index 77ba0448e..bfc924469 100644 --- a/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs +++ b/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs @@ -13,6 +13,12 @@ namespace Squidex.Infrastructure.Log public sealed class ConsoleLogChannel : ILogChannel, IDisposable { private readonly ConsoleLogProcessor processor = new ConsoleLogProcessor(); + private readonly bool useColors; + + public ConsoleLogChannel(bool useColors = false) + { + this.useColors = useColors; + } public void Dispose() { @@ -23,13 +29,16 @@ namespace Squidex.Infrastructure.Log { var color = 0; - if (logLevel == SemanticLogLevel.Warning) - { - color = 0xffff00; - } - else if (logLevel >= SemanticLogLevel.Error) + if (useColors) { - color = 0xff0000; + if (logLevel == SemanticLogLevel.Warning) + { + color = 0xffff00; + } + else if (logLevel >= SemanticLogLevel.Error) + { + color = 0xff0000; + } } processor.EnqueueMessage(new LogMessageEntry { Message = message, Color = color }); diff --git a/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs b/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs index fffabfcd4..cb8ec5acc 100644 --- a/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs +++ b/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs @@ -18,6 +18,10 @@ namespace Squidex.Infrastructure.Log.Internal this.logToStdError = logToStdError; } + public void Reset() + { + } + public void WriteLine(int color, string message) { if (color != 0 && logToStdError) diff --git a/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs b/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs index b536f509f..06c76e40e 100644 --- a/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs +++ b/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs @@ -86,6 +86,10 @@ namespace Squidex.Infrastructure.Log.Internal { Debug.WriteLine($"Failed to shutdown log queue grateful: {ex}."); } + finally + { + console.Reset(); + } } } } diff --git a/src/Squidex.Infrastructure/Log/Internal/IConsole.cs b/src/Squidex.Infrastructure/Log/Internal/IConsole.cs index d5c45b274..c996fe108 100644 --- a/src/Squidex.Infrastructure/Log/Internal/IConsole.cs +++ b/src/Squidex.Infrastructure/Log/Internal/IConsole.cs @@ -10,5 +10,7 @@ namespace Squidex.Infrastructure.Log.Internal public interface IConsole { void WriteLine(int color, string message); + + void Reset(); } } diff --git a/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs b/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs index 7275761e2..3a0b136f6 100644 --- a/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs +++ b/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs @@ -18,6 +18,11 @@ namespace Squidex.Infrastructure.Log.Internal this.logToStdError = logToStdError; } + public void Reset() + { + Console.ResetColor(); + } + public void WriteLine(int color, string message) { if (color != 0) diff --git a/src/Squidex.Infrastructure/NamedId.cs b/src/Squidex.Infrastructure/NamedId.cs index e8f99f6d4..e0c8106be 100644 --- a/src/Squidex.Infrastructure/NamedId.cs +++ b/src/Squidex.Infrastructure/NamedId.cs @@ -1,70 +1,17 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Linq; - namespace Squidex.Infrastructure { - public delegate bool Parser(string input, out T result); - - public sealed class NamedId : IEquatable> + public static class NamedId { - public T Id { get; } - - public string Name { get; } - - public NamedId(T id, string name) + public static NamedId Of(T id, string name) { - Guard.NotNull(id, nameof(id)); - Guard.NotNull(name, nameof(name)); - - Id = id; - - Name = name; - } - - public override string ToString() - { - return $"{Id},{Name}"; - } - - public override bool Equals(object obj) - { - return Equals(obj as NamedId); - } - - public bool Equals(NamedId other) - { - return other != null && (ReferenceEquals(this, other) || (Id.Equals(other.Id) && Name.Equals(other.Name))); - } - - public override int GetHashCode() - { - return (Id.GetHashCode() * 397) ^ Name.GetHashCode(); - } - - public static NamedId Parse(string value, Parser parser) - { - Guard.NotNull(value, nameof(value)); - - var parts = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (parts.Length < 2) - { - throw new ArgumentException("Named id must have more than 2 parts divided by commata."); - } - - if (!parser(parts[0], out var id)) - { - throw new ArgumentException("Named id must be a valid guid."); - } - - return new NamedId(id, string.Join(",", parts.Skip(1))); + return new NamedId(id, name); } } } diff --git a/src/Squidex.Infrastructure/NamedId{T}.cs b/src/Squidex.Infrastructure/NamedId{T}.cs new file mode 100644 index 000000000..e8f99f6d4 --- /dev/null +++ b/src/Squidex.Infrastructure/NamedId{T}.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; + +namespace Squidex.Infrastructure +{ + public delegate bool Parser(string input, out T result); + + public sealed class NamedId : IEquatable> + { + public T Id { get; } + + public string Name { get; } + + public NamedId(T id, string name) + { + Guard.NotNull(id, nameof(id)); + Guard.NotNull(name, nameof(name)); + + Id = id; + + Name = name; + } + + public override string ToString() + { + return $"{Id},{Name}"; + } + + public override bool Equals(object obj) + { + return Equals(obj as NamedId); + } + + public bool Equals(NamedId other) + { + return other != null && (ReferenceEquals(this, other) || (Id.Equals(other.Id) && Name.Equals(other.Name))); + } + + public override int GetHashCode() + { + return (Id.GetHashCode() * 397) ^ Name.GetHashCode(); + } + + public static NamedId Parse(string value, Parser parser) + { + Guard.NotNull(value, nameof(value)); + + var parts = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 2) + { + throw new ArgumentException("Named id must have more than 2 parts divided by commata."); + } + + if (!parser(parts[0], out var id)) + { + throw new ArgumentException("Named id must be a valid guid."); + } + + return new NamedId(id, string.Join(",", parts.Skip(1))); + } + } +} diff --git a/src/Squidex.Infrastructure/ValidationError.cs b/src/Squidex.Infrastructure/ValidationError.cs index 69eca55d6..47b4f8fe1 100644 --- a/src/Squidex.Infrastructure/ValidationError.cs +++ b/src/Squidex.Infrastructure/ValidationError.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Linq; namespace Squidex.Infrastructure { @@ -33,5 +34,17 @@ namespace Squidex.Infrastructure this.propertyNames = propertyNames ?? FallbackProperties; } + + public ValidationError WithPrefix(string prefix) + { + if (propertyNames.Length > 0) + { + return new ValidationError(Message, propertyNames.Select(x => $"{prefix}.{x}").ToArray()); + } + else + { + return new ValidationError(Message, prefix); + } + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs index 03acbbdad..d52320731 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs @@ -242,7 +242,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator private SwaggerOperations AddOperation(SwaggerOperationMethod method, string entityName, string path, Action updater) { - var operations = document.Paths.GetOrAdd(path, k => new SwaggerOperations()); + var operations = document.Paths.GetOrAddNew(path); var operation = new SwaggerOperation(); updater(operation); diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs index c88f8cad2..adf41afad 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs @@ -78,7 +78,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { name = char.ToUpperInvariant(name[0]) + name.Substring(1); - return new JsonSchema4 { Reference = document.Definitions.GetOrAdd(name, x => schema) }; + return new JsonSchema4 { Reference = document.Definitions.GetOrAdd(name, schema, (k, c) => c) }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs index 8ce6cd996..b2330cfd8 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs @@ -31,9 +31,9 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [Required] public FieldPropertiesDto Properties { get; set; } - public AddField ToCommand() + public AddField ToCommand(long? parentId = null) { - return SimpleMapper.Map(this, new AddField { Properties = Properties.ToProperties() }); + return SimpleMapper.Map(this, new AddField { ParentFieldId = parentId, Properties = Properties.ToProperties() }); } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs index 5dd3b2129..9750fc7c4 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs @@ -25,6 +25,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Converters return properties.Accept(Instance); } + public FieldPropertiesDto Visit(ArrayFieldProperties properties) + { + return SimpleMapper.Map(properties, new ArrayFieldPropertiesDto()); + } + public FieldPropertiesDto Visit(BooleanFieldProperties properties) { return SimpleMapper.Map(properties, new BooleanFieldPropertiesDto()); diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs index 9399ba0d0..540dcfdc2 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs @@ -57,9 +57,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models foreach (var fieldDto in Fields) { var fieldProperties = fieldDto?.Properties.ToProperties(); - var fieldInstance = SimpleMapper.Map(fieldDto, new CreateSchemaField { Properties = fieldProperties }); + var field = SimpleMapper.Map(fieldDto, new CreateSchemaField { Properties = fieldProperties }); - command.Fields.Add(fieldInstance); + if (fieldDto.Nested != null) + { + field.Nested = new List(); + + foreach (var nestedFieldDto in fieldDto.Nested) + { + var nestedFieldProperties = nestedFieldDto?.Properties.ToProperties(); + var nestedField = SimpleMapper.Map(fieldDto, new CreateSchemaNestedField { Properties = fieldProperties }); + + field.Nested.Add(nestedField); + } + } + + command.Fields.Add(field); } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs index a96975669..75a09a657 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Squidex.Areas.Api.Controllers.Schemas.Models @@ -43,5 +44,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + /// + /// The nested fields. + /// + public List Nested { get; set; } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaNestedFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaNestedFieldDto.cs new file mode 100644 index 000000000..f25e2cadb --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaNestedFieldDto.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class CreateSchemaNestedFieldDto + { + /// + /// The name of the field. Must be unique within the schema. + /// + [Required] + [RegularExpression("^[a-zA-Z0-9]+(\\-[a-zA-Z0-9]+)*$")] + public string Name { get; set; } + + /// + /// Defines if the field is hidden. + /// + public bool IsHidden { get; set; } + + /// + /// Defines if the field is disabled. + /// + public bool IsDisabled { get; set; } + + /// + /// The field properties. + /// + [Required] + public FieldPropertiesDto Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs index 5296b1ece..d9eab980f 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Squidex.Areas.Api.Controllers.Schemas.Models @@ -49,5 +50,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + /// + /// The nested fields. + /// + public List Nested { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs new file mode 100644 index 000000000..b818b6815 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields +{ + [JsonSchema("Array")] + public sealed class ArrayFieldPropertiesDto : FieldPropertiesDto + { + /// + /// The minimum allowed items for the field value. + /// + public int? MinItems { get; set; } + + /// + /// The maximum allowed items for the field value. + /// + public int? MaxItems { get; set; } + + public override FieldProperties ToProperties() + { + var result = SimpleMapper.Map(this, new ArrayFieldProperties()); + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs new file mode 100644 index 000000000..334c50376 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class NestedFieldDto + { + /// + /// The id of the field. + /// + public long FieldId { get; set; } + + /// + /// The name of the field. Must be unique within the schema. + /// + [Required] + [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] + public string Name { get; set; } + + /// + /// Defines if the field is hidden. + /// + public bool IsHidden { get; set; } + + /// + /// Defines if the field is locked. + /// + public bool IsLocked { get; set; } + + /// + /// Defines if the field is disabled. + /// + public bool IsDisabled { get; set; } + + /// + /// The field properties. + /// + [Required] + public FieldPropertiesDto Properties { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs index c7a7812ac..ab28c7f99 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs @@ -19,9 +19,9 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [Required] public List FieldIds { get; set; } - public ReorderFields ToCommand() + public ReorderFields ToCommand(long? parentId = null) { - return new ReorderFields { FieldIds = FieldIds }; + return new ReorderFields { ParentFieldId = parentId, FieldIds = FieldIds }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index c61a19654..850c6f938 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using NodaTime; using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; +using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; @@ -117,7 +118,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models foreach (var field in schema.SchemaDef.Fields) { var fieldPropertiesDto = FieldPropertiesDtoFactory.Create(field.RawProperties); - var fieldInstanceDto = SimpleMapper.Map(field, + var fieldDto = SimpleMapper.Map(field, new FieldDto { FieldId = field.Id, @@ -125,7 +126,25 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models Partitioning = field.Partitioning.Key }); - response.Fields.Add(fieldInstanceDto); + if (field is IArrayField arrayField) + { + fieldDto.Nested = new List(); + + foreach (var nestedField in arrayField.Fields) + { + var nestedFieldPropertiesDto = FieldPropertiesDtoFactory.Create(nestedField.RawProperties); + var nestedFieldDto = SimpleMapper.Map(nestedField, + new NestedFieldDto + { + FieldId = nestedField.Id, + Properties = nestedFieldPropertiesDto, + }); + + fieldDto.Nested.Add(nestedFieldDto); + } + } + + response.Fields.Add(fieldDto); } return response; diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs index 36cff85e4..0a55fe8e9 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs @@ -18,9 +18,9 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [Required] public FieldPropertiesDto Properties { get; set; } - public UpdateField ToCommand(long id) + public UpdateField ToCommand(long id, long? parentId = null) { - return new UpdateField { FieldId = id, Properties = Properties?.ToProperties() }; + return new UpdateField { ParentFieldId = parentId, FieldId = id, Properties = Properties?.ToProperties() }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs index 245205873..0299cee87 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs @@ -58,6 +58,35 @@ namespace Squidex.Areas.Api.Controllers.Schemas return StatusCode(201, response); } + /// + /// Add a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The field object that needs to be added to the schema. + /// + /// 201 => Schema field created. + /// 400 => Schema field properties not valid. + /// 409 => Schema field name already in use. + /// 404 => Schema, field or app not found. + /// + [HttpPost] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/")] + [ProducesResponseType(typeof(EntityCreatedDto), 201)] + [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task PostNestedField(string app, string name, long parentId, [FromBody] AddFieldDto request) + { + var context = await CommandBus.PublishAsync(request.ToCommand(parentId)); + + var result = context.Result>(); + var response = EntityCreatedDto.FromResult(result); + + return StatusCode(201, response); + } + /// /// Reorders the fields. /// @@ -73,13 +102,36 @@ namespace Squidex.Areas.Api.Controllers.Schemas [Route("apps/{app}/schemas/{name}/fields/ordering/")] [ProducesResponseType(typeof(ErrorDto), 400)] [ApiCosts(1)] - public async Task PutFieldOrdering(string app, string name, [FromBody] ReorderFieldsDto request) + public async Task PutSchemaFieldOrdering(string app, string name, [FromBody] ReorderFieldsDto request) { await CommandBus.PublishAsync(request.ToCommand()); return NoContent(); } + /// + /// Reorders the nested fields. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The request that contains the field ids. + /// + /// 204 => Schema fields reorderd. + /// 400 => Schema field ids do not cover the fields of the schema. + /// 404 => Schema, field or app not found. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/ordering/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task PutNestedFieldOrdering(string app, string name, long parentId, [FromBody] ReorderFieldsDto request) + { + await CommandBus.PublishAsync(request.ToCommand(parentId)); + + return NoContent(); + } + /// /// Update a schema field. /// @@ -104,6 +156,31 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + /// + /// Update a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to update. + /// The field object that needs to be added to the schema. + /// + /// 204 => Schema field updated. + /// 400 => Schema field properties not valid or field is locked. + /// 404 => Schema, field or app not found. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")] + [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task PutNestedField(string app, string name, long parentId, long id, [FromBody] UpdateFieldDto request) + { + await CommandBus.PublishAsync(request.ToCommand(id, parentId)); + + return NoContent(); + } + /// /// Lock a schema field. /// @@ -116,7 +193,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A hidden field is not part of the API response, but can still be edited in the portal. + /// A locked field cannot be updated or deleted. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/lock/")] @@ -129,6 +206,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + /// + /// Lock a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to lock. + /// + /// 204 => Schema field hidden. + /// 400 => Schema field already hidden. + /// 404 => Field, schema, or app not found. + /// + /// + /// A locked field cannot be edited or deleted. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/lock/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task LockNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new LockField { ParentFieldId = parentId, FieldId = id }); + + return NoContent(); + } + /// /// Hide a schema field. /// @@ -141,7 +244,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A locked field cannot be edited or deleted. + /// A hidden field is not part of the API response, but can still be edited in the portal. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/hide/")] @@ -154,6 +257,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + /// + /// Hide a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to hide. + /// + /// 204 => Schema field hidden. + /// 400 => Schema field already hidden. + /// 404 => Field, schema, or app not found. + /// + /// + /// A hidden field is not part of the API response, but can still be edited in the portal. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/hide/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task HideNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new HideField { ParentFieldId = parentId, FieldId = id }); + + return NoContent(); + } + /// /// Show a schema field. /// @@ -179,6 +308,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + /// + /// Show a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to show. + /// + /// 204 => Schema field shown. + /// 400 => Schema field already visible. + /// 404 => Schema, field or app not found. + /// + /// + /// A hidden field is not part of the API response, but can still be edited in the portal. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/show/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task ShowNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new ShowField { ParentFieldId = parentId, FieldId = id }); + + return NoContent(); + } + /// /// Enable a schema field. /// @@ -191,8 +346,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A disabled field cannot not be edited in the squidex portal anymore, - /// but will be part of the API response. + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/enable/")] @@ -205,6 +359,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + /// + /// Enable a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to enable. + /// + /// 204 => Schema field enabled. + /// 400 => Schema field already enabled. + /// 404 => Schema, field or app not found. + /// + /// + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/enable/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task EnableNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new EnableField { ParentFieldId = parentId, FieldId = id }); + + return NoContent(); + } + /// /// Disable a schema field. /// @@ -217,8 +397,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A disabled field cannot not be edited in the squidex portal anymore, - /// but will be part of the API response. + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/disable/")] @@ -231,6 +410,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + /// + /// Disable nested a schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to disable. + /// + /// 204 => Schema field disabled. + /// 400 => Schema field already disabled. + /// 404 => Schema, field or app not found. + /// + /// + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/disable/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task DisableNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new DisableField { ParentFieldId = parentId, FieldId = id }); + + return NoContent(); + } + /// /// Delete a schema field. /// @@ -251,5 +456,27 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + + /// + /// Delete a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to disable. + /// + /// 204 => Schema field deleted. + /// 400 => Field is locked. + /// 404 => Schema, field or app not found. + /// + [HttpDelete] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")] + [ApiCosts(1)] + public async Task DeleteNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new DeleteField { ParentFieldId = parentId, FieldId = id }); + + return NoContent(); + } } } \ No newline at end of file diff --git a/src/Squidex/Config/Domain/LoggingServices.cs b/src/Squidex/Config/Domain/LoggingServices.cs index e4ee51850..486c29dff 100644 --- a/src/Squidex/Config/Domain/LoggingServices.cs +++ b/src/Squidex/Config/Domain/LoggingServices.cs @@ -42,6 +42,13 @@ namespace Squidex.Config.Domain .As(); } + var useColors = config.GetValue("logging:colors"); + + if (console == null) + { + console = new ConsoleLogChannel(useColors); + } + services.AddSingletonAs(console) .As(); diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs index 8f724b1a9..f41214737 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs @@ -57,7 +57,7 @@ namespace Squidex.Pipeline.CommandMiddlewares throw new InvalidOperationException("Cannot resolve app."); } - return new NamedId(appFeature.App.Id, appFeature.App.Name); + return NamedId.Of(appFeature.App.Id, appFeature.App.Name); } } } \ No newline at end of file diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs index e385b6025..2825ad8f6 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -68,7 +68,7 @@ namespace Squidex.Pipeline.CommandMiddlewares if (appFeature != null && appFeature.App != null) { - appId = new NamedId(appFeature.App.Id, appFeature.App.Name); + appId = NamedId.Of(appFeature.App.Id, appFeature.App.Name); } } @@ -96,7 +96,7 @@ namespace Squidex.Pipeline.CommandMiddlewares throw new DomainObjectNotFoundException(schemaName, typeof(ISchemaEntity)); } - return new NamedId(schema.Id, schema.Name); + return NamedId.Of(schema.Id, schema.Name); } } diff --git a/src/Squidex/app-config/fix-coverage-loader.js b/src/Squidex/app-config/fix-coverage-loader.js deleted file mode 100644 index d5e5f2ff9..000000000 --- a/src/Squidex/app-config/fix-coverage-loader.js +++ /dev/null @@ -1,47 +0,0 @@ -function fixCoverage(contents) { - this.cacheable(); - - var ignores = [ - { name: 'arguments', line: 'var _a' }, - { name: 'decorate', line: 'var __decorate =', }, - { name: 'metadata', line: 'var __metadata =', }, - { name: 'extends', line: 'var __extends =', }, - { name: 'export', line: 'function __export' } - ]; - - var updates = 0; - var rows = contents.split('\n'); - - for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) { - var row = rows[rowIndex].trim(); - - for (var ignoreIndex = 0; ignoreIndex < ignores.length; ignoreIndex++) { - var ignore = ignores[ignoreIndex]; - - if (row.indexOf(ignore.line) >= 0) { - rows.splice(rowIndex, 0, '/* istanbul ignore next: TypeScript ' + ignore.name + ' */'); - rowIndex++; - updates++; - break; - } - } - - if (row.indexOf('hasOwnProperty') >= 0) { - rows.splice(rowIndex, 0, '/* istanbul ignore else */'); - rowIndex++; - updates++; - } - - if (updates === ignores.length) { - break; - } - } - - if (updates > 0) { - return rows.join('\n'); - } else { - return contents; - } -} - -module.exports = fixCoverage; \ No newline at end of file diff --git a/src/Squidex/app-config/karma-test-shim.js b/src/Squidex/app-config/karma-test-shim.js index 13e285d15..cd4be9556 100644 --- a/src/Squidex/app-config/karma-test-shim.js +++ b/src/Squidex/app-config/karma-test-shim.js @@ -11,8 +11,6 @@ require('zone.js/dist/jasmine-patch'); require('zone.js/dist/async-test'); require('zone.js/dist/fake-async-test'); -require('rxjs/Rx'); - var testing = require('@angular/core/testing'); var browser = require('@angular/platform-browser-dynamic/testing'); diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index e0746044b..8497259b1 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/src/Squidex/app-config/webpack.config.js @@ -1,9 +1,13 @@ - var webpack = require('webpack'), - path = require('path'), - HtmlWebpackPlugin = require('html-webpack-plugin'), - ExtractTextPlugin = require('extract-text-webpack-plugin'), -TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'), - helpers = require('./helpers'); +const webpack = require('webpack'), + path = require('path'), + helpers = require('./helpers'); + +const plugins = { + // https://github.com/webpack-contrib/mini-css-extract-plugin + MiniCssExtractPlugin: require('mini-css-extract-plugin'), + // https://github.com/dividab/tsconfig-paths-webpack-plugin + TsconfigPathsPlugin: require('tsconfig-paths-webpack-plugin') +}; module.exports = { /** @@ -21,12 +25,11 @@ module.exports = { modules: [ helpers.root('app'), helpers.root('app', 'theme'), - helpers.root('app-libs'), helpers.root('node_modules') ], plugins: [ - new TsconfigPathsPlugin() + new plugins.TsconfigPathsPlugin() ] }, @@ -45,19 +48,19 @@ module.exports = { { test: /\.ts$/, use: [{ - loader: 'awesome-typescript-loader' + loader: 'awesome-typescript-loader' }, { loader: 'angular2-router-loader' }, { loader: 'angular2-template-loader' }, { - loader: 'tslint-loader' + loader: 'tslint-loader' }], exclude: /node_modules/ }, { test: /\.ts$/, use: [{ - loader: 'awesome-typescript-loader' + loader: 'awesome-typescript-loader' }], include: /node_modules/ }, { @@ -83,21 +86,17 @@ module.exports = { }] }, { test: /\.css$/, - /* - * Extract the content from a bundle to a file - * - * See: https://github.com/webpack-contrib/extract-text-webpack-plugin - */ - use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader?sourceMap' }) + use: [ + plugins.MiniCssExtractPlugin.loader, + { + loader: 'css-loader' + }] }, { test: /\.scss$/, use: [{ loader: 'raw-loader' }, { - loader: 'sass-loader', - options: { - includePaths: [helpers.root('app', 'theme')] - } + loader: 'sass-loader', options: { includePaths: [helpers.root('app', 'theme')] } }], exclude: helpers.root('app', 'theme') } @@ -105,6 +104,13 @@ module.exports = { }, plugins: [ + /* + * Puts each bundle into a file and appends the hash of the file to the path. + * + * See: https://github.com/webpack-contrib/mini-css-extract-plugin + */ + new plugins.MiniCssExtractPlugin('[name].css'), + new webpack.LoaderOptionsPlugin({ options: { tslint: { diff --git a/src/Squidex/app-config/webpack.run.base.js b/src/Squidex/app-config/webpack.run.base.js index 18274c8fc..e32a69fd2 100644 --- a/src/Squidex/app-config/webpack.run.base.js +++ b/src/Squidex/app-config/webpack.run.base.js @@ -1,8 +1,12 @@ - var webpack = require('webpack'), - webpackMerge = require('webpack-merge'), -HtmlWebpackPlugin = require('html-webpack-plugin'), - commonConfig = require('./webpack.config.js'), - helpers = require('./helpers'); +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + commonConfig = require('./webpack.config.js'); + +const plugins = { + HtmlWebpackPlugin: require('html-webpack-plugin') +}; module.exports = webpackMerge(commonConfig, { /** @@ -17,28 +21,15 @@ module.exports = webpackMerge(commonConfig, { }, plugins: [ - /** - * Shares common code between the pages. - * - * See: https://webpack.js.org/plugins/commons-chunk-plugin/ - */ - new webpack.optimize.CommonsChunkPlugin({ - name: ['app', 'shims'] - }), - - /** - * Simplifies creation of HTML files to serve your webpack bundles. - * This is especially useful for webpack bundles that include a hash in the filename - * which changes every compilation. - * - * See: https://github.com/ampedandwired/html-webpack-plugin - */ - new HtmlWebpackPlugin({ - template: 'wwwroot/index.html', hash: true + new plugins.HtmlWebpackPlugin({ + hash: true, + chunks: ['shims', 'app'], + chunksSortMode: 'manual', + template: 'wwwroot/index.html' }), - new HtmlWebpackPlugin({ - template: 'wwwroot/theme.html', hash: true, filename: 'theme.html' + new plugins.HtmlWebpackPlugin({ + template: 'wwwroot/theme.html', hash: true, chunksSortMode: 'none', filename: 'theme.html' }) ] }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.dev.js b/src/Squidex/app-config/webpack.run.dev.js index 21a138e16..357dd1f5a 100644 --- a/src/Squidex/app-config/webpack.run.dev.js +++ b/src/Squidex/app-config/webpack.run.dev.js @@ -1,19 +1,17 @@ - var webpackMerge = require('webpack-merge'), - ExtractTextPlugin = require('extract-text-webpack-plugin'), - runConfig = require('./webpack.run.base.js'), - helpers = require('./helpers'); +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + runConfig = require('./webpack.run.base.js'); module.exports = webpackMerge(runConfig, { - /** - * Developer tool to enhance debugging - * - * See: https://webpack.js.org/configuration/devtool/#devtool - * See: https://webpack.js.org/guides/build-performance/ - */ - devtool: 'cheap-module-source-map', + mode: 'development', + + devtool: 'source-map', output: { filename: '[name].js', + // Set the public path, because we are running the website from another port (5000) publicPath: 'http://localhost:3000/' }, @@ -37,24 +35,17 @@ module.exports = webpackMerge(runConfig, { }, { loader: 'css-loader' }, { - loader: 'sass-loader?sourceMap', - options: { - includePaths: [helpers.root('app', 'theme')] - } + loader: 'sass-loader?sourceMap', options: { includePaths: [helpers.root('app', 'theme')] } }], include: helpers.root('app', 'theme') } ] }, - plugins: [ - new ExtractTextPlugin('[name].css') - ], - devServer: { - historyApiFallback: true, stats: 'minimal', headers: { 'Access-Control-Allow-Origin': '*' - } + }, + historyApiFallback: true } }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.prod.js b/src/Squidex/app-config/webpack.run.prod.js index 6e1e076db..097661ea9 100644 --- a/src/Squidex/app-config/webpack.run.prod.js +++ b/src/Squidex/app-config/webpack.run.prod.js @@ -1,16 +1,20 @@ - var webpack = require('webpack'), - webpackMerge = require('webpack-merge'), -ExtractTextPlugin = require('extract-text-webpack-plugin'), - ngToolsWebpack = require('@ngtools/webpack'), - runConfig = require('./webpack.run.base.js'), - helpers = require('./helpers'); - -var ENV = process.env.NODE_ENV = process.env.ENV = 'production'; +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + runConfig = require('./webpack.run.base.js'); +const plugins = { + // https://www.npmjs.com/package/@ngtools/webpack + NgToolsWebpack: require('@ngtools/webpack'), + // https://github.com/webpack-contrib/mini-css-extract-plugin + MiniCssExtractPlugin: require('mini-css-extract-plugin') +}; + helpers.removeLoaders(runConfig, ['scss', 'ts']); module.exports = webpackMerge(runConfig, { - devtool: 'source-map', + mode: 'production', output: { /** @@ -58,7 +62,13 @@ module.exports = webpackMerge(runConfig, { * * See: https://github.com/webpack-contrib/extract-text-webpack-plugin */ - use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader?minimize!sass-loader?sourceMap' }), + use: [ + plugins.MiniCssExtractPlugin.loader, + { + loader: 'css-loader', options: { minimize: true }, + }, { + loader: 'sass-loader' + }], /* * Do not include component styles */ @@ -68,10 +78,7 @@ module.exports = webpackMerge(runConfig, { use: [{ loader: 'raw-loader' }, { - loader: 'sass-loader', - options: { - includePaths: [helpers.root('app', 'theme')] - } + loader: 'sass-loader', options: { includePaths: [helpers.root('app', 'theme')] } }], exclude: helpers.root('app', 'theme'), }, { @@ -84,31 +91,11 @@ module.exports = webpackMerge(runConfig, { }, plugins: [ - new webpack.NoEmitOnErrorsPlugin(), - new webpack.DefinePlugin({ 'process.env': { 'ENV': JSON.stringify(ENV) } }), - new webpack.optimize.ModuleConcatenationPlugin(), - - /* - * Puts each bundle into a file and appends the hash of the file to the path. - * - * See: https://github.com/webpack/extract-text-webpack-plugin - */ - new ExtractTextPlugin('[name].css'), - - new webpack.optimize.UglifyJsPlugin({ - beautify: false, - mangle: { - screw_ie8: true, keep_fnames: true - }, - compress: { - screw_ie8: true, warnings: false - }, - comments: false - }), - - new ngToolsWebpack.AngularCompilerPlugin({ - tsConfigPath: './tsconfig.json', - entryModule: 'app/app.module#AppModule' - }), + new plugins.NgToolsWebpack.AngularCompilerPlugin({ + entryModule: 'app/app.module#AppModule', + sourceMap: false, + skipSourceGeneration: false, + tsConfigPath: './tsconfig.json' + }) ] }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.test.coverage.js b/src/Squidex/app-config/webpack.test.coverage.js index 4bfdb9654..e6e63f882 100644 --- a/src/Squidex/app-config/webpack.test.coverage.js +++ b/src/Squidex/app-config/webpack.test.coverage.js @@ -1,8 +1,8 @@ - -var webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - testConfig = require('./webpack.test.js'); +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + testConfig = require('./webpack.test.js'); helpers.removeLoaders(testConfig, ['ts']); @@ -25,9 +25,7 @@ module.exports = webpackMerge(testConfig, { test: /\.ts$/, use: [{ loader: 'istanbul-instrumenter-loader' - }, { - loader: helpers.root('app-config', 'fix-coverage-loader') - }, { + },{ loader: 'awesome-typescript-loader' }, { loader: 'angular2-router-loader' diff --git a/src/Squidex/app-config/webpack.test.js b/src/Squidex/app-config/webpack.test.js index 04955323e..1dd89b9e3 100644 --- a/src/Squidex/app-config/webpack.test.js +++ b/src/Squidex/app-config/webpack.test.js @@ -1,14 +1,17 @@ - var webpack = require('webpack'), -webpackMerge = require('webpack-merge'), -commonConfig = require('./webpack.config.js'), - helpers = require('./helpers'); + const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + commonConfig = require('./webpack.config.js'); module.exports = webpackMerge(commonConfig, { + mode: 'development', + /** * Source map for Karma from the help of karma-sourcemap-loader & karma-webpack * * Do not change, leave as is or it wont work. * See: https://webpack.js.org/configuration/devtool/ */ - devtool: 'inline-source-map', + devtool: 'inline-source-map' }); \ No newline at end of file diff --git a/src/Squidex/app/app.ts b/src/Squidex/app/app.ts index bb9ddbbf5..4b7765637 100644 --- a/src/Squidex/app/app.ts +++ b/src/Squidex/app/app.ts @@ -12,7 +12,7 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app.module'; -if (process.env.ENV === 'production') { +if (process.env.NODE_ENV === 'production') { enableProdMode(); } diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts index 57860d68d..15414e732 100644 --- a/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Observable } from 'rxjs'; +import { of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { UsersState } from './../state/users.state'; @@ -22,7 +22,7 @@ describe('UnsetUserGuard', () => { it('should unset user', () => { usersState.setup(x => x.select(null)) - .returns(() => Observable.of(null)); + .returns(() => of(null)); let result: boolean; diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.ts index 50937ac96..1a8d42be2 100644 --- a/src/Squidex/app/features/administration/guards/unset-user.guard.ts +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.ts @@ -8,6 +8,7 @@ import { Injectable } from '@angular/core'; import { CanActivate } from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { UsersState } from './../state/users.state'; @@ -19,6 +20,6 @@ export class UnsetUserGuard implements CanActivate { } public canActivate(): Observable { - return this.usersState.select(null).map(u => u === null); + return this.usersState.select(null).pipe(map(u => u === null)); } } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts index 783214daa..8fb04cde5 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -6,7 +6,7 @@ */ import { Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { UserDto } from './../services/users.service'; @@ -32,7 +32,7 @@ describe('UserMustExistGuard', () => { it('should load user and return true when found', () => { usersState.setup(x => x.select('123')) - .returns(() => Observable.of({})); + .returns(() => of({})); let result: boolean; @@ -47,7 +47,7 @@ describe('UserMustExistGuard', () => { it('should load user and return false when not found', () => { usersState.setup(x => x.select('123')) - .returns(() => Observable.of(null)); + .returns(() => of(null)); let result: boolean; diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts index 7f6994db0..db67514f9 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts @@ -8,6 +8,7 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; import { Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; import { allParams } from '@app/framework'; @@ -25,13 +26,13 @@ export class UserMustExistGuard implements CanActivate { const userId = allParams(route)['userId']; const result = - this.usersState.select(userId) - .do(dto => { + this.usersState.select(userId).pipe( + tap(dto => { if (!dto) { this.router.navigate(['/404']); } - }) - .map(u => u !== null); + }), + map(u => u !== null)); return result; } diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts index 0c0464b1e..2813e338f 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts @@ -6,7 +6,8 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Observable, Subscription } from 'rxjs'; +import { Subscription, timer } from 'rxjs'; +import { onErrorResumeNext, switchMap } from 'rxjs/operators'; import { ModalView } from '@app/shared'; @@ -34,28 +35,28 @@ export class EventConsumersPageComponent implements OnDestroy, OnInit { } public ngOnInit() { - this.eventConsumersState.load(false, true).onErrorResumeNext().subscribe(); + this.eventConsumersState.load(false, true).pipe(onErrorResumeNext()).subscribe(); this.timerSubscription = - Observable.timer(2000, 2000) - .switchMap(x => this.eventConsumersState.load(true, true).onErrorResumeNext()) + timer(2000, 2000).pipe( + switchMap(x => this.eventConsumersState.load(true, true)), onErrorResumeNext()) .subscribe(); } public reload() { - this.eventConsumersState.load(true, false).onErrorResumeNext().subscribe(); + this.eventConsumersState.load(true, false).pipe(onErrorResumeNext()).subscribe(); } public start(es: EventConsumerDto) { - this.eventConsumersState.start(es).onErrorResumeNext().subscribe(); + this.eventConsumersState.start(es).pipe(onErrorResumeNext()).subscribe(); } public stop(es: EventConsumerDto) { - this.eventConsumersState.stop(es).onErrorResumeNext().subscribe(); + this.eventConsumersState.stop(es).pipe(onErrorResumeNext()).subscribe(); } public reset(es: EventConsumerDto) { - this.eventConsumersState.reset(es).onErrorResumeNext().subscribe(); + this.eventConsumersState.reset(es).pipe(onErrorResumeNext()).subscribe(); } public trackByEventConsumer(index: number, es: EventConsumerDto) { diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index aac5aea12..8d8c5c052 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -7,6 +7,7 @@ import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; +import { onErrorResumeNext } from 'rxjs/operators'; import { UserDto } from './../../services/users.service'; import { UsersState } from './../../state/users.state'; @@ -25,31 +26,31 @@ export class UsersPageComponent implements OnInit { } public ngOnInit() { - this.usersState.load().onErrorResumeNext().subscribe(); + this.usersState.load().pipe(onErrorResumeNext()).subscribe(); } public reload() { - this.usersState.load(true).onErrorResumeNext().subscribe(); + this.usersState.load(true).pipe(onErrorResumeNext()).subscribe(); } public search() { - this.usersState.search(this.usersFilter.value).onErrorResumeNext().subscribe(); + this.usersState.search(this.usersFilter.value).pipe(onErrorResumeNext()).subscribe(); } public goPrev() { - this.usersState.goPrev().onErrorResumeNext().subscribe(); + this.usersState.goPrev().pipe(onErrorResumeNext()).subscribe(); } public goNext() { - this.usersState.goNext().onErrorResumeNext().subscribe(); + this.usersState.goNext().pipe(onErrorResumeNext()).subscribe(); } public lock(user: UserDto) { - this.usersState.lock(user).onErrorResumeNext().subscribe(); + this.usersState.lock(user).pipe(onErrorResumeNext()).subscribe(); } public unlock(user: UserDto) { - this.usersState.unlock(user).onErrorResumeNext().subscribe(); + this.usersState.unlock(user).pipe(onErrorResumeNext()).subscribe(); } public trackByUser(index: number, userInfo: { user: UserDto }) { diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts index e870f1d24..5eacb6d62 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -32,7 +32,7 @@ describe('EventConsumersService', () => { it('should make get request to get event consumers', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - let eventConsumers: EventConsumerDto[] | null = null; + let eventConsumers: EventConsumerDto[]; eventConsumersService.getEventConsumers().subscribe(result => { eventConsumers = result; @@ -60,10 +60,11 @@ describe('EventConsumersService', () => { } ]); - expect(eventConsumers).toEqual([ - new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), - new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') - ]); + expect(eventConsumers!).toEqual( + [ + new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), + new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') + ]); })); it('should make put request to start event consumer', diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts index 45b32a9db..c768c9231 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -8,12 +8,16 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; -import '@app/framework/angular/http/http-extensions'; +import { + ApiUrlConfig, + HTTP, + Model, + pretifyError +} from '@app/shared'; -import { ApiUrlConfig, HTTP } from '@app/shared'; - -export class EventConsumerDto { +export class EventConsumerDto extends Model { constructor( public readonly name: string, public readonly isStopped: boolean, @@ -21,6 +25,11 @@ export class EventConsumerDto { public readonly error: string, public readonly position: string ) { + super(); + } + + public with(value: Partial): EventConsumerDto { + return this.clone(value); } } @@ -35,8 +44,8 @@ export class EventConsumersService { public getEventConsumers(): Observable { const url = this.apiUrl.buildUrl('/api/event-consumers'); - return HTTP.getVersioned(this.http, url) - .map(response => { + return HTTP.getVersioned(this.http, url).pipe( + map(response => { const body = response.payload.body; const items: any[] = body; @@ -49,28 +58,28 @@ export class EventConsumersService { item.error, item.position); }); - }) - .pretifyError('Failed to load event consumers. Please reload.'); + }), + pretifyError('Failed to load event consumers. Please reload.')); } public putStart(name: string): Observable { const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); - return HTTP.putVersioned(this.http, url, {}) - .pretifyError('Failed to start event consumer. Please reload.'); + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to start event consumer. Please reload.')); } public putStop(name: string): Observable { const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); - return HTTP.putVersioned(this.http, url, {}) - .pretifyError('Failed to stop event consumer. Please reload.'); + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to stop event consumer. Please reload.')); } public putReset(name: string): Observable { const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); - return HTTP.putVersioned(this.http, url, {}) - .pretifyError('Failed to reset event consumer. Please reload.'); + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to reset event consumer. Please reload.')); } } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index 06b3e974d..4e769e6e7 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -38,7 +38,7 @@ describe('UsersService', () => { it('should make get request to get many users', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - let users: UsersDto | null = null; + let users: UsersDto; userManagementService.getUsers(20, 30).subscribe(result => { users = result; @@ -67,7 +67,7 @@ describe('UsersService', () => { ] }); - expect(users).toEqual( + expect(users!).toEqual( new UsersDto(100, [ new UserDto('123', 'mail1@domain.com', 'User1', true), new UserDto('456', 'mail2@domain.com', 'User2', true) @@ -77,7 +77,7 @@ describe('UsersService', () => { it('should make get request with query to get many users', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - let users: UsersDto | null = null; + let users: UsersDto; userManagementService.getUsers(20, 30, 'my-query').subscribe(result => { users = result; @@ -106,7 +106,7 @@ describe('UsersService', () => { ] }); - expect(users).toEqual( + expect(users!).toEqual( new UsersDto(100, [ new UserDto('123', 'mail1@domain.com', 'User1', true), new UserDto('456', 'mail2@domain.com', 'User2', true) @@ -116,7 +116,7 @@ describe('UsersService', () => { it('should make get request to get single user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - let user: UserDto | null = null; + let user: UserDto; userManagementService.getUser('123').subscribe(result => { user = result; @@ -135,7 +135,7 @@ describe('UsersService', () => { isLocked: true }); - expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', true)); + expect(user!).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', true)); })); it('should make post request to create user', @@ -143,7 +143,7 @@ describe('UsersService', () => { const dto = new CreateUserDto('mail@squidex.io', 'Squidex User', 'password'); - let user: UserDto | null = null; + let user: UserDto; userManagementService.postUser(dto).subscribe(result => { user = result; @@ -156,7 +156,7 @@ describe('UsersService', () => { req.flush({ id: '123', pictureUrl: 'path/to/image1' }); - expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, false)); + expect(user!).toEqual(new UserDto('123', dto.email, dto.displayName, false)); })); it('should make put request to update user', diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index e604ba0e8..4b23dbff4 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -8,26 +8,40 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; -import '@app/framework/angular/http/http-extensions'; +import { + ApiUrlConfig, + HTTP, + Model, + pretifyError +} from '@app/shared'; -import { ApiUrlConfig, HTTP } from '@app/shared'; - -export class UsersDto { +export class UsersDto extends Model { constructor( public readonly total: number, public readonly items: UserDto[] ) { + super(); + } + + public with(value: Partial): UsersDto { + return this.clone(value); } } -export class UserDto { +export class UserDto extends Model { constructor( public readonly id: string, public readonly email: string, public readonly displayName: string, public readonly isLocked: boolean ) { + super(); + } + + public with(value: Partial): UserDto { + return this.clone(value); } } @@ -60,8 +74,8 @@ export class UsersService { public getUsers(take: number, skip: number, query?: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); - return HTTP.getVersioned(this.http, url) - .map(response => { + return HTTP.getVersioned(this.http, url).pipe( + map(response => { const body = response.payload.body; const items: any[] = body.items; @@ -75,15 +89,15 @@ export class UsersService { }); return new UsersDto(body.total, users); - }) - .pretifyError('Failed to load users. Please reload.'); + }), + pretifyError('Failed to load users. Please reload.')); } public getUser(id: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management/${id}`); - return HTTP.getVersioned(this.http, url) - .map(response => { + return HTTP.getVersioned(this.http, url).pipe( + map(response => { const body = response.payload.body; return new UserDto( @@ -91,15 +105,15 @@ export class UsersService { body.email, body.displayName, body.isLocked); - }) - .pretifyError('Failed to load user. Please reload.'); + }), + pretifyError('Failed to load user. Please reload.')); } public postUser(dto: CreateUserDto): Observable { const url = this.apiUrl.buildUrl('api/user-management'); - return HTTP.postVersioned(this.http, url, dto) - .map(response => { + return HTTP.postVersioned(this.http, url, dto).pipe( + map(response => { const body = response.payload.body; return new UserDto( @@ -107,28 +121,28 @@ export class UsersService { dto.email, dto.displayName, false); - }) - .pretifyError('Failed to create user. Please reload.'); + }), + pretifyError('Failed to create user. Please reload.')); } public putUser(id: string, dto: UpdateUserDto): Observable { const url = this.apiUrl.buildUrl(`api/user-management/${id}`); - return HTTP.putVersioned(this.http, url, dto) - .pretifyError('Failed to update user. Please reload.'); + return HTTP.putVersioned(this.http, url, dto).pipe( + pretifyError('Failed to update user. Please reload.')); } public lockUser(id: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); - return HTTP.putVersioned(this.http, url, {}) - .pretifyError('Failed to load users. Please retry.'); + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to load users. Please retry.')); } public unlockUser(id: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); - return HTTP.putVersioned(this.http, url, {}) - .pretifyError('Failed to load users. Please retry.'); + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to load users. Please retry.')); } } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index 514ca72d7..45aa75468 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -5,7 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Observable } from 'rxjs'; +import { of, throwError } from 'rxjs'; +import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { DialogService } from '@app/shared'; @@ -29,7 +30,7 @@ describe('EventConsumersState', () => { eventConsumersService = Mock.ofType(); eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => Observable.of(oldConsumers)); + .returns(() => of(oldConsumers)); eventConsumersState = new EventConsumersState(dialogs.object, eventConsumersService.object); eventConsumersState.load().subscribe(); @@ -39,36 +40,44 @@ describe('EventConsumersState', () => { expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers); expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); + expect().nothing(); + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); it('should show notification on load when reload is true', () => { eventConsumersState.load(true).subscribe(); + expect().nothing(); + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); it('should show notification on load error when silent is true', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => Observable.throw({})); + .returns(() => throwError({})); - eventConsumersState.load(true, true).onErrorResumeNext().subscribe(); + eventConsumersState.load(true, true).pipe(onErrorResumeNext()).subscribe(); + + expect().nothing(); dialogs.verify(x => x.notifyError(It.isAny()), Times.once()); }); it('should not show notification on load error when flag is false', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => Observable.throw({})); + .returns(() => throwError({})); + + eventConsumersState.load().pipe(onErrorResumeNext()).subscribe(); - eventConsumersState.load().onErrorResumeNext().subscribe(); + expect().nothing(); dialogs.verify(x => x.notifyError(It.isAny()), Times.never()); }); it('should unmark as stopped when started', () => { eventConsumersService.setup(x => x.putStart(oldConsumers[1].name)) - .returns(() => Observable.of({})); + .returns(() => of({})); eventConsumersState.start(oldConsumers[1]).subscribe(); @@ -79,7 +88,7 @@ describe('EventConsumersState', () => { it('should mark as stopped when stopped', () => { eventConsumersService.setup(x => x.putStop(oldConsumers[0].name)) - .returns(() => Observable.of({})); + .returns(() => of({})); eventConsumersState.stop(oldConsumers[0]).subscribe(); @@ -90,7 +99,7 @@ describe('EventConsumersState', () => { it('should mark as resetting when reset', () => { eventConsumersService.setup(x => x.putReset(oldConsumers[0].name)) - .returns(() => Observable.of({})); + .returns(() => of({})); eventConsumersState.reset(oldConsumers[0]).subscribe(); diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.ts b/src/Squidex/app/features/administration/state/event-consumers.state.ts index bb43f806c..ef14e7963 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.ts @@ -6,13 +6,13 @@ */ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; - -import '@app/framework/utils/rxjs-extensions'; +import { Observable, throwError } from 'rxjs'; +import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; import { DialogService, ImmutableArray, + notify, State } from '@app/shared'; @@ -27,12 +27,12 @@ interface Snapshot { @Injectable() export class EventConsumersState extends State { public eventConsumers = - this.changes.map(x => x.eventConsumers) - .distinctUntilChanged(); + this.changes.pipe(map(x => x.eventConsumers), + distinctUntilChanged()); public isLoaded = - this.changes.map(x => !!x.isLoaded) - .distinctUntilChanged(); + this.changes.pipe(map(x => !!x.isLoaded), + distinctUntilChanged()); constructor( private readonly dialogs: DialogService, @@ -46,8 +46,8 @@ export class EventConsumersState extends State { this.resetState(); } - return this.eventConsumersService.getEventConsumers() - .do(dtos => { + return this.eventConsumersService.getEventConsumers().pipe( + tap(dtos => { if (isReload && !silent) { this.dialogs.notifyInfo('Event Consumers reloaded.'); } @@ -57,51 +57,51 @@ export class EventConsumersState extends State { return { ...s, eventConsumers, isLoaded: true }; }); - }) - .catch(error => { + }), + catchError(error => { if (silent) { this.dialogs.notifyError(error); } - return Observable.throw(error); - }); + return throwError(error); + })); } - public start(es: EventConsumerDto): Observable { - return this.eventConsumersService.putStart(es.name) - .do(() => { - this.replaceEventConsumer(setStopped(es, false)); - }) - .notify(this.dialogs); + public start(eventConsumer: EventConsumerDto): Observable { + return this.eventConsumersService.putStart(eventConsumer.name).pipe( + tap(() => { + this.replaceEventConsumer(setStopped(eventConsumer, false)); + }), + notify(this.dialogs)); } - public stop(es: EventConsumerDto): Observable { - return this.eventConsumersService.putStop(es.name) - .do(() => { - this.replaceEventConsumer(setStopped(es, true)); - }) - .notify(this.dialogs); + public stop(eventConsumer: EventConsumerDto): Observable { + return this.eventConsumersService.putStop(eventConsumer.name).pipe( + tap(() => { + this.replaceEventConsumer(setStopped(eventConsumer, true)); + }), + notify(this.dialogs)); } - public reset(es: EventConsumerDto): Observable { - return this.eventConsumersService.putReset(es.name) - .do(() => { - this.replaceEventConsumer(reset(es)); - }) - .notify(this.dialogs); + public reset(eventConsumer: EventConsumerDto): Observable { + return this.eventConsumersService.putReset(eventConsumer.name).pipe( + tap(() => { + this.replaceEventConsumer(reset(eventConsumer)); + }), + notify(this.dialogs)); } - private replaceEventConsumer(es: EventConsumerDto) { + private replaceEventConsumer(eventConsumer: EventConsumerDto) { this.next(s => { - const eventConsumers = s.eventConsumers.replaceBy('name', es); + const eventConsumers = s.eventConsumers.replaceBy('name', eventConsumer); return { ...s, eventConsumers }; }); } } -const setStopped = (es: EventConsumerDto, isStoped: boolean) => - new EventConsumerDto(es.name, isStoped, false, es.error, es.position); +const setStopped = (eventConsumer: EventConsumerDto, isStopped: boolean) => + eventConsumer.with({ isStopped }); -const reset = (es: EventConsumerDto) => - new EventConsumerDto(es.name, es.isStopped, true, es.error, es.position); \ No newline at end of file +const reset = (eventConsumer: EventConsumerDto) => + eventConsumer.with({ isResetting: true }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index 664feb8d7..a81b1ab0c 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Observable } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { AuthService, DialogService } from '@app/shared'; @@ -44,7 +44,7 @@ describe('UsersState', () => { usersService = Mock.ofType(); usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => Observable.of(new UsersDto(200, oldUsers))); + .returns(() => of(new UsersDto(200, oldUsers))); usersState = new UsersState(authService.object, dialogs.object, usersService.object); usersState.load().subscribe(); @@ -64,6 +64,8 @@ describe('UsersState', () => { it('should show notification on load when reload is true', () => { usersState.load(true).subscribe(); + expect().nothing(); + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); @@ -76,7 +78,7 @@ describe('UsersState', () => { ]; usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => Observable.of(new UsersDto(200, newUsers))); + .returns(() => of(new UsersDto(200, newUsers))); usersState.load().subscribe(); @@ -98,7 +100,7 @@ describe('UsersState', () => { it('should return user on select and load when not loaded', () => { usersService.setup(x => x.getUser('id3')) - .returns(() => Observable.of(newUser)); + .returns(() => of(newUser)); let selectedUser: UserDto; @@ -127,7 +129,7 @@ describe('UsersState', () => { it('should return null on select when user is not found', () => { usersService.setup(x => x.getUser('unknown')) - .returns(() => Observable.throw({})); + .returns(() => throwError({})); let selectedUser: UserDto; @@ -141,7 +143,7 @@ describe('UsersState', () => { it('should mark as locked when locked', () => { usersService.setup(x => x.lockUser('id1')) - .returns(() => Observable.of({})); + .returns(() => of({})); usersState.select('id1').subscribe(); usersState.lock(oldUsers[0]).subscribe(); @@ -149,12 +151,12 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(0); expect(user_1.user.isLocked).toBeTruthy(); - expect(user_1).toBe(usersState.snapshot.selectedUser); + expect(user_1).toBe(usersState.snapshot.selectedUser!); }); it('should unmark as locked when unlocked', () => { usersService.setup(x => x.unlockUser('id2')) - .returns(() => Observable.of({})); + .returns(() => of({})); usersState.select('id2').subscribe(); usersState.unlock(oldUsers[1]).subscribe(); @@ -162,14 +164,14 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(1); expect(user_1.user.isLocked).toBeFalsy(); - expect(user_1).toBe(usersState.snapshot.selectedUser); + expect(user_1).toBe(usersState.snapshot.selectedUser!); }); it('should update user properties when updated', () => { const request = new UpdateUserDto('new@mail.com', 'New'); usersService.setup(x => x.putUser('id1', request)) - .returns(() => Observable.of({})); + .returns(() => of({})); usersState.select('id1').subscribe(); usersState.update(oldUsers[0], request).subscribe(); @@ -178,14 +180,14 @@ describe('UsersState', () => { expect(user_1.user.email).toEqual('new@mail.com'); expect(user_1.user.displayName).toEqual('New'); - expect(user_1).toBe(usersState.snapshot.selectedUser); + expect(user_1).toBe(usersState.snapshot.selectedUser!); }); it('should add user to snapshot when created', () => { const request = new CreateUserDto(newUser.email, newUser.displayName, 'password'); usersService.setup(x => x.postUser(request)) - .returns(() => Observable.of(newUser)); + .returns(() => of(newUser)); usersState.create(request).subscribe(); @@ -199,18 +201,20 @@ describe('UsersState', () => { it('should load next page and prev page when paging', () => { usersService.setup(x => x.getUsers(10, 10, undefined)) - .returns(() => Observable.of(new UsersDto(200, []))); + .returns(() => of(new UsersDto(200, []))); usersState.goNext().subscribe(); usersState.goPrev().subscribe(); + expect().nothing(); + usersService.verify(x => x.getUsers(10, 10, undefined), Times.once()); usersService.verify(x => x.getUsers(10, 0, undefined), Times.exactly(2)); }); it('should load with query when searching', () => { usersService.setup(x => x.getUsers(10, 0, 'my-query')) - .returns(() => Observable.of(new UsersDto(0, []))); + .returns(() => of(new UsersDto(0, []))); usersState.search('my-query').subscribe(); diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index cd871f0d7..1caad1047 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -7,7 +7,8 @@ import { Injectable } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; @@ -16,6 +17,7 @@ import { DialogService, Form, ImmutableArray, + notify, Pager, State, ValidatorsEx @@ -89,20 +91,20 @@ interface Snapshot { @Injectable() export class UsersState extends State { public users = - this.changes.map(x => x.users) - .distinctUntilChanged(); + this.changes.pipe(map(x => x.users), + distinctUntilChanged()); public usersPager = - this.changes.map(x => x.usersPager) - .distinctUntilChanged(); + this.changes.pipe(map(x => x.usersPager), + distinctUntilChanged()); public selectedUser = - this.changes.map(x => x.selectedUser) - .distinctUntilChanged(); + this.changes.pipe(map(x => x.selectedUser), + distinctUntilChanged()); public isLoaded = - this.changes.map(x => !!x.isLoaded) - .distinctUntilChanged(); + this.changes.pipe(map(x => !!x.isLoaded), + distinctUntilChanged()); constructor( private readonly authState: AuthService, @@ -113,24 +115,24 @@ export class UsersState extends State { } public select(id: string | null): Observable { - return this.loadUser(id) - .do(selectedUser => { + return this.loadUser(id).pipe( + tap(selectedUser => { this.next(s => ({ ...s, selectedUser })); - }) - .map(x => x && x.user); + }), + map(x => x && x.user)); } private loadUser(id: string | null) { return !id ? - Observable.of(null) : - Observable.of(this.snapshot.users.find(x => x.user.id === id)) - .switchMap(user => { + of(null) : + of(this.snapshot.users.find(x => x.user.id === id)).pipe( + switchMap(user => { if (!user) { - return this.usersService.getUser(id).map(x => this.createUser(x)).catch(() => Observable.of(null)); + return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null))); } else { - return Observable.of(user); + return of(user); } - }); + })); } public load(isReload = false): Observable { @@ -145,8 +147,8 @@ export class UsersState extends State { return this.usersService.getUsers( this.snapshot.usersPager.pageSize, this.snapshot.usersPager.skip, - this.snapshot.usersQuery) - .do(dtos => { + this.snapshot.usersQuery).pipe( + tap(dtos => { if (isReload) { this.dialogs.notifyInfo('Users reloaded.'); } @@ -163,43 +165,43 @@ export class UsersState extends State { return { ...s, users, usersPager, selectedUser, isLoaded: true }; }); - }) - .notify(this.dialogs); + }), + notify(this.dialogs)); } public create(request: CreateUserDto): Observable { - return this.usersService.postUser(request) - .do(dto => { + return this.usersService.postUser(request).pipe( + tap(dto => { this.next(s => { const users = s.users.pushFront(this.createUser(dto)); const usersPager = s.usersPager.incrementCount(); return { ...s, users, usersPager }; }); - }); + })); } public update(user: UserDto, request: UpdateUserDto): Observable { - return this.usersService.putUser(user.id, request) - .do(() => { + return this.usersService.putUser(user.id, request).pipe( + tap(() => { this.replaceUser(update(user, request)); - }); + })); } public lock(user: UserDto): Observable { - return this.usersService.lockUser(user.id) - .do(() => { + return this.usersService.lockUser(user.id).pipe( + tap(() => { this.replaceUser(setLocked(user, true)); - }) - .notify(this.dialogs); + }), + notify(this.dialogs)); } public unlock(user: UserDto): Observable { - return this.usersService.unlockUser(user.id) - .do(() => { + return this.usersService.unlockUser(user.id).pipe( + tap(() => { this.replaceUser(setLocked(user, false)); - }) - .notify(this.dialogs); + }), + notify(this.dialogs)); } public search(query: string): Observable { @@ -247,7 +249,7 @@ export class UsersState extends State { const update = (user: UserDto, request: UpdateUserDto) => - new UserDto(user.id, request.email, request.displayName, user.isLocked); + user.with(request); const setLocked = (user: UserDto, isLocked: boolean) => - new UserDto(user.id, user.email, user.displayName, isLocked); \ No newline at end of file + user.with({ isLocked }); \ No newline at end of file diff --git a/src/Squidex/app/features/api/module.ts b/src/Squidex/app/features/api/module.ts index 7f7614cdc..f4701b425 100644 --- a/src/Squidex/app/features/api/module.ts +++ b/src/Squidex/app/features/api/module.ts @@ -7,7 +7,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { DndModule } from 'ng2-dnd'; import { SqxFrameworkModule, @@ -37,7 +36,6 @@ const routes: Routes = [ @NgModule({ imports: [ - DndModule, SqxFrameworkModule, SqxSharedModule, RouterModule.forChild(routes) diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss index 41707fac7..6d3c84b1c 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss @@ -18,4 +18,8 @@ overflow: hidden; } } + + .CodeMirror-linenumbers { + min-width: 29px; + } } \ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts index 9744728ff..8c59c1a4b 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts @@ -5,8 +5,9 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; -import { Observable } from 'rxjs'; +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; @@ -20,7 +21,7 @@ import { AppsState, GraphQlService } from '@app/shared'; styleUrls: ['./graphql-page.component.scss'], templateUrl: './graphql-page.component.html' }) -export class GraphQLPageComponent implements OnInit { +export class GraphQLPageComponent implements AfterViewInit { @ViewChild('graphiQLContainer') public graphiQLContainer: ElementRef; @@ -30,7 +31,7 @@ export class GraphQLPageComponent implements OnInit { ) { } - public ngOnInit() { + public ngAfterViewInit() { ReactDOM.render( React.createElement(GraphiQL, { fetcher: (params: any) => { @@ -42,7 +43,7 @@ export class GraphQLPageComponent implements OnInit { } private request(params: any) { - return this.graphQlService.query(this.appsState.appName, params).catch(response => Observable.of(response.error)).toPromise(); + return this.graphQlService.query(this.appsState.appName, params).pipe(catchError(response => of(response.error))).toPromise(); } } diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.ts b/src/Squidex/app/features/apps/pages/apps-page.component.ts index fc78b8020..93a2d5fa6 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.ts +++ b/src/Squidex/app/features/apps/pages/apps-page.component.ts @@ -6,6 +6,7 @@ */ import { Component, OnInit } from '@angular/core'; +import { take } from 'rxjs/operators'; import { AppsState, @@ -33,7 +34,8 @@ export class AppsPageComponent implements OnInit { } public ngOnInit() { - this.appsState.apps.take(1) + this.appsState.apps.pipe( + take(1)) .subscribe(apps => { if (this.onboardingService.shouldShow('dialog') && apps.length === 0) { this.onboardingService.disable('dialog'); diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.ts b/src/Squidex/app/features/assets/pages/assets-page.component.ts index ad35b6020..18173f82b 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.ts +++ b/src/Squidex/app/features/assets/pages/assets-page.component.ts @@ -9,6 +9,7 @@ import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; +import { onErrorResumeNext } from 'rxjs/operators'; import { AppsState, AssetsState } from '@app/shared'; @@ -27,23 +28,23 @@ export class AssetsPageComponent implements OnInit { } public ngOnInit() { - this.assetsState.load().onErrorResumeNext().subscribe(); + this.assetsState.load().pipe(onErrorResumeNext()).subscribe(); } public reload() { - this.assetsState.load(true).onErrorResumeNext().subscribe(); + this.assetsState.load(true).pipe(onErrorResumeNext()).subscribe(); } public search() { - this.assetsState.search(this.assetsFilter.value).onErrorResumeNext().subscribe(); + this.assetsState.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe(); } public goNext() { - this.assetsState.goNext().onErrorResumeNext().subscribe(); + this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe(); } public goPrev() { - this.assetsState.goPrev().onErrorResumeNext().subscribe(); + this.assetsState.goPrev().pipe(onErrorResumeNext()).subscribe(); } } diff --git a/src/Squidex/app/features/content/declarations.ts b/src/Squidex/app/features/content/declarations.ts index 7fdcbae6e..cd59c22c5 100644 --- a/src/Squidex/app/features/content/declarations.ts +++ b/src/Squidex/app/features/content/declarations.ts @@ -12,9 +12,11 @@ export * from './pages/contents/contents-page.component'; export * from './pages/contents/search-form.component'; export * from './pages/schemas/schemas-page.component'; +export * from './shared/array-editor.component'; export * from './shared/assets-editor.component'; export * from './shared/content-item.component'; export * from './shared/content-status.component'; export * from './shared/contents-selector.component'; export * from './shared/due-time-selector.component'; +export * from './shared/field-editor.component'; export * from './shared/references-editor.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/content/module.ts b/src/Squidex/app/features/content/module.ts index e7064c595..a372b141c 100644 --- a/src/Squidex/app/features/content/module.ts +++ b/src/Squidex/app/features/content/module.ts @@ -7,7 +7,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { DndModule } from 'ng2-dnd'; import { CanDeactivateGuard, @@ -20,6 +19,7 @@ import { } from '@app/shared'; import { + ArrayEditorComponent, AssetsEditorComponent, ContentFieldComponent, ContentHistoryComponent, @@ -29,6 +29,7 @@ import { ContentsSelectorComponent, ContentStatusComponent, DueTimeSelectorComponent, + FieldEditorComponent, ReferencesEditorComponent, SchemasPageComponent, SearchFormComponent @@ -82,10 +83,10 @@ const routes: Routes = [ imports: [ SqxFrameworkModule, SqxSharedModule, - DndModule, RouterModule.forChild(routes) ], declarations: [ + ArrayEditorComponent, AssetsEditorComponent, ContentFieldComponent, ContentHistoryComponent, @@ -95,6 +96,7 @@ const routes: Routes = [ ContentsPageComponent, ContentsSelectorComponent, DueTimeSelectorComponent, + FieldEditorComponent, ReferencesEditorComponent, SchemasPageComponent, SearchFormComponent diff --git a/src/Squidex/app/features/content/pages/content/content-field.component.html b/src/Squidex/app/features/content/pages/content/content-field.component.html index 7d69f79db..3b7f60e12 100644 --- a/src/Squidex/app/features/content/pages/content/content-field.component.html +++ b/src/Squidex/app/features/content/pages/content/content-field.component.html @@ -1,10 +1,4 @@
- - - Disabled -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - {{hints}} - - + +
diff --git a/src/Squidex/app/features/content/pages/content/content-field.component.ts b/src/Squidex/app/features/content/pages/content/content-field.component.ts index b03bede89..00480b402 100644 --- a/src/Squidex/app/features/content/pages/content/content-field.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-field.component.ts @@ -10,9 +10,11 @@ import { AbstractControl, FormGroup } from '@angular/forms'; import { AppLanguageDto, - FieldDto, + EditContentForm, fieldInvariant, - ImmutableArray + ImmutableArray, + RootFieldDto, + Types } from '@app/shared'; @Component({ @@ -22,7 +24,10 @@ import { }) export class ContentFieldComponent implements OnChanges { @Input() - public field: FieldDto; + public form: EditContentForm; + + @Input() + public field: RootFieldDto; @Input() public fieldForm: FormGroup; @@ -30,14 +35,11 @@ export class ContentFieldComponent implements OnChanges { @Input() public language: AppLanguageDto; - @Output() - public languageChange = new EventEmitter(); - @Input() public languages: ImmutableArray; - @Input() - public contentFormSubmitted: boolean; + @Output() + public languageChange = new EventEmitter(); public selectedFormControl: AbstractControl; @@ -49,7 +51,9 @@ export class ContentFieldComponent implements OnChanges { } if (changes['language']) { - this.selectedFormControl['_clearChangeFns'](); + if (Types.isFunction(this.selectedFormControl['_clearChangeFns'])) { + this.selectedFormControl['_clearChangeFns'](); + } } } } diff --git a/src/Squidex/app/features/content/pages/content/content-history.component.ts b/src/Squidex/app/features/content/pages/content/content-history.component.ts index f16610367..09763dc08 100644 --- a/src/Squidex/app/features/content/pages/content/content-history.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-history.component.ts @@ -7,7 +7,8 @@ import { Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; +import { merge, Observable, timer } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; import { allParams, @@ -48,8 +49,11 @@ export class ContentHistoryComponent { } public events: Observable = - Observable.timer(0, 10000).merge(this.messageBus.of(HistoryChannelUpdated).delay(1000)) - .switchMap(app => this.historyService.getHistory(this.appsState.appName, this.channel)); + merge( + timer(0, 10000), + this.messageBus.of(HistoryChannelUpdated).pipe(delay(1000)) + ).pipe( + switchMap(app => this.historyService.getHistory(this.appsState.appName, this.channel))); constructor( private readonly appsState: AppsState, diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 83b038a5e..35ba07fa3 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -31,7 +31,7 @@
-