Browse Source

Graphql schemas (#924)

* Improvements to teams.

* Fixes

* Fixes api

* Fixes to teams.

* Simplify some interfaces.

* Teemp

* Fix tests and type names.

* Temp

* Added UI and a few fixes.

* Improve type parsing.
pull/925/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
adc6464c87
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      backend/i18n/frontend_en.json
  2. 3
      backend/i18n/frontend_it.json
  3. 3
      backend/i18n/frontend_nl.json
  4. 3
      backend/i18n/frontend_zh.json
  5. 3
      backend/i18n/source/frontend_en.json
  6. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  8. 27
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs
  9. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  10. 104
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs
  11. 75
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicResolver.cs
  12. 70
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicSchemaBuilder.cs
  13. 120
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ErrorVisitor.cs
  14. 80
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ReservedNames.cs
  15. 109
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs
  16. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs
  17. 25
      backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/FieldPropertiesValidator.cs
  18. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs
  19. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs
  20. 73
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/NamesTests.cs
  21. 116
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs
  22. 50
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs
  23. 1
      frontend/src/app/features/schemas/declarations.ts
  24. 3
      frontend/src/app/features/schemas/module.ts
  25. 9
      frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html
  26. 3
      frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.scss
  27. 11
      frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.html
  28. 2
      frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.scss
  29. 26
      frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.ts
  30. 8
      frontend/src/app/shared/state/schemas.forms.ts

3
backend/i18n/frontend_en.json

@ -799,6 +799,8 @@
"schemas.field.empty": "No field created yet.",
"schemas.field.enable": "Enable in UI",
"schemas.field.enabledMarker": "Enabled",
"schemas.field.graphQLSchema": "GraphQL Schema",
"schemas.field.graphQLSchemaHint": "Define a GraphQL Schema for the json values. You can use multiple types for nested objects.\nThe first type becomes the field type. Only objects are supported as root types.",
"schemas.field.halfWidth": "Half Width",
"schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.",
"schemas.field.hiddenMarker": "Hidden",
@ -825,6 +827,7 @@
"schemas.field.show": "Show in API",
"schemas.field.tabCommon": "Common",
"schemas.field.tabEditing": "Editing",
"schemas.field.tabMore": "More",
"schemas.field.tabValidation": "Validation",
"schemas.field.tagsHint": "Tags to annotate your field for automation processes.",
"schemas.field.unique": "Unique",

3
backend/i18n/frontend_it.json

@ -799,6 +799,8 @@
"schemas.field.empty": "Nessun campo è stato ancora creato.",
"schemas.field.enable": "Abilita nella UI",
"schemas.field.enabledMarker": "Abilitato",
"schemas.field.graphQLSchema": "GraphQL Schema",
"schemas.field.graphQLSchemaHint": "Define a GraphQL Schema for the json values. You can use multiple types for nested objects.\nThe first type becomes the field type. Only objects are supported as root types.",
"schemas.field.halfWidth": "Metà della Larghezza",
"schemas.field.halfWidthHint": "Mostra il campo con solo la metà della larghezza nella pagina in fase di modifica o creazione, quando c'è spazio sufficiente.",
"schemas.field.hiddenMarker": "Nascosto",
@ -825,6 +827,7 @@
"schemas.field.show": "Mostra nelle API",
"schemas.field.tabCommon": "Comune",
"schemas.field.tabEditing": "Modifica",
"schemas.field.tabMore": "More",
"schemas.field.tabValidation": "Validazione",
"schemas.field.tagsHint": "Tag per segnalare il tuo campo nei processi automatici.",
"schemas.field.unique": "Univoco",

3
backend/i18n/frontend_nl.json

@ -799,6 +799,8 @@
"schemas.field.empty": "Nog geen veld aangemaakt.",
"schemas.field.enable": "Inschakelen in gebruikersinterface",
"schemas.field.enabledMarker": "Ingeschakeld",
"schemas.field.graphQLSchema": "GraphQL Schema",
"schemas.field.graphQLSchemaHint": "Define a GraphQL Schema for the json values. You can use multiple types for nested objects.\nThe first type becomes the field type. Only objects are supported as root types.",
"schemas.field.halfWidth": "Half Breedte",
"schemas.field.halfWidthHint": "Toon het veld met alleen de halve breedte op de bewerk- of maakpagina, als er voldoende ruimte is.",
"schemas.field.hiddenMarker": "Verborgen",
@ -825,6 +827,7 @@
"schemas.field.show": "Weergeven in API",
"schemas.field.tabCommon": "Algemeen",
"schemas.field.tabEditing": "Bewerken",
"schemas.field.tabMore": "More",
"schemas.field.tabValidation": "Validatie",
"schemas.field.tagsHint": "Tags om uw veld te annoteren voor automatiseringsprocessen.",
"schemas.field.unique": "Uniek",

3
backend/i18n/frontend_zh.json

@ -799,6 +799,8 @@
"schemas.field.empty": "尚未创建字段。",
"schemas.field.enable": "在 UI 中启用",
"schemas.field.enabledMarker": "已启用",
"schemas.field.graphQLSchema": "GraphQL Schema",
"schemas.field.graphQLSchemaHint": "Define a GraphQL Schema for the json values. You can use multiple types for nested objects.\nThe first type becomes the field type. Only objects are supported as root types.",
"schemas.field.halfWidth": "半宽",
"schemas.field.halfWidthHint": "在编辑或创建页面上,当有足够的空间时,只显示半宽的字段。",
"schemas.field.hiddenMarker": "Hidden",
@ -825,6 +827,7 @@
"schemas.field.show": "在 API 中显示",
"schemas.field.tabCommon": "通用",
"schemas.field.tabEditing": "Editing",
"schemas.field.tabMore": "More",
"schemas.field.tabValidation": "Validation",
"schemas.field.tagsHint": "为自动化流程注释您的领域的标签。",
"schemas.field.unique": "Unique",

3
backend/i18n/source/frontend_en.json

@ -799,6 +799,8 @@
"schemas.field.empty": "No field created yet.",
"schemas.field.enable": "Enable in UI",
"schemas.field.enabledMarker": "Enabled",
"schemas.field.graphQLSchema": "GraphQL Schema",
"schemas.field.graphQLSchemaHint": "Define a GraphQL Schema for the json values. You can use multiple types for nested objects.\nThe first type becomes the field type. Only objects are supported as root types.",
"schemas.field.halfWidth": "Half Width",
"schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.",
"schemas.field.hiddenMarker": "Hidden",
@ -825,6 +827,7 @@
"schemas.field.show": "Show in API",
"schemas.field.tabCommon": "Common",
"schemas.field.tabEditing": "Editing",
"schemas.field.tabMore": "More",
"schemas.field.tabValidation": "Validation",
"schemas.field.tagsHint": "Tags to annotate your field for automation processes.",
"schemas.field.unique": "Unique",

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs

@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Core.Schemas
{
public sealed record JsonFieldProperties : FieldProperties
{
public string? GraphQLSchema { get; set; }
public override T Accept<T, TArgs>(IFieldPropertiesVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this, args);

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
.WithoutContentEnrichment());
}
public async Task<IUser?> FindUserAsync(RefToken refToken,
public async ValueTask<IUser?> FindUserAsync(RefToken refToken,
CancellationToken ct)
{
if (refToken.IsClient)

27
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Dynamic;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using GraphQLSchema = GraphQL.Types.Schema;
@ -25,10 +26,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
private readonly Dictionary<SchemaInfo, ContentResultGraphType> contentResultTypes = new Dictionary<SchemaInfo, ContentResultGraphType>(ReferenceEqualityComparer.Instance);
private readonly Dictionary<FieldInfo, EmbeddableStringGraphType> embeddableStringTypes = new Dictionary<FieldInfo, EmbeddableStringGraphType>();
private readonly Dictionary<string, EnumerationGraphType?> enumTypes = new Dictionary<string, EnumerationGraphType?>();
private readonly Dictionary<string, IGraphType[]> dynamicTypes = new Dictionary<string, IGraphType[]>();
private readonly FieldVisitor fieldVisitor;
private readonly FieldInputVisitor fieldInputVisitor;
private readonly PartitionResolver partitionResolver;
private readonly List<SchemaInfo> allSchemas = new List<SchemaInfo>();
private readonly HashSet<SchemaInfo> allSchemas = new HashSet<SchemaInfo>();
private readonly ReservedNames typeNames = ReservedNames.ForTypes();
private readonly GraphQLOptions options;
static Builder()
@ -54,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public GraphQLSchema BuildSchema(IEnumerable<ISchemaEntity> schemas)
{
// Do not add schema without fields.
allSchemas.AddRange(SchemaInfo.Build(schemas).Where(x => x.Fields.Count > 0));
allSchemas.AddRange(SchemaInfo.Build(schemas, typeNames).Where(x => x.Fields.Count > 0));
// Only published normal schemas (not components are used for entities).
var schemaInfos = allSchemas.Where(x => x.Schema.SchemaDef.IsPublished && x.Schema.SchemaDef.Type != SchemaType.Component).ToList();
@ -114,6 +117,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
newSchema.RegisterType(contentType);
}
foreach (var customType in dynamicTypes.SelectMany(x => x.Value))
{
newSchema.RegisterType(customType);
}
newSchema.RegisterVisitor(ErrorVisitor.Instance);
newSchema.Initialize();
return newSchema;
@ -151,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public IObjectGraphType? GetComponentType(DomainId schemaId)
{
var schema = allSchemas.Find(x => x.Schema.Id == schemaId);
var schema = allSchemas.FirstOrDefault(x => x.Schema.Id == schemaId);
if (schema == null)
{
@ -161,6 +170,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
return componentTypes.GetValueOrDefault(schema);
}
public IGraphType[] GetDynamicTypes(string? schema)
{
var graphQLSchema = schema;
if (string.IsNullOrWhiteSpace(graphQLSchema))
{
return Array.Empty<GraphType>();
}
return dynamicTypes.GetOrAdd(graphQLSchema, x => DynamicSchemaBuilder.ParseTypes(x, typeNames));
}
public EmbeddableStringGraphType GetEmbeddableString(FieldInfo fieldInfo, StringFieldProperties properties)
{
return embeddableStringTypes.GetOrAdd(fieldInfo, x => new EmbeddableStringGraphType(this, x, properties));

7
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs

@ -177,6 +177,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public FieldGraphSchema Visit(IField<JsonFieldProperties> field, FieldInfo args)
{
var schema = builder.GetDynamicTypes(field.Properties.GraphQLSchema);
if (schema.Length > 0)
{
return new (schema[0], JsonNoop, null);
}
return new (Scalars.Json, JsonPath, ContentActions.Json.Arguments);
}

104
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs

@ -7,7 +7,6 @@
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Text;
#pragma warning disable MA0048 // File name must match type name
@ -36,16 +35,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public IReadOnlyList<FieldInfo> Fields { get; init; }
private SchemaInfo(ISchemaEntity schema, string typeName, Names rootScope)
private SchemaInfo(ISchemaEntity schema, string typeName, ReservedNames typeNames)
{
Schema = schema;
ComponentType = rootScope[$"{typeName}Component"];
ContentResultType = rootScope[$"{typeName}ResultDto"];
ComponentType = typeNames[$"{typeName}Component"];
ContentResultType = typeNames[$"{typeName}ResultDto"];
ContentType = typeName;
DataFlatType = rootScope[$"{typeName}FlatDataDto"];
DataInputType = rootScope[$"{typeName}DataInputDto"];
DataType = rootScope[$"{typeName}DataDto"];
DataFlatType = typeNames[$"{typeName}FlatDataDto"];
DataInputType = typeNames[$"{typeName}DataInputDto"];
DataType = typeNames[$"{typeName}DataDto"];
TypeName = typeName;
}
@ -54,17 +53,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
return TypeName;
}
public static IEnumerable<SchemaInfo> Build(IEnumerable<ISchemaEntity> schemas)
public static IEnumerable<SchemaInfo> Build(IEnumerable<ISchemaEntity> schemas, ReservedNames typeNames)
{
var rootScope = new Names();
foreach (var schema in schemas.OrderBy(x => x.Created))
{
var typeName = rootScope[schema.TypeName()];
var typeName = typeNames[schema.TypeName()];
yield return new SchemaInfo(schema, typeName, rootScope)
yield return new SchemaInfo(schema, typeName, typeNames)
{
Fields = FieldInfo.Build(schema.SchemaDef.Fields, $"{typeName}Data", rootScope).ToList()
Fields = FieldInfo.Build(schema.SchemaDef.Fields, $"{typeName}Data", typeNames).ToList()
};
}
}
@ -100,21 +97,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public IReadOnlyList<FieldInfo> Fields { get; init; }
private FieldInfo(IField field, string fieldName, string typeName, Names rootScope)
private FieldInfo(IField field, string fieldName, string typeName, ReservedNames typeNames)
{
Field = field;
EmbeddableStringType = rootScope[$"{typeName}EmbeddableString"];
EmbeddedEnumType = rootScope[$"{typeName}Enum"];
EmbeddableStringType = typeNames[$"{typeName}EmbeddableString"];
EmbeddedEnumType = typeNames[$"{typeName}Enum"];
FieldName = fieldName;
FieldNameDynamic = $"{fieldName}__Dynamic";
LocalizedInputType = rootScope[$"{typeName}InputDto"];
LocalizedType = rootScope[$"{typeName}Dto"];
LocalizedTypeDynamic = rootScope[$"{typeName}Dto__Dynamic"];
NestedInputType = rootScope[$"{typeName}ChildInputDto"];
NestedType = rootScope[$"{typeName}ChildDto"];
UnionComponentType = rootScope[$"{typeName}ComponentUnionDto"];
UnionReferenceType = rootScope[$"{typeName}UnionDto"];
LocalizedInputType = typeNames[$"{typeName}InputDto"];
LocalizedType = typeNames[$"{typeName}Dto"];
LocalizedTypeDynamic = typeNames[$"{typeName}Dto__Dynamic"];
NestedInputType = typeNames[$"{typeName}ChildInputDto"];
NestedType = typeNames[$"{typeName}ChildDto"];
UnionComponentType = typeNames[$"{typeName}ComponentUnionDto"];
UnionReferenceType = typeNames[$"{typeName}UnionDto"];
}
public override string ToString()
@ -122,83 +119,34 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
return FieldName;
}
internal static IEnumerable<FieldInfo> Build(IEnumerable<IField> fields, string typeName, Names rootScope)
internal static IEnumerable<FieldInfo> Build(IEnumerable<IField> fields, string typeName, ReservedNames typeNames)
{
var typeScope = new Names();
var typeScope = ReservedNames.ForFields();
foreach (var field in fields.ForApi())
{
// Field names must be unique within the scope of the parent type.
var fieldName = typeScope[field.Name.ToCamelCase(), false];
var fieldName = typeScope[field.Name.ToCamelCase()];
// Type names must be globally unique.
var fieldTypeName = rootScope[$"{typeName}{field.TypeName()}"];
var fieldTypeName = typeNames[$"{typeName}{field.TypeName()}"];
var nested = new List<FieldInfo>();
if (field is IArrayField arrayField)
{
nested = Build(arrayField.Fields, fieldTypeName, rootScope).ToList();
nested = Build(arrayField.Fields, fieldTypeName, typeNames).ToList();
}
yield return new FieldInfo(
field,
fieldName,
fieldTypeName,
rootScope)
typeNames)
{
Fields = nested
};
}
}
}
internal sealed class Names
{
// Reserver names that are used for other GraphQL types.
private static readonly HashSet<string> ReservedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Asset",
"AssetResultDto",
"Content",
"Component",
"EntityCreatedResultDto",
"EntitySavedResultDto",
"JsonScalar",
"JsonPrimitive",
"User"
};
private readonly Dictionary<string, int> takenNames = new Dictionary<string, int>();
public string this[string name, bool isEntity = true]
{
get => GetName(name, isEntity);
}
private string GetName(string name, bool isEntity)
{
Guard.NotNullOrEmpty(name);
if (!char.IsLetter(name[0]))
{
name = "gql_" + name;
}
else if (isEntity && ReservedNames.Contains(name))
{
name = $"{name}Entity";
}
// Avoid duplicate names.
if (!takenNames.TryGetValue(name, out var offset))
{
takenNames[name] = 0;
return name;
}
takenNames[name] = ++offset;
// Add + 1 to all offsets for backwards-compatibility.
return $"{name}{offset + 1}";
}
}
}

