Browse Source

Rich text bugfixes. (#1267)

* Rich text bugfixes.

* Fix tests

* Fix test containers.
pull/1269/head
Sebastian Stehle 4 weeks ago
committed by GitHub
parent
commit
eba4a8d5ed
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      .github/workflows/dev.yml
  2. 13
      .github/workflows/release.yml
  3. 2
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs
  4. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  5. 3
      backend/src/Squidex.Data.MongoDb/Infrastructure/MongoRepositoryBase.cs
  6. 12
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  7. 7
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/RichTextExtensions.cs
  8. 11
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/RichTextMark.cs
  9. 34
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/RichTextNode.cs
  10. 46
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/SquidexRichText.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  12. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs
  13. 21
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/StringReferenceExtractor.cs
  14. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  15. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueValidator.cs
  17. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  18. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs
  19. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  20. 12
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  21. 24
      backend/src/Squidex/Squidex.csproj
  22. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/RichTextTests.cs
  23. 22
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/StringReferenceExtractorTests.cs
  24. 42
      frontend/generator/Generator/Program.cs
  25. 3
      frontend/package.json
  26. 27
      frontend/src/app/features/content/pages/content/content-page.component.html
  27. 6
      frontend/src/app/features/content/pages/content/content-page.component.ts
  28. 1
      frontend/src/app/features/content/pages/contents/contents-page.component.ts
  29. 6
      frontend/src/app/features/content/shared/forms/array-editor.component.html
  30. 6
      frontend/src/app/features/content/shared/forms/array-item.component.html
  31. 1
      frontend/src/app/features/content/shared/forms/component-section.component.html
  32. 98
      frontend/src/app/features/content/shared/forms/content-field.component.html
  33. 40
      frontend/src/app/features/content/shared/forms/content-field.component.scss
  34. 8
      frontend/src/app/features/content/shared/forms/content-field.component.ts
  35. 2
      frontend/src/app/features/content/shared/forms/field-copy-button.component.html
  36. 76
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  37. 10
      frontend/src/app/features/content/shared/forms/field-editor.component.scss
  38. 4
      frontend/src/app/features/content/shared/forms/field-editor.component.ts
  39. 30
      frontend/src/app/features/content/shared/forms/field-languages.component.html
  40. 7
      frontend/src/app/features/content/shared/forms/field-languages.component.scss
  41. 2
      frontend/src/app/features/content/shared/list/content.component.html
  42. 2
      frontend/src/app/features/schemas/pages/schema/fields/field-group.component.html
  43. 4
      frontend/src/app/features/schemas/pages/schema/fields/field-group.component.scss
  44. 6
      frontend/src/app/shared/components/forms/geolocation-editor.component.html
  45. 4
      frontend/src/app/shared/model/custom.ts
  46. 4
      frontend/src/app/shared/model/generated.ts
  47. 4517
      frontend/src/app/theme/icomoon/demo.html
  48. BIN
      frontend/src/app/theme/icomoon/fonts/icomoon.eot
  49. 2
      frontend/src/app/theme/icomoon/fonts/icomoon.svg
  50. BIN
      frontend/src/app/theme/icomoon/fonts/icomoon.ttf
  51. BIN
      frontend/src/app/theme/icomoon/fonts/icomoon.woff
  52. 2
      frontend/src/app/theme/icomoon/selection.json
  53. 16
      frontend/src/app/theme/icomoon/style.css
  54. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_json_with_dot.verified.txt
  55. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_non_published_content.verified.txt
  56. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_null_localized_text.verified.txt
  57. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_published_content.verified.txt
  58. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_strange_text.verified.txt
  59. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_get_content_by_version.verified.txt

10
.github/workflows/dev.yml

@ -37,10 +37,20 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Prepare - Setup dotnet
id: dotnet
uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
# Note: Unless a concrete version is specified in the `global.json` file,
# the latest .NET version installed on the runner (including preinstalled
# versions) will be used by default. To control this behavior, you may want
# to use temporary `global.json` files.
# https://github.com/actions/setup-dotnet/blob/main/README.md#matrix-testing
- name: Create `global.json`
run: |
echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json
- name: Test - TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainer

13
.github/workflows/release.yml

@ -32,10 +32,23 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Prepare - Setup dotnet
id: dotnet
uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: 8.0.x
# Note: Unless a concrete version is specified in the `global.json` file,
# the latest .NET version installed on the runner (including preinstalled
# versions) will be used by default. To control this behavior, you may want
# to use temporary `global.json` files.
# https://github.com/actions/setup-dotnet/blob/main/README.md#matrix-testing
- name: Create `global.json`
run: |
echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json
- name: Test - TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainer
- name: Test - TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainer

2
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs

@ -11,7 +11,7 @@ namespace Squidex.Extensions.Text.ElasticSearch;
public sealed class ElasticSearchClient : IElasticSearchClient
{
private readonly IElasticLowLevelClient elasticSearch;
private readonly ElasticLowLevelClient elasticSearch;
public ElasticSearchClient(string configurationString)
{

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -40,13 +40,13 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.Json.Microsoft" Version="8.0.3" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.NetTopologySuite" Version="8.0.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.30.0" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.30.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.30.0" />
<PackageReference Include="Squidex.Hosting" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.30.0" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.33.0" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.33.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.33.0" />
<PackageReference Include="Squidex.Hosting" Version="7.33.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.33.0" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

3
backend/src/Squidex.Data.MongoDb/Infrastructure/MongoRepositoryBase.cs

@ -95,8 +95,7 @@ public abstract class MongoRepositoryBase<T> : MongoBase<T>, IInitializable
{
var databaseName = Database.DatabaseNamespace.DatabaseName;
var error = new ConfigurationError($"MongoDb connection failed to connect to database {databaseName}.");
var error = new ConfigurationError($"MongoDb connection failed to connect to database {databaseName}: {ex.Message}.");
throw new ConfigurationException(error, ex);
}
}

12
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -25,12 +25,12 @@
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.30.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.30.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.30.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.30.0" />
<PackageReference Include="Squidex.Hosting" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.30.0" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.33.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.33.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.33.0" />
<PackageReference Include="Squidex.Hosting" Version="7.33.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.33.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

7
backend/src/Squidex.Domain.Apps.Core.Model/Contents/RichTextExtensions.cs

@ -11,13 +11,6 @@ namespace Squidex.Domain.Apps.Core.Contents;
public static class RichTextExtensions
{
public static bool TryGetEnum<T>(this JsonValue value, out T enumValue) where T : struct
{
enumValue = default;
return value.Value is string text && Enum.TryParse(text, true, out enumValue);
}
public static int GetIntAttr(this JsonObject? attrs, string name, int defaultValue = 0)
{
if (attrs?.TryGetValue(name, out var value) == true && value.Value is double attr)

11
backend/src/Squidex.Domain.Apps.Core.Model/Contents/RichTextMark.cs

@ -14,12 +14,10 @@ internal sealed class RichTextMark : IMark
{
private JsonObject? attrs;
public MarkType Type { get; private set; }
public string Type { get; private set; } = string.Empty;
public bool TryUse(JsonValue source)
public bool TryUse(JsonValue source, RichTextOptions options)
{
Type = MarkType.Undefined;
attrs = null;
if (source.Value is not JsonObject obj)
@ -32,7 +30,7 @@ internal sealed class RichTextMark : IMark
{
switch (key)
{
case "type" when value.TryGetEnum<MarkType>(out var type):
case "type" when value.Value is string type && options.IsSupportedMarkType(type):
Type = type;
break;
case "attrs" when value.Value is JsonObject attrs:
@ -41,8 +39,7 @@ internal sealed class RichTextMark : IMark
}
}
isValid &= Type != MarkType.Undefined;
isValid &= !string.IsNullOrWhiteSpace(Type);
return isValid;
}

34
backend/src/Squidex.Domain.Apps.Core.Model/Contents/RichTextNode.cs

@ -20,7 +20,7 @@ public sealed class RichTextNode : INode
internal struct State
{
public JsonObject? Root;
public NodeType Type;
public string Type;
public JsonArray? Marks;
public JsonObject? Attrs;
public JsonArray? Content;
@ -28,7 +28,7 @@ public sealed class RichTextNode : INode
public int MarkIndex;
}
public NodeType Type
public string Type
{
get => currentState.Type;
}
@ -43,11 +43,11 @@ public sealed class RichTextNode : INode
get => currentState.Text;
}
public static bool TryCreate(JsonValue source, out RichTextNode node)
public static bool TryCreate(JsonValue source, RichTextOptions options, out RichTextNode node)
{
var candidate = new RichTextNode();
if (candidate.TryUse(source, true))
if (candidate.TryUse(source, true, options))
{
node = candidate;
return true;
@ -57,17 +57,17 @@ public sealed class RichTextNode : INode
return false;
}
public static RichTextNode Create(JsonValue source)
public static RichTextNode Create(JsonValue source, RichTextOptions options)
{
var node = new RichTextNode();
// We assume that we have made the validation before.
node.TryUse(source, false);
node.TryUse(source, false, options);
return node;
}
public bool TryUse(JsonValue source, bool recursive = false)
public bool TryUse(JsonValue source, bool recursive, RichTextOptions options)
{
State state = default;
@ -84,7 +84,7 @@ public sealed class RichTextNode : INode
{
switch (key)
{
case "type" when value.TryGetEnum<NodeType>(out var type):
case "type" when value.Value is string type && options.IsSupportedNodeType(type):
state.Type = type;
break;
case "attrs" when value.Value is JsonObject attrs:
@ -103,17 +103,16 @@ public sealed class RichTextNode : INode
}
currentState = state;
isValid &= Type != NodeType.Undefined;
isValid &= !string.IsNullOrWhiteSpace(Type);
if (isValid && recursive)
{
if (state.Content != null)
{
foreach (var content in state.Content)
foreach (var contentObj in state.Content)
{
// We have already validated this before.
isValid &= TryUse((JsonObject)content.Value!, recursive);
isValid &= TryUse((JsonObject)contentObj.Value!, recursive, options);
}
}
@ -122,13 +121,12 @@ public sealed class RichTextNode : INode
foreach (var markObj in state.Marks)
{
// We have already validated this before.
isValid &= mark.TryUse((JsonObject)markObj.Value!);
isValid &= mark.TryUse((JsonObject)markObj.Value!, options);
}
}
}
currentState = state;
return isValid;
}
@ -142,7 +140,7 @@ public sealed class RichTextNode : INode
return currentState.Attrs.GetStringAttr(name, defaultValue);
}
public IMark? GetNextMark()
public IMark? GetNextMark(RichTextOptions options)
{
if (currentState.Marks == null || currentState.MarkIndex >= currentState.Marks.Count)
{
@ -150,11 +148,11 @@ public sealed class RichTextNode : INode
}
// We have already validated this before.
mark.TryUse((JsonObject)currentState.Marks[currentState.MarkIndex++].Value!);
mark.TryUse((JsonObject)currentState.Marks[currentState.MarkIndex++].Value!, options);
return mark;
}
public void IterateContent<T>(T state, Action<INode, T, bool, bool> action)
public void IterateContent<T>(T state, RichTextOptions options, Action<INode, T, bool, bool> action)
{
var prevState = currentState;
@ -170,7 +168,7 @@ public sealed class RichTextNode : INode
var isLast = i == prevState.Content.Count - 1;
// We have already validated this before.
TryUse((JsonObject)item.Value!, false);
TryUse((JsonObject)item.Value!, false, options);
action(this, state, isFirst, isLast);
i++;
}

46
backend/src/Squidex.Domain.Apps.Core.Model/Contents/SquidexRichText.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Text.RichText.Model;
namespace Squidex.Domain.Apps.Core.Contents;
public static class SquidexRichText
{
public static class NodeTypes
{
public const string ContentLink = "contentLink";
}
private class ExtendedOptions : RichTextOptions
{
public override bool IsSupportedMarkType(string type)
{
return base.IsSupportedMarkType(type) || IsExtension(type);
}
public override bool IsSupportedNodeType(string type)
{
return base.IsSupportedNodeType(type) || IsExtension(type);
}
private static bool IsExtension(string type)
{
return type.StartsWith("x-", StringComparison.OrdinalIgnoreCase);
}
}
public static readonly RichTextOptions Options = new ExtendedOptions
{
NodeTypes =
[
..RichTextOptions.Default.NodeTypes,
NodeTypes.ContentLink,
],
MarkTypes = RichTextOptions.Default.MarkTypes,
};
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.5.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="7.30.0" />
<PackageReference Include="Squidex.Flows" Version="7.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

2
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs

@ -106,7 +106,7 @@ public sealed class StringFormatter : IFieldPropertiesVisitor<string, StringForm
public string Visit(RichTextFieldProperties properties, Args args)
{
return RichTextNode.Create(args.Value).ToText(100);
return RichTextNode.Create(args.Value, SquidexRichText.Options).ToText(100);
}
public string Visit(StringFieldProperties properties, Args args)

21
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/StringReferenceExtractor.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Text.RegularExpressions;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Squidex.Text.RichText.Model;
@ -81,7 +82,7 @@ public sealed class StringReferenceExtractor
if (node.Type == NodeType.Text)
{
IMark? mark = null;
while ((mark = node.GetNextMark()) != null)
while ((mark = node.GetNextMark(SquidexRichText.Options)) != null)
{
if (mark.Type == MarkType.Link)
{
@ -91,8 +92,19 @@ public sealed class StringReferenceExtractor
}
}
}
else if (node.Type == SquidexRichText.NodeTypes.ContentLink)
{
var contentId = node.GetStringAttr("contentId");
if (!string.IsNullOrWhiteSpace(contentId))
{
result.Add(DomainId.Create(contentId));
}
}
node.IterateContent((result, patterns), static (node, s, _, _) => Visit(node, s.result, s.patterns));
node.IterateContent(
(result, patterns),
SquidexRichText.Options,
static (node, s, _, _) => Visit(node, s.result, s.patterns));
}
Visit(node, result, contentsPatterns);
@ -117,7 +129,10 @@ public sealed class StringReferenceExtractor
result.AddRange(GetEmbeddedIds(src, patterns));
}
node.IterateContent((result, patterns), static (node, s, _, _) => Visit(node, s.result, s.patterns));
node.IterateContent(
(result, patterns),
SquidexRichText.Options,
static (node, s, _, _) => Visit(node, s.result, s.patterns));
}
Visit(node, result, assetsPatterns);

4
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.30.0" />
<PackageReference Include="Squidex.AI" Version="7.33.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

2
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs

@ -98,7 +98,7 @@ public sealed class JsonValueConverter : IFieldPropertiesVisitor<(object? Result
return (args.Value, null);
}
if (RichTextNode.TryCreate(args.Value, out var node))
if (RichTextNode.TryCreate(args.Value, SquidexRichText.Options, out var node))
{
return (node.ToText(), null);
}

2
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueValidator.cs

@ -97,7 +97,7 @@ public sealed class JsonValueValidator : IFieldPropertiesVisitor<bool, JsonValue
public bool Visit(RichTextFieldProperties properties, Args args)
{
return args.Value.Type == JsonValueType.Null || RichTextNode.TryCreate(args.Value, out _);
return args.Value.Type == JsonValueType.Null || RichTextNode.TryCreate(args.Value, SquidexRichText.Options, out _);
}
public bool Visit(StringFieldProperties properties, Args args)

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

@ -104,7 +104,7 @@ internal sealed class FieldVisitor(Builder builder) : IFieldVisitor<FieldGraphSc
switch (value.Value)
{
case JsonObject obj:
return RichTextNode.Create(obj);
return RichTextNode.Create(obj, SquidexRichText.Options);
default:
ThrowHelper.NotSupportedException();
return default!;

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs

@ -28,7 +28,7 @@ public sealed class CalculatePreviewText : IContentEnricherStep
}
}
private void AddTexts(Schema schema, RichTextNode node, IEnumerable<EnrichedContent> contents)
private static void AddTexts(Schema schema, RichTextNode node, IEnumerable<EnrichedContent> contents)
{
foreach (var content in contents)
{
@ -46,7 +46,7 @@ public sealed class CalculatePreviewText : IContentEnricherStep
foreach (var (partitionKey, partitionValue) in fieldData)
{
// Only handle the content if the text is valid.
if (node.TryUse(partitionValue))
if (node.TryUse(partitionValue, false, SquidexRichText.Options))
{
fieldReference[partitionKey] = node.ToText(100);
}

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="7.30.0" />
<PackageReference Include="Squidex.Caching" Version="7.30.0" />
<PackageReference Include="Squidex.Events" Version="7.30.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.30.0" />
<PackageReference Include="Squidex.Log" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging" Version="7.30.0" />
<PackageReference Include="Squidex.Text" Version="7.30.0" />
<PackageReference Include="Squidex.Assets" Version="7.33.0" />
<PackageReference Include="Squidex.Caching" Version="7.33.0" />
<PackageReference Include="Squidex.Events" Version="7.33.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.33.0" />
<PackageReference Include="Squidex.Log" Version="7.33.0" />
<PackageReference Include="Squidex.Messaging" Version="7.33.0" />
<PackageReference Include="Squidex.Text" Version="7.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

12
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -155,15 +155,13 @@ public sealed class ContentDto : Resource
private ContentDto CreateLinksAsync(EnrichedContent content, Resources resources, string schema)
{
var app = resources.App;
var values = new { app, schema, id = Id };
var values = new { app = resources.App, schema, id = Id };
AddSelfLink(resources.Url<ContentsController>(x => nameof(x.GetContent), values));
if (Version > 0)
{
var versioned = new { app, schema, values.id, version = Version - 1 };
var versioned = new { app = resources.App, schema, values.id, version = Version - 1 };
AddGetLink("previous",
resources.Url<ContentsController>(x => nameof(x.GetContentVersion), versioned));
@ -205,6 +203,12 @@ public sealed class ContentDto : Resource
resources.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
if (!content.IsSingleton && resources.CanCreateContent(schema))
{
AddPostLink("clone",
resources.Url<ContentsController>(x => nameof(x.PostContent), values));
}
if (content.CanUpdate && resources.CanUpdateContent(schema))
{
AddPatchLink("patch",

24
backend/src/Squidex/Squidex.csproj

@ -60,17 +60,17 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.4.1" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.33.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.33.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="21.8.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.30.0" />
<PackageReference Include="Squidex.Hosting" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.30.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.33.0" />
<PackageReference Include="Squidex.Hosting" Version="7.33.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.33.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.4.3" />
<PackageReference Include="YDotNet.Native" Version="0.4.3" />
@ -84,11 +84,11 @@
</ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.30.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.33.0" />
</ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.30.0" />
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.33.0" />
</ItemGroup>
<PropertyGroup>

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/RichTextTests.cs

@ -19,7 +19,7 @@ public class RichTextTests
{
var json = TestUtils.DefaultSerializer.Deserialize<JsonValue>(File.ReadAllText("Model/Contents/ComplexText.json"));
node.TryUse(json);
node.TryUse(json, false, SquidexRichText.Options);
}
[Fact]

22
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/StringReferenceExtractorTests.cs

@ -65,7 +65,7 @@ public class StringReferenceExtractorTests
.Add("attrs", JsonValue.Object()
.Add("href", href))))));
var ids = sut.GetEmbeddedContentIds(RichTextNode.Create(source));
var ids = sut.GetEmbeddedContentIds(RichTextNode.Create(source, SquidexRichText.Options));
Assert.Contains(DomainId.Create("my_content-42"), ids.ToList());
}
@ -101,8 +101,26 @@ public class StringReferenceExtractorTests
.Add("attrs", JsonValue.Object()
.Add("src", src))));
var ids = sut.GetEmbeddedAssetIds(RichTextNode.Create(source));
var ids = sut.GetEmbeddedAssetIds(RichTextNode.Create(source, SquidexRichText.Options));
Assert.Contains(DomainId.Create("my_asset-42"), ids.ToList());
}
[Fact]
public void Should_extract_content_id_from_mark()
{
var id = DomainId.NewGuid();
var source = JsonValue.Object()
.Add("type", "paragraph")
.Add("content", JsonValue.Array(
JsonValue.Object()
.Add("type", "contentLink")
.Add("attrs", JsonValue.Object()
.Add("contentId", id))));
var ids = sut.GetEmbeddedContentIds(RichTextNode.Create(source, SquidexRichText.Options));
Assert.Contains(id, ids.ToList());
}
}

