Browse Source

Feature/embedding (#879)

* Backend work.

* Frontend work.
pull/880/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
0d26d39ad8
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/frontend_zh.json
  5. 2
      backend/i18n/source/frontend_en.json
  6. 27
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
  7. 9
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
  8. 5
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs
  9. 106
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/StringReferenceExtractor.cs
  10. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs
  13. 20
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  14. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs
  15. 35
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
  16. 64
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs
  17. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldEnumType.cs
  18. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldGraphSchema.cs
  19. 58
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  20. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs
  21. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs
  22. 30
      backend/src/Squidex.Web/Services/UrlGenerator.cs
  23. 11
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs
  24. 8
      backend/src/Squidex/Config/Domain/QueryServices.cs
  25. 64
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/StringReferenceExtractorTests.cs
  26. 150
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  27. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  28. 15
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs
  29. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs
  30. 21
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs
  31. 5
      frontend/src/app/features/api/pages/graphql/graphql-page.component.scss
  32. 14
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  33. 2
      frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html
  34. 2
      frontend/src/app/features/schemas/pages/schema/fields/types/components-validation.component.html
  35. 2
      frontend/src/app/features/schemas/pages/schema/fields/types/references-validation.component.html
  36. 35
      frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.html
  37. 12
      frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts
  38. 9
      frontend/src/app/shared/components/forms/markdown-editor.component.html
  39. 79
      frontend/src/app/shared/components/forms/markdown-editor.component.ts
  40. 9
      frontend/src/app/shared/components/forms/rich-editor.component.html
  41. 86
      frontend/src/app/shared/components/forms/rich-editor.component.ts
  42. 12
      frontend/src/app/shared/services/schemas.types.ts
  43. 2
      frontend/src/app/shared/state/schemas.forms.ts

2
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.",

2
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à.",

2
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.",

2
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": "您可以将字段标记为可本地化。这意味着这取决于语言,例如城市名称。",

2
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.",

27
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs

@ -861,6 +861,33 @@ namespace Squidex.Domain.Apps.Core {
}
}
/// <summary>
/// Looks up a localized string similar to The referenced assets..
/// </summary>
public static string StringFieldAssets {
get {
return ResourceManager.GetString("StringFieldAssets", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The referenced content items..
/// </summary>
public static string StringFieldReferences {
get {
return ResourceManager.GetString("StringFieldReferences", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The text of this field..
/// </summary>
public static string StringFieldText {
get {
return ResourceManager.GetString("StringFieldText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The current number of calls..
/// </summary>

9
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx

@ -384,6 +384,15 @@
<data name="SchemaId" xml:space="preserve">
<value>The ID of the schema.</value>
</data>
<data name="StringFieldAssets" xml:space="preserve">
<value>The referenced assets.</value>
</data>
<data name="StringFieldReferences" xml:space="preserve">
<value>The referenced content items.</value>
</data>
<data name="StringFieldText" xml:space="preserve">
<value>The text of this field.</value>
</data>
<data name="UsageCallsCurrent" xml:space="preserve">
<value>The current number of calls.</value>
</data>

5
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<DomainId>? SchemaIds { get; init; }
public override T Accept<T, TArgs>(IFieldPropertiesVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this, args);

106
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<Regex> contentsPatterns = new List<Regex>();
private readonly List<Regex> assetsPatterns = new List<Regex>();
public StringReferenceExtractor(IUrlGenerator urlGenerator)
{
AddAssetPattern(@"assets?:(?<Id>[a-z0-9\-_9]+)");
AddAssetUrlPatterns(urlGenerator.AssetContentBase());
AddAssetUrlPatterns(urlGenerator.AssetContentCDNBase());
AddContentPattern(@"contents?:(?<Id>[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 + @"(.+)\/(.+)\/(?<Id>[a-z0-9\-_9]+)");
}
private void AddAssetUrlPatterns(string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return;
}
if (!baseUrl.EndsWith('/'))
{
baseUrl += "/";
}
baseUrl = Regex.Escape(baseUrl);
AddAssetPattern(baseUrl + @"(?<Id>[a-z0-9\-_9]+)");
AddAssetPattern(baseUrl + @"(.+)\/(?<Id>[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<DomainId> 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<DomainId> 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);
}
}
}
}
}

8
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<DomainId> appId, DomainId assetId, long fileVersion);
string? AssetThumbnail(NamedId<DomainId> appId, string idOrSlug, AssetType assetType);
string AssetsUI(NamedId<DomainId> appId, string? @ref = null);
string AssetContentCDNBase();
string AssetContent(NamedId<DomainId> appId, string idOrSlug);
string AssetContentBase();
@ -30,6 +30,10 @@ namespace Squidex.Domain.Apps.Core
string ClientsUI(NamedId<DomainId> appId);
string ContentCDNBase();
string ContentBase();
string ContentsUI(NamedId<DomainId> appId, NamedId<DomainId> schemaId);
string ContentUI(NamedId<DomainId> appId, NamedId<DomainId> schemaId, DomainId contentId);

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

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

20
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<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IJsonValue value, TimeSpan cacheDuration,
public Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IJsonValue value, TimeSpan cacheDuration,
CancellationToken ct)
{
var ids = ParseIds(value);
if (ids == null)
return GetAssetsAsync(ids, cacheDuration, ct);
}
public async Task<IReadOnlyList<IEnrichedAssetEntity>> GetAssetsAsync(List<DomainId>? 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<IReadOnlyList<IEnrichedContentEntity>> GetReferencedContentsAsync(IJsonValue value, TimeSpan cacheDuration,
public Task<IReadOnlyList<IEnrichedContentEntity>> GetReferencedContentsAsync(IJsonValue value, TimeSpan cacheDuration,
CancellationToken ct)
{
var ids = ParseIds(value);
if (ids == null)
return GetContentsAsync(ids, cacheDuration, ct);
}
public async Task<IReadOnlyList<IEnrichedContentEntity>> GetContentsAsync(List<DomainId>? ids, TimeSpan cacheDuration,
CancellationToken ct)
{
if (ids == null || ids.Count == 0)
{
return EmptyContents;
}

17
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<SchemaInfo, ComponentGraphType> componentTypes = new Dictionary<SchemaInfo, ComponentGraphType>(ReferenceEqualityComparer.Instance);
private readonly Dictionary<SchemaInfo, ContentGraphType> contentTypes = new Dictionary<SchemaInfo, ContentGraphType>(ReferenceEqualityComparer.Instance);
private readonly Dictionary<SchemaInfo, ContentResultGraphType> contentResultTypes = new Dictionary<SchemaInfo, ContentResultGraphType>(ReferenceEqualityComparer.Instance);
private readonly Dictionary<FieldInfo, EmbeddableStringGraphType> embeddableStringTypes = new Dictionary<FieldInfo, EmbeddableStringGraphType>();
private readonly Dictionary<string, EnumerationGraphType?> enumTypes = new Dictionary<string, EnumerationGraphType?>();
private readonly 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<string> values)
{
return enumTypes.GetOrAdd(name, x => FieldEnumType.TryCreate(name, values));

35
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<string, object>(async (value, fieldContext, context) =>
{
var cacheDuration = fieldContext.CacheDuration();
var ids = context.Resolve<StringReferenceExtractor>().GetEmbeddedAssetIds(value).ToList();
return await context.GetAssetsAsync(ids, cacheDuration, fieldContext.CancellationToken);
});
public static readonly IFieldResolver ResolveStringFieldContents = Resolvers.Async<string, object>(async (value, fieldContext, context) =>
{
var cacheDuration = fieldContext.CacheDuration();
var ids = context.Resolve<StringReferenceExtractor>().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<string, string>(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<T>(Func<JsonObject, T> resolver)
{
return Resolvers.Sync(resolver);

64
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<string>
{
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<DomainId>? 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;
}
}
}

1
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<string> values)
{
// The name is used for equal comparison. Therefore it is important to treat it as readonly.
Name = name;
foreach (var value in values)

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

58
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<FieldGraphSchema, FieldInfo>
{
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<AssetsFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<AssetsFieldProperties> field, FieldInfo args)
{
return (SharedTypes.AssetsList, Assets, null);
return new (SharedTypes.AssetsList, Assets, null);
}
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<BooleanFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<BooleanFieldProperties> field, FieldInfo args)
{
return (AllTypes.Boolean, JsonBoolean, null);
return new (AllTypes.Boolean, JsonBoolean, null);
}
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<ComponentFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<ComponentFieldProperties> 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<ComponentsFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<ComponentsFieldProperties> 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<DateTimeFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<DateTimeFieldProperties> field, FieldInfo args)
{
return (AllTypes.DateTime, JsonDateTime, null);
return new (AllTypes.DateTime, JsonDateTime, null);
}
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<JsonFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<JsonFieldProperties> field, FieldInfo args)
{
return (AllTypes.Json, JsonPath, ContentActions.Json.Arguments);
return new (AllTypes.Json, JsonPath, ContentActions.Json.Arguments);
}
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<GeolocationFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<GeolocationFieldProperties> field, FieldInfo args)
{
return (AllTypes.Json, JsonPath, ContentActions.Json.Arguments);
return new (AllTypes.Json, JsonPath, ContentActions.Json.Arguments);
}
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<NumberFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<NumberFieldProperties> field, FieldInfo args)
{
return (AllTypes.Float, JsonNumber, null);
return new (AllTypes.Float, JsonNumber, null);
}
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<StringFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<StringFieldProperties> 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<TagsFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<TagsFieldProperties> 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<ReferencesFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<ReferencesFieldProperties> 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<UIFieldProperties> field, FieldInfo args)
public FieldGraphSchema Visit(IField<UIFieldProperties> field, FieldInfo args)
{
return default;
}

7
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<FieldInfo> Fields { get; }
private FieldInfo(IField field, string typeName, Names names, Names parentNames, IReadOnlyList<FieldInfo> 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"];

4
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."
};

30
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> assetOptions,
IOptions<ContentOptions> contentOptions)
{
this.contentOptions = contentOptions.Value;
this.assetFileStore = assetFileStore;
this.assetOptions = assetOptions.Value;
this.urlGenerator = urlGenerator;
CanGenerateAssetSourceUrl = allowAssetSourceUrl;
}
public string? AssetThumbnail(NamedId<DomainId> 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<DomainId> appId, NamedId<DomainId> schemaId)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/content/{schemaId.Name}", false);

11
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
/// </summary>
public ReadonlyList<string>? AllowedValues { get; set; }
/// <summary>
/// The allowed schema ids that can be embedded.
/// </summary>
public ReadonlyList<DomainId>? SchemaIds { get; init; }
/// <summary>
/// Indicates if the field value must be unique. Ignored for nested fields and localized fields.
/// </summary>
public bool IsUnique { get; set; }
/// <summary>
/// Indicates that other content items or references are embedded.
/// </summary>
public bool IsEmbeddable { get; set; }
/// <summary>
/// Indicates that the inline editor is enabled for this field.
/// </summary>

8
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<GraphQLOptions>(config,
"graphql");
services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<UrlGenerator>(c, exposeSourceUrl))
services.AddSingletonAs<StringReferenceExtractor>()
.AsSelf();
services.AddSingletonAs<UrlGenerator>()
.As<IUrlGenerator>();
services.AddSingletonAs<InstantGraphType>()

64
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<IUrlGenerator>();
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());
}
}
}