75
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicResolver.cs

@ -0,0 +1,75 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL;
using GraphQL.Resolvers;
using Squidex.Infrastructure.Json.Objects;
#pragma warning disable RECS0018 // Comparison of floating point numbers with equality operator
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Dynamic
{
internal sealed class DynamicResolver : IFieldResolver
{
public static readonly DynamicResolver Instance = new DynamicResolver();
public async ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{
if (context.Source is JsonObject jsonObject)
{
var name = context.FieldDefinition.Name;
if (!jsonObject.TryGetValue(name, out var jsonValue))
{
return null;
}
var value = Convert(jsonValue);
return value;
}
var result = await NameFieldResolver.Instance.ResolveAsync(context);
return result;
}
private static object? Convert(JsonValue json)
{
var value = json.Value;
switch (value)
{
case double d:
{
var asInteger = (long)d;
if (asInteger == d)
{
return asInteger;
}
break;
}
case JsonArray a:
{
var result = new List<object?>();
foreach (var item in a)
{
result.Add(Convert(item));
}
return result;
}
}
return value;
}
}
}

70
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicSchemaBuilder.cs

@ -0,0 +1,70 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.Types;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Dynamic
{
internal static class DynamicSchemaBuilder
{
public static IGraphType[] ParseTypes(string? typeDefinitions, ReservedNames typeNames)
{
if (string.IsNullOrWhiteSpace(typeDefinitions))
{
return Array.Empty<IGraphType>();
}
Schema schema;
try
{
schema = Schema.For(typeDefinitions);
}
catch
{
return Array.Empty<IGraphType>();
}
var map = schema.AdditionalTypeInstances.ToDictionary(x => x.Name);
IGraphType? Convert(IGraphType? type)
{
switch (type)
{
case GraphQLTypeReference reference:
return map.GetValueOrDefault(reference.TypeName) ?? reference;
case NonNullGraphType nonNull:
return new NonNullGraphType(Convert(nonNull.ResolvedType));
case ListGraphType list:
return new ListGraphType(Convert(list.ResolvedType));
default:
return type;
}
}
var result = new List<IGraphType>();
foreach (var type in schema.AdditionalTypeInstances)
{
if (type is IComplexGraphType complexGraphType)
{
type.Name = typeNames[type.Name];
foreach (var field in complexGraphType.Fields)
{
// Assign a resolver to support json values.
field.Resolver = DynamicResolver.Instance;
field.ResolvedType = Convert(field.ResolvedType);
}
}
result.Add(type);
}
return result.ToArray();
}
}
}

