Browse Source

Feature/UI Fields (#438)

* Better UI to manage list and reference fields.
pull/439/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
e0383fcf2c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 20
      backend/extensions/Squidex.SamplePlugin/Squidex.SamplePlugin.csproj
  2. 48
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldNames.cs
  3. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs
  4. 20
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs
  5. 62
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs
  6. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  7. 7
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaScripts.cs
  8. 27
      backend/src/Squidex.Domain.Apps.Core.Operations/EventSynchronization/SchemaSynchronizer.cs
  9. 7
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs
  10. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs
  11. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs
  12. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs
  13. 18
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs
  14. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs
  15. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs
  16. 14
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs
  17. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs
  18. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs
  19. 16
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs
  20. 18
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpsertCommand.cs
  21. 10
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs
  22. 128
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs
  23. 2
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs
  24. 15
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  25. 15
      backend/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs
  26. 20
      backend/src/Squidex.Domain.Apps.Events/Schemas/SchemaUIFieldsConfigured.cs
  27. 20
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  28. 6
      backend/src/Squidex.Infrastructure/Validation/Not.cs
  29. 26
      backend/src/Squidex.Web/ApiModelValidationAttribute.cs
  30. 31
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs
  31. 10
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs
  32. 15
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs
  33. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs
  34. 2
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs
  35. 29
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  36. 2
      backend/src/Squidex/Config/Domain/SerializationServices.cs
  37. 34
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs
  38. 36
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs
  39. 7
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs
  40. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs
  41. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs
  42. 29
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs
  43. 170
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs
  44. 66
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs
  45. 2
      frontend/app/_theme.html
  46. 2
      frontend/app/features/apps/pages/apps-page.component.html
  47. 2
      frontend/app/features/rules/pages/rules/rules-page.component.html
  48. 2
      frontend/app/features/schemas/declarations.ts
  49. 49
      frontend/app/features/schemas/module.ts
  50. 32
      frontend/app/features/schemas/pages/schema/field-list.component.html
  51. 34
      frontend/app/features/schemas/pages/schema/field-list.component.scss
  52. 57
      frontend/app/features/schemas/pages/schema/field-list.component.ts
  53. 8
      frontend/app/features/schemas/pages/schema/field.component.ts
  54. 30
      frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts
  55. 2
      frontend/app/features/schemas/pages/schema/schema-fields.component.ts
  56. 15
      frontend/app/features/schemas/pages/schema/schema-page.component.html
  57. 33
      frontend/app/features/schemas/pages/schema/schema-page.component.ts
  58. 26
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.html
  59. 21
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss
  60. 74
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts
  61. 2
      frontend/app/features/settings/pages/backups/backups-page.component.html
  62. 2
      frontend/app/features/settings/pages/clients/clients-page.component.html
  63. 2
      frontend/app/features/settings/pages/contributors/contributors-page.component.html
  64. 2
      frontend/app/features/settings/pages/languages/languages-page.component.html
  65. 2
      frontend/app/features/settings/pages/patterns/patterns-page.component.html
  66. 2
      frontend/app/features/settings/pages/roles/roles-page.component.html
  67. 1
      frontend/app/features/settings/pages/workflows/workflow-transition.component.scss
  68. 2
      frontend/app/features/settings/pages/workflows/workflows-page.component.html
  69. 2
      frontend/app/framework/angular/forms/tag-editor.component.scss
  70. 3
      frontend/app/shared/components/asset-uploader.component.scss
  71. 2
      frontend/app/shared/components/help.component.html
  72. 2
      frontend/app/shared/components/search-form.component.html
  73. 31
      frontend/app/shared/services/schemas.service.spec.ts
  74. 43
      frontend/app/shared/services/schemas.service.ts
  75. 2
      frontend/app/shared/services/schemas.types.ts
  76. 2
      frontend/app/shared/state/clients.state.ts
  77. 48
      frontend/app/shared/state/contents.forms.spec.ts
  78. 4
      frontend/app/shared/state/schemas.forms.ts
  79. 28
      frontend/app/shared/state/schemas.state.spec.ts
  80. 11
      frontend/app/shared/state/schemas.state.ts
  81. 2
      frontend/app/shared/state/workflows.state.ts
  82. 1
      frontend/app/shell/declarations.ts
  83. 2
      frontend/app/shell/module.ts
  84. 20
      frontend/app/shell/pages/internal/internal-area.component.html
  85. 54
      frontend/app/shell/pages/internal/internal-area.component.scss
  86. 27
      frontend/app/shell/pages/internal/logo.component.ts
  87. 5
      frontend/app/shell/pages/internal/profile-menu.component.html
  88. 8
      frontend/app/shell/pages/internal/profile-menu.component.scss
  89. 2
      frontend/app/theme/_bootstrap-vars.scss
  90. 1
      frontend/app/theme/_vars.scss
  91. 16
      frontend/app/theme/icomoon/demo.html
  92. BIN
      frontend/app/theme/icomoon/fonts/icomoon.eot
  93. 1
      frontend/app/theme/icomoon/fonts/icomoon.svg
  94. BIN
      frontend/app/theme/icomoon/fonts/icomoon.ttf
  95. BIN
      frontend/app/theme/icomoon/fonts/icomoon.woff
  96. 2
      frontend/app/theme/icomoon/selection.json
  97. 13
      frontend/app/theme/icomoon/style.css

20
backend/extensions/Squidex.SamplePlugin/Squidex.SamplePlugin.csproj

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />
<ProjectReference Include="..\..\src\Squidex.Web\Squidex.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<CodeAnalysisRuleSet>..\..\Squidex.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
</Project>

48
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldNames.cs

@ -0,0 +1,48 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace Squidex.Domain.Apps.Core.Schemas
{
public sealed class FieldNames : ReadOnlyCollection<string>
{
private static readonly List<string> EmptyNames = new List<string>();
public static readonly FieldNames Empty = new FieldNames(new List<string>());
public FieldNames(params string[] fields)
: base(fields?.ToList() ?? EmptyNames)
{
}
public FieldNames(IList<string> list)
: base(list ?? EmptyNames)
{
}
public FieldNames Add(string field)
{
var list = this.ToList();
list.Add(field);
return new FieldNames(list);
}
public FieldNames Remove(string field)
{
var list = this.ToList();
list.Remove(field);
return new FieldNames(list);
}
}
}

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

@ -13,10 +13,6 @@ namespace Squidex.Domain.Apps.Core.Schemas
{
public bool IsRequired { get; set; }
public bool IsListField { get; set; }
public bool IsReferenceField { get; set; }
public string? Placeholder { get; set; }
public string? EditorUrl { get; set; }

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

@ -32,13 +32,19 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
public SchemaProperties Properties { get; set; }
[JsonProperty]
public SchemaScripts Scripts { get; set; }
public SchemaScripts? Scripts { get; set; }
[JsonProperty]
public FieldNames? FieldsInLists { get; set; }
[JsonProperty]
public FieldNames? FieldsInReferences { get; set; }
[JsonProperty]
public JsonFieldModel[] Fields { get; set; }
[JsonProperty]
public Dictionary<string, string> PreviewUrls { get; set; }
public Dictionary<string, string>? PreviewUrls { get; set; }
public JsonSchemaModel()
{
@ -100,6 +106,16 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
schema = schema.ConfigureScripts(Scripts);
}
if (FieldsInLists?.Count > 0)
{
schema = schema.ConfigureFieldsInLists(FieldsInLists);
}
if (FieldsInReferences?.Count > 0)
{
schema = schema.ConfigureFieldsInReferences(FieldsInReferences);
}
if (PreviewUrls?.Count > 0)
{
schema = schema.ConfigurePreviewUrls(PreviewUrls);

62
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs

@ -18,9 +18,11 @@ namespace Squidex.Domain.Apps.Core.Schemas
private readonly string name;
private readonly bool isSingleton;
private string category;
private FieldNames fieldsInLists = FieldNames.Empty;
private FieldNames fieldsInReferences = FieldNames.Empty;
private FieldCollection<RootField> fields = FieldCollection<RootField>.Empty;
private IReadOnlyDictionary<string, string> previewUrls = EmptyPreviewUrls;
private SchemaScripts scripts = new SchemaScripts();
private SchemaScripts scripts = SchemaScripts.Empty;
private SchemaProperties properties;
private bool isPublished;
@ -69,6 +71,16 @@ namespace Squidex.Domain.Apps.Core.Schemas
get { return fields; }
}
public FieldNames FieldsInLists
{
get { return fieldsInLists; }
}
public FieldNames FieldsInReferences
{
get { return fieldsInReferences; }
}
public SchemaScripts Scripts
{
get { return scripts; }
@ -123,6 +135,42 @@ namespace Squidex.Domain.Apps.Core.Schemas
});
}
[Pure]
public Schema ConfigureFieldsInLists(FieldNames names)
{
return Clone(clone =>
{
clone.fieldsInLists = names ?? FieldNames.Empty;
});
}
[Pure]
public Schema ConfigureFieldsInLists(params string[] names)
{
return Clone(clone =>
{
clone.fieldsInLists = new FieldNames(names);
});
}
[Pure]
public Schema ConfigureFieldsInReferences(FieldNames names)
{
return Clone(clone =>
{
clone.fieldsInReferences = names ?? FieldNames.Empty;
});
}
[Pure]
public Schema ConfigureFieldsInReferences(params string[] names)
{
return Clone(clone =>
{
clone.fieldsInReferences = new FieldNames(names);
});
}
[Pure]
public Schema Publish()
{
@ -162,7 +210,17 @@ namespace Squidex.Domain.Apps.Core.Schemas
[Pure]
public Schema DeleteField(long fieldId)
{
return UpdateFields(f => f.Remove(fieldId));
if (!FieldsById.TryGetValue(fieldId, out var field))
{
return this;
}
return Clone(clone =>
{
clone.fields = fields.Remove(fieldId);
clone.fieldsInLists = fieldsInLists.Remove(field.Name);
clone.fieldsInReferences = fieldsInReferences.Remove(field.Name);
});
}
[Pure]

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

@ -66,14 +66,14 @@ namespace Squidex.Domain.Apps.Core.Schemas
public static IEnumerable<RootField> ReferenceFields(this Schema schema)
{
var references = schema.Fields.Where(x => x.RawProperties.IsReferenceField);
var references = schema.FieldsInReferences.Select(x => schema.FieldsByName.GetOrDefault(x)).Where(x => x != null).ToList();
if (references.Any())
{
return references;
}
references = schema.Fields.Where(x => x.RawProperties.IsListField);
references = schema.FieldsInLists.Select(x => schema.FieldsByName.GetOrDefault(x)).Where(x => x != null).ToList();
if (references.Any())
{
@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
private static bool IsListField(this IField field, Schema schema)
{
return field.RawProperties.IsListField || schema.Fields.Count == 1;
return schema.FieldsInLists.Contains(field.Name) || schema.Fields.Count == 1;
}
}
}

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

@ -9,6 +9,13 @@ namespace Squidex.Domain.Apps.Core.Schemas
{
public sealed class SchemaScripts : Freezable
{
public static readonly SchemaScripts Empty = new SchemaScripts();
static SchemaScripts()
{
Empty.Freeze();
}
public string Change { get; set; }
public string Create { get; set; }

27
backend/src/Squidex.Domain.Apps.Core.Operations/EventSynchronization/SchemaSynchronizer.cs

@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
if (!source.PreviewUrls.EqualsDictionary(target.PreviewUrls))
{
yield return E(new SchemaPreviewUrlsConfigured { PreviewUrls = target.PreviewUrls.ToDictionary(x => x.Key, x => x.Value) });
yield return E(new SchemaPreviewUrlsConfigured { PreviewUrls = target.PreviewUrls.ToDictionary() });
}
if (source.IsPublished != target.IsPublished)
@ -72,6 +72,16 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
{
yield return E(@event);
}
if (!source.FieldsInLists.SequenceEqual(target.FieldsInLists))
{
yield return E(new SchemaUIFieldsConfigured { FieldsInLists = target.FieldsInLists });
}
if (!source.FieldsInReferences.SequenceEqual(target.FieldsInReferences))
{
yield return E(new SchemaUIFieldsConfigured { FieldsInReferences = target.FieldsInReferences });
}
}
}
@ -90,8 +100,7 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
return @event;
}
var sourceIds = new List<NamedId<long>>(source.Ordered.Select(x => x.NamedId()));
var sourceNames = sourceIds.Select(x => x.Name).ToList();
var sourceIds = source.Ordered.Select(x => x.NamedId()).ToList();
if (!options.NoFieldDeletion)
{
@ -102,7 +111,6 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
var id = sourceField.NamedId();
sourceIds.Remove(id);
sourceNames.Remove(id.Name);
yield return E(new FieldDeleted { FieldId = id });
}
@ -133,7 +141,6 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
canCreateField = true;
sourceIds.Remove(id);
sourceNames.Remove(id.Name);
yield return E(new FieldDeleted { FieldId = id });
}
@ -160,7 +167,6 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
};
sourceIds.Add(id);
sourceNames.Add(id.Name);
}
if (id != null && (sourceField == null || CanUpdate(sourceField, targetField)))
@ -198,13 +204,14 @@ namespace Squidex.Domain.Apps.Core.EventSynchronization
}
}
if (sourceNames.Count > 1)
if (sourceIds.Count > 1)
{
var targetNames = target.Ordered.Select(x => x.Name);
var sourceNames = sourceIds.Select(x => x.Name).ToList();
var targetNames = target.Ordered.Select(x => x.Name).ToList();
if (sourceNames.Intersect(targetNames).Count() == target.Ordered.Count && !sourceNames.SequenceEqual(targetNames))
if (sourceNames.SetEquals(targetNames) && !sourceNames.SequenceEqual(targetNames))
{
var fieldIds = targetNames.Select(x => sourceIds.FirstOrDefault(y => y.Name == x).Id).ToList();
var fieldIds = targetNames.Select(x => sourceIds.Find(y => y.Name == x)!.Id).ToList();
yield return new SchemaFieldsReordered { FieldIds = fieldIds, ParentFieldId = parentId };
}

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

@ -122,12 +122,7 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
sb.Append(value);
}
var referenceFields = schema.Fields.Where(x => x.RawProperties.IsReferenceField);
if (!referenceFields.Any())
{
referenceFields = schema.Fields.Take(1);
}
var referenceFields = schema.ReferenceFields();
foreach (var referenceField in referenceFields)
{

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs

@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class AssetFieldBuilder : FieldBuilder
{
public AssetFieldBuilder(UpsertSchemaField field)
: base(field)
public AssetFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs

@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class BooleanFieldBuilder : FieldBuilder
{
public BooleanFieldBuilder(UpsertSchemaField field)
: base(field)
public BooleanFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs

@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class DateTimeFieldBuilder : FieldBuilder
{
public DateTimeFieldBuilder(UpsertSchemaField field)
: base(field)
public DateTimeFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}

18
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs

@ -14,15 +14,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
public abstract class FieldBuilder
{
private readonly UpsertSchemaField field;
private readonly UpsertCommand schema;
protected T Properties<T>() where T : FieldProperties
{
return (T)field.Properties;
}
protected FieldBuilder(UpsertSchemaField field)
protected FieldBuilder(UpsertSchemaField field, UpsertCommand schema)
{
this.field = field;
this.schema = schema;
}
public FieldBuilder Label(string? label)
@ -62,14 +64,24 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
public FieldBuilder ShowInList()
{
field.Properties.IsListField = true;
if (schema.FieldsInReferences == null)
{
schema.FieldsInReferences = new FieldNames();
}
schema.FieldsInReferences.Add(field.Name);
return this;
}
public FieldBuilder ShowInReferences()
{
field.Properties.IsReferenceField = true;
if (schema.FieldsInLists == null)
{
schema.FieldsInLists = new FieldNames();
}
schema.FieldsInLists.Add(field.Name);
return this;
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs

@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class JsonFieldBuilder : FieldBuilder
{
public JsonFieldBuilder(UpsertSchemaField field)
: base(field)
public JsonFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs

@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class NumberFieldBuilder : FieldBuilder
{
public NumberFieldBuilder(UpsertSchemaField field)
: base(field)
public NumberFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}
}

14
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs

@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<AssetsFieldProperties>(name);
configure(new AssetFieldBuilder(field));
configure(new AssetFieldBuilder(field, command));
return this;
}
@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<BooleanFieldProperties>(name);
configure(new BooleanFieldBuilder(field));
configure(new BooleanFieldBuilder(field, command));
return this;
}
@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<DateTimeFieldProperties>(name);
configure(new DateTimeFieldBuilder(field));
configure(new DateTimeFieldBuilder(field, command));
return this;
}
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<JsonFieldProperties>(name);
configure(new JsonFieldBuilder(field));
configure(new JsonFieldBuilder(field, command));
return this;
}
@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<NumberFieldProperties>(name);
configure(new NumberFieldBuilder(field));
configure(new NumberFieldBuilder(field, command));
return this;
}
@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<StringFieldProperties>(name);
configure(new StringFieldBuilder(field));
configure(new StringFieldBuilder(field, command));
return this;
}
@ -119,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
var field = AddField<TagsFieldProperties>(name);
configure(new TagsFieldBuilder(field));
configure(new TagsFieldBuilder(field, command));
return this;
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs

@ -13,8 +13,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class StringFieldBuilder : FieldBuilder
{
public StringFieldBuilder(UpsertSchemaField field)
: base(field)
public StringFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs

@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders
{
public class TagsFieldBuilder : FieldBuilder
{
public TagsFieldBuilder(UpsertSchemaField field)
: base(field)
public TagsFieldBuilder(UpsertSchemaField field, UpsertCommand schema)
: base(field, schema)
{
}
}

16
backend/extensions/Squidex.SamplePlugin/SamplePlugin.cs → backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs

@ -5,18 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins;
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.SamplePlugin
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
public sealed class SamplePlugin : IPlugin
public sealed class ConfigureUIFields : SchemaCommand
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
throw new NotImplementedException();
}
public FieldNames? FieldsInLists { get; set; }
public FieldNames? FieldsInReferences { get; set; }
}
}

18
backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpsertCommand.cs

@ -20,11 +20,15 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands
public SchemaFields Fields { get; set; }
public SchemaScripts Scripts { get; set; }
public FieldNames? FieldsInReferences { get; set; }
public FieldNames? FieldsInLists { get; set; }
public SchemaScripts? Scripts { get; set; }
public SchemaProperties Properties { get; set; }
public Dictionary<string, string> PreviewUrls { get; set; }
public Dictionary<string, string>? PreviewUrls { get; set; }
public Schema ToSchema(string name, bool isSingleton)
{
@ -45,6 +49,16 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands
schema = schema.ConfigurePreviewUrls(PreviewUrls);
}
if (FieldsInLists != null)
{
schema = schema.ConfigureFieldsInLists(FieldsInLists);
}
if (FieldsInReferences != null)
{
schema = schema.ConfigureFieldsInLists(FieldsInReferences);
}
if (!string.IsNullOrWhiteSpace(Category))
{
schema = schema.ChangeCategory(Category);

10
backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs

@ -23,16 +23,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
if (properties != null)
{
if (!properties.IsForApi() && properties.IsListField)
{
yield return new ValidationError("UI field cannot be a list field.", nameof(properties.IsListField));
}
if (!properties.IsForApi() && properties.IsReferenceField)
{
yield return new ValidationError("UI field cannot be a reference field.", nameof(properties.IsReferenceField));
}
foreach (var error in properties.Accept(Instance))
{
yield return error;

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

@ -55,20 +55,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false);
}
Validate.It(() => "Cannot reorder schema fields.", error =>
Validate.It(() => "Cannot reorder schema fields.", e =>
{
if (command.FieldIds == null)
{
error("Field ids is required.", nameof(command.FieldIds));
e(Not.Defined("Field ids"), nameof(command.FieldIds));
}
if (arrayField == null)
{
ValidateFieldIds(error, command, schema.FieldsById);
ValidateFieldIds(command, schema.FieldsById, e);
}
else
{
ValidateFieldIds(error, command, arrayField.FieldsById);
ValidateFieldIds(command, arrayField.FieldsById, e);
}
});
}
@ -77,11 +77,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
Guard.NotNull(command);
Validate.It(() => "Cannot configure preview urls.", error =>
Validate.It(() => "Cannot configure preview urls.", e =>
{
if (command.PreviewUrls == null)
{
error("Preview Urls is required.", nameof(command.PreviewUrls));
e(Not.Defined("Preview Urls"), nameof(command.PreviewUrls));
}
});
}
@ -106,6 +106,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
public static void CanConfigureUIFields(Schema schema, ConfigureUIFields command)
{
Guard.NotNull(command);
Validate.It(() => "Cannot configure UI fields.", e =>
{
ValidateFieldNames(schema, command.FieldsInLists, nameof(command.FieldsInLists), e);
ValidateFieldNames(schema, command.FieldsInReferences, nameof(command.FieldsInReferences), e);
});
}
public static void CanUpdate(Schema schema, UpdateSchema command)
{
Guard.NotNull(command);
@ -138,17 +149,23 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
fieldIndex++;
fieldPrefix = $"Fields[{fieldIndex}]";
ValidateRootField(field, fieldPrefix, e);
ValidateRootField(field, command, fieldPrefix, e);
}
if (command.Fields.Select(x => x?.Name).Distinct().Count() != command.Fields.Count)
foreach (var fieldName in command.Fields.Duplicates(x => x.Name))
{
if (fieldName.IsPropertyName())
{
e("Fields cannot have duplicate names.", nameof(command.Fields));
e($"Field '{fieldName}' has been added twice.", nameof(command.Fields));
}
}
}
private static void ValidateRootField(UpsertSchemaField field, string prefix, AddValidation e)
ValidateFieldNames(command, command.FieldsInLists, nameof(command.FieldsInLists), e);
ValidateFieldNames(command, command.FieldsInReferences, nameof(command.FieldsInReferences), e);
}
private static void ValidateRootField(UpsertSchemaField field, UpsertCommand command, string prefix, AddValidation e)
{
if (field == null)
{
@ -175,7 +192,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
nestedIndex++;
nestedPrefix = $"{prefix}.Nested[{nestedIndex}]";
ValidateNestedField(nestedField, nestedPrefix, e);
ValidateNestedField(nestedField, field.Nested, nestedPrefix, e);
}
}
else if (field.Nested.Count > 0)
@ -183,15 +200,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
e("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}");
}
if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count)
foreach (var fieldName in field.Nested.Duplicates(x => x.Name))
{
e("Fields cannot have duplicate names.", $"{prefix}.Nested");
if (fieldName.IsPropertyName())
{
e($"Field '{fieldName}' has been added twice.", $"{prefix}.Nested");
}
}
}
}
}
private static void ValidateNestedField(UpsertSchemaNestedField nestedField, string prefix, AddValidation e)
private static void ValidateNestedField(UpsertSchemaNestedField nestedField, IEnumerable<UpsertSchemaFieldBase> fields, string prefix, AddValidation e)
{
if (nestedField == null)
{
@ -212,7 +232,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
if (!field.Name.IsPropertyName())
{
e("Field name must be a valid javascript property name.", $"{prefix}.{nameof(field.Name)}");
e(Not.ValidPropertyName("Field name"), $"{prefix}.{nameof(field.Name)}");
}
if (field.Properties == null)
@ -240,11 +260,85 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
private static void ValidateFieldIds<T>(AddValidation error, ReorderFields c, IReadOnlyDictionary<long, T> fields)
private static void ValidateFieldNames(Schema schema, FieldNames? fields, string path, AddValidation e)
{
if (fields != null)
{
var fieldIndex = 0;
var fieldPrefix = string.Empty;
foreach (var fieldName in fields)
{
fieldIndex++;
fieldPrefix = $"{path}[{fieldIndex}]";
if (string.IsNullOrWhiteSpace(fieldName))
{
e(Not.Defined("Field"), fieldPrefix);
}
else if (!schema.FieldsByName.TryGetValue(fieldName, out var field))
{
e($"Field is not part of the schema.", fieldPrefix);
}
else if (!field.IsForApi())
{
e($"Field cannot be an UI field.", fieldPrefix);
}
}
foreach (var duplicate in fields.Duplicates())
{
if (!string.IsNullOrWhiteSpace(duplicate))
{
e($"Field '{duplicate}' has been added twice.", path);
}
}
}
}
private static void ValidateFieldNames(UpsertCommand command, FieldNames? fields, string path, AddValidation e)
{
if (fields != null)
{
var fieldIndex = 0;
var fieldPrefix = string.Empty;
foreach (var fieldName in fields)
{
fieldIndex++;
fieldPrefix = $"{path}[{fieldIndex}]";
var field = command?.Fields?.Find(x => x.Name == fieldName);
if (string.IsNullOrWhiteSpace(fieldName))
{
e(Not.Defined("Field"), fieldPrefix);
}
else if (field == null)
{
e($"Field is not part of the schema.", fieldPrefix);
}
else if (field?.Properties.IsForApi() != true)
{
e($"Field cannot be an UI field.", fieldPrefix);
}
}
foreach (var duplicate in fields.Duplicates())
{
if (!string.IsNullOrWhiteSpace(duplicate))
{
e($"Field '{duplicate}' has been added twice.", path);
}
}
}
}
private static void ValidateFieldIds<T>(ReorderFields c, IReadOnlyDictionary<long, T> fields, AddValidation e)
{
if (c.FieldIds != null && (c.FieldIds.Count != fields.Count || c.FieldIds.Any(x => !fields.ContainsKey(x))))
{
error("Field ids do not cover all fields.", nameof(c.FieldIds));
e("Field ids do not cover all fields.", nameof(c.FieldIds));
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs

@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
if (!command.Name.IsPropertyName())
{
e("Name must be a valid javascript property name.", nameof(command.Name));
e(Not.ValidPropertyName("Name"), nameof(command.Name));
}
if (command.Properties == null)

15
backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -224,6 +224,16 @@ namespace Squidex.Domain.Apps.Entities.Schemas
return Snapshot;
});
case ConfigureUIFields configureUIFields:
return UpdateReturn(configureUIFields, c =>
{
GuardSchema.CanConfigureUIFields(Snapshot.SchemaDef, c);
ConfigureUIFields(c);
return Snapshot;
});
case DeleteSchema deleteSchema:
return Update(deleteSchema, c =>
{
@ -331,6 +341,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas
RaiseEvent(command, new SchemaPreviewUrlsConfigured());
}
public void ConfigureUIFields(ConfigureUIFields command)
{
RaiseEvent(command, new SchemaUIFieldsConfigured());
}
public void Update(UpdateSchema command)
{
RaiseEvent(command, new SchemaUpdated());

15
backend/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs

@ -70,6 +70,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State
break;
}
case SchemaUIFieldsConfigured e:
{
if (e.FieldsInLists != null)
{
SchemaDef = SchemaDef.ConfigureFieldsInLists(e.FieldsInLists);
}
if (e.FieldsInReferences != null)
{
SchemaDef = SchemaDef.ConfigureFieldsInReferences(e.FieldsInReferences);
}
break;
}
case SchemaCategoryChanged e:
{
SchemaDef = SchemaDef.ChangeCategory(e.Name);

20
backend/src/Squidex.Domain.Apps.Events/Schemas/SchemaUIFieldsConfigured.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Schemas
{
[EventType(nameof(SchemaUIFieldsConfigured))]
public sealed class SchemaUIFieldsConfigured : SchemaEvent
{
public FieldNames FieldsInLists { get; set; }
public FieldNames FieldsInReferences { get; set; }
}
}

20
backend/src/Squidex.Infrastructure/CollectionExtensions.cs

@ -13,6 +13,11 @@ namespace Squidex.Infrastructure
{
public static class CollectionExtensions
{
public static bool SetEquals<T>(this ICollection<T> source, ICollection<T> other)
{
return source.Intersect(other).Count() == other.Count;
}
public static IResultList<T> SortSet<T, TKey>(this IResultList<T> input, Func<T, TKey> idProvider, IReadOnlyList<TKey> ids) where T : class
{
return ResultList.Create(input.Total, SortList(input, idProvider, ids));
@ -23,6 +28,16 @@ namespace Squidex.Infrastructure
return ids.Select(id => input.FirstOrDefault(x => Equals(idProvider(x), id))).Where(x => x != null);
}
public static IEnumerable<T> Duplicates<T>(this IEnumerable<T> input)
{
return input.GroupBy(x => x).Where(x => x.Count() > 1).Select(x => x.Key);
}
public static IEnumerable<TResult> Duplicates<TResult, T>(this IEnumerable<T> input, Func<T, TResult> selector)
{
return input.GroupBy(selector).Where(x => x.Count() > 1).Select(x => x.Key);
}
public static void AddRange<T>(this ICollection<T> target, IEnumerable<T> source)
{
foreach (var value in source)
@ -135,6 +150,11 @@ namespace Squidex.Infrastructure
return other != null && dictionary.Count == other.Count && !dictionary.Except(other, comparer).Any();
}
public static Dictionary<TKey, TValue> ToDictionary<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dictionary) where TKey : notnull
{
return dictionary.ToDictionary(x => x.Key, x => x.Value);
}
public static TValue GetOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key) where TKey : notnull
{
return dictionary.GetOrCreate(key, _ => default!);

6
backend/src/Squidex.Infrastructure/Validation/Not.cs

@ -29,6 +29,12 @@ namespace Squidex.Infrastructure.Validation
return $"{Upper(property)} is not a valid slug.";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ValidPropertyName(string property)
{
return $"{Upper(property)} is not a Javascript property name.";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string GreaterThan(string property, string other)
{

26
backend/src/Squidex.Web/ApiModelValidationAttribute.cs

@ -7,6 +7,7 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Newtonsoft.Json;
using Squidex.Infrastructure.Validation;
@ -27,20 +28,30 @@ namespace Squidex.Web
{
var errors = new List<ValidationError>();
foreach (var m in context.ModelState)
foreach (var (key, value) in context.ModelState)
{
foreach (var e in m.Value.Errors)
if (value.ValidationState == ModelValidationState.Invalid)
{
if (!string.IsNullOrWhiteSpace(e.ErrorMessage) && (allErrors || e.Exception is JsonException))
if (string.IsNullOrWhiteSpace(key))
{
errors.Add(new ValidationError(e.ErrorMessage, m.Key));
errors.Add(new ValidationError("Request body has an invalid format."));
}
else if (e.Exception is JsonException jsonException)
else
{
foreach (var error in value.Errors)
{
if (!string.IsNullOrWhiteSpace(error.ErrorMessage) && ShouldExpose(error))
{
errors.Add(new ValidationError(error.ErrorMessage, key));
}
else if (error.Exception is JsonException jsonException)
{
errors.Add(new ValidationError(jsonException.Message));
}
}
}
}
}
if (errors.Count > 0)
{
@ -48,5 +59,10 @@ namespace Squidex.Web
}
}
}
private bool ShouldExpose(ModelError error)
{
return allErrors || error.Exception is JsonException;
}
}
}

31
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Schemas.Models
{
public sealed class ConfigureUIFieldsDto
{
/// <summary>
/// The name of fields that are used in content lists.
/// </summary>
public FieldNames? FieldsInLists { get; set; }
/// <summary>
/// The name of fields that are used in content references.
/// </summary>
public FieldNames? FieldsInReferences { get; set; }
public ConfigureUIFields ToCommand()
{
return SimpleMapper.Map(this, new ConfigureUIFields());
}
}
}

10
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs

@ -43,16 +43,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
/// </summary>
public bool IsRequired { get; set; }
/// <summary>
/// Determines if the field should be displayed in lists.
/// </summary>
public bool IsListField { get; set; }
/// <summary>
/// Determines if the field should be displayed in reference lists.
/// </summary>
public bool IsReferenceField { get; set; }
/// <summary>
/// Optional url to the editor.
/// </summary>

15
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs

@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
@ -21,13 +22,27 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
/// <summary>
/// The scripts.
/// </summary>
[Required]
public SchemaScriptsDto Scripts { get; set; } = new SchemaScriptsDto();
/// <summary>
/// The preview Urls.
/// </summary>
[Required]
public Dictionary<string, string> PreviewUrls { get; set; } = EmptyPreviewUrls;
/// <summary>
/// The name of fields that are used in content lists.
/// </summary>
[Required]
public FieldNames FieldsInLists { get; set; }
/// <summary>
/// The name of fields that are used in content references.
/// </summary>
[Required]
public FieldNames FieldsInReferences { get; set; }
/// <summary>
/// The list of fields.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs

@ -119,14 +119,15 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
if (allowUpdate)
{
AddPostLink("fields/add", controller.Url<SchemaFieldsController>(x => nameof(x.PostField), values));
AddPutLink("fields/order", controller.Url<SchemaFieldsController>(x => nameof(x.PutSchemaFieldOrdering), values));
AddPutLink("fields/ui", controller.Url<SchemaFieldsController>(x => nameof(x.PutSchemaUIFields), values));
AddPutLink("update", controller.Url<SchemasController>(x => nameof(x.PutSchema), values));
AddPutLink("update/category", controller.Url<SchemasController>(x => nameof(x.PutCategory), values));
AddPutLink("update/sync", controller.Url<SchemasController>(x => nameof(x.PutSchemaSync), values));
AddPutLink("update/urls", controller.Url<SchemasController>(x => nameof(x.PutPreviewUrls), values));
AddPostLink("fields/add", controller.Url<SchemaFieldsController>(x => nameof(x.PostField), values));
}
if (controller.HasPermission(Permissions.AppSchemasScripts, app, Name))

2
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs

@ -27,7 +27,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
/// <summary>
/// Optional fields.
/// </summary>
public List<UpsertSchemaFieldDto?> Fields { get; set; }
public List<UpsertSchemaFieldDto?>? Fields { get; set; }
/// <summary>
/// The optional preview urls.

29
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs

@ -50,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
var response = await InvokeCommandAsync(app, command);
return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name = request.Name }, response);
return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name }, response);
}
/// <summary>
@ -77,7 +77,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas
var response = await InvokeCommandAsync(app, command);
return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name = request.Name }, response);
return CreatedAtAction(nameof(SchemasController.GetSchema), "Schemas", new { app, name }, response);
}
/// <summary>
/// Configure UI fields.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="request">The request that contains the field names.</param>
/// <returns>
/// 200 => Schema UI fields defined.
/// 400 => Schema field contains invalid field names.
/// 404 => Schema or app not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/ui/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ApiPermission(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutSchemaUIFields(string app, string name, [FromBody] ConfigureUIFieldsDto request)
{
var command = request.ToCommand();
var response = await InvokeCommandAsync(app, command);
return Ok(response);
}
/// <summary>

