Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/439/head
Sebastian Stehle 6 years ago
parent
commit
381d292b20
  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. 2
      backend/src/Squidex.Domain.Users/DefaultUserResolver.cs
  28. 2
      backend/src/Squidex.Domain.Users/UserManagerExtensions.cs
  29. 20
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  30. 14
      backend/src/Squidex.Infrastructure/Security/PermissionSet.cs
  31. 6
      backend/src/Squidex.Infrastructure/Validation/Not.cs
  32. 26
      backend/src/Squidex.Web/ApiModelValidationAttribute.cs
  33. 31
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureUIFieldsDto.cs
  34. 10
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs
  35. 15
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs
  36. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs
  37. 2
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpsertSchemaDto.cs
  38. 29
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  39. 30
      backend/src/Squidex/Areas/IdentityServer/Config/CreateAdminHost.cs
  40. 2
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs
  41. 2
      backend/src/Squidex/Config/Domain/SerializationServices.cs
  42. 12
      backend/src/Squidex/Config/MyIdentityOptions.cs
  43. 4
      backend/src/Squidex/appsettings.json
  44. 34
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/SchemaTests.cs
  45. 36
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EventSynchronization/SchemaSynchronizerTests.cs
  46. 7
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceFormattingTests.cs
  47. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs
  48. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs
  49. 29
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaFieldTests.cs
  50. 170
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/GuardSchemaTests.cs
  51. 66
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaGrainTests.cs
  52. 20
      backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs
  53. 2
      frontend/app/_theme.html
  54. 2
      frontend/app/features/apps/pages/apps-page.component.html
  55. 2
      frontend/app/features/rules/pages/rules/rules-page.component.html
  56. 2
      frontend/app/features/schemas/declarations.ts
  57. 49
      frontend/app/features/schemas/module.ts
  58. 32
      frontend/app/features/schemas/pages/schema/field-list.component.html
  59. 34
      frontend/app/features/schemas/pages/schema/field-list.component.scss
  60. 57
      frontend/app/features/schemas/pages/schema/field-list.component.ts
  61. 8
      frontend/app/features/schemas/pages/schema/field.component.ts
  62. 30
      frontend/app/features/schemas/pages/schema/forms/field-form-common.component.ts
  63. 2
      frontend/app/features/schemas/pages/schema/schema-fields.component.ts
  64. 15
      frontend/app/features/schemas/pages/schema/schema-page.component.html
  65. 33
      frontend/app/features/schemas/pages/schema/schema-page.component.ts
  66. 26
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.html
  67. 21
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.scss
  68. 74
      frontend/app/features/schemas/pages/schema/schema-ui-form.component.ts
  69. 2
      frontend/app/features/settings/pages/backups/backups-page.component.html
  70. 2
      frontend/app/features/settings/pages/clients/clients-page.component.html
  71. 2
      frontend/app/features/settings/pages/contributors/contributors-page.component.html
  72. 2
      frontend/app/features/settings/pages/languages/languages-page.component.html
  73. 2
      frontend/app/features/settings/pages/patterns/patterns-page.component.html
  74. 2
      frontend/app/features/settings/pages/roles/roles-page.component.html
  75. 1
      frontend/app/features/settings/pages/workflows/workflow-transition.component.scss
  76. 2
      frontend/app/features/settings/pages/workflows/workflows-page.component.html
  77. 2
      frontend/app/framework/angular/forms/tag-editor.component.scss
  78. 3
      frontend/app/shared/components/asset-uploader.component.scss
  79. 2
      frontend/app/shared/components/help.component.html
  80. 2
      frontend/app/shared/components/search-form.component.html
  81. 31
      frontend/app/shared/services/schemas.service.spec.ts
  82. 43
      frontend/app/shared/services/schemas.service.ts
  83. 2
      frontend/app/shared/services/schemas.types.ts
  84. 2
      frontend/app/shared/state/clients.state.ts
  85. 48
      frontend/app/shared/state/contents.forms.spec.ts
  86. 4
      frontend/app/shared/state/schemas.forms.ts
  87. 28
      frontend/app/shared/state/schemas.state.spec.ts
  88. 11
      frontend/app/shared/state/schemas.state.ts
  89. 2
      frontend/app/shared/state/workflows.state.ts
  90. 1
      frontend/app/shell/declarations.ts
  91. 2
      frontend/app/shell/module.ts
  92. 20
      frontend/app/shell/pages/internal/internal-area.component.html
  93. 54
      frontend/app/shell/pages/internal/internal-area.component.scss
  94. 27
      frontend/app/shell/pages/internal/logo.component.ts
  95. 5
      frontend/app/shell/pages/internal/profile-menu.component.html
  96. 8
      frontend/app/shell/pages/internal/profile-menu.component.scss
  97. 2
      frontend/app/theme/_bootstrap-vars.scss
  98. 1
      frontend/app/theme/_vars.scss
  99. 16
      frontend/app/theme/icomoon/demo.html
  100. BIN
      frontend/app/theme/icomoon/fonts/icomoon.eot

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; }
}
}