120
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ErrorVisitor.cs

@ -0,0 +1,120 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL;
using GraphQL.Resolvers;
using GraphQL.Types;
using GraphQL.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
internal sealed class ErrorVisitor : BaseSchemaNodeVisitor
{
public static readonly ErrorVisitor Instance = new ErrorVisitor();
internal sealed class ErrorResolver : IFieldResolver
{
private readonly IFieldResolver inner;
public ErrorResolver(IFieldResolver inner)
{
this.inner = inner;
}
public async ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{
try
{
return await inner.ResolveAsync(context);
}
catch (ValidationException ex)
{
throw new ExecutionError(ex.Message);
}
catch (DomainException ex)
{
throw new ExecutionError(ex.Message);
}
catch (Exception ex)
{
var logFactory = context.RequestServices!.GetRequiredService<ILoggerFactory>();
logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name);
throw;
}
}
}
internal sealed class ErrorSourceStreamResolver : ISourceStreamResolver
{
private readonly ISourceStreamResolver inner;
public ErrorSourceStreamResolver(ISourceStreamResolver inner)
{
this.inner = inner;
}
public async ValueTask<IObservable<object?>> ResolveAsync(IResolveFieldContext context)
{
try
{
return await inner.ResolveAsync(context);
}
catch (ValidationException ex)
{
throw new ExecutionError(ex.Message);
}
catch (DomainException ex)
{
throw new ExecutionError(ex.Message);
}
catch (Exception ex)
{
var logFactory = context.RequestServices!.GetRequiredService<ILoggerFactory>();
logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name);
throw;
}
}
}
private ErrorVisitor()
{
}
public override void VisitObjectFieldDefinition(FieldType field, IObjectGraphType type, ISchema schema)
{
if (type.Name.StartsWith("__", StringComparison.Ordinal))
{
return;
}
if (field.StreamResolver != null)
{
if (field.StreamResolver is ErrorSourceStreamResolver)
{
return;
}
field.StreamResolver = new ErrorSourceStreamResolver(field.StreamResolver);
}
else
{
if (field.Resolver is ErrorResolver)
{
return;
}
field.Resolver = new ErrorResolver(field.Resolver ?? NameFieldResolver.Instance);
}
}
}
}