2
backend/src/Squidex/Config/Domain/SerializationServices.cs

@ -115,6 +115,8 @@ namespace Squidex.Config.Domain
{
mvc.AddNewtonsoftJson(options =>
{
options.AllowInputFormatterExceptionMessages = false;
ConfigureJson(options.SerializerSettings, TypeNameHandling.None);
});

34
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs

@ -209,6 +209,22 @@ namespace Squidex.Domain.Apps.Core.Model.Schemas
Assert.Empty(schema_2.FieldsById);
}
[Fact]
public void Should_also_remove_deleted_fields_from_lists()
{
var field = CreateField(1);
var schema_1 = schema_0
.AddField(field)
.ConfigureFieldsInLists(field.Name)
.ConfigureFieldsInReferences(field.Name);
var schema_2 = schema_1.DeleteField(1);
Assert.Empty(schema_2.FieldsById);
Assert.Empty(schema_2.FieldsInLists);
Assert.Empty(schema_2.FieldsInReferences);
}
[Fact]
public void Should_return_same_schema_if_field_to_delete_does_not_exist()
{
@ -283,6 +299,22 @@ namespace Squidex.Domain.Apps.Core.Model.Schemas
Assert.Equal("Category", schema_1.Category);
}
[Fact]
public void Should_set_list_fields()
{
var schema_1 = schema_0.ConfigureFieldsInLists("1");
Assert.Equal(new[] { "1" }, schema_1.FieldsInLists);
}
[Fact]
public void Should_set_reference_fields()
{
var schema_1 = schema_0.ConfigureFieldsInReferences("2");
Assert.Equal(new[] { "2" }, schema_1.FieldsInReferences);
}
[Fact]
public void Should_configure_scripts()
{
@ -319,6 +351,8 @@ namespace Squidex.Domain.Apps.Core.Model.Schemas
var schemaSource =
TestUtils.MixedSchema(true)
.ChangeCategory("Category")
.ConfigureFieldsInLists("field2")
.ConfigureFieldsInReferences("field1")
.ConfigurePreviewUrls(new Dictionary<string, string>
{
["web"] = "Url"

36
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs

@ -140,6 +140,42 @@ namespace Squidex.Domain.Apps.Core.Operations.EventSynchronization
);
}
[Fact]
public void Should_create_events_if_list_fields_changed()
{
var sourceSchema =
new Schema("source")
.ConfigureFieldsInLists("1", "2");
var targetSchema =
new Schema("target")
.ConfigureFieldsInLists("2", "1");
var events = sourceSchema.Synchronize(targetSchema, jsonSerializer, idGenerator);
events.ShouldHaveSameEvents(
new SchemaUIFieldsConfigured { FieldsInLists = new FieldNames("2", "1") }
);
}
[Fact]
public void Should_create_events_if_reference_fields_changed()
{
var sourceSchema =
new Schema("source")
.ConfigureFieldsInReferences("1", "2");
var targetSchema =
new Schema("target")
.ConfigureFieldsInReferences("2", "1");
var events = sourceSchema.Synchronize(targetSchema, jsonSerializer, idGenerator);
events.ShouldHaveSameEvents(
new SchemaUIFieldsConfigured { FieldsInReferences = new FieldNames("2", "1") }
);
}
[Fact]
public void Should_create_events_if_nested_field_deleted()
{

7
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs

@ -27,10 +27,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var schema =
new Schema("my-schema")
.AddString(1, "ref1", Partitioning.Invariant,
new StringFieldProperties { IsReferenceField = true })
new StringFieldProperties())
.AddString(2, "ref2", Partitioning.Invariant,
new StringFieldProperties { IsReferenceField = true })
.AddString(3, "non-ref", Partitioning.Invariant);
new StringFieldProperties())
.AddString(3, "non-ref", Partitioning.Invariant)
.ConfigureFieldsInReferences("ref1", "ref2");
var formatted = data.FormatReferences(schema, languages);