150
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>') {
id
data {
myEmbeds {
iv {
text
contents {
... on Content {
id
}
... on MyRefSchema1 {
data {
schemaRef1Field {
iv
}
}
}
}
}
}
}
}
}", contentId);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentRefId), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(0, contentRef));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentId), A<CancellationToken>._))
.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>') {
id
data {
myEmbeds {
iv {
text
assets {
id
}
}
}
}
}
}", contentId);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentId), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(1, content));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null,
A<Q>.That.HasIdsWithoutTotal(assetRefId), A<CancellationToken>._))
.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()
{

10
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<ExecutionResult> ExecuteAsync(ExecutionOptions options, string? permissionId = null)
@ -138,6 +139,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
.AddSingleton<InstantGraphType>()
.AddSingleton<JsonGraphType>()
.AddSingleton<JsonNoopGraphType>()
.AddSingleton<StringReferenceExtractor>()
.BuildServiceProvider();
var schemasHash = A.Fake<ISchemasHash>();

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

2
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 = "<query-script>" }));
}
}

21
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<DomainId> 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<DomainId> appId, string? @ref = null)
{
throw new NotSupportedException();
}

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

14
frontend/src/app/features/content/shared/forms/field-editor.component.html

@ -184,13 +184,23 @@
<textarea class="form-control" [formControl]="$any(fieldForm)" [placeholder]="field.displayPlaceholder" rows="5"></textarea>
</ng-container>
<ng-container *ngSwitchCase="'RichText'">
<sqx-rich-editor [formControl]="$any(fieldForm)" #editor [folderId]="field.rawProperties.folderId"></sqx-rich-editor>
<sqx-rich-editor [formControl]="$any(fieldForm)" #editor
[folderId]="field.rawProperties.folderId"
[language]="language"
[languages]="languages"
[schemaIds]="field.rawProperties.schemaIds">
</sqx-rich-editor>
</ng-container>
<ng-container *ngSwitchCase="'Html'">
<sqx-code-editor [formControl]="$any(fieldForm)" #editor mode="ace/mode/html" [height]="350" ></sqx-code-editor>
</ng-container>
<ng-container *ngSwitchCase="'Markdown'">
<sqx-markdown-editor [formControl]="$any(fieldForm)" #editor [folderId]="field.rawProperties.folderId"></sqx-markdown-editor>
<sqx-markdown-editor [formControl]="$any(fieldForm)" #editor
[folderId]="field.rawProperties.folderId"
[language]="language"
[languages]="languages"
[schemaIds]="field.rawProperties.schemaIds">
</sqx-markdown-editor>
</ng-container>
<ng-container *ngSwitchCase="'StockPhoto'">
<sqx-stock-photo-editor [formControl]="$any(fieldForm)"></sqx-stock-photo-editor>