2
backend/src/Squidex.Domain.Users/DefaultUserResolver.cs

@ -69,7 +69,7 @@ namespace Squidex.Domain.Users
}
else
{
return await userManager.FindByEmailWithClaimsAsyncAsync(idOrEmail);
return await userManager.FindByEmailWithClaimsAsync(idOrEmail);
}
}
}

2
backend/src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -55,7 +55,7 @@ namespace Squidex.Domain.Users
return await userManager.ResolveUserAsync(user);
}
public static async Task<UserWithClaims?> FindByEmailWithClaimsAsyncAsync(this UserManager<IdentityUser> userManager, string email)
public static async Task<UserWithClaims?> FindByEmailWithClaimsAsync(this UserManager<IdentityUser> userManager, string email)
{
if (email == null)
{

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!);

14
backend/src/Squidex.Infrastructure/Security/PermissionSet.cs

@ -48,6 +48,20 @@ namespace Squidex.Infrastructure.Security
display = new Lazy<string>(() => string.Join(";", this.permissions));
}
public PermissionSet Add(string permission)
{
Guard.NotNullOrEmpty(permission);
return Add(new Permission(permission));
}
public PermissionSet Add(Permission permission)
{
Guard.NotNull(permission);
return new PermissionSet(permissions.Union(Enumerable.Repeat(permission, 1)).Distinct());
}
public bool Allows(Permission? other)
{
if (other == null)

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>

30
backend/src/Squidex/Areas/IdentityServer/Config/CreateAdminHost.cs

@ -19,6 +19,7 @@ using Squidex.Domain.Users;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Shared.Users;
namespace Squidex.Areas.IdentityServer.Config
{
@ -49,9 +50,30 @@ namespace Squidex.Areas.IdentityServer.Config
var adminEmail = identityOptions.AdminEmail;
var adminPass = identityOptions.AdminPassword;
if (userManager.SupportsQueryableUsers && !userManager.Users.Any())
var isEmpty = IsEmpty(userManager);
if (isEmpty || identityOptions.AdminRecreate)
{
try
{
var user = await userManager.FindByEmailWithClaimsAsync(adminEmail);
if (user != null)
{
if (identityOptions.AdminRecreate)
{
var permissions = user.Permissions().Add(Permissions.Admin);
var values = new UserValues
{
Password = adminPass,
Permissions = permissions
};
await userManager.UpdateAsync(user.Identity, values);
}
}
else
{
var values = new UserValues
{
@ -63,6 +85,7 @@ namespace Squidex.Areas.IdentityServer.Config
await userManager.CreateAsync(userFactory, values);
}
}
catch (Exception ex)
{
log.LogError(ex, w => w
@ -73,5 +96,10 @@ namespace Squidex.Areas.IdentityServer.Config
}
}
}
private static bool IsEmpty(UserManager<IdentityUser> userManager)
{
return userManager.SupportsQueryableUsers && !userManager.Users.Any();
}
}
}

2
backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs

@ -268,7 +268,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
{
var email = externalLogin.Principal.FindFirst(ClaimTypes.Email).Value;
user = await userManager.FindByEmailWithClaimsAsyncAsync(email);
user = await userManager.FindByEmailWithClaimsAsync(email);
if (user != null)
{

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);
});

12
backend/src/Squidex/Config/MyIdentityOptions.cs

@ -11,6 +11,10 @@ namespace Squidex.Config
{
public sealed class MyIdentityOptions
{
public string PrivacyUrl { get; set; }
public string AuthorityUrl { get; set; }
public string AdminEmail { get; set; }
public string AdminPassword { get; set; }
@ -45,11 +49,7 @@ namespace Squidex.Config
public Dictionary<string, string[]> OidcRoleMapping { get; set; }
public string AuthorityUrl { get; set; }
public string PrivacyUrl { get; set; }
public bool RequiresHttps { get; set; }
public bool AdminRecreate { get; set; }
public bool AllowPasswordAuth { get; set; }
@ -57,6 +57,8 @@ namespace Squidex.Config
public bool NoConsent { get; set; }
public bool RequiresHttps { get; set; }
public bool ShowPII { get; set; }
public bool IsAdminConfigured()

4
backend/src/Squidex/appsettings.json

@ -428,6 +428,10 @@
*/
"adminEmail": "",
"adminPassword": "",
/*
* Recreate the admin if it does not exist or the password does not match.
*/
"adminRecreate": false,
/*
* Client with all admin permissions.
*/

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 })
);
}

20
backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs

@ -113,5 +113,25 @@ namespace Squidex.Infrastructure.Security
Assert.False(sut.Includes(null));
}
[Fact]
public void Should_add_permission_by_string()
{
var sut =
new PermissionSet("app.contents")
.Add("admin.*");
Assert.True(sut.Includes(new Permission("admin")));
}
[Fact]
public void Should_add_permission()
{
var sut =
new PermissionSet("app.contents")
.Add(new Permission("admin.*"));
Assert.True(sut.Includes(new Permission("admin")));
}
}
}

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.

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save