9
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs

@ -42,16 +42,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
ResolveImage = true,
MinItems = 2,
MaxItems = 3,
IsListField = true
MaxItems = 3
})
.AddAssets(2, "asset2", Partitioning.Language, new AssetsFieldProperties
{
ResolveImage = true,
MinItems = 1,
MaxItems = 1,
IsListField = true,
});
MaxItems = 1
})
.ConfigureFieldsInLists("asset1", "asset2");
A.CallTo(() => assetUrlGenerator.GenerateUrl(A<string>.Ignored))
.ReturnsLazily(new Func<string, string>(id => $"url/to/{id}"));

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs

@ -42,16 +42,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var refSchemaDef =
new Schema("my-ref")
.AddString(1, "name", Partitioning.Invariant,
new StringFieldProperties { IsReferenceField = true })
new StringFieldProperties())
.AddNumber(2, "number", Partitioning.Invariant,
new NumberFieldProperties { IsReferenceField = true });
new NumberFieldProperties())
.ConfigureFieldsInReferences("name", "number");
var schemaDef =
new Schema(schemaId.Name)
.AddReferences(1, "ref1", Partitioning.Invariant, new ReferencesFieldProperties
{
ResolveReference = true,
IsListField = true,
MinItems = 1,
MaxItems = 1,
SchemaId = refSchemaId1.Id
@ -59,11 +59,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.AddReferences(2, "ref2", Partitioning.Invariant, new ReferencesFieldProperties
{
ResolveReference = true,
IsListField = true,
MinItems = 1,
MaxItems = 1,
SchemaId = refSchemaId2.Id
});
})
.ConfigureFieldsInLists("ref1", "ref2");
void SetupSchema(NamedId<Guid> id, Schema def)
{

29
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs

@ -253,24 +253,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
GuardSchemaField.CanUpdate(schema_0, command);
}
[Fact]
public void CanUpdate_should_throw_exception_if_marking_a_ui_field_as_list_field()
{
var command = new UpdateField { FieldId = 4, Properties = new UIFieldProperties { IsListField = true } };
ValidationAssert.Throws(() => GuardSchemaField.CanUpdate(schema_0, command),
new ValidationError("UI field cannot be a list field.", "Properties.IsListField"));
}
[Fact]
public void CanUpdate_should_throw_exception_if_marking_a_ui_field_as_reference_field()
{
var command = new UpdateField { FieldId = 4, Properties = new UIFieldProperties { IsReferenceField = true } };
ValidationAssert.Throws(() => GuardSchemaField.CanUpdate(schema_0, command),
new ValidationError("UI field cannot be a reference field.", "Properties.IsReferenceField"));
}
[Fact]
public void CanUpdate_should_throw_exception_if_properties_null()
{
@ -313,7 +295,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
var command = new AddField { Name = "INVALID_NAME", Properties = validProperties };
ValidationAssert.Throws(() => GuardSchemaField.CanAdd(schema_0, command),
new ValidationError("Name must be a valid javascript property name.", "Name"));
new ValidationError("Name is not a Javascript property name.", "Name"));
}
[Fact]
@ -343,15 +325,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
new ValidationError("Partitioning is not a valid value.", "Partitioning"));
}
[Fact]
public void CanAdd_should_throw_exception_if_creating_a_ui_field_as_list_field()
{
var command = new AddField { Name = "field5", Properties = new UIFieldProperties { IsListField = true } };
ValidationAssert.Throws(() => GuardSchemaField.CanAdd(schema_0, command),
new ValidationError("UI field cannot be a list field.", "Properties.IsListField"));
}
[Fact]
public void CanAdd_should_throw_exception_if_parent_not_exists()
{

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

@ -29,7 +29,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
schema_0 =
new Schema("my-schema")
.AddString(1, "field1", Partitioning.Invariant)
.AddString(2, "field2", Partitioning.Invariant);
.AddString(2, "field2", Partitioning.Invariant)
.AddUI(4, "field4", Partitioning.Invariant);
}
[Fact]
@ -60,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Field name must be a valid javascript property name.",
new ValidationError("Field name is not a Javascript property name.",
"Fields[1].Name"));
}
@ -159,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Fields cannot have duplicate names.",
new ValidationError("Field 'field1' has been added twice.",
"Fields"));
}
@ -190,7 +191,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Field name must be a valid javascript property name.",
new ValidationError("Field name is not a Javascript property name.",
"Fields[1].Nested[1].Name"));
}
@ -320,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Fields cannot have duplicate names.",
new ValidationError("Field 'nested1' has been added twice.",
"Fields[1].Nested"));
}
@ -335,45 +336,116 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
new UpsertSchemaField
{
Name = "field1",
Properties = new UIFieldProperties
{
IsListField = true,
IsReferenceField = true
},
Properties = new UIFieldProperties(),
IsHidden = true,
IsDisabled = true,
Partitioning = Partitioning.Invariant.Key
}
},
FieldsInLists = new FieldNames("field1"),
FieldsInReferences = new FieldNames("field1"),
Name = "new-schema"
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("UI field cannot be a list field.",
"Fields[1].Properties.IsListField"),
new ValidationError("UI field cannot be a reference field.",
"Fields[1].Properties.IsReferenceField"),
new ValidationError("UI field cannot be hidden.",
"Fields[1].IsHidden"),
new ValidationError("UI field cannot be disabled.",
"Fields[1].IsDisabled"));
"Fields[1].IsDisabled"),
new ValidationError("Field cannot be an UI field.",
"FieldsInLists[1]"),
new ValidationError("Field cannot be an UI field.",
"FieldsInReferences[1]"));
}
[Fact]
public void CanCreate_should_not_throw_exception_if_command_is_valid()
public void CanCreate_should_throw_exception_if_invalid_lists_field_are_used()
{
var command = new CreateSchema
{
Fields = new List<UpsertSchemaField>
{
new UpsertSchemaField
{
Name = "field1",
Properties = new StringFieldProperties(),
Partitioning = Partitioning.Invariant.Key
},
new UpsertSchemaField
{
Name = "field4",
Properties = new UIFieldProperties(),
Partitioning = Partitioning.Invariant.Key
}
},
FieldsInLists = new FieldNames(null!, null!, "field3", "field1", "field1", "field4"),
FieldsInReferences = null,
Name = "new-schema"
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Field is required.",
"FieldsInLists[1]"),
new ValidationError("Field is required.",
"FieldsInLists[2]"),
new ValidationError("Field is not part of the schema.",
"FieldsInLists[3]"),
new ValidationError("Field cannot be an UI field.",
"FieldsInLists[6]"),
new ValidationError("Field 'field1' has been added twice.",
"FieldsInLists"));
}
[Fact]
public void CanCreate_should_throw_exception_if_invalid_references_field_are_used()
{
var command = new CreateSchema
{
AppId = appId,
Fields = new List<UpsertSchemaField>
{
new UpsertSchemaField
{
Name = "field1",
Properties = new StringFieldProperties
Properties = new StringFieldProperties(),
Partitioning = Partitioning.Invariant.Key
},
new UpsertSchemaField
{
IsListField = true
Name = "field4",
Properties = new UIFieldProperties(),
Partitioning = Partitioning.Invariant.Key
}
},
FieldsInLists = null,
FieldsInReferences = new FieldNames(null!, null!, "field3", "field1", "field1", "field4"),
Name = "new-schema"
};
ValidationAssert.Throws(() => GuardSchema.CanCreate(command),
new ValidationError("Field is required.",
"FieldsInReferences[1]"),
new ValidationError("Field is required.",
"FieldsInReferences[2]"),
new ValidationError("Field is not part of the schema.",
"FieldsInReferences[3]"),
new ValidationError("Field cannot be an UI field.",
"FieldsInReferences[6]"),
new ValidationError("Field 'field1' has been added twice.",
"FieldsInReferences"));
}
[Fact]
public void CanCreate_should_not_throw_exception_if_command_is_valid()
{
var command = new CreateSchema
{
AppId = appId,
Fields = new List<UpsertSchemaField>
{
new UpsertSchemaField
{
Name = "field1",
Properties = new StringFieldProperties(),
IsHidden = true,
IsDisabled = true,
Partitioning = Partitioning.Invariant.Key
@ -404,12 +476,70 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
},
FieldsInLists = new FieldNames("field1"),
FieldsInReferences = new FieldNames("field1"),
Name = "new-schema"
};
GuardSchema.CanCreate(command);
}
[Fact]
public void CanConfigureUIFields_should_throw_exception_if_invalid_lists_field_are_used()
{
var command = new ConfigureUIFields
{
FieldsInLists = new FieldNames(null!, null!, "field3", "field1", "field1", "field4"),
FieldsInReferences = null
};
ValidationAssert.Throws(() => GuardSchema.CanConfigureUIFields(schema_0, command),
new ValidationError("Field is required.",
"FieldsInLists[1]"),
new ValidationError("Field is required.",
"FieldsInLists[2]"),
new ValidationError("Field is not part of the schema.",
"FieldsInLists[3]"),
new ValidationError("Field cannot be an UI field.",
"FieldsInLists[6]"),
new ValidationError("Field 'field1' has been added twice.",
"FieldsInLists"));
}
[Fact]
public void CanConfigureUIFields_should_throw_exception_if_invalid_references_field_are_used()
{
var command = new ConfigureUIFields
{
FieldsInLists = null,
FieldsInReferences = new FieldNames(null!, null!, "field3", "field1", "field1", "field4")
};
ValidationAssert.Throws(() => GuardSchema.CanConfigureUIFields(schema_0, command),
new ValidationError("Field is required.",
"FieldsInReferences[1]"),
new ValidationError("Field is required.",
"FieldsInReferences[2]"),
new ValidationError("Field is not part of the schema.",
"FieldsInReferences[3]"),
new ValidationError("Field cannot be an UI field.",
"FieldsInReferences[6]"),
new ValidationError("Field 'field1' has been added twice.",
"FieldsInReferences"));
}
[Fact]
public void CanConfigureUIFields_should_not_throw_exception_if_command_is_valid()
{
var command = new ConfigureUIFields
{
FieldsInLists = new FieldNames("field1"),
FieldsInReferences = new FieldNames("field2")
};
GuardSchema.CanConfigureUIFields(schema_0, command);
}
[Fact]
public void CanPublish_should_throw_exception_if_already_published()
{
@ -484,7 +614,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
[Fact]
public void CanReorder_should_not_throw_exception_if_field_ids_are_valid()
{
var command = new ReorderFields { FieldIds = new List<long> { 1, 2 } };
var command = new ReorderFields { FieldIds = new List<long> { 1, 2, 4 } };
GuardSchema.CanReorder(schema_0, command);
}

66
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs

@ -154,6 +154,52 @@ namespace Squidex.Domain.Apps.Entities.Schemas
);
}
[Fact]
public async Task ConfigureUIFields_should_create_events_for_list_fields()
{
var command = new ConfigureUIFields
{
FieldsInLists = new FieldNames(fieldName)
};
await ExecuteCreateAsync();
await ExecuteAddFieldAsync(fieldName);
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(command.FieldsInLists, sut.Snapshot.SchemaDef.FieldsInLists);
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new SchemaUIFieldsConfigured { FieldsInLists = command.FieldsInLists })
);
}
[Fact]
public async Task ConfigureUIFields_should_create_events_for_reference_fields()
{
var command = new ConfigureUIFields
{
FieldsInReferences = new FieldNames(fieldName)
};
await ExecuteCreateAsync();
await ExecuteAddFieldAsync(fieldName);
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(command.FieldsInReferences, sut.Snapshot.SchemaDef.FieldsInReferences);
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new SchemaUIFieldsConfigured { FieldsInReferences = command.FieldsInReferences })
);
}
[Fact]
public async Task Publish_should_create_events_and_update_state()
{
@ -630,18 +676,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{
var command = new SynchronizeSchema
{
Scripts = new SchemaScripts
{
Query = "<query-script"
},
PreviewUrls = new Dictionary<string, string>
{
["Web"] = "web-url"
},
Fields = new List<UpsertSchemaField>
{
new UpsertSchemaField { Name = fieldId.Name, Properties = ValidProperties() }
},
Category = "My-Category"
};
@ -651,17 +685,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas
result.ShouldBeEquivalent(sut.Snapshot);
Assert.NotNull(GetField(1));
Assert.Equal(command.Category, sut.Snapshot.SchemaDef.Category);
Assert.Equal(command.Scripts, sut.Snapshot.SchemaDef.Scripts);
Assert.Equal(command.PreviewUrls, sut.Snapshot.SchemaDef.PreviewUrls);
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new SchemaCategoryChanged { Name = command.Category }),
CreateEvent(new SchemaScriptsConfigured { Scripts = command.Scripts }),
CreateEvent(new SchemaPreviewUrlsConfigured { PreviewUrls = command.PreviewUrls }),
CreateEvent(new FieldAdded { FieldId = fieldId, Name = fieldId.Name, Properties = command.Fields[0].Properties, Partitioning = Partitioning.Invariant.Key })
CreateEvent(new SchemaCategoryChanged { Name = command.Category })
);
}