80
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ReservedNames.cs

@ -0,0 +1,80 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
public sealed class ReservedNames
{
private readonly Dictionary<string, int> takenNames;
public string this[string name]
{
get => GetName(name);
}
private ReservedNames(Dictionary<string, int> takenNames)
{
this.takenNames = takenNames;
}
public static ReservedNames ForFields()
{
var reserved = new Dictionary<string, int>();
return new ReservedNames(reserved);
}
public static ReservedNames ForTypes()
{
// Reserver names that are used for other GraphQL types.
var reserved = new Dictionary<string, int>
{
["Asset"] = 1,
["AssetResultDto"] = 1,
["Content"] = 1,
["Component"] = 1,
["EnrichedAssetEvent"] = 1,
["EnrichedContentEvent"] = 1,
["EntityCreatedResultDto"] = 1,
["EntitySavedResultDto"] = 1,
["JsonObject"] = 1,
["JsonScalar"] = 1,
["JsonPrimitive"] = 1,
["User"] = 1,
};
return new ReservedNames(reserved);
}
private string GetName(string name)
{
Guard.NotNullOrEmpty(name);
if (!char.IsLetter(name[0]))
{
name = "gql_" + name;
}
if (!takenNames.TryGetValue(name, out var offset))
{
// If the name is free, we do not add an offset.
takenNames[name] = 1;
return name;
}
else
{
// Add + 1 to all offsets for backwards-compatibility.
takenNames[name] = ++offset;
return $"{name}{offset}";
}
}
}
}

