diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index eb52109d1..eced713cb 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -798,6 +798,8 @@ "schemas.field.hide": "Hide in API", "schemas.field.hintsHint": "Describe this field for documentation and the UI.", "schemas.field.inlineEditable": "Inline Editable", + "schemas.field.isEmbeddable": "Is embedding contents and assets", + "schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.", "schemas.field.labelHint": "Display name for documentation and the UI.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 561378312..91afe9cfa 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -798,6 +798,8 @@ "schemas.field.hide": "Nascondi nelle API", "schemas.field.hintsHint": "Descrivi questo schema per la documentazione e le interfacce utente.", "schemas.field.inlineEditable": "Modificabile sulla stessa linea", + "schemas.field.isEmbeddable": "Is embedding contents and assets", + "schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.", "schemas.field.labelHint": "Nome da visualizzare per la documentazione e le interfacce utente.", "schemas.field.localizable": "Consente la localizzazione", "schemas.field.localizableHint": "Puoi impostare il campo per consentire la localizzazione, ossia che dipende dalla lingua che utilizzi come ad esempio i nomi delle città.", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index cf67f11f8..ed9565696 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -798,6 +798,8 @@ "schemas.field.hide": "Verbergen in API", "schemas.field.hintsHint": "Beschrijf dit schema voor documentatie en gebruikersinterfaces.", "schemas.field.inlineEditable": "Inline bewerkbaar", + "schemas.field.isEmbeddable": "Is embedding contents and assets", + "schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.", "schemas.field.labelHint": "Weergavenaam voor documentatie en gebruikersinterfaces.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "Je kunt het veld markeren als lokaliseerbaar. Dit betekent dat het afhankelijk is van de taal, bijvoorbeeld de naam van een stad.", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 0143e0c20..c3b946c97 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -798,6 +798,8 @@ "schemas.field.hide": "隐藏在 API", "schemas.field.hintsHint": "为文档和 UI 描述这个字段。", "schemas.field.inlineEditable": "内联可编辑", + "schemas.field.isEmbeddable": "Is embedding contents and assets", + "schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.", "schemas.field.labelHint": "文档和 UI 的显示名称。", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "您可以将字段标记为可本地化。这意味着这取决于语言,例如城市名称。", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index eb52109d1..eced713cb 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -798,6 +798,8 @@ "schemas.field.hide": "Hide in API", "schemas.field.hintsHint": "Describe this field for documentation and the UI.", "schemas.field.inlineEditable": "Inline Editable", + "schemas.field.isEmbeddable": "Is embedding contents and assets", + "schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.", "schemas.field.labelHint": "Display name for documentation and the UI.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.", diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs index 564e7a5d9..68ca51d23 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs @@ -861,6 +861,33 @@ namespace Squidex.Domain.Apps.Core { } } + /// + /// Looks up a localized string similar to The referenced assets.. + /// + public static string StringFieldAssets { + get { + return ResourceManager.GetString("StringFieldAssets", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The referenced content items.. + /// + public static string StringFieldReferences { + get { + return ResourceManager.GetString("StringFieldReferences", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The text of this field.. + /// + public static string StringFieldText { + get { + return ResourceManager.GetString("StringFieldText", resourceCulture); + } + } + /// /// Looks up a localized string similar to The current number of calls.. /// diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx index d5fcff1bf..25ca0e595 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx @@ -384,6 +384,15 @@ The ID of the schema. + + The referenced assets. + + + The referenced content items. + + + The text of this field. + The current number of calls. diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs index a9daab01d..a10b6f98e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; namespace Squidex.Domain.Apps.Core.Schemas @@ -37,6 +38,8 @@ namespace Squidex.Domain.Apps.Core.Schemas public bool IsUnique { get; init; } + public bool IsEmbeddable { get; init; } + public bool InlineEditable { get; init; } public bool CreateEnum { get; init; } @@ -45,6 +48,8 @@ namespace Squidex.Domain.Apps.Core.Schemas public StringFieldEditor Editor { get; init; } + public ReadonlyList? SchemaIds { get; init; } + public override T Accept(IFieldPropertiesVisitor visitor, TArgs args) { return visitor.Visit(this, args); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/StringReferenceExtractor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/StringReferenceExtractor.cs new file mode 100644 index 000000000..24452831d --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/StringReferenceExtractor.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text.RegularExpressions; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.ExtractReferenceIds +{ + public sealed class StringReferenceExtractor + { + private readonly List contentsPatterns = new List(); + private readonly List assetsPatterns = new List(); + + public StringReferenceExtractor(IUrlGenerator urlGenerator) + { + AddAssetPattern(@"assets?:(?[a-z0-9\-_9]+)"); + AddAssetUrlPatterns(urlGenerator.AssetContentBase()); + AddAssetUrlPatterns(urlGenerator.AssetContentCDNBase()); + + AddContentPattern(@"contents?:(?[a-z0-9\-_9]+)"); + AddContentUrlPatterns(urlGenerator.ContentBase()); + AddContentUrlPatterns(urlGenerator.ContentCDNBase()); + } + + private void AddContentUrlPatterns(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return; + } + + if (!baseUrl.EndsWith('/')) + { + baseUrl += "/"; + } + + baseUrl = Regex.Escape(baseUrl); + + AddContentPattern(baseUrl + @"(.+)\/(.+)\/(?[a-z0-9\-_9]+)"); + } + + private void AddAssetUrlPatterns(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return; + } + + if (!baseUrl.EndsWith('/')) + { + baseUrl += "/"; + } + + baseUrl = Regex.Escape(baseUrl); + + AddAssetPattern(baseUrl + @"(?[a-z0-9\-_9]+)"); + AddAssetPattern(baseUrl + @"(.+)\/(?[a-z0-9\-_9]+)"); + } + + private void AddAssetPattern(string pattern) + { + assetsPatterns.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)); + } + + private void AddContentPattern(string pattern) + { + contentsPatterns.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)); + } + + public IEnumerable GetEmbeddedContentIds(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + yield break; + } + + foreach (var pattern in contentsPatterns) + { + foreach (Match match in pattern.Matches(text)) + { + yield return DomainId.Create(match.Groups["Id"].Value); + } + } + } + + public IEnumerable GetEmbeddedAssetIds(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + yield break; + } + + foreach (var pattern in assetsPatterns) + { + foreach (Match match in pattern.Matches(text)) + { + yield return DomainId.Create(match.Groups["Id"].Value); + } + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs index d0885e533..72c096764 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs @@ -12,14 +12,14 @@ namespace Squidex.Domain.Apps.Core { public interface IUrlGenerator { - bool CanGenerateAssetSourceUrl { get; } - string? AssetSource(NamedId appId, DomainId assetId, long fileVersion); string? AssetThumbnail(NamedId appId, string idOrSlug, AssetType assetType); string AssetsUI(NamedId appId, string? @ref = null); + string AssetContentCDNBase(); + string AssetContent(NamedId appId, string idOrSlug); string AssetContentBase(); @@ -30,6 +30,10 @@ namespace Squidex.Domain.Apps.Core string ClientsUI(NamedId appId); + string ContentCDNBase(); + + string ContentBase(); + string ContentsUI(NamedId appId, NamedId schemaId); string ContentUI(NamedId appId, NamedId schemaId, DomainId contentId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs index 4259ec309..9dd5601dc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Assets public long MaxSize { get; set; } = 5 * 1024 * 1024; + public string? CDN { get; set; } + public TimeSpan TimeoutFind { get; set; } = TimeSpan.FromSeconds(1); public TimeSpan TimeoutQuery { get; set; } = TimeSpan.FromSeconds(5); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs index 1c18c1029..f80cb8523 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs @@ -15,6 +15,8 @@ namespace Squidex.Domain.Apps.Entities.Contents public int MaxResults { get; set; } = 200; + public string? CDN { get; set; } + public TimeSpan TimeoutFind { get; set; } = TimeSpan.FromSeconds(1); public TimeSpan TimeoutQuery { get; set; } = TimeSpan.FromSeconds(5); 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 49c9b2834..170ed5728 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -77,12 +77,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return content; } - public async Task> GetReferencedAssetsAsync(IJsonValue value, TimeSpan cacheDuration, + public Task> GetReferencedAssetsAsync(IJsonValue value, TimeSpan cacheDuration, CancellationToken ct) { var ids = ParseIds(value); - if (ids == null) + return GetAssetsAsync(ids, cacheDuration, ct); + } + + public async Task> GetAssetsAsync(List? ids, TimeSpan cacheDuration, + CancellationToken ct) + { + if (ids == null || ids.Count == 0) { return EmptyAssets; } @@ -107,12 +113,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return await LoadAsync(ids); } - public async Task> GetReferencedContentsAsync(IJsonValue value, TimeSpan cacheDuration, + public Task> GetReferencedContentsAsync(IJsonValue value, TimeSpan cacheDuration, CancellationToken ct) { var ids = ParseIds(value); - if (ids == null) + return GetContentsAsync(ids, cacheDuration, ct); + } + + public async Task> GetContentsAsync(List? ids, TimeSpan cacheDuration, + CancellationToken ct) + { + if (ids == null || ids.Count == 0) { return EmptyContents; } 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 0eff96f84..ec7787237 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 @@ -6,7 +6,6 @@ // ========================================================================== using GraphQL; -using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; @@ -24,6 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private readonly Dictionary componentTypes = new Dictionary(ReferenceEqualityComparer.Instance); private readonly Dictionary contentTypes = new Dictionary(ReferenceEqualityComparer.Instance); private readonly Dictionary contentResultTypes = new Dictionary(ReferenceEqualityComparer.Instance); + private readonly Dictionary embeddableStringTypes = new Dictionary(); private readonly Dictionary enumTypes = new Dictionary(); private readonly FieldVisitor fieldVisitor; private readonly FieldInputVisitor fieldInputVisitor; @@ -107,6 +107,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return newSchema; } + public FieldGraphSchema GetGraphType(FieldInfo fieldInfo) + { + return fieldInfo.Field.Accept(fieldVisitor, fieldInfo); + } + public IFieldPartitioning ResolvePartition(Partitioning key) { return partitionResolver(key); @@ -117,11 +122,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return fieldInfo.Field.Accept(fieldInputVisitor, fieldInfo); } - public (IGraphType?, IFieldResolver?, QueryArguments?) GetGraphType(FieldInfo fieldInfo) - { - return fieldInfo.Field.Accept(fieldVisitor, fieldInfo); - } - public IObjectGraphType GetContentResultType(SchemaInfo schemaId) { return contentResultTypes.GetOrDefault(schemaId); @@ -149,6 +149,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return componentTypes.GetOrDefault(schema); } + public EmbeddableStringGraphType GetEmbeddableString(FieldInfo fieldInfo, StringFieldProperties properties) + { + return embeddableStringTypes.GetOrAdd(fieldInfo, x => new EmbeddableStringGraphType(this, x, properties)); + } + public EnumerationGraphType? GetEnumeration(string name, IEnumerable values) { return enumTypes.GetOrAdd(name, x => FieldEnumType.TryCreate(name, values)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs index 1e0bdad7f..cd5a192c9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs @@ -9,6 +9,7 @@ using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.ExtractReferenceIds; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; using Squidex.Infrastructure.Json.Objects; @@ -16,6 +17,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { internal static class ContentFields { + public static readonly IFieldResolver ResolveStringFieldAssets = Resolvers.Async(async (value, fieldContext, context) => + { + var cacheDuration = fieldContext.CacheDuration(); + + var ids = context.Resolve().GetEmbeddedAssetIds(value).ToList(); + + return await context.GetAssetsAsync(ids, cacheDuration, fieldContext.CancellationToken); + }); + + public static readonly IFieldResolver ResolveStringFieldContents = Resolvers.Async(async (value, fieldContext, context) => + { + var cacheDuration = fieldContext.CacheDuration(); + + var ids = context.Resolve().GetEmbeddedContentIds(value).ToList(); + + return await context.GetContentsAsync(ids, cacheDuration, fieldContext.CancellationToken); + }); + public static readonly FieldType Id = new FieldType { Name = "id", @@ -136,6 +155,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents Description = FieldDescriptions.EditToken }; + public static readonly FieldType StringFieldText = new FieldType + { + Name = "text", + ResolvedType = AllTypes.String, + Resolver = Resolvers.Sync(x => x), + Description = FieldDescriptions.StringFieldText + }; + + public static readonly FieldType StringFieldAssets = new FieldType + { + Name = "assets", + ResolvedType = new NonNullGraphType(SharedTypes.AssetsList), + Resolver = ResolveStringFieldAssets, + Description = FieldDescriptions.StringFieldAssets + }; + private static IFieldResolver Resolve(Func resolver) { return Resolvers.Sync(resolver); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs new file mode 100644 index 000000000..20d01a75a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs @@ -0,0 +1,64 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Types; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents +{ + internal sealed class EmbeddableStringGraphType : ObjectGraphType + { + public EmbeddableStringGraphType(Builder builder, FieldInfo fieldInfo, StringFieldProperties properties) + { + // The name is used for equal comparison. Therefore it is important to treat it as readonly. + Name = fieldInfo.EmbeddableStringType; + + AddField(ContentFields.StringFieldText); + AddField(ContentFields.StringFieldAssets); + + var referenceType = ResolveReferences(builder, fieldInfo, properties.SchemaIds); + + if (referenceType != null) + { + AddField(new FieldType + { + Name = "contents", + ResolvedType = new NonNullGraphType(new ListGraphType(new NonNullGraphType(referenceType))), + Resolver = ContentFields.ResolveStringFieldContents, + Description = FieldDescriptions.StringFieldReferences + }); + } + } + + private static IGraphType? ResolveReferences(Builder builder, FieldInfo fieldInfo, ReadonlyList? schemaIds) + { + IGraphType? contentType = null; + + if (schemaIds?.Count == 1) + { + contentType = builder.GetContentType(schemaIds[0]); + } + + if (contentType == null) + { + var union = new ReferenceUnionGraphType(builder, fieldInfo, schemaIds); + + if (!union.HasType) + { + return default; + } + + contentType = union; + } + + return contentType; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldEnumType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldEnumType.cs index f52d4f087..4445f3392 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldEnumType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldEnumType.cs @@ -14,6 +14,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { public FieldEnumType(string name, IEnumerable values) { + // The name is used for equal comparison. Therefore it is important to treat it as readonly. Name = name; foreach (var value in values) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldGraphSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldGraphSchema.cs new file mode 100644 index 000000000..fe419817a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldGraphSchema.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Resolvers; +using GraphQL.Types; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents +{ + public record struct FieldGraphSchema(IGraphType? Type, IFieldResolver? Resolver, QueryArguments? Arguments); +} 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 84cb8026a..1b6f9177c 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 @@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { public delegate object ValueResolver(IJsonValue value, IResolveFieldContext fieldContext, GraphQLExecutionContext context); - internal sealed class FieldVisitor : IFieldVisitor<(IGraphType?, IFieldResolver?, QueryArguments?), FieldInfo> + internal sealed class FieldVisitor : IFieldVisitor { public static readonly IFieldResolver JsonNoop = CreateValueResolver((value, fieldContext, contex) => value); public static readonly IFieldResolver JsonPath = CreateValueResolver(ContentActions.Json.Resolver); @@ -100,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents this.builder = builder; } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IArrayField field, FieldInfo args) + public FieldGraphSchema Visit(IArrayField field, FieldInfo args) { if (args.Fields.Count == 0) { @@ -114,20 +114,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return default; } - return (new ListGraphType(new NonNullGraphType(type)), JsonNoop, null); + return new (new ListGraphType(new NonNullGraphType(type)), JsonNoop, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { - return (SharedTypes.AssetsList, Assets, null); + return new (SharedTypes.AssetsList, Assets, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { - return (AllTypes.Boolean, JsonBoolean, null); + return new (AllTypes.Boolean, JsonBoolean, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { var type = ResolveComponent(args, field.Properties.SchemaIds); @@ -136,10 +136,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return default; } - return (type, JsonNoop, null); + return new (type, JsonNoop, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { var type = ResolveComponent(args, field.Properties.SchemaIds); @@ -148,34 +148,38 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return default; } - return (new ListGraphType(new NonNullGraphType(type)), JsonNoop, null); + return new (new ListGraphType(new NonNullGraphType(type)), JsonNoop, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { - return (AllTypes.DateTime, JsonDateTime, null); + return new (AllTypes.DateTime, JsonDateTime, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { - return (AllTypes.Json, JsonPath, ContentActions.Json.Arguments); + return new (AllTypes.Json, JsonPath, ContentActions.Json.Arguments); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { - return (AllTypes.Json, JsonPath, ContentActions.Json.Arguments); + return new (AllTypes.Json, JsonPath, ContentActions.Json.Arguments); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { - return (AllTypes.Float, JsonNumber, null); + return new (AllTypes.Float, JsonNumber, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { var type = AllTypes.String; - if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum) + if (field.Properties.IsEmbeddable) + { + type = builder.GetEmbeddableString(args, field.Properties); + } + else if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum) { var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues); @@ -185,10 +189,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents } } - return (type, JsonString, null); + return new (type, JsonString, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { var type = AllTypes.Strings; @@ -202,10 +206,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents } } - return (type, JsonStrings, null); + return new (type, JsonStrings, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { var type = ResolveReferences(args, field.Properties.SchemaIds); @@ -214,10 +218,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return default; } - return (new ListGraphType(new NonNullGraphType(type)), References, null); + return new (new ListGraphType(new NonNullGraphType(type)), References, null); } - public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) + public FieldGraphSchema Visit(IField field, FieldInfo args) { return default; } 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 43d2676f9..ecc0b86f3 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 @@ -108,6 +108,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public string ReferenceType { get; } + public string EmbeddableStringType { get; } + public IReadOnlyList Fields { get; } private FieldInfo(IField field, string typeName, Names names, Names parentNames, IReadOnlyList fields) @@ -116,11 +118,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents ComponentType = names[$"{typeName}ComponentUnionDto"]; DisplayName = field.DisplayName(); + EmbeddableStringType = names[$"{typeName}EmbeddableString"]; + EnumName = names[$"{fieldName}Enum"]; Field = field; - Fields = fields; FieldName = fieldName; FieldNameDynamic = names[$"{fieldName}__Dynamic"]; - EnumName = names[$"{fieldName}Enum"]; + Fields = fields; LocalizedInputType = names[$"{typeName}InputDto"]; LocalizedType = names[$"{typeName}Dto"]; LocalizedTypeDynamic = names[$"{typeName}Dto__Dynamic"]; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs index 07aab8f21..970b76440 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs @@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = "queryAssets", Arguments = AssetActions.Query.Arguments, - ResolvedType = AssetsList, + ResolvedType = new NonNullGraphType(AssetsList), Resolver = AssetActions.Query.Resolver, Description = "Get assets." }; @@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = "queryAssetsWithTotal", Arguments = AssetActions.Query.Arguments, - ResolvedType = AssetsResult, + ResolvedType = new NonNullGraphType(AssetsResult), Resolver = AssetActions.Query.ResolverWithTotal, Description = "Get assets and total count." }; diff --git a/backend/src/Squidex.Web/Services/UrlGenerator.cs b/backend/src/Squidex.Web/Services/UrlGenerator.cs index 50a8f5e69..5f2941975 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -5,9 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents; using Squidex.Infrastructure; using IGenericUrlGenerator = Squidex.Hosting.IUrlGenerator; @@ -17,16 +19,17 @@ namespace Squidex.Web.Services { private readonly IAssetFileStore assetFileStore; private readonly IGenericUrlGenerator urlGenerator; + private readonly AssetOptions assetOptions; + private readonly ContentOptions contentOptions; - public bool CanGenerateAssetSourceUrl { get; } - - public UrlGenerator(IGenericUrlGenerator urlGenerator, IAssetFileStore assetFileStore, bool allowAssetSourceUrl) + public UrlGenerator(IGenericUrlGenerator urlGenerator, IAssetFileStore assetFileStore, + IOptions assetOptions, + IOptions contentOptions) { + this.contentOptions = contentOptions.Value; this.assetFileStore = assetFileStore; - + this.assetOptions = assetOptions.Value; this.urlGenerator = urlGenerator; - - CanGenerateAssetSourceUrl = allowAssetSourceUrl; } public string? AssetThumbnail(NamedId appId, string idOrSlug, AssetType assetType) @@ -39,6 +42,11 @@ namespace Squidex.Web.Services return urlGenerator.BuildUrl($"api/assets/{appId.Name}/{idOrSlug}?width=100&mode=Max"); } + public string AssetContentCDNBase() + { + return contentOptions.CDN ?? string.Empty; + } + public string AssetContentBase() { return urlGenerator.BuildUrl("api/assets/"); @@ -79,6 +87,16 @@ namespace Squidex.Web.Services return urlGenerator.BuildUrl($"app/{appId.Name}/settings/clients", false); } + public string ContentCDNBase() + { + return contentOptions.CDN ?? string.Empty; + } + + public string ContentBase() + { + return urlGenerator.BuildUrl("api/content/", false); + } + public string ContentsUI(NamedId appId, NamedId schemaId) { return urlGenerator.BuildUrl($"app/{appId.Name}/content/{schemaId.Name}", false); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs index faabf3529..8db13e685 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Reflection; @@ -73,11 +74,21 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields /// public ReadonlyList? AllowedValues { get; set; } + /// + /// The allowed schema ids that can be embedded. + /// + public ReadonlyList? SchemaIds { get; init; } + /// /// Indicates if the field value must be unique. Ignored for nested fields and localized fields. /// public bool IsUnique { get; set; } + /// + /// Indicates that other content items or references are embedded. + /// + public bool IsEmbeddable { get; set; } + /// /// Indicates that the inline editor is enabled for this field. /// diff --git a/backend/src/Squidex/Config/Domain/QueryServices.cs b/backend/src/Squidex/Config/Domain/QueryServices.cs index 0cef3d645..aa132bb2c 100644 --- a/backend/src/Squidex/Config/Domain/QueryServices.cs +++ b/backend/src/Squidex/Config/Domain/QueryServices.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.ExtractReferenceIds; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; using Squidex.Web.Services; @@ -16,12 +17,13 @@ namespace Squidex.Config.Domain { public static void AddSquidexQueries(this IServiceCollection services, IConfiguration config) { - var exposeSourceUrl = config.GetOptionalValue("assetStore:exposeSourceUrl", true); - services.Configure(config, "graphql"); - services.AddSingletonAs(c => ActivatorUtilities.CreateInstance(c, exposeSourceUrl)) + services.AddSingletonAs() + .AsSelf(); + + services.AddSingletonAs() .As(); services.AddSingletonAs() diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/StringReferenceExtractorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/StringReferenceExtractorTests.cs new file mode 100644 index 000000000..604f49d00 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/StringReferenceExtractorTests.cs @@ -0,0 +1,64 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FakeItEasy; +using Squidex.Domain.Apps.Core.ExtractReferenceIds; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds +{ + public class StringReferenceExtractorTests + { + private readonly StringReferenceExtractor sut; + + public StringReferenceExtractorTests() + { + var urlGenerator = A.Fake(); + + A.CallTo(() => urlGenerator.ContentBase()) + .Returns("https://cloud.squidex.io/api/content/"); + + A.CallTo(() => urlGenerator.ContentCDNBase()) + .Returns("https://contents.squidex.io/"); + + A.CallTo(() => urlGenerator.AssetContentBase()) + .Returns("https://cloud.squidex.io/api/assets/"); + + A.CallTo(() => urlGenerator.AssetContentCDNBase()) + .Returns("https://assets.squidex.io/"); + + sut = new StringReferenceExtractor(urlGenerator); + } + + [Theory] + [InlineData("before content:a_b-123|after")] + [InlineData("before contents:a_b-123|after")] + [InlineData("before https://cloud.squidex.io/api/content/my-app/my-schema/a_b-123|after")] + [InlineData("before https://contents.squidex.io/my-app/my-schema/a_b-123|after")] + public void Should_extract_content_id(string input) + { + var ids = sut.GetEmbeddedContentIds(input); + + Assert.Contains(DomainId.Create("a_b-123"), ids.ToList()); + } + + [Theory] + [InlineData("before asset:a_b-123|after")] + [InlineData("before assets:a_b-123|after")] + [InlineData("before https://cloud.squidex.io/api/assets/a_b-123|after")] + [InlineData("before https://cloud.squidex.io/api/assets/my-app/a_b-123|after")] + [InlineData("before https://assets.squidex.io/a_b-123|after")] + [InlineData("before https://assets.squidex.io/my-app/a_b-123|after")] + public void Should_extract_asset_id(string input) + { + var ids = sut.GetEmbeddedAssetIds(input); + + Assert.Contains(DomainId.Create("a_b-123"), ids.ToList()); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 6c30e25d3..05dc11870 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -400,6 +400,89 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL AssertResult(expected, result); } + [Fact] + public async Task Should_also_fetch_embedded_contents_if_field_is_included_in_query() + { + var contentRefId = DomainId.NewGuid(); + var contentRef = TestContent.CreateRef(TestSchemas.Ref1Id, contentRefId, "schemaRef1Field", "ref1"); + + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(contentId, contentRefId); + + var query = CreateQuery(@" + query { + findMySchemaContent(id: '') { + id + data { + myEmbeds { + iv { + text + contents { + ... on Content { + id + } + ... on MyRefSchema1 { + data { + schemaRef1Field { + iv + } + } + } + } + } + } + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) + .Returns(ResultList.CreateFrom(0, contentRef)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .Returns(ResultList.CreateFrom(1, content)); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = content.Id, + data = new + { + myEmbeds = new + { + iv = new + { + text = $"assets:{DomainId.Empty}, contents:{contentRefId}", + contents = new[] + { + new + { + id = contentRefId, + data = new + { + schemaRef1Field = new + { + iv = "ref1" + } + } + } + } + } + } + } + } + } + }; + + AssertResult(expected, result); + } + [Fact] public async Task Should_also_fetch_referenced_contents_if_field_is_included_in_query() { @@ -904,6 +987,73 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL AssertResult(expected, result); } + [Fact] + public async Task Should_also_fetch_embedded_assets_if_field_is_included_in_query() + { + var assetRefId = DomainId.NewGuid(); + var assetRef = TestAsset.Create(assetRefId); + + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(contentId, assetId: assetRefId); + + var query = CreateQuery(@" + query { + findMySchemaContent(id: '') { + id + data { + myEmbeds { + iv { + text + assets { + id + } + } + } + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .Returns(ResultList.CreateFrom(1, content)); + + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, + A.That.HasIdsWithoutTotal(assetRefId), A._)) + .Returns(ResultList.CreateFrom(0, assetRef)); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = content.Id, + data = new + { + myEmbeds = new + { + iv = new + { + text = $"assets:{assetRefId}, contents:{DomainId.Empty}", + assets = new[] + { + new + { + id = assetRefId + } + } + } + } + } + } + } + }; + + AssertResult(expected, result); + } + [Fact] public async Task Should_also_fetch_referenced_assets_if_field_is_included_in_query() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index cf41dbb5f..ed0f2f350 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; using Squidex.Caching; using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.ExtractReferenceIds; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; @@ -60,12 +61,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL requestContext = new Context(Mocks.FrontendUser(), TestApp.Default); } - protected void AssertResult(object expected, ExecutionResult result) + protected void AssertResult(object lhs, ExecutionResult result) { - var resultJson = serializer.Serialize(result, true); - var expectJson = serializer.Serialize(expected, true); + var rhsJson = serializer.Serialize(result, true); + var lhsJson = serializer.Serialize(lhs, true); - Assert.Equal(expectJson, resultJson); + Assert.Equal(lhsJson, rhsJson); } protected Task ExecuteAsync(ExecutionOptions options, string? permissionId = null) @@ -138,6 +139,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .BuildServiceProvider(); var schemasHash = A.Fake(); 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 95542ba85..0ed5d35e6 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 @@ -236,6 +236,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .Add("nested-number", 20) .Add("nested-boolean", false)))); + if (assetId != default || refId != default) + { + data.AddField("my-embeds", + new ContentFieldData() + .AddInvariant(JsonValue.Create($"assets:{assetId}, contents:{refId}"))); + } + var content = new ContentEntity { Id = id, @@ -483,6 +490,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL }; } + if (assetId != default || refId != default) + { + result["myEmbeds"] = new + { + iv = $"assets:{assetId}, contents:{refId}" + }; + } + return result; } 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 3a95df24f..7c55ba240 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 @@ -76,6 +76,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL new BooleanFieldProperties()) .AddNumber(122, "nested-number", new NumberFieldProperties())) + .AddString(16, "my-embeds", Partitioning.Invariant, + new StringFieldProperties { IsEmbeddable = true, SchemaIds = ReadonlyList.Create(Ref1.Id, Ref2.Id) }) .SetScripts(new SchemaScripts { Query = "" })); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs index 8ef8ed25d..37856ef6f 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs @@ -35,12 +35,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData return $"contents/{schemaId.Name}/{contentId}"; } - public string AssetsUI(NamedId appId, string? @ref = null) + public string AssetContentBase() { - throw new NotSupportedException(); + return "$assets/"; } - public string AssetContentBase() + public string AssetContentCDNBase() + { + return $"cdn/assets/"; + } + + public string ContentBase() + { + return $"contents/"; + } + + public string ContentCDNBase() + { + return $"cdn/contents/"; + } + + public string AssetsUI(NamedId appId, string? @ref = null) { throw new NotSupportedException(); } diff --git a/frontend/src/app/features/api/pages/graphql/graphql-page.component.scss b/frontend/src/app/features/api/pages/graphql/graphql-page.component.scss index 3db4ba295..8b53d28d1 100644 --- a/frontend/src/app/features/api/pages/graphql/graphql-page.component.scss +++ b/frontend/src/app/features/api/pages/graphql/graphql-page.component.scss @@ -35,6 +35,11 @@ } } + /* stylelint-disable-next-line selector-class-pattern */ + .CodeMirror { + padding: 0; + } + /* stylelint-disable-next-line selector-class-pattern */ .CodeMirror-linenumbers { min-width: 29px; diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.html b/frontend/src/app/features/content/shared/forms/field-editor.component.html index bb21aaf27..b96f56025 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.html +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.html @@ -184,13 +184,23 @@ - + + - + + diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html b/frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html index 44c4a67a6..6898877cc 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html @@ -1,6 +1,6 @@
- +
- +
- +
+
+ + +
+ + + + {{ 'schemas.fieldTypes.string.folderIdHint' | sqxTranslate }} + +
+
+
@@ -60,14 +72,27 @@
- - -
- +
+
+ + +
- {{ 'schemas.fieldTypes.string.folderIdHint' | sqxTranslate }} + {{ 'schemas.field.isEmbeddableHint' | sqxTranslate }}
+ +
+ + +
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts b/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts index 85713c746..271eb1969 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts +++ b/frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts @@ -8,7 +8,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; -import { FieldDto, ResourceOwner, STRING_FIELD_EDITORS, StringFieldPropertiesDto, valueProjection$ } from '@app/shared'; +import { FieldDto, ResourceOwner, STRING_FIELD_EDITORS, StringFieldPropertiesDto, valueProjection$, SchemaTagSource } from '@app/shared'; @Component({ selector: 'sqx-string-ui[field][fieldForm][properties]', @@ -29,6 +29,13 @@ export class StringUIComponent extends ResourceOwner implements OnChanges { public hideAllowedValues?: Observable; public hideInlineEditable?: Observable; + public hideSchemaIds?: Observable; + + constructor( + public readonly schemasSource: SchemaTagSource, + ) { + super(); + } public ngOnChanges(changes: SimpleChanges) { if (changes['fieldForm']) { @@ -42,6 +49,9 @@ export class StringUIComponent extends ResourceOwner implements OnChanges { this.hideInlineEditable = valueProjection$(editor, x => !(x && (x === 'Input' || x === 'Dropdown' || x === 'Slug'))); + this.hideSchemaIds = + valueProjection$(this.fieldForm.controls['isEmbeddable'], x => !x); + this.own( this.hideAllowedValues.subscribe(isSelection => { if (isSelection) { diff --git a/frontend/src/app/shared/components/forms/markdown-editor.component.html b/frontend/src/app/shared/components/forms/markdown-editor.component.html index 8677f53e4..0104a09d3 100644 --- a/frontend/src/app/shared/components/forms/markdown-editor.component.html +++ b/frontend/src/app/shared/components/forms/markdown-editor.component.html @@ -10,4 +10,13 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/app/shared/components/forms/markdown-editor.component.ts b/frontend/src/app/shared/components/forms/markdown-editor.component.ts index 603bd0a5a..cda460cf9 100644 --- a/frontend/src/app/shared/components/forms/markdown-editor.component.ts +++ b/frontend/src/app/shared/components/forms/markdown-editor.component.ts @@ -8,7 +8,8 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, Renderer2, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { marked } from 'marked'; -import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; +import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; +import { ContentDto } from '@app/shared'; declare const SimpleMDE: any; @@ -34,6 +35,15 @@ export class MarkdownEditorComponent extends StatefulControlComponent; + + @Input() + public language!: LanguageDto; + + @Input() + public languages!: ReadonlyArray; + @Input() public folderId?: string; @@ -53,6 +63,8 @@ export class MarkdownEditorComponent extends StatefulControlComponent { + private showAssetSelector = () => { if (this.snapshot.isDisabled) { return; } @@ -86,6 +98,14 @@ export class MarkdownEditorComponent extends StatefulControlComponent { + if (this.snapshot.isDisabled) { + return; + } + + this.contentsDialog.show(); + }; + public ngAfterViewInit() { Promise.all([ this.resourceLoader.loadLocalStyle('dependencies/simplemde/simplemde.min.css'), @@ -170,10 +190,17 @@ export class MarkdownEditorComponent extends StatefulControlComponent 0 ? + { + name: 'contents', + action: this.showContentsSelector, + className: 'icon-contents icon-bold', + title: 'Insert Contents', + } : null, ], element: this.editor.nativeElement, }); @@ -204,7 +231,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent) { - const content = this.buildMarkups(assets); + const content = this.buildAssetsMarkup(assets); if (content.length > 0) { this.simplemde.codemirror.replaceSelection(content); @@ -213,6 +240,16 @@ export class MarkdownEditorComponent extends StatefulControlComponent) { + const content = this.buildContentsMarkup(contents); + + if (content.length > 0) { + this.simplemde.codemirror.replaceSelection(content); + } + + this.contentsDialog.hide(); + } + public insertFiles(files: ReadonlyArray) { const doc = this.simplemde.codemirror.getDoc(); @@ -251,7 +288,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent { if (Types.is(asset, AssetDto)) { - replaceText(this.buildMarkup(asset)); + replaceText(this.buildAssetMarkup(asset)); } }, error: error => { @@ -274,17 +311,39 @@ export class MarkdownEditorComponent extends StatefulControlComponent) { + let markup = ''; for (const asset of assets) { - content += this.buildMarkup(asset); + markup += this.buildAssetMarkup(asset); } - return content; + return markup; + } + + private buildContentsMarkup(contents: ReadonlyArray) { + let markup = ''; + + for (const content of contents) { + markup += this.buildContentMarkup(content); + } + + return markup; + } + + private buildContentMarkup(content: ContentDto) { + const name = + content.referenceFields + .map(f => getContentValue(content, this.language, f, false)) + .map(v => v.formatted) + .defined() + .join(', ') + || 'content'; + + return `[${name}](${this.apiUrl.buildUrl(content._links['self'].href)}')`; } - private buildMarkup(asset: AssetDto) { + private buildAssetMarkup(asset: AssetDto) { const name = asset.fileNameWithoutExtension; if (asset.type === 'Image' || asset.mimeType === 'image/svg+xml' || asset.fileName.endsWith('.svg')) { diff --git a/frontend/src/app/shared/components/forms/rich-editor.component.html b/frontend/src/app/shared/components/forms/rich-editor.component.html index 2fb4d1bf8..72f9aa5de 100644 --- a/frontend/src/app/shared/components/forms/rich-editor.component.html +++ b/frontend/src/app/shared/components/forms/rich-editor.component.html @@ -6,4 +6,13 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/app/shared/components/forms/rich-editor.component.ts b/frontend/src/app/shared/components/forms/rich-editor.component.ts index 627115039..c3921ba3b 100644 --- a/frontend/src/app/shared/components/forms/rich-editor.component.ts +++ b/frontend/src/app/shared/components/forms/rich-editor.component.ts @@ -7,7 +7,8 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, Output, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, LocalizerService, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; +import { ContentDto } from '@app/shared'; +import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal'; declare const tinymce: any; @@ -31,6 +32,15 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im @Output() public assetPluginClick = new EventEmitter(); + @Input() + public schemaIds?: ReadonlyArray; + + @Input() + public language!: LanguageDto; + + @Input() + public languages!: ReadonlyArray; + @Input() public folderId = ''; @@ -44,11 +54,12 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im public assetsDialog = new DialogModel(); + public contentsDialog = new DialogModel(); + constructor(changeDetector: ChangeDetectorRef, private readonly apiUrl: ApiUrlConfig, private readonly assetUploader: AssetUploaderState, private readonly resourceLoader: ResourceLoaderService, - private readonly localizer: LocalizerService, ) { super(changeDetector, {}); } @@ -82,7 +93,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im }); } - private showSelector = () => { + private showAssetsSelector = () => { if (this.snapshot.isDisabled) { return; } @@ -90,6 +101,14 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im this.assetsDialog.show(); }; + private showContentsSelector = () => { + if (this.snapshot.isDisabled) { + return; + } + + this.contentsDialog.show(); + }; + private getEditorOptions(target: any): any { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; @@ -117,12 +136,21 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im setup: (editor: any) => { editor.ui.registry.addButton('assets', { - onAction: self.showSelector, + onAction: self.showAssetsSelector, icon: 'gallery', text: '', - tooltip: this.localizer.getOrKey('assets.insertAssets'), + tooltip: 'Insert Assets', }); + if (this.schemaIds && this.schemaIds.length > 0) { + editor.ui.registry.addButton('contents', { + onAction: self.showContentsSelector, + icon: 'duplicate', + text: '', + tooltip: 'Insert Contents', + }); + } + editor.on('init', () => { self.tinyEditor = editor; @@ -222,7 +250,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im } public insertAssets(assets: ReadonlyArray) { - const content = this.buildMarkups(assets); + const content = this.buildAssetsMarkup(assets); if (content.length > 0) { this.tinyEditor.execCommand('mceInsertContent', false, content); @@ -231,6 +259,16 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im this.assetsDialog.hide(); } + public insertContents(contents: ReadonlyArray) { + const content = this.buildContentsMarkup(contents); + + if (content.length > 0) { + this.tinyEditor.execCommand('mceInsertContent', false, content); + } + + this.contentsDialog.hide(); + } + public insertFiles(files: ReadonlyArray) { for (const file of files) { this.uploadFile(file); @@ -252,7 +290,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im .subscribe({ next: asset => { if (Types.is(asset, AssetDto)) { - replaceText(this.buildMarkup(asset)); + replaceText(this.buildAssetMarkup(asset)); } }, error: error => { @@ -263,17 +301,39 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im }); } - private buildMarkups(assets: readonly AssetDto[]) { - let content = ''; + private buildAssetsMarkup(assets: ReadonlyArray) { + let markup = ''; for (const asset of assets) { - content += this.buildMarkup(asset); + markup += this.buildAssetMarkup(asset); } - return content; + return markup; + } + + private buildContentsMarkup(contents: ReadonlyArray) { + let markup = ''; + + for (const content of contents) { + markup += this.buildContentMarkup(content); + } + + return markup; + } + + private buildContentMarkup(content: ContentDto) { + const name = + content.referenceFields + .map(f => getContentValue(content, this.language, f, false)) + .map(v => v.formatted) + .defined() + .join(', ') + || 'content'; + + return `${name}`; } - private buildMarkup(asset: AssetDto) { + private buildAssetMarkup(asset: AssetDto) { const name = asset.fileNameWithoutExtension; if (asset.type === 'Image' || asset.mimeType === 'image/svg+xml' || asset.fileName.endsWith('.svg')) { @@ -295,5 +355,5 @@ const DEFAULT_PROPS = { max_height: 800, removed_menuitems: 'newdocument', resize: true, - toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter | bullist numlist outdent indent | link image media | assets', + toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter | bullist numlist outdent indent | link image media | assets contents', }; diff --git a/frontend/src/app/shared/services/schemas.types.ts b/frontend/src/app/shared/services/schemas.types.ts index 88a916870..1c64e8f75 100644 --- a/frontend/src/app/shared/services/schemas.types.ts +++ b/frontend/src/app/shared/services/schemas.types.ts @@ -429,22 +429,24 @@ export class StringFieldPropertiesDto extends FieldPropertiesDto { public readonly fieldType = 'String'; public readonly allowedValues?: ReadonlyArray; + public readonly contentType?: StringContentType; public readonly createEnum: boolean = false; public readonly defaultValue?: string; public readonly defaultValues?: DefaultValue; public readonly editor: StringFieldEditor = 'Input'; + public readonly folderId?: string; public readonly inlineEditable: boolean = false; + public readonly isEmbeddable: boolean = false; public readonly isUnique: boolean = false; - public readonly folderId?: string; + public readonly maxCharacters?: number; public readonly maxLength?: number; - public readonly minLength?: number; public readonly maxWords?: number; - public readonly minWords?: number; - public readonly maxCharacters?: number; public readonly minCharacters?: number; - public readonly contentType?: StringContentType; + public readonly minLength?: number; + public readonly minWords?: number; public readonly pattern?: string; public readonly patternMessage?: string; + public readonly schemaIds?: ReadonlyArray; public get isComplexUI() { return this.editor !== 'Input' && this.editor !== 'Color' && this.editor !== 'Radio' && this.editor !== 'Slug' && this.editor !== 'TextArea'; diff --git a/frontend/src/app/shared/state/schemas.forms.ts b/frontend/src/app/shared/state/schemas.forms.ts index 234d040de..e6267008d 100644 --- a/frontend/src/app/shared/state/schemas.forms.ts +++ b/frontend/src/app/shared/state/schemas.forms.ts @@ -331,6 +331,7 @@ export class EditFieldFormVisitor implements FieldPropertiesVisitor { this.config['defaultValues'] = new FormControl(undefined); this.config['folderId'] = new FormControl(undefined); this.config['inlineEditable'] = new FormControl(undefined); + this.config['isEmbeddable'] = new FormControl(undefined); this.config['isUnique'] = new FormControl(undefined); this.config['maxCharacters'] = new FormControl(undefined); this.config['maxLength'] = new FormControl(undefined); @@ -340,6 +341,7 @@ export class EditFieldFormVisitor implements FieldPropertiesVisitor { this.config['minWords'] = new FormControl(undefined); this.config['pattern'] = new FormControl(undefined); this.config['patternMessage'] = new FormControl(undefined); + this.config['schemaIds'] = new FormControl(undefined); } public visitTags() {