42
frontend/generator/Generator/Program.cs

@ -8,9 +8,6 @@ namespace Generator;
internal partial class Program
{
private static readonly Regex RegexComputed = new Regex("return this\\.compute\\('(?<Name>[^']*)',");
private static readonly Regex RegexProperty = new Regex("public get (?<Name>[^(]*)\\(\\) {");
static async Task Main(string[] args)
{
var cacheFile = "cache.json";
@ -24,7 +21,8 @@ internal partial class Program
File.WriteAllText(cacheFile, schemaText);
}
var codePath = GetCodePath();
var (rootPath, codePath) = GetCodePath();
Console.WriteLine($"Using root folder {rootPath}");
var document = await OpenApiDocument.FromJsonAsync(File.ReadAllText(cacheFile));
@ -112,7 +110,7 @@ internal partial class Program
}
}
var extensionFile = Path.Combine(codePath, @"..\\..\\src\\app\\shared\\model\\custom.ts");
var extensionFile = Path.Combine(rootPath, @"src\\app\\shared\\model\\custom.ts");
var extensionCode = File.ReadAllText(extensionFile);
var classes =
@ -131,7 +129,7 @@ internal partial class Program
settings.TypeScriptGeneratorSettings.ExtensionCode = extensionCode;
settings.TypeScriptGeneratorSettings.GenerateConstructorInterface = true;
settings.TypeScriptGeneratorSettings.InlineNamedDictionaries = true;
settings.TypeScriptGeneratorSettings.TemplateDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Templates");
settings.TypeScriptGeneratorSettings.TemplateDirectory = Path.Combine(codePath, "Templates");
var generator = new TypeScriptClientGenerator(document, settings);
@ -143,29 +141,30 @@ internal partial class Program
code = code.Replace(": Date |", ": DateTime |");
code = code.Replace("DtoDto", "Dto");
var targetFolder = Path.Combine(codePath, @"..\\..\\src\\app\\shared\\model\\generated.ts");
var targetFolder = Path.Combine(rootPath, @"src\\app\\shared\\model\\generated.ts");
ValidateComputed(code);
File.WriteAllText(targetFolder, code);
Console.WriteLine("Code Generation completed");
}
[GeneratedRegex("class (?<ClassName>[^\\)]*) extends generated\\.")]
private static partial Regex ClassNameRegex();
private static string GetCodePath()
private static (string RootPath, string CodePath) GetCodePath()
{
var folder = new DirectoryInfo(Directory.GetCurrentDirectory());
while (true)
while (folder != null)
{
if (folder.Name.Equals("Generator"))
var subFolders = folder.GetDirectories();
if (subFolders.Any(x => x.Name == "src"))
{
return folder.FullName;
return (folder.FullName, Path.Combine(folder.FullName, "generator/Generator"));
}
folder = folder.Parent!;
folder = folder.Parent;
}
throw new InvalidOperationException("Cannot find code folder.");
}
private static void ValidateComputed(string code)
@ -182,7 +181,7 @@ internal partial class Program
private static void ValidateLine(string[] lines, string line, int index)
{
var computed = RegexComputed.Match(line);
var computed = ComputedRegex().Match(line);
if (!computed.Success)
{
return;
@ -191,7 +190,7 @@ internal partial class Program
var cacheKey = computed.Groups["Name"].Value;
var previousLine = lines[index - 1];
var property = RegexProperty.Match(previousLine);
var property = PropertyRegex().Match(previousLine);
if (!property.Success)
{
Console.WriteLine($"Line {index}: Cannot find property for computed '{cacheKey}'");
@ -204,4 +203,13 @@ internal partial class Program
Console.WriteLine($"Line {index}: Invalid cache key '{cacheKey}' for property '{propertyName}'");
}
}
[GeneratedRegex("return this\\.compute\\('(?<Name>[^']*)',")]
private static partial Regex ComputedRegex();
[GeneratedRegex("class (?<ClassName>[^\\)]*) extends generated\\.")]
private static partial Regex ClassNameRegex();
[GeneratedRegex("public get (?<Name>[^(]*)\\(\\) {")]
private static partial Regex PropertyRegex();
}