2
frontend/app/_theme.html

@ -1285,7 +1285,7 @@
<div class="panel-sidebar">
<a href="#" class="panel-link">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</div>

2
frontend/app/features/apps/pages/apps-page.component.html

@ -34,7 +34,7 @@
</span>
</div>
<div class="card-text">
<div class="card-text" *ngIf="app.description">
{{app.description}}
</div>
</div>

2
frontend/app/features/rules/pages/rules/rules-page.component.html

@ -65,7 +65,7 @@
</ng-container>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left" #helpLink>
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
<sqx-onboarding-tooltip helpId="help" [for]="helpLink" position="left-top" after="180000">

2
frontend/app/features/schemas/declarations.ts

@ -30,6 +30,7 @@ export * from './pages/schema/forms/field-form-ui.component';
export * from './pages/schema/forms/field-form-validation.component';
export * from './pages/schema/forms/field-form.component';
export * from './pages/schema/field-list.component';
export * from './pages/schema/field-wizard.component';
export * from './pages/schema/field.component';
export * from './pages/schema/schema-edit-form.component';
@ -38,6 +39,7 @@ export * from './pages/schema/schema-fields.component';
export * from './pages/schema/schema-page.component';
export * from './pages/schema/schema-preview-urls-form.component';
export * from './pages/schema/schema-scripts-form.component';
export * from './pages/schema/schema-ui-form.component';
export * from './pages/schemas/schema-form.component';
export * from './pages/schemas/schemas-page.component';

49
frontend/app/features/schemas/module.ts