109
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs

@ -7,13 +7,11 @@
using GraphQL;
using GraphQL.Resolvers;
using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared;
@ -23,116 +21,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
public static IFieldResolver Sync<TSource, T>(Func<TSource, T> resolver)
{
return new SyncResolver<TSource, T>((source, context, execution) => resolver(source));
return new FuncFieldResolver<TSource, T>(x => resolver(x.Source));
}
public static IFieldResolver Sync<TSource, T>(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver)
{
return new SyncResolver<TSource, T>(resolver);
return new FuncFieldResolver<TSource, T>(x => resolver(x.Source, x, (GraphQLExecutionContext)x.UserContext));
}
public static IFieldResolver Async<TSource, T>(Func<TSource, Task<T>> resolver)
public static IFieldResolver Async<TSource, T>(Func<TSource, ValueTask<T?>> resolver)
{
return new AsyncResolver<TSource, T>((source, context, execution) => resolver(source));
return new FuncFieldResolver<TSource, T>(x => resolver(x.Source));
}
public static IFieldResolver Async<TSource, T>(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, Task<T>> resolver)
public static IFieldResolver Async<TSource, T>(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, ValueTask<T?>> resolver)
{
return new AsyncResolver<TSource, T>(resolver);
}
private abstract class BaseResolver<T, TOut> where T : TOut
{
protected async ValueTask<TOut> ResolveWithErrorHandlingAsync(IResolveFieldContext context)
{
var executionContext = (GraphQLExecutionContext)context.UserContext!;
try
{
return await ResolveCoreAsync(context, executionContext);
}
catch (ValidationException ex)
{
throw new ExecutionError(ex.Message);
}
catch (DomainException ex)
{
throw new ExecutionError(ex.Message);
}
catch (Exception ex)
{
var logFactory = executionContext.Resolve<ILoggerFactory>();
logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name);
throw;
}
}
protected abstract ValueTask<T> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext);
}
private sealed class SyncResolver<TSource, T> : BaseResolver<T, object?>, IFieldResolver
{
private readonly Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver;
public SyncResolver(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver)
{
this.resolver = resolver;
}
protected override ValueTask<T> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
return new ValueTask<T>(resolver((TSource)context.Source!, context, executionContext));
}
public ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{
return ResolveWithErrorHandlingAsync(context);
}
}
private sealed class AsyncResolver<TSource, T> : BaseResolver<T, object?>, IFieldResolver
{
private readonly Func<TSource, IResolveFieldContext, GraphQLExecutionContext, Task<T>> resolver;
public AsyncResolver(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, Task<T>> resolver)
{
this.resolver = resolver;
}
protected override async ValueTask<T> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
return await resolver((TSource)context.Source!, context, executionContext);
}
public ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{
return ResolveWithErrorHandlingAsync(context);
}
}
private sealed class SyncStreamResolver : BaseResolver<IObservable<object?>, IObservable<object?>>, ISourceStreamResolver
{
private readonly Func<IResolveFieldContext, GraphQLExecutionContext, IObservable<object?>> resolver;
public SyncStreamResolver(Func<IResolveFieldContext, GraphQLExecutionContext, IObservable<object?>> resolver)
{
this.resolver = resolver;
}
protected override ValueTask<IObservable<object?>> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
return new ValueTask<IObservable<object?>>(resolver(context, executionContext));
}
public ValueTask<IObservable<object?>> ResolveAsync(IResolveFieldContext context)
{
return ResolveWithErrorHandlingAsync(context);
}
return new FuncFieldResolver<TSource, T>(x => resolver(x.Source, x, (GraphQLExecutionContext)x.UserContext));
}
public static IFieldResolver Command(string permissionId, Func<IResolveFieldContext, ICommand> action)
{
return new AsyncResolver<object, object>(async (source, fieldContext, context) =>
return Async<object, object>(async (source, fieldContext, context) =>
{
var schemaId = fieldContext.FieldDefinition.SchemaNamedId();
@ -161,8 +70,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static ISourceStreamResolver Stream(string permissionId, Func<IResolveFieldContext, AppSubscription> action)
{
return new SyncStreamResolver((fieldContext, context) =>
return new SourceStreamResolver<object>(fieldContext =>
{
var context = (GraphQLExecutionContext)fieldContext.UserContext;
if (!context.Context.UserPermissions.Includes(PermissionIds.ForApp(permissionId, context.Context.App.Name)))
{
throw new DomainForbiddenException(T.Get("common.errorNoPermission"));

7
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs

@ -102,13 +102,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
return field.GetMetadata<NamedId<DomainId>>(nameof(SchemaNamedId))!;
}
private static FieldType WithMetadata(this FieldType field, string key, object value)
{
field.Metadata[key] = value;
return field;
}
internal static IGraphType? Flatten(this QueryArgument type)
{
return type.ResolvedType?.Flatten();

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

@ -9,6 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using GraphQLSchema = GraphQL.Types.Schema;
namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards
{
@ -146,7 +147,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards
public IEnumerable<ValidationError> Visit(JsonFieldProperties properties, None args)
{
yield break;
if (!IsValidGraphQLSchema(properties.GraphQLSchema))
{
yield return new ValidationError(Not.Valid(nameof(properties.GraphQLSchema)),
nameof(properties.GraphQLSchema));
}
}
public IEnumerable<ValidationError> Visit(NumberFieldProperties properties, None args)
@ -291,5 +296,23 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards
{
return max.HasValue && min.HasValue && min.Value.CompareTo(max.Value) < 0;
}
private static bool IsValidGraphQLSchema(string? schema)
{
if (string.IsNullOrWhiteSpace(schema))
{
return true;
}
try
{
GraphQLSchema.For(schema);
return true;
}
catch
{
return false;
}
}
}
}

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

@ -12,6 +12,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields
{
public sealed class JsonFieldPropertiesDto : FieldPropertiesDto
{
/// <summary>
/// The GraphQL schema.
/// </summary>
public string? GraphQLSchema { get; set; }
public override FieldProperties ToProperties()
{
var result = SimpleMapper.Map(this, new JsonFieldProperties());

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs

@ -191,7 +191,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var graphQLSchema = await CreateSut(schema).GetSchemaAsync(TestApp.Default);
Assert.Contains(graphQLSchema.AllTypes, x => x.Name == "ContentEntity");
Assert.Contains(graphQLSchema.AllTypes, x => x.Name == "Content2");
}
[Fact]

73
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/NamesTests.cs

@ -0,0 +1,73 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public sealed class NamesTests
{
[Fact]
public void Should_return_name_if_not_taken()
{
var sut = ReservedNames.ForFields();
var result = sut["myName"];
Assert.Equal("myName", result);
}
[Fact]
public void Should_return_corrected_name_if_not_taken()
{
var sut = ReservedNames.ForFields();
var result = sut["2myName"];
Assert.Equal("gql_2myName", result);
}
[Fact]
public void Should_return_name_with_offset_if_taken()
{
var sut = ReservedNames.ForFields();
var result1 = sut["myName"];
var result2 = sut["myName"];
var result3 = sut["myName"];
Assert.Equal("myName", result1);
Assert.Equal("myName2", result2);
Assert.Equal("myName3", result3);
}
[Fact]
public void Should_return_corrected_name_with_offset_if_taken()
{
var sut = ReservedNames.ForFields();
var result1 = sut["2myName"];
var result2 = sut["2myName"];
var result3 = sut["2myName"];
Assert.Equal("gql_2myName", result1);
Assert.Equal("gql_2myName2", result2);
Assert.Equal("gql_2myName3", result3);
}
[Fact]
public void Should_return_name_with_offset_if_reserved()
{
var sut = ReservedNames.ForTypes();
var result = sut["Content"];
Assert.Equal("Content2", result);
}
}
}

116
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs

@ -42,6 +42,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
iv
ivValue: iv(path: ""value"")
}
myJson2 {
iv {
__typename
rootString,
rootInt,
rootFloat,
rootBoolean,
rootArray,
rootObject {
__typename
nestedString,
nestedInt,
nestedFloat,
nestedBoolean,
nestedArray,
}
}
}
myString {
iv
}
@ -128,6 +146,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
flatData {
myJson
myJsonValue: myJson(path: ""value"")
myJson2 {
__typename
rootString,
rootInt,
rootFloat,
rootBoolean,
rootArray,
rootObject {
__typename
nestedString,
nestedInt,
nestedFloat,
nestedBoolean,
nestedArray,
}
}
myString
myStringEnum
myLocalizedString
@ -226,6 +260,30 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
.AddInvariant(
new JsonObject()
.Add("value", 1)))
.AddField("my-json2",
new ContentFieldData()
.AddInvariant(
JsonValue.Object()
.Add("rootString", "Root String")
.Add("rootInt", 42)
.Add("rootFloat", 3.14)
.Add("rootBoolean", true)
.Add("rootArray",
JsonValue.Array()
.Add("1")
.Add("2")
.Add("3"))
.Add("rootObject",
JsonValue.Object()
.Add("nestedString", "Nested String")
.Add("nestedInt", 42)
.Add("nestedFloat", 3.14)
.Add("nestedBoolean", true)
.Add("nestedArray",
JsonValue.Array()
.Add("1")
.Add("2")
.Add("3")))))
.AddField("my-array",
new ContentFieldData()
.AddInvariant(JsonValue.Array(
@ -370,6 +428,25 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
value = 1
}
},
["myJson2"] = new
{
iv = new Dictionary<string, object>
{
["rootString"] = "Root String",
["rootInt"] = 42,
["rootFloat"] = 3.14,
["rootBoolean"] = true,
["rootArray"] = new[] { "1", "2", "3" },
["rootObject"] = new Dictionary<string, object>
{
["nestedString"] = "Nested String",
["nestedInt"] = 42,
["nestedFloat"] = 3.14,
["nestedBoolean"] = true,
["nestedArray"] = new[] { "1", "2", "3" },
}
}
},
["myString"] = new
{
iv = (string?)null
@ -513,6 +590,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
},
ivValue = 1
},
["myJson2"] = new
{
iv = new Dictionary<string, object>
{
["__typename"] = "JsonObject2",
["rootString"] = "Root String",
["rootInt"] = 42,
["rootFloat"] = 3.14,
["rootBoolean"] = true,
["rootArray"] = new[] { "1", "2", "3" },
["rootObject"] = new Dictionary<string, object>
{
["__typename"] = "JsonNested",
["nestedString"] = "Nested String",
["nestedInt"] = 42,
["nestedFloat"] = 3.14,
["nestedBoolean"] = true,
["nestedArray"] = new[] { "1", "2", "3" },
}
}
},
["myString"] = new
{
iv = (string?)null
@ -641,6 +739,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
value = 1
},
["myJsonValue"] = 1,
["myJson2"] = new Dictionary<string, object>
{
["__typename"] = "JsonObject2",
["rootString"] = "Root String",
["rootInt"] = 42,
["rootFloat"] = 3.14,
["rootBoolean"] = true,
["rootArray"] = new[] { "1", "2", "3" },
["rootObject"] = new Dictionary<string, object>
{
["__typename"] = "JsonNested",
["nestedString"] = "Nested String",
["nestedInt"] = 42,
["nestedFloat"] = 3.14,
["nestedBoolean"] = true,
["nestedArray"] = new[] { "1", "2", "3" },
}
},
["myString"] = null,
["myStringEnum"] = "EnumA",
["myLocalizedString"] = "de-DE",

50
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs

@ -38,45 +38,65 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var enums = ReadonlyList.Create("EnumA", "EnumB", "EnumC");
var jsonSchema = @"
type JsonObject {
rootString: String
rootInt: Int
rootFloat: Float
rootBoolean: Boolean,
rootArray: [String]
rootObject: JsonNested
}
type JsonNested {
nestedString: String
nestedInt: Int
nestedFloat: Float
nestedBoolean: Boolean,
nestedArray: [String]
}";
Default = Mocks.Schema(TestApp.DefaultId, DefaultId,
new Schema(DefaultId.Name)
.Publish()
.AddJson(1, "my-json", Partitioning.Invariant,
new JsonFieldProperties())
.AddString(2, "my-string", Partitioning.Invariant,
.AddJson(2, "my-json2", Partitioning.Invariant,
new JsonFieldProperties { GraphQLSchema = jsonSchema })
.AddString(3, "my-string", Partitioning.Invariant,
new StringFieldProperties())
.AddString(3, "my-string-enum", Partitioning.Invariant,
.AddString(4, "my-string-enum", Partitioning.Invariant,
new StringFieldProperties { AllowedValues = enums, CreateEnum = true })
.AddString(4, "my-localized-string", Partitioning.Language,
.AddString(5, "my-localized-string", Partitioning.Language,
new StringFieldProperties())
.AddNumber(5, "my-number", Partitioning.Invariant,
.AddNumber(6, "my-number", Partitioning.Invariant,
new NumberFieldProperties())
.AddAssets(6, "my-assets", Partitioning.Invariant,
.AddAssets(7, "my-assets", Partitioning.Invariant,
new AssetsFieldProperties())
.AddBoolean(7, "my-boolean", Partitioning.Invariant,
.AddBoolean(8, "my-boolean", Partitioning.Invariant,
new BooleanFieldProperties())
.AddDateTime(8, "my-datetime", Partitioning.Invariant,
.AddDateTime(9, "my-datetime", Partitioning.Invariant,
new DateTimeFieldProperties())
.AddReferences(9, "my-references", Partitioning.Invariant,
.AddReferences(10, "my-references", Partitioning.Invariant,
new ReferencesFieldProperties { SchemaId = Ref1Id.Id })
.AddReferences(10, "my-union", Partitioning.Invariant,
.AddReferences(11, "my-union", Partitioning.Invariant,
new ReferencesFieldProperties())
.AddGeolocation(11, "my-geolocation", Partitioning.Invariant,
.AddGeolocation(12, "my-geolocation", Partitioning.Invariant,
new GeolocationFieldProperties())
.AddComponent(12, "my-component", Partitioning.Invariant,
.AddComponent(13, "my-component", Partitioning.Invariant,
new ComponentFieldProperties { SchemaId = Ref1Id.Id })
.AddComponents(13, "my-components", Partitioning.Invariant,
.AddComponents(14, "my-components", Partitioning.Invariant,
new ComponentsFieldProperties { SchemaIds = ReadonlyList.Create(Ref1.Id, Ref2.Id) })
.AddTags(14, "my-tags", Partitioning.Invariant,
.AddTags(15, "my-tags", Partitioning.Invariant,
new TagsFieldProperties())
.AddTags(15, "my-tags-enum", Partitioning.Invariant,
.AddTags(16, "my-tags-enum", Partitioning.Invariant,
new TagsFieldProperties { AllowedValues = enums, CreateEnum = true })
.AddArray(100, "my-array", Partitioning.Invariant, f => f
.AddBoolean(121, "nested-boolean",
new BooleanFieldProperties())
.AddNumber(122, "nested-number",
new NumberFieldProperties()))
.AddString(16, "my-embeds", Partitioning.Invariant,
.AddString(17, "my-embeds", Partitioning.Invariant,
new StringFieldProperties { IsEmbeddable = true, SchemaIds = ReadonlyList.Create(Ref1.Id, Ref2.Id) })
.SetScripts(new SchemaScripts { Query = "<query-script>" }));
}

1
frontend/src/app/features/schemas/declarations.ts

@ -27,6 +27,7 @@ export * from './pages/schema/fields/types/date-time-ui.component';
export * from './pages/schema/fields/types/date-time-validation.component';
export * from './pages/schema/fields/types/geolocation-ui.component';
export * from './pages/schema/fields/types/geolocation-validation.component';
export * from './pages/schema/fields/types/json-more.component';
export * from './pages/schema/fields/types/json-ui.component';
export * from './pages/schema/fields/types/json-validation.component';
export * from './pages/schema/fields/types/number-ui.component';

3
frontend/src/app/features/schemas/module.ts

@ -8,7 +8,7 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HelpComponent, LoadSchemasGuard, SchemaMustExistGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { ArrayValidationComponent, AssetsUIComponent, AssetsValidationComponent, BooleanUIComponent, BooleanValidationComponent, ComponentsUIComponent, ComponentsValidationComponent, DateTimeUIComponent, DateTimeValidationComponent, FieldComponent, FieldFormCommonComponent, FieldFormComponent, FieldFormUIComponent, FieldFormValidationComponent, FieldListComponent, FieldWizardComponent, GeolocationUIComponent, GeolocationValidationComponent, JsonUIComponent, JsonValidationComponent, NumberUIComponent, NumberValidationComponent, ReferencesUIComponent, ReferencesValidationComponent, SchemaEditFormComponent, SchemaExportFormComponent, SchemaFieldRulesFormComponent, SchemaFieldsComponent, SchemaFormComponent, SchemaPageComponent, SchemaPreviewUrlsFormComponent, SchemaScriptNamePipe, SchemaScriptsFormComponent, SchemasPageComponent, SchemaUIFormComponent, StringUIComponent, StringValidationComponent, TagsUIComponent, TagsValidationComponent } from './declarations';
import { ArrayValidationComponent, AssetsUIComponent, AssetsValidationComponent, BooleanUIComponent, BooleanValidationComponent, ComponentsUIComponent, ComponentsValidationComponent, DateTimeUIComponent, DateTimeValidationComponent, FieldComponent, FieldFormCommonComponent, FieldFormComponent, FieldFormUIComponent, FieldFormValidationComponent, FieldListComponent, FieldWizardComponent, GeolocationUIComponent, GeolocationValidationComponent, JsonMoreComponent, JsonUIComponent, JsonValidationComponent, NumberUIComponent, NumberValidationComponent, ReferencesUIComponent, ReferencesValidationComponent, SchemaEditFormComponent, SchemaExportFormComponent, SchemaFieldRulesFormComponent, SchemaFieldsComponent, SchemaFormComponent, SchemaPageComponent, SchemaPreviewUrlsFormComponent, SchemaScriptNamePipe, SchemaScriptsFormComponent, SchemasPageComponent, SchemaUIFormComponent, StringUIComponent, StringValidationComponent, TagsUIComponent, TagsValidationComponent } from './declarations';
import { ComponentUIComponent } from './pages/schema/fields/types/component-ui.component';
import { ComponentValidationComponent } from './pages/schema/fields/types/component-validation.component';
@ -66,6 +66,7 @@ const routes: Routes = [
FieldWizardComponent,
GeolocationUIComponent,
GeolocationValidationComponent,
JsonMoreComponent,
JsonUIComponent,
JsonValidationComponent,
NumberUIComponent,

9
frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html

@ -15,6 +15,11 @@
{{ 'schemas.field.tabEditing' | sqxTranslate }}
</a>
</li>
<li class="nav-item" [class.hidden]="field.properties.fieldType !== 'Json'">
<a class="nav-link" (click)="selectTab(3)" [class.active]="selectedTab === 3">
{{ 'schemas.field.tabMore' | sqxTranslate }}
</a>
</li>
</ul>
<div class="float-end" *ngIf="showButtons">
@ -53,4 +58,8 @@
[field]="field"
[schema]="schema">
</sqx-field-form-ui>
</div>
<div class="table-items-row-details-tab" [class.hidden]="selectedTab !== 3">
<sqx-json-more [fieldForm]="fieldForm" [field]="field" [properties]="field.rawProperties"></sqx-json-more>
</div>

3
frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.scss

@ -2,5 +2,6 @@
@import 'vars';
.table-items-row-details-tab {
padding-right: 3rem;
padding-left: 2rem;
padding-right: 2rem;
}

11
frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.html

@ -0,0 +1,11 @@
<div [formGroup]="fieldForm">
<div class="form-group">
<label>{{ 'schemas.field.graphQLSchema' | sqxTranslate }}</label>
<sqx-code-editor formControlName="graphQLSchema" mode="ace/mode/graphqlschema" [height]="350"></sqx-code-editor>
<sqx-form-hint>
{{ 'schemas.field.graphQLSchemaHint' | sqxTranslate }}
</sqx-form-hint>
</div>
</div>

2
frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.scss

@ -0,0 +1,2 @@
@import 'mixins';
@import 'vars';

26
frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.ts

@ -0,0 +1,26 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FieldDto, JsonFieldPropertiesDto } from '@app/shared';
@Component({
selector: 'sqx-json-more[field][fieldForm][properties]',
styleUrls: ['json-more.component.scss'],
templateUrl: 'json-more.component.html',
})
export class JsonMoreComponent {
@Input()
public fieldForm!: FormGroup;
@Input()
public field!: FieldDto;
@Input()
public properties!: JsonFieldPropertiesDto;
}

8
frontend/src/app/shared/state/schemas.forms.ts

@ -302,6 +302,10 @@ export class EditFieldFormVisitor implements FieldPropertiesVisitor<any> {
this.config['minValue'] = new FormControl(undefined, ValidatorsEx.validDateTime());
}
public visitJson() {
this.config['graphQLSchema'] = new FormControl(undefined);
}
public visitNumber() {
this.config['allowedValues'] = new FormControl(undefined);
this.config['defaultValue'] = new FormControl(undefined);
@ -357,10 +361,6 @@ export class EditFieldFormVisitor implements FieldPropertiesVisitor<any> {
return undefined;
}
public visitJson() {
return undefined;
}
public visitUI() {
return undefined;
}

Loading…
Cancel
Save