3
frontend/package.json

@ -11,7 +11,8 @@
"test:coverage": "ng test --no-watch --code-coverage --browsers=ChromeHeadlessNoSandbox",
"watch": "ng build --watch --configuration development",
"storybook": "ng run squidex:storybook",
"storybook-build": "ng run squidex:build-storybook"
"storybook-build": "ng run squidex:build-storybook",
"generate": "dotnet run --project generator/Generator/Generator.csproj"
},
"private": true,
"dependencies": {

27
frontend/src/app/features/content/pages/content/content-page.component.html

@ -64,7 +64,7 @@
(languageChange)="changeLanguage($event)"
[languages]="languages"
[percents]="contentForm.translationStatus | async" />
@if (content.canDelete && schema.type !== "Singleton") {
@if (content.canDelete || content.canClone) {
<button
class="btn btn-outline-secondary ms-2"
#buttonOptions
@ -74,14 +74,23 @@
<i class="icon-dots"></i>
</button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
<a
class="dropdown-item dropdown-item-delete"
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete()">
{{ "common.delete" | sqxTranslate }}
</a>
@if (content.canClone) {
<a (click)="clone()"
class="dropdown-item">
{{ "common.clone" | sqxTranslate }}
</a>
}
@if (content.canDelete) {
<a
class="dropdown-item dropdown-item-delete"
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete()">
{{ "common.delete" | sqxTranslate }}
</a>
}
</sqx-dropdown-menu>
}

6
frontend/src/app/features/content/pages/content/content-page.component.ts

@ -400,6 +400,12 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
this.contentForm.setEnabled(!this.content || this.content.canUpdate);
}
public clone() {
this.tempService.put(this.content!.data);
this.router.navigate(['../new'], { relativeTo: this.route });
}
public changeShowIdInput(value: boolean) {
this.localStore.setBoolean(Settings.Local.CONTENT_ID_INPUT, value);

1
frontend/src/app/features/content/pages/contents/contents-page.component.ts

@ -223,7 +223,6 @@ export class ContentsPageComponent implements OnInit {
this.contentsService.getContent(this.contentsState.appName, this.contentsState.schemaName, content.id)
.subscribe(currentContent => {
this.tempService.put(currentContent.data);
this.router.navigate(['new'], { relativeTo: this.route });
});
}

6
frontend/src/app/features/content/shared/forms/array-editor.component.html

@ -123,12 +123,12 @@
</div>
@if (items.length > 0) {
<div class="col-auto">
<div class="col-auto d-flex gap1">
<button class="btn btn-text-secondary" (click)="expandAll()" title="i18n:contents.arrayExpandAll" type="button">
<i class="icon-plus-square"></i>
<i class="icon-plus2"></i>
</button>
<button class="btn btn-text-secondary" (click)="collapseAll()" title="i18n:contents.arrayCollapseAll" type="button">
<i class="icon-minus-square"></i>
<i class="icon-minus2"></i>
</button>
</div>
}

6
frontend/src/app/features/content/shared/forms/array-item.component.html

@ -9,7 +9,7 @@
</div>
</div>
<div class="col-auto pe-4">
<div class="col-auto d-flex gap-1 pe-4">
<button class="btn btn-text-secondary" (click)="moveTop()" [disabled]="isDisabled || isFirst" title="i18n:contents.arrayMoveTop" type="button">
<i class="icon-caret-top"></i>
</button>
@ -33,7 +33,7 @@
(click)="expand()"
title="i18n:contents.arrayExpandItem"
type="button">
<i class="icon-plus-square"></i>
<i class="icon-plus2"></i>
</button>
<button
class="btn btn-text-secondary"
@ -41,7 +41,7 @@
(click)="collapse()"
title="i18n:contents.arrayCollapseItem"
type="button">
<i class="icon-minus-square"></i>
<i class="icon-minus2"></i>
</button>
</div>

1
frontend/src/app/features/content/shared/forms/component-section.component.html

@ -25,6 +25,7 @@
[hasChatBot]="hasChatBot"
[index]="index"
[isComparing]="isComparing"
[isCollapsed]="false"
[language]="language"
[languages]="languages" />
}

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

@ -3,34 +3,8 @@
<sqx-focus-marker [controlId]="formModel.path">
@if (!(formModel.hiddenChanges | async)) {
<div class="table-items-row table-items-row-summary" [class.field-invalid]="isInvalid | async">
<div class="languages-container">
<div class="languages-buttons">
<div class="languages-inner">
<sqx-field-languages
[formModel]="formModel"
[language]="language"
(languageChange)="languageChange.emit($event)"
[languages]="languages"
[showAllControls]="showAllControls"
(showAllControlsChange)="changeShowAllControls($event)" />
<sqx-field-copy-button [formModel]="formModel" [languages]="languages" />
@if (isTranslatable) {
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow ms-1"
(click)="translate()"
[disabled]="formModel.field.isDisabled"
tabindex="-1"
title="i18n:contents.autotranslate"
type="button">
<i class="icon-translate"></i>
</button>
}
</div>
</div>
</div>
@if (showAllControls) {
@for (language of languages; track language) {
@for (language of languages; track language; let i = $index) {
<div class="form-group">
<sqx-field-editor
[comments]="commentsState"
@ -41,8 +15,21 @@
[formModel]="formModel.get(language)"
[hasChatBot]="hasChatBot"
[isComparing]="!!formModelCompare"
[isCollapsed]="isCollapsed"
[language]="language"
[languages]="languages" />
[languages]="languages">
<ng-container beforeButtons>
@if (i === 0) {
<ng-container *ngTemplateOutlet="sharedButtons" />
}
</ng-container>
<ng-container afterButtons>
@if (i === 0) {
<ng-container *ngTemplateOutlet="toggleButton" />
}
</ng-container>
</sqx-field-editor>
</div>
}
} @else {
@ -54,8 +41,16 @@
[formModel]="getControl()"
[hasChatBot]="hasChatBot"
[isComparing]="!!formModelCompare"
[isCollapsed]="isCollapsed"
[language]="language"
[languages]="languages" />
[languages]="languages">
<ng-container beforeButtons>
<ng-container *ngTemplateOutlet="sharedButtons" />
</ng-container>
<ng-container afterButtons>
<ng-container *ngTemplateOutlet="toggleButton" />
</ng-container>
</sqx-field-editor>
}
</div>
}
@ -76,20 +71,6 @@
@if (!(formModelCompare!.hiddenChanges | async)) {
<div class="table-items-row table-items-row-summary">
<div class="languages-container">
<div class="languages-buttons-compare">
<div class="languages-inner">
<sqx-field-languages
[formModel]="formModelCompare!"
[language]="language"
(languageChange)="languageChange.emit($event)"
[languages]="languages"
[showAllControls]="showAllControls"
(showAllControlsChange)="changeShowAllControls($event)" />
</div>
</div>
</div>
@if (showAllControls) {
@for (language of languages; track language) {
<div class="form-group">
@ -101,6 +82,7 @@
[formModel]="formModelCompare.get(language)"
[hasChatBot]="hasChatBot"
[isComparing]="!!formModelCompare"
[isCollapsed]="isCollapsed"
[language]="language"
[languages]="languages" />
</div>
@ -113,6 +95,7 @@
[formModel]="getControlCompare()!"
[hasChatBot]="hasChatBot"
[isComparing]="!!formModelCompare"
[isCollapsed]="isCollapsed"
[language]="language"
[languages]="languages" />
}
@ -121,3 +104,32 @@
</div>
}
</div>
<ng-template #sharedButtons>
<sqx-field-languages
[formModel]="formModel"
[language]="language"
(languageChange)="languageChange.emit($event)"
[languages]="languages"
[showAllControls]="showAllControls"
(showAllControlsChange)="changeShowAllControls($event)" />
<sqx-field-copy-button [formModel]="formModel" [languages]="languages" />
@if (isTranslatable) {
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="translate()"
[disabled]="formModel.field.isDisabled"
tabindex="-1"
title="i18n:contents.autotranslate"
type="button">
<i class="icon-translate"></i>
</button>
}
</ng-template>
<ng-template #toggleButton>
<button class="btn btn-sm btn-square btn-outline-secondary" (click)="toggle()" type="button"
title="i18n:contents.arrayCollapseItem">
<i [class.icon-minus2]="!isCollapsed" [class.icon-plus2]="isCollapsed"></i>
</button>
</ng-template>

40
frontend/src/app/features/content/shared/forms/content-field.component.scss

@ -8,42 +8,6 @@
padding-top: 1.125rem;
}
.languages {
&-container {
position: relative;
}
&-buttons,
&-buttons-compare {
display: flex;
flex-grow: 1;
flex-wrap: nowrap;
justify-content: right;
margin-bottom: 0;
margin-top: -1px;
white-space: nowrap;
z-index: 1;
}
&-inner {
background: $color-white;
}
&-buttons {
@include absolute(100%, 7rem);
margin: 0;
margin-top: -9px;
overflow: hidden;
}
&-buttons-compare {
@include absolute(100%, 0);
margin: 0;
margin-top: -9px;
overflow: hidden;
}
}
.col-6 {
padding-right: .5rem;
@ -82,6 +46,10 @@
padding-top: .5rem;
}
.btn-square {
width: 2rem;
}
:host {
&:last-child {
margin-bottom: 0;

8
frontend/src/app/features/content/shared/forms/content-field.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { AsyncPipe } from '@angular/common';
import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
import { booleanAttribute, Component, EventEmitter, HostBinding, inject, Input, numberAttribute, Optional, Output } from '@angular/core';
import { Observable } from 'rxjs';
import { AppLanguageDto, AppsState, changed$, CommentsState, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, SchemaDto, Settings, TooltipDirective, TranslateDto, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared';
@ -23,6 +23,7 @@ import { FieldLanguagesComponent } from './field-languages.component';
FieldEditorComponent,
FieldLanguagesComponent,
FocusMarkerComponent,
NgTemplateOutlet,
TooltipDirective,
],
})
@ -64,6 +65,7 @@ export class ContentFieldComponent {
public isDifferent?: Observable<boolean>;
public isInvalid?: Observable<boolean>;
public isCollapsed = false;
public isDisabled?: Observable<boolean>;
public readonly hasTranslator = inject(UIOptions).value.canUseTranslator;
@ -186,4 +188,8 @@ export class ContentFieldComponent {
private showAllControlsKey() {
return Settings.Local.FIELD_ALL(this.schema?.id, this.formModel.field.fieldId);
}
public toggle() {
this.isCollapsed = !this.isCollapsed;
}
}

2
frontend/src/app/features/content/shared/forms/field-copy-button.component.html

@ -1,6 +1,6 @@
@if (isLocalized) {
<button
class="btn btn-outline-secondary btn-sm ms-1 dropdown-toggle"
class="btn btn-outline-secondary btn-sm dropdown-toggle"
#button
(click)="dropdown.toggle()"
tabindex="-1"

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

@ -1,37 +1,47 @@
@if (formModel) {
<div class="field" [class.expanded]="isExpanded">
<fieldset class="buttons-container" [disabled]="isDisabled | async">
<div class="buttons">
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="chatDialog.show()"
[disabled]="!hasChatBot || !isString"
tabindex="-1"
type="button">
AI
</button>
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow ms-1"
(click)="toggleExpanded()"
tabindex="-1"
title="i18n:contents.fieldFullscreen"
type="button">
<i class="icon-fullscreen"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary btn-clear force no-focus-shadow ms-1"
confirmRememberKey="unsetValue"
confirmText="i18n:contents.unsetValueConfirmText"
confirmTitle="i18n:contents.unsetValueConfirmTitle"
[disabled]="isEmpty | async"
(sqxConfirmClick)="unset()"
tabindex="-1"
title="i18n:contents.unsetValue"
type="button">
<i class="icon-close"></i>
</button>
<div class="field" [class.expanded]="isExpanded && !isCollapsed">
<div class="buttons-container">
<div class="buttons d-flex gap-1">
<fieldset class="d-flex gap-1" [disabled]="(isDisabled | async) || isCollapsed">
<ng-content select="[beforeButtons]"></ng-content>
@if (isString) {
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="chatDialog.show()"
[disabled]="!hasChatBot || isCollapsed"
tabindex="-1"
type="button">
AI
</button>
}
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="toggleExpanded()"
[disabled]="isCollapsed"
tabindex="-1"
title="i18n:contents.fieldFullscreen"
type="button">
<i class="icon-fullscreen"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary btn-clear force no-focus-shadow"
confirmRememberKey="unsetValue"
confirmText="i18n:contents.unsetValueConfirmText"
confirmTitle="i18n:contents.unsetValueConfirmTitle"
[disabled]="isEmpty | async"
(sqxConfirmClick)="unset()"
tabindex="-1"
title="i18n:contents.unsetValue"
type="button">
<i class="icon-close"></i>
</button>
</fieldset>
<ng-content select="[afterButtons]"></ng-content>
</div>
</fieldset>
</div>
<label class="mb-1">
{{ field.displayName }} {{ displaySuffix }} <span class="field-required" [class.hidden]="!field.properties.isRequired">*</span>
</label>
@ -43,7 +53,7 @@
<sqx-control-errors [fieldName]="field.displayName" [for]="$any(fieldForm)" />
}
<div>
<div [class.hidden]="isCollapsed">
@if (field.properties.editorUrl) {
<sqx-iframe-editor
#editor

10
frontend/src/app/features/content/shared/forms/field-editor.component.scss

@ -37,10 +37,12 @@
position: relative;
}
.btn-clear {
i {
font-size: $font-smallest;
}
.btn-clear i {
font-size: $font-smallest;
}
.btn {
width: 2rem;
}
}

4
frontend/src/app/features/content/shared/forms/field-editor.component.ts

@ -91,6 +91,9 @@ export class FieldEditorComponent {
@Input({ required: true })
public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ required: true })
public isCollapsed = false;
@Input({ transform: numberAttribute })
public index: number | null | undefined;
@ -179,7 +182,6 @@ export class FieldEditorComponent {
public unset() {
this.formModel.unset();
}
public setValue(content: string | HTTP.UploadFile | null | undefined) {
this.chatDialog.hide();

30
frontend/src/app/features/content/shared/forms/field-languages.component.html

@ -1,16 +1,16 @@
@if (formModel.field.isLocalizable && languages.length > 1) {
@if (!formModel.field.properties.isComplexUI) {
<button class="btn btn-sm btn-outline-secondary force no-focus-shadow" (click)="toggleShowAllControls()" type="button">
@if (showAllControls) {
<span>{{ "contents.languageModeSingle" | sqxTranslate }}</span>
} @else {
<span>{{ "contents.languageModeAll" | sqxTranslate }}</span>
}
</button>
}
@if (formModel.field.properties.isComplexUI || !showAllControls) {
<div class="button-container ms-1">
<div class="d-flex gap-1">
@if (formModel.field.isLocalizable && languages.length > 1) {
@if (!formModel.field.properties.isComplexUI) {
<button class="btn btn-sm btn-outline-secondary force no-focus-shadow" (click)="toggleShowAllControls()" type="button">
@if (showAllControls) {
<span>{{ "contents.languageModeSingle" | sqxTranslate }}</span>
} @else {
<span>{{ "contents.languageModeAll" | sqxTranslate }}</span>
}
</button>
}
@if (formModel.field.properties.isComplexUI || !showAllControls) {
<sqx-language-selector
[exists]="formModel.translationStatus | async"
hintAfter="120000"
@ -21,6 +21,6 @@
[languages]="languages"
size="sm"
sqxTourStep="languages" />
</div>
}
}
}
</div>

7
frontend/src/app/features/content/shared/forms/field-languages.component.scss

@ -1,13 +1,6 @@
@import 'mixins';
@import 'vars';
.button-container {
display: inline-block;
max-width: none;
min-width: 5rem;
text-align: right;
}
sqx-language-selector {
text-align: right;
}

2
frontend/src/app/features/content/shared/list/content.component.html

@ -39,7 +39,7 @@
confirmRememberKey="deleteContent"
confirmText="i18n:contents.deleteConfirmText"
confirmTitle="i18n:contents.deleteConfirmTitle"
(sqxConfirmClick)="delete.emit()">
(sqxConfirmClick)="delete.emit()">
{{ "common.delete" | sqxTranslate }}
</a>
</sqx-dropdown-menu>

2
frontend/src/app/features/schemas/pages/schema/fields/field-group.component.html

@ -4,7 +4,7 @@
<sqx-field [field]="$any(fieldGroup.separator)" [languages]="languages" [parent]="parent" plain="true" [schema]="schema" [settings]="settings">
<div class="d-flex align-items-center">
<ng-content></ng-content>
<button class="btn btn-sm btn-text-secondary ms-2" (click)="toggle()" type="button">
<button class="btn btn-sm btn-square btn-text-secondary ms-2" (click)="toggle()" type="button">
<i [class.icon-caret-down]="!snapshot.isCollapsed" [class.icon-caret-right]="snapshot.isCollapsed"></i>
</button>
</div>

4
frontend/src/app/features/schemas/pages/schema/fields/field-group.component.scss

@ -13,4 +13,8 @@ $field-line: #c7cfd7;
.cdk-drop-list-dragging {
border: 0;
}
.btn-square {
width: 2rem;
}

6
frontend/src/app/shared/components/forms/geolocation-editor.component.html

@ -40,12 +40,12 @@
</div>
</form>
<div class="col-auto">
<div class="col-auto d-flex gap-1">
<button class="btn btn-text-secondary" [class.hidden]="!snapshot.isMapHidden" (click)="hideMap(false)" title="i18n:common.mapShow" type="button">
<i class="icon-plus-square"></i>
<i class="icon-plus2"></i>
</button>
<button class="btn btn-text-secondary" [class.hidden]="snapshot.isMapHidden" (click)="hideMap(true)" title="i18n:common.mapHide" type="button">
<i class="icon-minus-square"></i>
<i class="icon-minus2"></i>
</button>
</div>
</div>

4
frontend/src/app/shared/model/custom.ts

@ -232,6 +232,10 @@ export class ContentDto extends generated.ContentDto {
return this.compute('canPublish', () => this.statusUpdates.find(x => x.status === 'Published'));
}
public get canClone() {
return this.compute('canClone', () => hasAnyLink(this._links, 'clone'));
}
public get canDelete() {
return this.compute('canDelete', () => hasAnyLink(this._links, 'update'));
}

4
frontend/src/app/shared/model/generated.ts

@ -11443,6 +11443,10 @@ export class ContentDto extends ResourceDto implements IContentDto {
return this.compute('canPublish', () => this.statusUpdates.find(x => x.status === 'Published'));
}
public get canClone() {
return this.compute('canClone', () => hasAnyLink(this._links, 'clone'));
}
public get canDelete() {
return this.compute('canDelete', () => hasAnyLink(this._links, 'update'));
}

4517
frontend/src/app/theme/icomoon/demo.html

File diff suppressed because it is too large

BIN
frontend/src/app/theme/icomoon/fonts/icomoon.eot

Binary file not shown.

2
frontend/src/app/theme/icomoon/fonts/icomoon.svg

@ -157,6 +157,8 @@
<glyph unicode="&#xe993;" glyph-name="lightbulb_outline" d="M634 380.667q92 64 92 174 0 88-63 151t-151 63-151-63-63-151q0-46 27-96t65-78l36-26v-98h172v98zM512 852.667q124 0 211-87t87-211q0-156-128-244v-98q0-18-12-30t-30-12h-256q-18 0-30 12t-12 30v98q-128 88-128 244 0 124 87 211t211 87zM384 42.667v42h256v-42q0-18-12-30t-30-12h-172q-18 0-30 12t-12 30z" />
<glyph unicode="&#xe994;" glyph-name="mouse-pointer" horiz-adv-x="661" d="M647.429 354.857c10.857-10.286 13.714-26.286 8-39.429-5.714-13.714-18.857-22.857-33.714-22.857h-218.286l114.857-272c8-18.857-1.143-40-19.429-48l-101.143-42.857c-18.857-8-40 1.143-48 19.429l-109.143 258.286-178.286-178.286c-6.857-6.857-16-10.857-25.714-10.857-4.571 0-9.714 1.143-13.714 2.857-13.714 5.714-22.857 18.857-22.857 33.714v859.429c0 14.857 9.143 28 22.857 33.714 4 1.714 9.143 2.857 13.714 2.857 9.714 0 18.857-3.429 25.714-10.857z" />
<glyph unicode="&#xe995;" glyph-name="shield" d="M512 48.555c-50.517 28.672-188.587 114.987-258.133 236.757-3.371 5.888-6.528 11.776-9.515 17.792-19.456 38.869-31.019 80.128-31.019 123.563v269.099l298.667 112 298.667-112v-269.099c0-43.435-11.563-84.693-30.976-123.605-2.987-5.973-6.187-11.904-9.515-17.792-69.589-121.771-207.659-208.043-258.133-236.757zM531.072-38.144c0 0 212.864 105.6 313.173 281.131 4.096 7.168 8.021 14.507 11.776 21.973 24.235 48.427 39.979 102.741 39.979 161.707v298.667c0 18.176-11.392 33.707-27.691 39.936l-341.333 128c-10.069 3.797-20.693 3.499-29.952 0l-341.333-128c-17.024-6.357-27.563-22.485-27.691-39.936v-298.667c0-58.965 15.744-113.28 40.021-161.749 3.712-7.467 7.637-14.763 11.776-21.973 100.309-175.531 313.173-281.131 313.173-281.131 12.459-6.229 26.453-5.803 38.144 0z" />
<glyph unicode="&#xe996;" glyph-name="minus2" d="M213.333 384h597.333c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-597.333c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667z" />
<glyph unicode="&#xe997;" glyph-name="plus2" d="M213.333 384h256v-256c0-23.552 19.115-42.667 42.667-42.667s42.667 19.115 42.667 42.667v256h256c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-256v256c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667v-256h-256c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" />
<glyph unicode="&#xf0c9;" glyph-name="list1" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" />

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

BIN
frontend/src/app/theme/icomoon/fonts/icomoon.ttf

Binary file not shown.

BIN
frontend/src/app/theme/icomoon/fonts/icomoon.woff

Binary file not shown.

2
frontend/src/app/theme/icomoon/selection.json

File diff suppressed because one or more lines are too long

16
frontend/src/app/theme/icomoon/style.css

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?hn1ts4');
src: url('fonts/icomoon.eot?hn1ts4#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?hn1ts4') format('truetype'),
url('fonts/icomoon.woff?hn1ts4') format('woff'),
url('fonts/icomoon.svg?hn1ts4#icomoon') format('svg');
src: url('fonts/icomoon.eot?7vouhu');
src: url('fonts/icomoon.eot?7vouhu#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?7vouhu') format('truetype'),
url('fonts/icomoon.woff?7vouhu') format('woff'),
url('fonts/icomoon.svg?7vouhu#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -25,6 +25,12 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-minus2:before {
content: "\e996";
}
.icon-plus2:before {
content: "\e997";
}
.icon-shield:before {
content: "\e995";
}

3
tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_json_with_dot.verified.txt

@ -15,6 +15,9 @@
Id: Guid_1,
Version: 1,
Links: {
clone: {
Method: POST
},
delete: {
Method: DELETE
},

3
tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_non_published_content.verified.txt

@ -12,6 +12,9 @@
EditingStatus: Draft,
Id: Guid_1,
Links: {
clone: {
Method: POST
},
delete: {
Method: DELETE
},

3
tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_null_localized_text.verified.txt

@ -12,6 +12,9 @@
Id: Guid_1,
Version: 1,
Links: {
clone: {
Method: POST
},
delete: {
Method: DELETE
},

3
tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_published_content.verified.txt

@ -13,6 +13,9 @@
Id: Guid_1,
Version: 1,
Links: {
clone: {
Method: POST
},
delete: {
Method: DELETE
},

3
tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_create_strange_text.verified.txt

@ -13,6 +13,9 @@
Id: Guid_1,
Version: 1,
Links: {
clone: {
Method: POST
},
delete: {
Method: DELETE
},

3
tools/TestSuite/TestSuite.ApiTests/Verify/ContentUpdateTests.Should_get_content_by_version.verified.txt

@ -13,6 +13,9 @@
Id: Guid_1,
Version: 1,
Links: {
clone: {
Method: POST
},
delete: {
Method: DELETE
},

Loading…
Cancel
Save