2
frontend/src/app/features/schemas/pages/schema/fields/types/component-validation.component.html

@ -1,6 +1,6 @@
<div [formGroup]="fieldForm">
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaId">{{ 'common.schemas' | sqxTranslate }}</label>
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaIds">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"

2
frontend/src/app/features/schemas/pages/schema/fields/types/components-validation.component.html

@ -18,7 +18,7 @@
</div>
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaId">{{ 'common.schemas' | sqxTranslate }}</label>
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaIds">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"

2
frontend/src/app/features/schemas/pages/schema/fields/types/references-validation.component.html

@ -1,6 +1,6 @@
<div [formGroup]="fieldForm">
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaId">{{ 'common.schemas' | sqxTranslate }}</label>
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaIds">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"

35
frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.html

@ -33,6 +33,18 @@
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_folderId">{{ 'schemas.fieldTypes.string.folderId' | sqxTranslate }}</label>
<div class="col-9">
<sqx-asset-folder-dropdown formControlName="folderId"></sqx-asset-folder-dropdown>
<sqx-form-hint>
{{ 'schemas.fieldTypes.string.folderIdHint' | sqxTranslate }}
</sqx-form-hint>
</div>
</div>
<div class="form-group row" [class.hidden]="hideAllowedValues | async">
<div class="col-9 offset-3">
<div class="form-check">
@ -60,14 +72,27 @@
</div>
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_folderId">{{ 'schemas.fieldTypes.string.folderId' | sqxTranslate }}</label>
<div class="col-9">
<sqx-asset-folder-dropdown formControlName="folderId"></sqx-asset-folder-dropdown>
<div class="col-9 offset-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="{{field.fieldId}}_fieldIsEmbeddable" formControlName="isEmbeddable">
<label class="form-check-label" for="{{field.fieldId}}_fieldIsEmbeddable">
{{ 'schemas.field.isEmbeddable' | sqxTranslate }}
</label>
</div>
<sqx-form-hint>
{{ 'schemas.fieldTypes.string.folderIdHint' | sqxTranslate }}
{{ 'schemas.field.isEmbeddableHint' | sqxTranslate }}
</sqx-form-hint>
</div>
</div>
<div class="form-group row" [class.hidden]="hideSchemaIds | async">
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldSchemaIds">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col-9">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" formControlName="schemaIds"
[converter]="(schemasSource.converter | async)!" [suggestions]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor>
</div>
</div>
</div>