@ -28,6 +28,7 @@ import {
FieldFormComponent,
FieldFormUIComponent,
FieldFormValidationComponent,
FieldListComponent,
FieldWizardComponent,
GeolocationUIComponent,
GeolocationValidationComponent,
@ -45,6 +46,7 @@ import {
SchemaPreviewUrlsFormComponent,
SchemaScriptsFormComponent,
SchemasPageComponent,
SchemaUIFormComponent,
StringUIComponent,
StringValidationComponent,
TagsUIComponent,
@ -60,13 +62,6 @@ const routes: Routes = [
path: ':schemaName',
canActivate: [SchemaMustExistGuard],
component: SchemaPageComponent,
children: [
{
path: '',
redirectTo: 'fields'
},
{
path: 'fields',
children: [
{
path: 'help',
@ -76,44 +71,6 @@ const routes: Routes = [
}
}
]
},
{
path: 'scripts',
children: [
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/scripts'
}
}
]
},
{
path: 'json',
children: [
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/schema-json'
}
}
]
},
{
path: 'more',
children: [
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/preview'
}
}
]
}
]
}
]
}
@ -141,6 +98,7 @@ const routes: Routes = [
FieldFormComponent,
FieldFormUIComponent,
FieldFormValidationComponent,
FieldListComponent,
FieldWizardComponent,
GeolocationUIComponent,
GeolocationValidationComponent,
@ -158,6 +116,7 @@ const routes: Routes = [
SchemaPreviewUrlsFormComponent,
SchemaScriptsFormComponent,
SchemasPageComponent,
SchemaUIFormComponent,
StringUIComponent,
StringValidationComponent,
TagsUIComponent,

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

@ -0,0 +1,32 @@
<div class="field-list field-list-assigned"
#assignedList="cdkDropList"
cdkDropList
[cdkDropListData]="fieldsAdded"
[cdkDropListConnectedTo]="[availableList]"
(cdkDropListDropped)="drop($event)">
<label>Assigned fields</label>
<div class="empty-hint" *ngIf="fieldsAdded.length === 0">
<sqx-form-hint>{{emptyText}}</sqx-form-hint>
</div>
<div *ngFor="let field of fieldsAdded" class="table-items-row" cdkDrag>
<i class="icon-drag2 drag-handle"></i> <span>{{field.displayName}}</span>
</div>
</div>
<div class="field-list field-list-available"
#availableList="cdkDropList"
cdkDropList
cdkDropListSortingDisabled
[cdkDropListData]="fieldsNotAdded"
[cdkDropListConnectedTo]="[assignedList]"
(cdkDropListDropped)="drop($event)">
<label>Unassigned fields</label>
<div *ngFor="let field of fieldsNotAdded" class="table-items-row" cdkDrag>
<i class="icon-drag2 drag-handle"></i> <span>{{field.displayName}}</span>
</div>
</div>

34
frontend/app/features/schemas/pages/schema/field-list.component.scss

@ -0,0 +1,34 @@
@import '_vars';
@import '_mixins';
.field-list {
& {
overflow-x: hidden;
overflow-y: auto;
padding: $panel-padding;
padding-top: 1rem;
}
&-assigned {
@include absolute(0, 50%, 0, 0);
border-right: 1px solid $color-border;
}
&-available {
@include absolute(0, 0, 0, 50%);
}
}
.cdk-drop-list-dragging {
.empty-hint {
display: none;
}
}
.drag-handle {
margin-right: 1rem;
}
label {
margin-bottom: 1rem;
}

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

@ -0,0 +1,57 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: readonly-array
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { FieldDto, SchemaDetailsDto } from '@app/shared';
@Component({
selector: 'sqx-field-list',
styleUrls: ['./field-list.component.scss'],
templateUrl: './field-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FieldListComponent implements OnChanges {
@Input()
public emptyText = '';
@Input()
public schema: SchemaDetailsDto;
@Input()
public fieldNames: ReadonlyArray<string>;
@Output()
public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>();
public fieldsAdded: FieldDto[];
public fieldsNotAdded: FieldDto[];
public ngOnChanges() {
this.fieldsAdded = this.fieldNames.map(n => this.schema.contentFields.find(y => y.name === n)!).filter(x => !!x);
this.fieldsNotAdded = this.schema.contentFields.filter(n => this.fieldNames.indexOf(n.name) < 0);
}
public drop(event: CdkDragDrop<FieldDto[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex);
}
const newNames = this.fieldsAdded.map(x => x.name);
this.fieldNamesChange.emit(newNames);
}
}

8
frontend/app/features/schemas/pages/schema/field.component.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
@ -19,7 +20,8 @@ import {
PatternDto,
RootFieldDto,
SchemaDetailsDto,
SchemasState
SchemasState,
sorted
} from '@app/shared';
@Component({
@ -98,8 +100,8 @@ export class FieldComponent implements OnChanges {
this.schemasState.hideField(this.schema, this.field);
}
public sortFields(fields: ReadonlyArray<NestedFieldDto>) {
this.schemasState.orderFields(this.schema, fields, <any>this.field).subscribe();
public sortFields(event: CdkDragDrop<ReadonlyArray<NestedFieldDto>>) {
this.schemasState.orderFields(this.schema, sorted(event), <any>this.field).subscribe();
}
public lockField() {

30
frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts

@ -54,36 +54,6 @@ import { FieldDto } from '@app/shared';
</div>
</div>
<div class="form-group row" *ngIf="field.properties.isContentField">
<div class="col-9 offset-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="{{field.fieldId}}_fieldListfield" formControlName="isListField" />
<label class="form-check-label" for="{{field.fieldId}}_fieldListfield">
List Field
</label>
</div>
<sqx-form-hint>
List fields are shown as a column in the content list.<br />When no list field is defined, the first field is used.
</sqx-form-hint>
</div>
</div>
<div class="form-group row" *ngIf="field.properties.isContentField">
<div class="col-9 offset-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="{{field.fieldId}}_fieldReferencefield" formControlName="isReferenceField" />
<label class="form-check-label" for="{{field.fieldId}}_fieldReferencefield">
Reference Field
</label>
</div>
<sqx-form-hint>
Reference fields are shown as a column in the content list when referenced by another content.<br />When no reference field is defined, the first field is used.
</sqx-form-hint>
</div>
</div>
<div class="form-group row" *ngIf="field.properties.isContentField">
<label class="col-3 col-form-label">Tags</label>

2
frontend/app/features/schemas/pages/schema/schema-fields.component.ts

@ -5,8 +5,6 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable:no-shadowed-variable
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { Component, Input, OnInit } from '@angular/core';

15
frontend/app/features/schemas/pages/schema/schema-page.component.html

@ -4,7 +4,7 @@
<ng-container header>
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [routerLink]="['./', tab.toLowerCase()]" routerLinkActive="active">{{tab}}</a>
<a class="nav-link" [class.active]="tab === selectedTab" (click)="selectTab(tab)">{{tab}}</a>
</li>
</ul>
</ng-container>
@ -60,16 +60,19 @@
<ng-container content>
<ng-container [ngSwitch]="selectedTab">
<ng-container *ngSwitchCase="'fields'">
<ng-container *ngSwitchCase="'Fields'">
<sqx-schema-fields [schema]="schema"></sqx-schema-fields>
</ng-container>
<ng-container *ngSwitchCase="'scripts'">
<ng-container *ngSwitchCase="'UI'">
<sqx-schema-ui-form [schema]="schema"></sqx-schema-ui-form>
</ng-container>
<ng-container *ngSwitchCase="'Scripts'">
<sqx-schema-scripts-form [schema]="schema"></sqx-schema-scripts-form>
</ng-container>
<ng-container *ngSwitchCase="'json'">
<ng-container *ngSwitchCase="'Json'">
<sqx-schema-export-form [schema]="schema"></sqx-schema-export-form>
</ng-container>
<ng-container *ngSwitchCase="'more'">
<ng-container *ngSwitchCase="'More'">
<div class="cards">
<sqx-schema-preview-urls-form [schema]="schema"></sqx-schema-preview-urls-form>
<sqx-schema-edit-form [schema]="schema"></sqx-schema-edit-form>
@ -80,7 +83,7 @@
<ng-container sidebar>
<div class="panel-nav">
<a class="panel-link" [routerLink]="[selectedTab, 'help']" routerLinkActive="active" title="Help" titlePosition="left">
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
</a>
</div>

33
frontend/app/features/schemas/pages/schema/schema-page.component.ts

@ -5,10 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable:no-shadowed-variable
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
fadeAnimation,
@ -16,8 +14,7 @@ import {
ModalModel,
ResourceOwner,
SchemaDetailsDto,
SchemasState,
Types
SchemasState
} from '@app/shared';
import {
@ -33,12 +30,14 @@ import {
]
})
export class SchemaPageComponent extends ResourceOwner implements OnInit {
public readonly exact = { exact: true };
public schema: SchemaDetailsDto;
public editOptionsDropdown = new ModalModel();
public selectableTabs: ReadonlyArray<string> = ['Fields', 'UI', 'Scripts', 'Json', 'More'];
public selectedTab = this.selectableTabs[0];
public selectedTab = 'fields';
public selectableTabs: ReadonlyArray<string> = ['Fields', 'Scripts', 'Json', 'More'];
public editOptionsDropdown = new ModalModel();
constructor(
public readonly schemasState: SchemasState,
@ -50,16 +49,6 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.updateTab();
this.own(
this.router.events
.subscribe(event => {
if (Types.is(event, NavigationEnd)) {
this.updateTab();
}
}));
this.own(
this.schemasState.selectedSchema
.subscribe(schema => {
@ -67,8 +56,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
}));
}
private updateTab() {
this.selectedTab = this.route.firstChild!.snapshot.routeConfig!.path!;
public cloneSchema() {
this.messageBus.emit(new SchemaCloning(this.schema.export()));
}
public publish() {
@ -86,10 +75,6 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
});
}
public cloneSchema() {
this.messageBus.emit(new SchemaCloning(this.schema.export()));
}
public selectTab(tab: string) {
this.selectedTab = tab;
}

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

@ -0,0 +1,26 @@
<form class="inner-form" (ngSubmit)="saveSchema()">
<div class="inner-header">
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [class.active]="selectedTab === tab" (click)="selectTab(tab)">{{tab}}</a>
</li>
</ul>
<button type="submit" class="float-right btn btn-primary" [class.invisible]="!isEditable">Save</button>
</div>
<div class="inner-main">
<sqx-field-list [class.hidden]="selectedTab !== 'List Fields'"
emptyText="Drop field here or reorder them to show the fields in the content list. When no list field is defined, the first field is used."
[schema]="schema"
[fieldNames]="state.fieldsInLists"
(fieldNamesChange)="setFieldsInLists($event)">
</sqx-field-list>
<sqx-field-list [class.hidden]="selectedTab !== 'Reference Fields'"
emptyText="Drop field here or reorder them to show the fields when referenced by another content. When no reference field is defined, the list fields are used instead."
[schema]="schema"
[fieldNames]="state.fieldsInReferences"
(fieldNamesChange)="setFieldsInReferences($event)">
</sqx-field-list>
</div>
</form>

21
frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss

@ -0,0 +1,21 @@
@import '_vars';
@import '_mixins';
:host,
.inner-form,
.inner-main {
@include flex-box;
@include flex-grow(1);
@include flex-direction(column);
}
.inner-header,
.inner-main {
position: relative;
}
.inner-header {
background: $color-theme-secondary;
border-bottom: 1px solid $color-border;
padding: 1rem $panel-padding;
}

74
frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts

@ -0,0 +1,74 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnChanges } from '@angular/core';
import {
DialogService,
SchemaDetailsDto,
SchemasState
} from '@app/shared';
type State = { fieldsInLists: ReadonlyArray<string>, fieldsInReferences: ReadonlyArray<string> };
@Component({
selector: 'sqx-schema-ui-form',
styleUrls: ['./schema-ui-form.component.scss'],
templateUrl: './schema-ui-form.component.html'
})
export class SchemaUIFormComponent implements OnChanges {
@Input()
public schema: SchemaDetailsDto;
public selectableTabs: ReadonlyArray<string> = ['List Fields', 'Reference Fields'];
public selectedTab = this.selectableTabs[0];
public isEditable = false;
public state: State = {
fieldsInLists: [],
fieldsInReferences: []
};
constructor(
private readonly dialogs: DialogService,
private readonly schemasState: SchemasState
) {
}
public ngOnChanges() {
this.isEditable = this.schema.canUpdate;
this.state = {
fieldsInLists: this.schema.fieldsInLists,
fieldsInReferences: this.schema.fieldsInReferences
};
}
public setFieldsInLists(names: ReadonlyArray<string>) {
this.state.fieldsInLists = names;
}
public setFieldsInReferences(names: ReadonlyArray<string>) {
this.state.fieldsInReferences = names;
}
public selectTab(tab: string) {
this.selectedTab = tab;
}
public saveSchema() {
if (!this.isEditable) {
return;
}
this.schemasState.configureUIFields(this.schema, this.state)
.subscribe(() => {
this.dialogs.notifyInfo('UI fields updated successfully.');
});
}
}

2
frontend/app/features/settings/pages/backups/backups-page.component.html

@ -40,7 +40,7 @@
<ng-container sidebar>
<div class="panel-nav">
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

2
frontend/app/features/settings/pages/clients/clients-page.component.html

@ -37,7 +37,7 @@
</a>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

2
frontend/app/features/settings/pages/contributors/contributors-page.component.html

@ -73,7 +73,7 @@
</a>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

2
frontend/app/features/settings/pages/languages/languages-page.component.html

@ -34,7 +34,7 @@
</a>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

2
frontend/app/features/settings/pages/patterns/patterns-page.component.html

@ -34,7 +34,7 @@
</a>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

2
frontend/app/features/settings/pages/roles/roles-page.component.html

@ -28,7 +28,7 @@
</a>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

1
frontend/app/features/settings/pages/workflows/workflow-transition.component.scss

@ -2,7 +2,6 @@
@import '_mixins';
.dashed {
@include placeholder-color($color-theme-secondary);
border-style: dashed;
border-width: 1px;
}

2
frontend/app/features/settings/pages/workflows/workflows-page.component.html

@ -43,7 +43,7 @@
<ng-container sidebar>
<div class="panel-nav">
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left">
<i class="icon-help"></i>
<i class="icon-help2"></i>
</a>
</div>
</ng-container>

2
frontend/app/framework/angular/forms/tag-editor.component.scss

@ -33,7 +33,6 @@ $focus-color: #b3d3ff;
}
&.dashed {
@include placeholder-color($color-theme-secondary);
border-style: dashed;
}
}
@ -49,6 +48,7 @@ div {
.blank {
& {
@include placeholder-color($color-input-placeholder);
padding: 0;
border: 0;
background: transparent;

3
frontend/app/shared/components/asset-uploader.component.scss

@ -3,8 +3,9 @@
.nav {
& {
padding-right: 2rem;
padding-right: .5rem;
}
.nav-item {
& {
line-height: 2rem;

2
frontend/app/shared/components/help.component.html

@ -1,4 +1,4 @@
<sqx-panel desiredWidth="16rem" isBlank="true" [isLazyLoaded]="false">
<sqx-panel desiredWidth="24rem" isBlank="true" [isLazyLoaded]="false">
<ng-container title>
Help
</ng-container>

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

@ -62,7 +62,7 @@
</ng-container>
<ng-container footer>
<button type="button" class="float-right btn btn-primary mr-2" (click)="search(true)">
<button type="button" class="float-right btn btn-primary" (click)="search(true)">
Submit
</button>
</ng-container>

31
frontend/app/shared/services/schemas.service.spec.ts

@ -366,6 +366,33 @@ describe('SchemasService', () => {
expect(schema!).toEqual(createSchemaDetails(12));
}));
it('should make put request to update ui fields',
inject([SchemasService, HttpTestingController], (schemasService: SchemasService, httpMock: HttpTestingController) => {
const dto = { fieldsInReferences: ['field1'] };
const resource: Resource = {
_links: {
['fields/ui']: { method: 'PUT', href: '/api/apps/my-app/schemas/my-schema/fields/ui' }
}
};
let schema: SchemaDetailsDto;
schemasService.putUIFields('my-app', resource, dto, version).subscribe(result => {
schema = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/schemas/my-schema/fields/ui');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12));
expect(schema!).toEqual(createSchemaDetails(12));
}));
it('should make put request to update field ordering',
inject([SchemasService, HttpTestingController], (schemasService: SchemasService, httpMock: HttpTestingController) => {
@ -751,6 +778,8 @@ describe('SchemasService', () => {
_links: {}
}
],
fieldsInLists: ['field1'],
fieldsInReferences: ['field1'],
scripts: {
query: '<script-query>',
create: '<script-create>',
@ -812,6 +841,8 @@ export function createSchemaDetails(id: number, suffix = '') {
new RootFieldDto({}, 19, 'field19', createProperties('String'), 'language', true, true, true),
new RootFieldDto({}, 20, 'field20', createProperties('Tags'), 'language', true, true, true)
],
['field1'],
['field1'],
{
query: '<script-query>',
create: '<script-create>',

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

@ -33,6 +33,8 @@ export type SchemasDto = {
readonly canCreate: boolean;
} & Resource;
type FieldNames = ReadonlyArray<string>;
export class SchemaDto {
public readonly _links: ResourceLinks;
@ -46,6 +48,7 @@ export class SchemaDto {
public readonly canUpdate: boolean;
public readonly canUpdateCategory: boolean;
public readonly canUpdateScripts: boolean;
public readonly canUpdateUIFields: boolean;
public readonly canUpdateUrls: boolean;
public readonly displayName: string;
@ -75,6 +78,7 @@ export class SchemaDto {
this.canUpdate = hasAnyLink(links, 'update');
this.canUpdateCategory = hasAnyLink(links, 'update/category');
this.canUpdateScripts = hasAnyLink(links, 'update/scripts');
this.canUpdateUIFields = hasAnyLink(links, 'fields/ui');
this.canUpdateUrls = hasAnyLink(links, 'update/urls');
this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name);
@ -82,6 +86,7 @@ export class SchemaDto {
}
export class SchemaDetailsDto extends SchemaDto {
public readonly contentFields: ReadonlyArray<RootFieldDto>;
public readonly listFields: ReadonlyArray<RootFieldDto>;
public readonly listFieldsEditable: ReadonlyArray<RootFieldDto>;
public readonly referenceFields: ReadonlyArray<RootFieldDto>;
@ -96,13 +101,17 @@ export class SchemaDetailsDto extends SchemaDto {
lastModifiedBy: string,
version: Version,
public readonly fields: ReadonlyArray<RootFieldDto> = [],
public readonly fieldsInLists: FieldNames = [],
public readonly fieldsInReferences: FieldNames = [],
public readonly scripts = {},
public readonly previewUrls = {}
) {
super(links, id, name, category, properties, isSingleton, isPublished, created, createdBy, lastModified, lastModifiedBy, version);
if (fields) {
this.listFields = this.fields.filter(x => x.properties.isListField && x.properties.isContentField);
this.contentFields = fields.filter(x => x.properties.isContentField);
this.listFields = findFields(fieldsInLists, this.contentFields);
if (this.listFields.length === 0 && this.fields.length > 0) {
this.listFields = [this.fields[0]];
@ -114,7 +123,7 @@ export class SchemaDetailsDto extends SchemaDto {
this.listFieldsEditable = this.listFields.filter(x => x.isInlineEditable);
this.referenceFields = this.fields.filter(x => x.properties.isReferenceField && x.properties.isContentField);
this.referenceFields = findFields(fieldsInReferences, this.contentFields);
if (this.referenceFields.length === 0) {
this.referenceFields = this.listFields;
@ -170,6 +179,10 @@ export class SchemaDetailsDto extends SchemaDto {
}
}
function findFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>) {
return names.map(x => fields.find(f => f.name === x)!).filter(x => !!x);
}
export class FieldDto {
public readonly _links: ResourceLinks;
@ -274,6 +287,11 @@ export interface AddFieldDto {
readonly properties: FieldPropertiesDto;
}
export interface UpdateUIFields {
readonly fieldsInLists?: FieldNames;
readonly fieldsInReferences?: FieldNames;
}
export interface CreateSchemaDto {
readonly name: string;
readonly fields?: ReadonlyArray<RootFieldDto>;
@ -290,8 +308,8 @@ export interface UpdateFieldDto {
}
export interface SynchronizeSchemaDto {
noFieldDeletiong?: boolean;
noFieldRecreation?: boolean;
readonly noFieldDeletiong?: boolean;
readonly noFieldRecreation?: boolean;
}
export interface UpdateSchemaDto {
@ -461,6 +479,21 @@ export class SchemasService {
pretifyError('Failed to add field. Please reload.'));
}
public putUIFields(appName: string, resource: Resource, dto: UpdateUIFields, version: Version): Observable<SchemaDetailsDto> {
const link = resource._links['fields/ui'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseSchemaWithDetails(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Schema', 'UIFieldsConfigured', appName);
}),
pretifyError('Failed to update UI fields. Please reload.'));
}
public putFieldOrdering(appName: string, resource: Resource, dto: ReadonlyArray<number>, version: Version): Observable<SchemaDetailsDto> {
const link = resource._links['fields/order'];
@ -630,6 +663,8 @@ function parseSchemaWithDetails(response: any) {
DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy,
new Version(response.version.toString()),
fields,
response.fieldsInLists,
response.fieldsInReferences,
response.scripts || {},
response.previewUrls || {});
}

2
frontend/app/shared/services/schemas.types.ts

@ -134,8 +134,6 @@ export abstract class FieldPropertiesDto {
public readonly editorUrl?: string;
public readonly hints?: string;
public readonly isListField: boolean = false;
public readonly isReferenceField: boolean = false;
public readonly isRequired: boolean = false;
public readonly label?: string;
public readonly placeholder?: string;

2
frontend/app/shared/state/clients.state.ts

@ -5,8 +5,6 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: no-shadowed-variable
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

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

@ -37,6 +37,10 @@ const {
} = TestValues;
describe('SchemaDetailsDto', () => {
const field1 = createField({ properties: createProperties('Array'), id: 1 });
const field2 = createField({ properties: createProperties('Array'), id: 2 });
const field3 = createField({ properties: createProperties('Array'), id: 3 });
it('should return label as display name', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto('Label') });
@ -55,24 +59,34 @@ describe('SchemaDetailsDto', () => {
expect(schema.displayName).toBe('schema1');
});
it('should return configured fields as list fields if no schema field are declared', () => {
const field1 = createField({ properties: createProperties('Array', { isListField: true }) });
const field2 = createField({ properties: createProperties('Array', { isListField: false }), id: 2 });
const field3 = createField({ properties: createProperties('Array', { isListField: true }), id: 3 });
it('should return configured fields as list fields if fields are declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInLists: ['field1', 'field3'] });
expect(schema.listFields).toEqual([field1, field3]);
});
it('should return first fields as list fields if no field is declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] });
expect(schema.listFields).toEqual([field1, field3]);
expect(schema.listFields).toEqual([field1]);
});
it('should return first fields as list fields if no schema field is declared', () => {
const field1 = createField({ properties: createProperties('Array') });
const field2 = createField({ properties: createProperties('Array'), id: 2 });
const field3 = createField({ properties: createProperties('Array'), id: 3 });
it('should return configured fields as references fields if fields are declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInReferences: ['field1', 'field3'] });
expect(schema.referenceFields).toEqual([field1, field3]);
});
it('should return lists fields as reference fields if no field is declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3], fieldsInLists: ['field2', 'field3'] });
expect(schema.referenceFields).toEqual([field2, field3]);
});
it('should return first field as reference fields if no field is declared', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] });
expect(schema.listFields).toEqual([field1]);
expect(schema.referenceFields).toEqual([field1]);
});
it('should return empty list fields if fields is empty', () => {
@ -814,9 +828,15 @@ describe('ContentForm', () => {
}
});
type SchemaValues = { properties?: SchemaPropertiesDto; id?: number; fields?: ReadonlyArray<RootFieldDto>; };
type SchemaValues = {
id?: number;
fields?: ReadonlyArray<RootFieldDto>;
fieldsInLists?: ReadonlyArray<string>;
fieldsInReferences?: ReadonlyArray<string>;
properties?: SchemaPropertiesDto;
};
function createSchema({ properties, id, fields }: SchemaValues = {}) {
function createSchema({ properties, id, fields, fieldsInLists, fieldsInReferences }: SchemaValues = {}) {
id = id || 1;
return new SchemaDetailsDto({},
@ -829,7 +849,9 @@ function createSchema({ properties, id, fields }: SchemaValues = {}) {
modified,
modifier,
new Version('1'),
fields);
fields,
fieldsInLists || [],
fieldsInReferences || []);
}
type FieldValues = { properties: FieldPropertiesDto; id?: number; partitioning?: string; isDisabled?: boolean, nested?: ReadonlyArray<NestedFieldDto> };

4
frontend/app/shared/state/schemas.forms.ts

@ -149,7 +149,7 @@ export class EditScriptsForm extends Form<FormGroup, { query?: string, create?:
}
}
export class EditFieldForm extends Form<FormGroup, { label?: string, hints?: string, placeholder?: string, editorUrl?: string, isRequired: boolean, isListField: boolean }> {
export class EditFieldForm extends Form<FormGroup, { label?: string, hints?: string, placeholder?: string, editorUrl?: string, isRequired: boolean }> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
label: ['',
@ -169,8 +169,6 @@ export class EditFieldForm extends Form<FormGroup, { label?: string, hints?: str
],
editorUrl: null,
isRequired: false,
isListField: false,
isReferenceField: false,
tags: []
}));
}

28
frontend/app/shared/state/schemas.state.spec.ts

@ -473,13 +473,31 @@ describe('SchemasState', () => {
expect(schemasState.snapshot.selectedSchema).toEqual(updated);
});
it('should update schema and selected schema when ui fields configured', () => {
const request = { fieldsInLists: [schema.fields[1].name] };
const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putUIFields(app, schema1, request, version))
.returns(() => of(updated)).verifiable();
schemasState.configureUIFields(schema1, request).subscribe();
const schema1New = <SchemaDetailsDto>schemasState.snapshot.schemas[0];
expect(schema1New).toEqual(updated);
expect(schemasState.snapshot.selectedSchema).toEqual(updated);
});
it('should update schema and selected schema when fields sorted', () => {
const request = [schema.fields[1], schema.fields[2]];
const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putFieldOrdering(app, schema1, [schema.fields[1].fieldId, schema.fields[2].fieldId], version))
schemasService.setup(x => x.putFieldOrdering(app, schema1, request.map(f => f.fieldId), version))
.returns(() => of(updated)).verifiable();
schemasState.orderFields(schema1, [schema.fields[1], schema.fields[2]]).subscribe();
schemasState.orderFields(schema1, request).subscribe();
const schema1New = <SchemaDetailsDto>schemasState.snapshot.schemas[0];
@ -488,12 +506,14 @@ describe('SchemasState', () => {
});
it('should update schema and selected schema when nested fields sorted', () => {
const request = [schema.fields[1], schema.fields[2]];
const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putFieldOrdering(app, schema.fields[0], [schema.fields[1].fieldId, schema.fields[2].fieldId], version))
schemasService.setup(x => x.putFieldOrdering(app, schema.fields[0], request.map(f => f.fieldId), version))
.returns(() => of(updated)).verifiable();
schemasState.orderFields(schema1, [schema.fields[1], schema.fields[2]], schema.fields[0]).subscribe();
schemasState.orderFields(schema1, request, schema.fields[0]).subscribe();
const schema1New = <SchemaDetailsDto>schemasState.snapshot.schemas[0];

11
frontend/app/shared/state/schemas.state.ts

@ -31,7 +31,8 @@ import {
SchemaDto,
SchemasService,
UpdateFieldDto,
UpdateSchemaDto
UpdateSchemaDto,
UpdateUIFields
} from './../services/schemas.service';
type AnyFieldDto = NestedFieldDto | RootFieldDto;
@ -268,6 +269,14 @@ export class SchemasState extends State<Snapshot> {
shareMapSubscribed(this.dialogs, x => getField(x, request, parent), { silent: true }));
}
public configureUIFields(schema: SchemaDto, request: UpdateUIFields): Observable<SchemaDetailsDto> {
return this.schemasService.putUIFields(this.appName, schema, request, schema.version).pipe(
tap(updated => {
this.replaceSchema(updated);
}),
shareSubscribed(this.dialogs));
}
public orderFields(schema: SchemaDto, fields: ReadonlyArray<any>, parent?: RootFieldDto): Observable<SchemaDetailsDto> {
return this.schemasService.putFieldOrdering(this.appName, parent || schema, fields.map(t => t.fieldId), schema.version).pipe(
tap(updated => {

2
frontend/app/shared/state/workflows.state.ts

@ -5,8 +5,6 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: no-shadowed-variable
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

1
frontend/app/shell/declarations.ts

@ -11,6 +11,7 @@ export * from './pages/forbidden/forbidden-page.component';
export * from './pages/home/home-page.component';
export * from './pages/internal/apps-menu.component';
export * from './pages/internal/internal-area.component';
export * from './pages/internal/logo.component';
export * from './pages/internal/profile-menu.component';
export * from './pages/login/login-page.component';
export * from './pages/logout/logout-page.component';

2
frontend/app/shell/module.ts

@ -17,6 +17,7 @@ import {
InternalAreaComponent,
LeftMenuComponent,
LoginPageComponent,
LogoComponent,
LogoutPageComponent,
NotFoundPageComponent,
ProfileMenuComponent
@ -42,6 +43,7 @@ import {
InternalAreaComponent,
LeftMenuComponent,
LoginPageComponent,
LogoComponent,
LogoutPageComponent,
NotFoundPageComponent,
ProfileMenuComponent

20
frontend/app/shell/pages/internal/internal-area.component.html

@ -1,12 +1,6 @@
<nav class="navbar fixed-top navbar-dark bg-primary bg-faded">
<nav class="navbar fixed-top navbar-dark bg-primary bg-faded navbar-expand">
<span class="navbar-brand" routerLink="/app">
<ng-container *ngIf="loadingService.loading | async; else noLoading">
<img class="loader" src="./images/loader-white.gif" @fade />
</ng-container>
<ng-template #noLoading>
<i class="icon-logo" @fade></i>
</ng-template>
<sqx-logo [isLoading]="loadingService.loading | async"></sqx-logo>
</span>
<div class="float-left apps-menu">
@ -25,13 +19,3 @@
<div class="main">
<router-outlet></router-outlet>
</div>
<a class="support-button row no-gutters" href="https://support.squidex.io" sqxExternalLink="noicon">
<div class="col-auto support-icon">
<i class="icon-support"></i>
</div>
<div class="col support-text">
Get help and support
</div>
</a>

54
frontend/app/shell/pages/internal/internal-area.component.scss

@ -22,57 +22,3 @@
line-height: $size-navbar-height;
float: left;
}
$support-icon-size: 50px;
$support-text-size: 200px;
.support {
&-button {
& {
@include fixed(50%, -$support-text-size, auto, auto);
@include transition(transform .3s ease);
background: $color-theme-blue;
font-size: .9rem;
font-weight: bold;
height: $support-icon-size;
line-break: normal;
line-height: $support-icon-size - 6px;
z-index: 100000000;
}
&:hover {
& {
@include translate(-$support-text-size, 0);
text-decoration: none;
}
.support-icon {
background: $color-theme-blue-dark;
}
}
}
&-icon,
&-text {
cursor: pointer;
color: $color-dark-foreground;
text-align: center;
}
&-text {
width: $support-text-size;
}
&-icon {
@include transition(background-color .4s ease);
background: $color-theme-blue;
line-height: $support-icon-size;
font-size: 1.5rem;
font-weight: normal;
width: $support-icon-size;
}
}
.loader {
@include absolute(12px, auto, auto, 40px);
}

27
frontend/app/shell/pages/internal/logo.component.ts

@ -0,0 +1,27 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'sqx-logo',
template: `
<img [class.hidden]="!isLoading" class="loader" src="./images/loader-white.gif" />
<i [class.hidden]="isLoading" class="icon-logo"></i>
`,
styles: [`
.loader {
position: absolute; top: 12px; left: 40px;
}`
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LogoComponent {
@Input()
public isLoading = false;
}

5
frontend/app/shell/pages/internal/profile-menu.component.html

@ -1,4 +1,9 @@
<ul class="nav navbar-nav">
<li class="nav-item nav-item-help">
<a class="nav-link" href="https://squidex.io/help" sqxExternalLink="noicon">
<i class="icon-help2"></i>
</a>
</li>
<li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" (click)="modalMenu.toggle()">
<span class="user">

8
frontend/app/shell/pages/internal/profile-menu.component.scss

@ -13,6 +13,14 @@
padding-bottom: 0;
}
}
&-item-help {
padding-top: 2px;
padding-right: 1rem;
font-size: 1.4rem;
font-weight: lighter;
vertical-align: middle;
}
}
.user-picture {

2
frontend/app/theme/_bootstrap-vars.scss

@ -29,7 +29,7 @@ $label-margin-bottom: .25rem;
$input-disabled-bg: $color-input-disabled;
$input-border-color: $color-input;
$input-placeholder-color: lighten($color-text-decent, 20%);
$input-placeholder-color: $color-input-placeholder;
$badge-bg-level: 2;

1
frontend/app/theme/_vars.scss

@ -41,6 +41,7 @@ $color-input: #dbe4eb;
$color-input-background: #fff;
$color-input-disabled: $color-theme-secondary;
$color-input-border: rgba(0, 0, 0, .15);
$color-input-placeholder: lighten($color-text-decent, 20%);
$color-dark1-background: #2e3842;
$color-dark1-foreground: #6a7681;

16
frontend/app/theme/icomoon/demo.html

@ -9,10 +9,24 @@
<link rel="stylesheet" href="style.css"></head>
<body>
<div class="bgc1 clearfix">
<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;125)</small></h1>
<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;126)</small></h1>
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 24</h1>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-help2"></span>
<span class="mls"> icon-help2</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e97a" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe97a;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-trigger-Manual"></span>

BIN
frontend/app/theme/icomoon/fonts/icomoon.eot

Binary file not shown.

1
frontend/app/theme/icomoon/fonts/icomoon.svg

@ -129,6 +129,7 @@
<glyph unicode="&#xe977;" glyph-name="corner-down-right" d="M128 768v-298.667c0-58.88 23.936-112.299 62.464-150.869s91.989-62.464 150.869-62.464h409.003l-140.501-140.501c-16.683-16.683-16.683-43.691 0-60.331s43.691-16.683 60.331 0l213.333 213.333c3.925 3.925 7.083 8.619 9.259 13.824s3.243 10.795 3.243 16.341c0 10.923-4.181 21.845-12.501 30.165l-213.333 213.333c-16.683 16.683-43.691 16.683-60.331 0s-16.683-43.691 0-60.331l140.501-140.501h-409.003c-35.371 0-67.285 14.293-90.496 37.504s-37.504 55.125-37.504 90.496v298.667c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667z" />
<glyph unicode="&#xe978;" glyph-name="filter-filled" horiz-adv-x="805" d="M801.714 782.286c5.714-13.714 2.857-29.714-8-40l-281.714-281.714v-424c0-14.857-9.143-28-22.286-33.714-4.571-1.714-9.714-2.857-14.286-2.857-9.714 0-18.857 3.429-25.714 10.857l-146.286 146.286c-6.857 6.857-10.857 16-10.857 25.714v277.714l-281.714 281.714c-10.857 10.286-13.714 26.286-8 40 5.714 13.143 18.857 22.286 33.714 22.286h731.429c14.857 0 28-9.143 33.714-22.286z" />
<glyph unicode="&#xe979;" glyph-name="trigger-Manual, play-line" d="M236.416 846.55c-6.528 4.267-14.507 6.784-23.083 6.784-23.552 0-42.667-19.115-42.667-42.667v-768c-0.043-7.765 2.133-15.872 6.784-23.083 12.757-19.84 39.125-25.557 58.965-12.8l597.333 384c4.864 3.072 9.344 7.424 12.8 12.8 12.757 19.84 6.997 46.208-12.8 58.965zM256 732.502l475.776-305.835-475.776-305.835z" />
<glyph unicode="&#xe97a;" glyph-name="help2" d="M512 682.667q70 0 120-50t50-120q0-54-64-111t-64-103h-84q0 46 20 79t44 48 44 37 20 50q0 34-26 59t-60 25-60-25-26-59h-84q0 70 50 120t120 50zM512 84.667q140 0 241 101t101 241-101 241-241 101-241-101-101-241 101-241 241-101zM512 852.667q176 0 301-125t125-301-125-301-301-125-301 125-125 301 125 301 301 125zM470 170.667v86h84v-86h-84z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" />
<glyph unicode="&#xf0c9;" glyph-name="list1" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" />

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

BIN
frontend/app/theme/icomoon/fonts/icomoon.ttf

Binary file not shown.

BIN
frontend/app/theme/icomoon/fonts/icomoon.woff

Binary file not shown.

2
frontend/app/theme/icomoon/selection.json

File diff suppressed because one or more lines are too long

13
frontend/app/theme/icomoon/style.css

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?kqipf');
src: url('fonts/icomoon.eot?kqipf#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?kqipf') format('truetype'),
url('fonts/icomoon.woff?kqipf') format('woff'),
url('fonts/icomoon.svg?kqipf#icomoon') format('svg');
src: url('fonts/icomoon.eot?d1ew60');
src: url('fonts/icomoon.eot?d1ew60#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?d1ew60') format('truetype'),
url('fonts/icomoon.woff?d1ew60') format('woff'),
url('fonts/icomoon.svg?d1ew60#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -25,6 +25,9 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-help2:before {
content: "\e97a";
}
.icon-trigger-Manual:before {
content: "\e979";
}

Loading…
Cancel
Save