From adc6464c877c9bca3dde4d1e25acca27c722e6ad Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 28 Sep 2022 20:09:13 +0200 Subject: [PATCH] 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. --- backend/i18n/frontend_en.json | 3 + backend/i18n/frontend_it.json | 3 + backend/i18n/frontend_nl.json | 3 + backend/i18n/frontend_zh.json | 3 + backend/i18n/source/frontend_en.json | 3 + .../Schemas/JsonFieldProperties.cs | 2 + .../GraphQL/GraphQLExecutionContext.cs | 2 +- .../Contents/GraphQL/Types/Builder.cs | 27 +++- .../GraphQL/Types/Contents/FieldVisitor.cs | 7 + .../GraphQL/Types/Contents/SchemaInfo.cs | 104 ++++----------- .../GraphQL/Types/Dynamic/DynamicResolver.cs | 75 +++++++++++ .../Types/Dynamic/DynamicSchemaBuilder.cs | 70 ++++++++++ .../Contents/GraphQL/Types/ErrorVisitor.cs | 120 ++++++++++++++++++ .../Contents/GraphQL/Types/ReservedNames.cs | 80 ++++++++++++ .../Contents/GraphQL/Types/Resolvers.cs | 109 ++-------------- .../GraphQL/Types/SharedExtensions.cs | 7 - .../Guards/FieldPropertiesValidator.cs | 25 +++- .../Models/Fields/JsonFieldPropertiesDto.cs | 5 + .../GraphQL/GraphQLIntrospectionTests.cs | 2 +- .../Contents/GraphQL/NamesTests.cs | 73 +++++++++++ .../Contents/GraphQL/TestContent.cs | 116 +++++++++++++++++ .../Contents/GraphQL/TestSchemas.cs | 50 +++++--- .../src/app/features/schemas/declarations.ts | 1 + frontend/src/app/features/schemas/module.ts | 3 +- .../fields/forms/field-form.component.html | 9 ++ .../fields/forms/field-form.component.scss | 3 +- .../fields/types/json-more.component.html | 11 ++ .../fields/types/json-more.component.scss | 2 + .../fields/types/json-more.component.ts | 26 ++++ .../src/app/shared/state/schemas.forms.ts | 8 +- 30 files changed, 741 insertions(+), 211 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicResolver.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicSchemaBuilder.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ErrorVisitor.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ReservedNames.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/NamesTests.cs create mode 100644 frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.html create mode 100644 frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.scss create mode 100644 frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.ts diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index a0dfbd855..9e5f7ef4b 100644 --- a/backend/i18n/frontend_en.json +++ b/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", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index b9a2c798a..6b770b2e7 100644 --- a/backend/i18n/frontend_it.json +++ b/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", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index fad02a121..535398f3c 100644 --- a/backend/i18n/frontend_nl.json +++ b/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", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 1bf64f473..393d10e8d 100644 --- a/backend/i18n/frontend_zh.json +++ b/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", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index a0dfbd855..9e5f7ef4b 100644 --- a/backend/i18n/source/frontend_en.json +++ b/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", diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs index 95c24cf1a..fc21b220d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs +++ b/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(IFieldPropertiesVisitor visitor, TArgs args) { return visitor.Visit(this, args); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index cbe874344..1fbb2a8ef 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/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 FindUserAsync(RefToken refToken, + public async ValueTask FindUserAsync(RefToken refToken, CancellationToken ct) { if (refToken.IsClient) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs index 101b5030d..abc04838b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs +++ b/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 contentResultTypes = new Dictionary(ReferenceEqualityComparer.Instance); private readonly Dictionary embeddableStringTypes = new Dictionary(); private readonly Dictionary enumTypes = new Dictionary(); + private readonly Dictionary dynamicTypes = new Dictionary(); private readonly FieldVisitor fieldVisitor; private readonly FieldInputVisitor fieldInputVisitor; private readonly PartitionResolver partitionResolver; - private readonly List allSchemas = new List(); + private readonly HashSet allSchemas = new HashSet(); + 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 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(); + } + + 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)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs index 487c5f27f..f47ab8b5d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs +++ b/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 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); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs index 10a9494e6..1d1ab297a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs +++ b/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 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 Build(IEnumerable schemas) + public static IEnumerable Build(IEnumerable 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 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 Build(IEnumerable fields, string typeName, Names rootScope) + internal static IEnumerable Build(IEnumerable 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(); 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 ReservedNames = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Asset", - "AssetResultDto", - "Content", - "Component", - "EntityCreatedResultDto", - "EntitySavedResultDto", - "JsonScalar", - "JsonPrimitive", - "User" - }; - private readonly Dictionary takenNames = new Dictionary(); - - 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}"; - } - } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicResolver.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicResolver.cs new file mode 100644 index 000000000..04e58019f --- /dev/null +++ b/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 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(); + + foreach (var item in a) + { + result.Add(Convert(item)); + } + + return result; + } + } + + return value; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicSchemaBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Dynamic/DynamicSchemaBuilder.cs new file mode 100644 index 000000000..069319baf --- /dev/null +++ b/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(); + } + + Schema schema; + try + { + schema = Schema.For(typeDefinitions); + } + catch + { + return Array.Empty(); + } + + 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(); + + 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(); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ErrorVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ErrorVisitor.cs new file mode 100644 index 000000000..43a90e55c --- /dev/null +++ b/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 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(); + + 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> 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(); + + 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); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ReservedNames.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ReservedNames.cs new file mode 100644 index 000000000..efe5c515c --- /dev/null +++ b/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 takenNames; + + public string this[string name] + { + get => GetName(name); + } + + private ReservedNames(Dictionary takenNames) + { + this.takenNames = takenNames; + } + + public static ReservedNames ForFields() + { + var reserved = new Dictionary(); + + return new ReservedNames(reserved); + } + + public static ReservedNames ForTypes() + { + // Reserver names that are used for other GraphQL types. + var reserved = new Dictionary + { + ["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}"; + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs index 96131df3b..e29520ad0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs +++ b/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(Func resolver) { - return new SyncResolver((source, context, execution) => resolver(source)); + return new FuncFieldResolver(x => resolver(x.Source)); } public static IFieldResolver Sync(Func resolver) { - return new SyncResolver(resolver); + return new FuncFieldResolver(x => resolver(x.Source, x, (GraphQLExecutionContext)x.UserContext)); } - public static IFieldResolver Async(Func> resolver) + public static IFieldResolver Async(Func> resolver) { - return new AsyncResolver((source, context, execution) => resolver(source)); + return new FuncFieldResolver(x => resolver(x.Source)); } - public static IFieldResolver Async(Func> resolver) + public static IFieldResolver Async(Func> resolver) { - return new AsyncResolver(resolver); - } - - private abstract class BaseResolver where T : TOut - { - protected async ValueTask 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(); - - logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name); - throw; - } - } - - protected abstract ValueTask ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext); - } - - private sealed class SyncResolver : BaseResolver, IFieldResolver - { - private readonly Func resolver; - - public SyncResolver(Func resolver) - { - this.resolver = resolver; - } - - protected override ValueTask ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext) - { - return new ValueTask(resolver((TSource)context.Source!, context, executionContext)); - } - - public ValueTask ResolveAsync(IResolveFieldContext context) - { - return ResolveWithErrorHandlingAsync(context); - } - } - - private sealed class AsyncResolver : BaseResolver, IFieldResolver - { - private readonly Func> resolver; - - public AsyncResolver(Func> resolver) - { - this.resolver = resolver; - } - - protected override async ValueTask ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext) - { - return await resolver((TSource)context.Source!, context, executionContext); - } - - public ValueTask ResolveAsync(IResolveFieldContext context) - { - return ResolveWithErrorHandlingAsync(context); - } - } - - private sealed class SyncStreamResolver : BaseResolver, IObservable>, ISourceStreamResolver - { - private readonly Func> resolver; - - public SyncStreamResolver(Func> resolver) - { - this.resolver = resolver; - } - - protected override ValueTask> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext) - { - return new ValueTask>(resolver(context, executionContext)); - } - - public ValueTask> ResolveAsync(IResolveFieldContext context) - { - return ResolveWithErrorHandlingAsync(context); - } + return new FuncFieldResolver(x => resolver(x.Source, x, (GraphQLExecutionContext)x.UserContext)); } public static IFieldResolver Command(string permissionId, Func action) { - return new AsyncResolver(async (source, fieldContext, context) => + return Async(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 action) { - return new SyncStreamResolver((fieldContext, context) => + return new SourceStreamResolver(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")); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs index c6b6043dc..b0afe2ba3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs +++ b/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>(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(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/FieldPropertiesValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/FieldPropertiesValidator.cs index b43e2f2e4..4fff24efb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/FieldPropertiesValidator.cs +++ b/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 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 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; + } + } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs index f30fa98ed..35019de9a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/JsonFieldPropertiesDto.cs +++ b/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 { + /// + /// The GraphQL schema. + /// + public string? GraphQLSchema { get; set; } + public override FieldProperties ToProperties() { var result = SimpleMapper.Map(this, new JsonFieldProperties()); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs index 90779f706..697571e0d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs +++ b/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] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/NamesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/NamesTests.cs new file mode 100644 index 000000000..4c5b9b822 --- /dev/null +++ b/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); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs index 4081f2408..226383f70 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs +++ b/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 + { + ["rootString"] = "Root String", + ["rootInt"] = 42, + ["rootFloat"] = 3.14, + ["rootBoolean"] = true, + ["rootArray"] = new[] { "1", "2", "3" }, + ["rootObject"] = new Dictionary + { + ["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 + { + ["__typename"] = "JsonObject2", + ["rootString"] = "Root String", + ["rootInt"] = 42, + ["rootFloat"] = 3.14, + ["rootBoolean"] = true, + ["rootArray"] = new[] { "1", "2", "3" }, + ["rootObject"] = new Dictionary + { + ["__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 + { + ["__typename"] = "JsonObject2", + ["rootString"] = "Root String", + ["rootInt"] = 42, + ["rootFloat"] = 3.14, + ["rootBoolean"] = true, + ["rootArray"] = new[] { "1", "2", "3" }, + ["rootObject"] = new Dictionary + { + ["__typename"] = "JsonNested", + ["nestedString"] = "Nested String", + ["nestedInt"] = 42, + ["nestedFloat"] = 3.14, + ["nestedBoolean"] = true, + ["nestedArray"] = new[] { "1", "2", "3" }, + } + }, ["myString"] = null, ["myStringEnum"] = "EnumA", ["myLocalizedString"] = "de-DE", diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs index 7c55ba240..077f93a70 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs +++ b/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 = "" })); } diff --git a/frontend/src/app/features/schemas/declarations.ts b/frontend/src/app/features/schemas/declarations.ts index b2da69974..29cda57a3 100644 --- a/frontend/src/app/features/schemas/declarations.ts +++ b/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'; diff --git a/frontend/src/app/features/schemas/module.ts b/frontend/src/app/features/schemas/module.ts index 06013aa95..a73ff8d57 100644 --- a/frontend/src/app/features/schemas/module.ts +++ b/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, diff --git a/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html b/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html index 5bdfa23cb..24ed6cd0d 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.html @@ -15,6 +15,11 @@ {{ 'schemas.field.tabEditing' | sqxTranslate }} +
@@ -53,4 +58,8 @@ [field]="field" [schema]="schema"> +
+ +
+
\ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.scss b/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.scss index 0550b6db4..20ea49ee5 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.scss +++ b/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; } \ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.html b/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.html new file mode 100644 index 000000000..14af1abf8 --- /dev/null +++ b/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.html @@ -0,0 +1,11 @@ +
+
+ + + + + + {{ 'schemas.field.graphQLSchemaHint' | sqxTranslate }} + +
+
\ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.scss b/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.scss new file mode 100644 index 000000000..2742d895e --- /dev/null +++ b/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.scss @@ -0,0 +1,2 @@ +@import 'mixins'; +@import 'vars'; \ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.ts b/frontend/src/app/features/schemas/pages/schema/fields/types/json-more.component.ts new file mode 100644 index 000000000..ac378c18f --- /dev/null +++ b/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; +} diff --git a/frontend/src/app/shared/state/schemas.forms.ts b/frontend/src/app/shared/state/schemas.forms.ts index e6267008d..1847b1e86 100644 --- a/frontend/src/app/shared/state/schemas.forms.ts +++ b/frontend/src/app/shared/state/schemas.forms.ts @@ -302,6 +302,10 @@ export class EditFieldFormVisitor implements FieldPropertiesVisitor { 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 { return undefined; } - public visitJson() { - return undefined; - } - public visitUI() { return undefined; }