12
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<boolean>;
public hideInlineEditable?: Observable<boolean>;
public hideSchemaIds?: Observable<boolean>;
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) {

9
frontend/src/app/shared/components/forms/markdown-editor.component.html

@ -10,4 +10,13 @@
<sqx-assets-selector
(select)="insertAssets($event)">
</sqx-assets-selector>
</ng-container>
<ng-container *sqxModal="contentsDialog">
<sqx-content-selector
[language]="language"
[languages]="languages"
[schemaIds]="schemaIds"
(select)="insertContents($event)">
</sqx-content-selector>
</ng-container>

79
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<State, str
private simplemde: any;
private value?: string;
@Input()
public schemaIds?: ReadonlyArray<string>;
@Input()
public language!: LanguageDto;
@Input()
public languages!: ReadonlyArray<LanguageDto>;
@Input()
public folderId?: string;
@ -53,6 +63,8 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
public assetsDialog = new DialogModel();
public contentsDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef,
private readonly apiUrl: ApiUrlConfig,
private readonly assetUploader: AssetUploaderState,
@ -78,7 +90,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
}
}
private showSelector = () => {
private showAssetSelector = () => {
if (this.snapshot.isDisabled) {
return;
}
@ -86,6 +98,14 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
this.assetsDialog.show();
};
private showContentsSelector = () => {
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<State, str
'|',
{
name: 'assets',
action: this.showSelector,
action: this.showAssetSelector,
className: 'icon-assets icon-bold',
title: 'Insert Assets',
},
this.schemaIds && this.schemaIds.length > 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<State, str
}
public insertAssets(assets: ReadonlyArray<AssetDto>) {
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<State, str
this.assetsDialog.hide();
}
public insertContents(contents: ReadonlyArray<ContentDto>) {
const content = this.buildContentsMarkup(contents);
if (content.length > 0) {
this.simplemde.codemirror.replaceSelection(content);
}
this.contentsDialog.hide();
}
public insertFiles(files: ReadonlyArray<File>) {
const doc = this.simplemde.codemirror.getDoc();
@ -251,7 +288,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
.subscribe({
next: asset => {
if (Types.is(asset, AssetDto)) {
replaceText(this.buildMarkup(asset));
replaceText(this.buildAssetMarkup(asset));
}
},
error: error => {
@ -274,17 +311,39 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
this.next({ isFullscreen });
}
private buildMarkups(assets: readonly AssetDto[]) {
let content = '';
private buildAssetsMarkup(assets: ReadonlyArray<AssetDto>) {
let markup = '';
for (const asset of assets) {
content += this.buildMarkup(asset);
markup += this.buildAssetMarkup(asset);
}
return content;
return markup;
}
private buildContentsMarkup(contents: ReadonlyArray<ContentDto>) {
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')) {

9
frontend/src/app/shared/components/forms/rich-editor.component.html

@ -6,4 +6,13 @@
<sqx-assets-selector
(select)="insertAssets($event)">
</sqx-assets-selector>
</ng-container>
<ng-container *sqxModal="contentsDialog">
<sqx-content-selector
[language]="language"
[languages]="languages"
[schemaIds]="schemaIds"
(select)="insertContents($event)">
</sqx-content-selector>
</ng-container>

86
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<any>();
@Input()
public schemaIds?: ReadonlyArray<string>;
@Input()
public language!: LanguageDto;
@Input()
public languages!: ReadonlyArray<LanguageDto>;
@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<AssetDto>) {
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<ContentDto>) {
const content = this.buildContentsMarkup(contents);
if (content.length > 0) {
this.tinyEditor.execCommand('mceInsertContent', false, content);
}
this.contentsDialog.hide();
}
public insertFiles(files: ReadonlyArray<File>) {
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<AssetDto>) {
let markup = '';
for (const asset of assets) {
content += this.buildMarkup(asset);
markup += this.buildAssetMarkup(asset);
}
return content;
return markup;
}
private buildContentsMarkup(contents: ReadonlyArray<ContentDto>) {
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 `<a href="${this.apiUrl.buildUrl(content._links['self'].href)}" alt="${name}">${name}</a>`;
}
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',
};

12
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<string>;
public readonly contentType?: StringContentType;
public readonly createEnum: boolean = false;
public readonly defaultValue?: string;
public readonly defaultValues?: DefaultValue<string>;
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<string>;
public get isComplexUI() {
return this.editor !== 'Input' && this.editor !== 'Color' && this.editor !== 'Radio' && this.editor !== 'Slug' && this.editor !== 'TextArea';

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

@ -331,6 +331,7 @@ export class EditFieldFormVisitor implements FieldPropertiesVisitor<any> {
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<any> {
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() {

Loading…
Cancel
Save