diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs index 106358450..8335ad4fe 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using Newtonsoft.Json; using NodaTime; namespace Squidex.Domain.Apps.Core.Rules @@ -31,5 +32,8 @@ namespace Squidex.Domain.Apps.Core.Rules public Instant Created { get; set; } public Instant Expires { get; set; } + + [JsonIgnore] + public Exception? Exception { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs new file mode 100644 index 000000000..099b67053 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Fluid; +using Fluid.Values; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.Extensions +{ + public sealed class EventFluidExtensions : IFluidExtension + { + private readonly IUrlGenerator urlGenerator; + + public EventFluidExtensions(IUrlGenerator urlGenerator) + { + Guard.NotNull(urlGenerator, nameof(urlGenerator)); + + this.urlGenerator = urlGenerator; + } + + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + TemplateContext.GlobalFilters.AddFilter("contentUrl", ContentUrl); + TemplateContext.GlobalFilters.AddFilter("assetContentUrl", AssetContentUrl); + } + + private FluidValue ContentUrl(FluidValue input, FilterArguments arguments, TemplateContext context) + { + if (input is ObjectValue objectValue) + { + if (context.GetValue("event")?.ToObjectValue() is EnrichedContentEvent contentEvent) + { + if (objectValue.ToObjectValue() is Guid guid && guid != Guid.Empty) + { + var result = urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, guid); + + return new StringValue(result); + } + } + } + + return NilValue.Empty; + } + + private FluidValue AssetContentUrl(FluidValue input, FilterArguments arguments, TemplateContext context) + { + if (input is ObjectValue objectValue) + { + if (objectValue.ToObjectValue() is Guid guid && guid != Guid.Empty) + { + var result = urlGenerator.AssetContent(guid); + + return new StringValue(result); + } + } + + return NilValue.Empty; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Scripting/EventScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs similarity index 91% rename from backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Scripting/EventScriptExtension.cs rename to backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs index c502aa6e3..14d91d048 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Scripting/EventScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs @@ -10,14 +10,14 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Core.HandleRules.Scripting +namespace Squidex.Domain.Apps.Core.HandleRules.Extensions { - public sealed class EventScriptExtension : IScriptExtension + public sealed class EventJintExtension : IJintExtension { private delegate JsValue EventDelegate(); private readonly IUrlGenerator urlGenerator; - public EventScriptExtension(IUrlGenerator urlGenerator) + public EventJintExtension(IUrlGenerator urlGenerator) { Guard.NotNull(urlGenerator, nameof(urlGenerator)); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs index d34f0492e..67e901325 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -15,6 +15,7 @@ using Newtonsoft.Json; using NodaTime.Text; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Templates; using Squidex.Infrastructure; using Squidex.Infrastructure.Json; using ValueTaskSupplement; @@ -28,6 +29,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules private static readonly Regex RegexPatternNew = new Regex(@"^\{(?(?[\w]+)_(?[\w\.\-]+))[\s]*(\|[\s]*(?[^\?}]+))?(\?[\s]*(?[^\}\s]+))?[\s]*\}", RegexOptions.Compiled); private readonly IJsonSerializer jsonSerializer; private readonly IEnumerable formatters; + private readonly ITemplateEngine templateEngine; private readonly IScriptEngine scriptEngine; private struct TextPart @@ -67,14 +69,16 @@ namespace Squidex.Domain.Apps.Core.HandleRules } } - public RuleEventFormatter(IJsonSerializer jsonSerializer, IEnumerable formatters, IScriptEngine scriptEngine) + public RuleEventFormatter(IJsonSerializer jsonSerializer, IEnumerable formatters, ITemplateEngine templateEngine, IScriptEngine scriptEngine) { Guard.NotNull(jsonSerializer, nameof(jsonSerializer)); Guard.NotNull(scriptEngine, nameof(scriptEngine)); + Guard.NotNull(templateEngine, nameof(templateEngine)); Guard.NotNull(formatters, nameof(formatters)); this.jsonSerializer = jsonSerializer; this.formatters = formatters; + this.templateEngine = templateEngine; this.scriptEngine = scriptEngine; } @@ -95,14 +99,24 @@ namespace Squidex.Domain.Apps.Core.HandleRules return text; } + if (TryGetTemplate(text.Trim(), out var template)) + { + var vars = new TemplateVars + { + ["event"] = @event + }; + + return await templateEngine.RenderAsync(template, vars); + } + if (TryGetScript(text.Trim(), out var script)) { - var context = new ScriptContext + var vars = new ScriptVars { ["event"] = @event }; - return scriptEngine.Interpolate(context, script); + return scriptEngine.Interpolate(vars, script); } var parts = BuildParts(text, @event); @@ -263,7 +277,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules case "trim": text = text.Trim(); break; - case "timestamp_ms": + case "timestamp": { var instant = InstantPattern.General.Parse(text); @@ -275,7 +289,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules break; } - case "timestamp_seconds": + case "timestamp_sec": { var instant = InstantPattern.General.Parse(text); @@ -336,5 +350,23 @@ namespace Squidex.Domain.Apps.Core.HandleRules return false; } + + private static bool TryGetTemplate(string text, out string script) + { + const string TemplateSuffix = ")"; + const string TemplatePrefix = "Liquid("; + + script = null!; + + var comparer = StringComparison.OrdinalIgnoreCase; + + if (text.StartsWith(TemplatePrefix, comparer) && text.EndsWith(TemplateSuffix, comparer)) + { + script = text.Substring(TemplatePrefix.Length, text.Length - TemplatePrefix.Length - TemplateSuffix.Length); + return true; + } + + return false; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index 30c74754f..4b1657e5f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -135,24 +135,35 @@ namespace Squidex.Domain.Apps.Core.HandleRules } var actionName = typeNameRegistry.GetName(actionType); - var actionData = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); - - var json = jsonSerializer.Serialize(actionData.Data); var job = new RuleJob { Id = Guid.NewGuid(), - ActionData = json, + ActionData = string.Empty, ActionName = actionName, AppId = enrichedEvent.AppId.Id, Created = now, - Description = actionData.Description, EventName = enrichedEvent.Name, ExecutionPartition = enrichedEvent.Partition, Expires = expires, RuleId = ruleId }; + try + { + var actionData = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); + + var json = jsonSerializer.Serialize(actionData.Data); + + job.ActionData = json; + job.Description = actionData.Description; + } + catch (Exception ex) + { + job.Description = "Failed to create job"; + job.Exception = ex; + } + result.Add(job); } catch (Exception ex) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeJintExtension.cs similarity index 91% rename from backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs rename to backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeJintExtension.cs index 3f50c38c8..8ceb9d47b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeJintExtension.cs @@ -12,11 +12,11 @@ using Jint.Native; namespace Squidex.Domain.Apps.Core.Scripting.Extensions { - public sealed class DateTimeScriptExtension : IScriptExtension + public sealed class DateTimeJintExtension : IJintExtension { private readonly Func formatDate; - public DateTimeScriptExtension() + public DateTimeJintExtension() { formatDate = new Func(FormatDate); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs similarity index 96% rename from backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpScriptExtension.cs rename to backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs index 39a3cef00..4f16a56eb 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs @@ -16,12 +16,12 @@ using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Core.Scripting.Extensions { - public sealed class HttpScriptExtension : IScriptExtension + public sealed class HttpJintExtension : IJintExtension { private delegate void GetJsonDelegate(string url, Action callback, JsValue? headers = null); private readonly IHttpClientFactory httpClientFactory; - public HttpScriptExtension(IHttpClientFactory httpClientFactory) + public HttpJintExtension(IHttpClientFactory httpClientFactory) { Guard.NotNull(httpClientFactory, nameof(httpClientFactory)); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringJintExtension.cs similarity index 95% rename from backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs rename to backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringJintExtension.cs index e582456ef..ddf775b97 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringJintExtension.cs @@ -12,14 +12,14 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting.Extensions { - public sealed class StringScriptExtension : IScriptExtension + public sealed class StringJintExtension : IJintExtension { private delegate JsValue StringSlugifyDelegate(string text, bool single = false); private readonly StringSlugifyDelegate slugify; private readonly Func toCamelCase; private readonly Func toPascalCase; - public StringScriptExtension() + public StringJintExtension() { slugify = new StringSlugifyDelegate(Slugify); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs similarity index 93% rename from backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptExtension.cs rename to backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs index aea19fb4b..8b1a75102 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs @@ -9,7 +9,7 @@ using Jint; namespace Squidex.Domain.Apps.Core.Scripting { - public interface IScriptExtension + public interface IJintExtension { void Extend(Engine engine) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs index 5db71761f..38224bd26 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs @@ -13,16 +13,16 @@ namespace Squidex.Domain.Apps.Core.Scripting { public interface IScriptEngine { - Task ExecuteAsync(ScriptContext context, string script); + Task ExecuteAsync(ScriptVars vars, string script); - Task ExecuteAndTransformAsync(ScriptContext context, string script); + Task ExecuteAndTransformAsync(ScriptVars vars, string script); - Task TransformAsync(ScriptContext context, string script); + Task TransformAsync(ScriptVars vars, string script); - Task GetAsync(ScriptContext context, string script); + Task GetAsync(ScriptVars vars, string script); - bool Evaluate(ScriptContext context, string script); + bool Evaluate(ScriptVars vars, string script); - string? Interpolate(ScriptContext context, string script); + string? Interpolate(ScriptVars vars, string script); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index a3a798b63..00beb39e3 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -26,23 +26,23 @@ namespace Squidex.Domain.Apps.Core.Scripting { public sealed class JintScriptEngine : IScriptEngine { - private readonly IScriptExtension[] extensions; + private readonly IJintExtension[] extensions; private readonly Parser parser; public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(200); public TimeSpan ExecutionTimeout { get; set; } = TimeSpan.FromMilliseconds(4000); - public JintScriptEngine(IMemoryCache memoryCache, IEnumerable? extensions = null) + public JintScriptEngine(IMemoryCache memoryCache, IEnumerable? extensions = null) { parser = new Parser(memoryCache); - this.extensions = extensions?.ToArray() ?? Array.Empty(); + this.extensions = extensions?.ToArray() ?? Array.Empty(); } - public async Task ExecuteAsync(ScriptContext context, string script) + public async Task ExecuteAsync(ScriptVars vars, string script) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); using (var cts = new CancellationTokenSource(ExecutionTimeout)) @@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Core.Scripting using (cts.Token.Register(() => tcs.TrySetCanceled())) { var engine = - CreateEngine(context, true, cts.Token, tcs.TrySetException, true) + CreateEngine(vars, true, cts.Token, tcs.TrySetException, true) .AddDisallow() .AddReject(); @@ -73,9 +73,9 @@ namespace Squidex.Domain.Apps.Core.Scripting } } - public async Task ExecuteAndTransformAsync(ScriptContext context, string script) + public async Task ExecuteAndTransformAsync(ScriptVars vars, string script) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); using (var cts = new CancellationTokenSource(ExecutionTimeout)) @@ -85,13 +85,13 @@ namespace Squidex.Domain.Apps.Core.Scripting using (cts.Token.Register(() => tcs.TrySetCanceled())) { var engine = - CreateEngine(context, true, cts.Token, tcs.TrySetException, true) + CreateEngine(vars, true, cts.Token, tcs.TrySetException, true) .AddDisallow() .AddReject(); engine.SetValue("complete", new Action(value => { - tcs.TrySetResult(context.Data!); + tcs.TrySetResult(vars.Data!); })); engine.SetValue("replace", new Action(() => @@ -108,7 +108,7 @@ namespace Squidex.Domain.Apps.Core.Scripting } else { - tcs.TrySetResult(context.Data!); + tcs.TrySetResult(vars.Data!); } } } @@ -118,7 +118,7 @@ namespace Squidex.Domain.Apps.Core.Scripting if (engine.GetValue("async") != true) { - tcs.TrySetResult(context.Data!); + tcs.TrySetResult(vars.Data!); } return await tcs.Task; @@ -126,9 +126,9 @@ namespace Squidex.Domain.Apps.Core.Scripting } } - public async Task TransformAsync(ScriptContext context, string script) + public async Task TransformAsync(ScriptVars vars, string script) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); using (var cts = new CancellationTokenSource(ExecutionTimeout)) @@ -137,11 +137,11 @@ namespace Squidex.Domain.Apps.Core.Scripting using (cts.Token.Register(() => tcs.TrySetCanceled())) { - var engine = CreateEngine(context, true, cts.Token, tcs.TrySetException, true); + var engine = CreateEngine(vars, true, cts.Token, tcs.TrySetException, true); engine.SetValue("complete", new Action(value => { - tcs.TrySetResult(context.Data!); + tcs.TrySetResult(vars.Data!); })); engine.SetValue("replace", new Action(() => @@ -158,7 +158,7 @@ namespace Squidex.Domain.Apps.Core.Scripting } else { - tcs.TrySetResult(context.Data!); + tcs.TrySetResult(vars.Data!); } } } @@ -168,7 +168,7 @@ namespace Squidex.Domain.Apps.Core.Scripting if (engine.GetValue("async") != true) { - tcs.TrySetResult(context.Data!); + tcs.TrySetResult(vars.Data!); } return await tcs.Task; @@ -176,14 +176,14 @@ namespace Squidex.Domain.Apps.Core.Scripting } } - public bool Evaluate(ScriptContext context, string script) + public bool Evaluate(ScriptVars vars, string script) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); try { - var engine = CreateEngine(context, false); + var engine = CreateEngine(vars, false); Execute(engine, script); @@ -197,14 +197,14 @@ namespace Squidex.Domain.Apps.Core.Scripting } } - public string? Interpolate(ScriptContext context, string script) + public string? Interpolate(ScriptVars vars, string script) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); try { - var engine = CreateEngine(context, false); + var engine = CreateEngine(vars, false); Execute(engine, script); @@ -218,9 +218,9 @@ namespace Squidex.Domain.Apps.Core.Scripting } } - public Task GetAsync(ScriptContext context, string script) + public Task GetAsync(ScriptVars vars, string script) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); using (var cts = new CancellationTokenSource(ExecutionTimeout)) @@ -232,7 +232,7 @@ namespace Squidex.Domain.Apps.Core.Scripting tcs.TrySetCanceled(); })) { - var engine = CreateEngine(context, true, cts.Token, ex => tcs.TrySetException(ex), true); + var engine = CreateEngine(vars, true, cts.Token, ex => tcs.TrySetException(ex), true); engine.SetValue("complete", new Action(value => { @@ -251,7 +251,7 @@ namespace Squidex.Domain.Apps.Core.Scripting } } - private Engine CreateEngine(ScriptContext context, bool nested, CancellationToken cancellationToken = default, ExceptionHandler? exceptionHandler = null, bool async = false) + private Engine CreateEngine(ScriptVars vars, bool nested, CancellationToken cancellationToken = default, ExceptionHandler? exceptionHandler = null, bool async = false) { var engine = new Engine(options => { @@ -271,16 +271,16 @@ namespace Squidex.Domain.Apps.Core.Scripting extension.Extend(engine); } - var executionContext = new ExecutionContext(engine, cancellationToken, exceptionHandler); + var executionvars = new ExecutionContext(engine, cancellationToken, exceptionHandler); - context.Add(executionContext, nested); + vars.Add(executionvars, nested); foreach (var extension in extensions) { - extension.Extend(executionContext, async); + extension.Extend(executionvars, async); } - return executionContext.Engine; + return executionvars.Engine; } private void Execute(Engine engine, string script) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs similarity index 97% rename from backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs rename to backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs index e61338a12..0a8870f68 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs @@ -16,9 +16,9 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting { - public sealed class ScriptContext : Dictionary + public sealed class ScriptVars : Dictionary { - public ScriptContext() + public ScriptVars() : base(StringComparer.OrdinalIgnoreCase) { } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 566034c6b..0bf841299 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -16,6 +16,7 @@ + diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs new file mode 100644 index 000000000..e3a4efcfa --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/ContentFluidExtension.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Fluid; +using Fluid.Values; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Templates.Extensions +{ + public sealed class ContentFluidExtension : IFluidExtension + { + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + FluidValue.SetTypeMapping(x => new ObjectValue(x)); + FluidValue.SetTypeMapping(x => new JsonArrayFluidValue(x)); + + memberAccessStrategy.Register( + (value, name) => value.GetOrDefault(name)); + + memberAccessStrategy.Register( + (value, name) => value.GetOrDefault(name)); + + memberAccessStrategy.Register( + (value, name) => value.GetOrDefault(name)); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/DateTimeFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/DateTimeFluidExtension.cs new file mode 100644 index 000000000..b7c9c1276 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/DateTimeFluidExtension.cs @@ -0,0 +1,79 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Fluid; +using Fluid.Values; +using NodaTime; + +namespace Squidex.Domain.Apps.Core.Templates.Extensions +{ + public class DateTimeFluidExtension : IFluidExtension + { + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + TemplateContext.GlobalFilters.AddFilter("format_date", FormatDate); + + TemplateContext.GlobalFilters.AddFilter("timestamp", FormatTimestamp); + TemplateContext.GlobalFilters.AddFilter("timestamp_sec", FormatTimestampSec); + } + + public static FluidValue FormatTimestamp(FluidValue input, FilterArguments arguments, TemplateContext context) + { + return FormatDate(input, x => FluidValue.Create(x.ToUnixTimeMilliseconds())); + } + + public static FluidValue FormatTimestampSec(FluidValue input, FilterArguments arguments, TemplateContext context) + { + return FormatDate(input, x => FluidValue.Create(x.ToUnixTimeMilliseconds() / 1000)); + } + + public static FluidValue FormatDate(FluidValue input, FilterArguments arguments, TemplateContext context) + { + if (arguments.Count == 1) + { + return FormatDate(input, x => Format(arguments, x)); + } + + return input; + } + + private static FluidValue FormatDate(FluidValue input, Func formatter) + { + switch (input) + { + case DateTimeValue dateTime: + { + var value = (DateTimeOffset)dateTime.ToObjectValue(); + + return formatter(value); + } + + case ObjectValue objectValue: + { + var value = objectValue.ToObjectValue(); + + if (value is Instant instant) + { + return formatter(instant.ToDateTimeOffset()); + } + + break; + } + } + + return input; + } + + private static FluidValue Format(FilterArguments arguments, DateTimeOffset value) + { + var formatted = value.ToString(arguments.At(0).ToStringValue()); + + return new StringValue(formatted); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/JsonArrayFluidValue.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/JsonArrayFluidValue.cs new file mode 100644 index 000000000..73aff2197 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/JsonArrayFluidValue.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Encodings.Web; +using Fluid; +using Fluid.Values; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Templates.Extensions +{ + public sealed class JsonArrayFluidValue : FluidValue + { + private readonly JsonArray value; + + public override FluidValues Type { get; } = FluidValues.Array; + + public JsonArrayFluidValue(JsonArray value) + { + this.value = value; + } + + public override bool Equals(FluidValue other) + { + return other is JsonArrayFluidValue array && array.value.Equals(value); + } + + public override bool ToBooleanValue() + { + return true; + } + + public override decimal ToNumberValue() + { + return 0; + } + + public override object ToObjectValue() + { + return new ObjectValue(value); + } + + public override string ToStringValue() + { + return value.ToString(); + } + + protected override FluidValue GetValue(string name, TemplateContext context) + { + switch (name) + { + case "size": + return NumberValue.Create(value.Count); + + case "first": + if (value.Count > 0) + { + return Create(value[0]); + } + + break; + + case "last": + if (value.Count > 0) + { + return Create(value[^1]); + } + + break; + } + + return NilValue.Instance; + } + + protected override FluidValue GetIndex(FluidValue index, TemplateContext context) + { + var i = (int)index.ToNumberValue(); + + if (i >= 0 && i < value.Count) + { + return Create(value[i]); + } + + return NilValue.Instance; + } + + public override IEnumerable Enumerate() + { + foreach (var item in value) + { + yield return Create(item); + } + } + + public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) + { + writer.Write(value.ToString()); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringFluidExtension.cs new file mode 100644 index 000000000..ffed40632 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringFluidExtension.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Fluid; +using Fluid.Values; +using Newtonsoft.Json; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Templates.Extensions +{ + public sealed class StringFluidExtension : IFluidExtension + { + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + TemplateContext.GlobalFilters.AddFilter("escape", Escape); + TemplateContext.GlobalFilters.AddFilter("slugify", Slugify); + TemplateContext.GlobalFilters.AddFilter("trim", Trim); + } + + public static FluidValue Slugify(FluidValue input, FilterArguments arguments, TemplateContext context) + { + if (input is StringValue value) + { + var result = value.ToStringValue().Slugify(); + + return FluidValue.Create(result); + } + + return input; + } + + public static FluidValue Escape(FluidValue input, FilterArguments arguments, TemplateContext context) + { + var result = input.ToStringValue(); + + result = JsonConvert.ToString(result); + result = result[1..^1]; + + return FluidValue.Create(result); + } + + public static FluidValue Trim(FluidValue input, FilterArguments arguments, TemplateContext context) + { + return FluidValue.Create(input.ToStringValue().Trim()); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidExtension.cs new file mode 100644 index 000000000..7687b9c31 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidExtension.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Fluid; +using Fluid.Values; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Core.Templates.Extensions +{ + public sealed class UserFluidExtension : IFluidExtension + { + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + FluidValue.SetTypeMapping(x => new UserFluidValue(x)); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidValue.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidValue.cs new file mode 100644 index 000000000..5df2aae82 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidValue.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using Fluid; +using Fluid.Values; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Core.Templates.Extensions +{ + public sealed class UserFluidValue : FluidValue + { + private readonly IUser value; + + public override FluidValues Type { get; } = FluidValues.Object; + + public UserFluidValue(IUser value) + { + this.value = value; + } + + protected override FluidValue GetValue(string name, TemplateContext context) + { + switch (name) + { + case "id": + return Create(value.Id); + case "email": + return Create(value.Email); + case "name": + return Create(value.DisplayName()); + default: + return Create(value.Claims.FirstOrDefault(x => string.Equals(name, x.Type, StringComparison.OrdinalIgnoreCase))?.Value); + } + } + + public override bool Equals(FluidValue other) + { + return other is UserFluidValue user && user.value.Id == value.Id; + } + + public override bool ToBooleanValue() + { + return true; + } + + public override decimal ToNumberValue() + { + return 0; + } + + public override object ToObjectValue() + { + return new UserFluidValue(value); + } + + public override string ToStringValue() + { + return value.Id; + } + + public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) + { + writer.Write(value.Id); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/FluidTemplateEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/FluidTemplateEngine.cs new file mode 100644 index 000000000..ededdd5da --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/FluidTemplateEngine.cs @@ -0,0 +1,93 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Fluid; +using Fluid.Values; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Templates +{ + public sealed class FluidTemplateEngine : ITemplateEngine + { + private readonly IEnumerable extensions; + + private sealed class SquidexTemplate : BaseFluidTemplate + { + public static void Setup(IEnumerable extensions) + { + foreach (var extension in extensions) + { + extension.RegisterLanguageExtensions(Factory); + } + } + + public static void SetupTypes(IEnumerable extensions) + { + var globalTypes = TemplateContext.GlobalMemberAccessStrategy; + + globalTypes.MemberNameStrategy = MemberNameStrategies.CamelCase; + + foreach (var extension in extensions) + { + extension.RegisterGlobalTypes(globalTypes); + } + + foreach (var type in SquidexCoreModel.Assembly.GetTypes().Where(x => x.IsEnum)) + { + FluidValue.SetTypeMapping(type, x => new StringValue(x.ToString())); + } + + globalTypes.Register>(); + globalTypes.Register>(); + globalTypes.Register>(); + globalTypes.Register(); + } + } + + public FluidTemplateEngine(IEnumerable extensions) + { + Guard.NotNull(extensions, nameof(extensions)); + + this.extensions = extensions; + + SquidexTemplate.Setup(extensions); + SquidexTemplate.SetupTypes(extensions); + } + + public async Task RenderAsync(string template, TemplateVars variables) + { + Guard.NotNull(variables, nameof(variables)); + + if (SquidexTemplate.TryParse(template, out var parsed, out var errors)) + { + var context = new TemplateContext(); + + foreach (var extension in extensions) + { + extension.BeforeRun(context); + } + + foreach (var (key, value) in variables) + { + context.MemberAccessStrategy.Register(value.GetType()); + + context.SetValue(key, value); + } + + var result = await parsed.RenderAsync(context); + + return result; + } + + throw new TemplateParseException(template, errors); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/IFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/IFluidExtension.cs new file mode 100644 index 000000000..70b911664 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/IFluidExtension.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Fluid; + +namespace Squidex.Domain.Apps.Core.Templates +{ + public interface IFluidExtension + { + void RegisterLanguageExtensions(FluidParserFactory factory) + { + } + + void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + } + + void BeforeRun(TemplateContext templateContext) + { + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/ITemplateEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/ITemplateEngine.cs new file mode 100644 index 000000000..5742f4690 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/ITemplateEngine.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Core.Templates +{ + public interface ITemplateEngine + { + Task RenderAsync(string template, TemplateVars variables); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/TemplateParseException.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/TemplateParseException.cs new file mode 100644 index 000000000..0fe18d3ad --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/TemplateParseException.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; + +namespace Squidex.Domain.Apps.Core.Templates +{ + [Serializable] + public class TemplateParseException : Exception + { + public IReadOnlyList Errors { get; } + + public TemplateParseException(string template, IEnumerable errors) + : base(BuildErrorMessage(errors, template)) + { + Errors = errors.ToList(); + } + + protected TemplateParseException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + Errors = (info.GetValue(nameof(Errors), typeof(List)) as List) ?? new List(); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(Errors), Errors.ToList()); + } + + private static string BuildErrorMessage(IEnumerable errors, string template) + { + var sb = new StringBuilder(); + + sb.AppendLine("Failed to parse template"); + + foreach (var error in errors) + { + sb.Append(" * "); + sb.AppendLine(error); + } + + sb.AppendLine(); + sb.AppendLine("Template:"); + sb.AppendLine(template); + + return sb.ToString(); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/TemplateVars.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/TemplateVars.cs new file mode 100644 index 000000000..ea6e5bee1 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/TemplateVars.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Templates +{ + public sealed class TemplateVars : Dictionary + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index 2ed216cb2..a63c9b962 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using MongoDB.Bson.Serialization; using MongoDB.Driver; using NodaTime; using Squidex.Domain.Apps.Core.HandleRules; @@ -90,9 +91,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules return Collection.UpdateOneAsync(x => x.Id == id, Update.Set(x => x.NextAttempt, nextAttempt)); } - public async Task EnqueueAsync(RuleJob job, Instant nextAttempt, CancellationToken ct = default) + public async Task EnqueueAsync(RuleJob job, Instant? nextAttempt, CancellationToken ct = default) { - var entity = SimpleMapper.Map(job, new MongoRuleEventEntity { Job = job, Created = nextAttempt, NextAttempt = nextAttempt }); + var entity = SimpleMapper.Map(job, new MongoRuleEventEntity { Job = job, Created = job.Created, NextAttempt = nextAttempt }); await Collection.InsertOneIfNotExistsAsync(entity, ct); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs index 64207be6d..b172897af 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs @@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return true; } - var context = new ScriptContext + var context = new ScriptVars { ["event"] = @event }; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs index b9c80b8e4..2c4271bab 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs @@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Comments return true; } - var context = new ScriptContext + var context = new ScriptVars { ["event"] = @event }; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index 05401a941..53533e1a5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -14,62 +14,23 @@ using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; -using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class ContentChangedTriggerHandler : RuleTriggerHandler, IRuleEventFormatter + public sealed class ContentChangedTriggerHandler : RuleTriggerHandler { private readonly IScriptEngine scriptEngine; private readonly IContentLoader contentLoader; - private readonly ILocalCache localCache; - public ContentChangedTriggerHandler(IScriptEngine scriptEngine, IContentLoader contentLoader, ILocalCache localCache) + public ContentChangedTriggerHandler(IScriptEngine scriptEngine, IContentLoader contentLoader) { Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(contentLoader, nameof(contentLoader)); - Guard.NotNull(localCache, nameof(localCache)); this.scriptEngine = scriptEngine; this.contentLoader = contentLoader; - this.localCache = localCache; - } - - public (bool Match, ValueTask) Format(EnrichedEvent @event, object value, string[] path) - { - if (value is JsonArray array && - array.Count > 0 && - array[0] is JsonString s && - Guid.TryParse(s.Value, out var referenceId)) - { - return (true, GetReferenceValueAsync(referenceId, path)); - } - - return default; - } - - private async ValueTask GetReferenceValueAsync(Guid referenceId, string[] path) - { - var reference = await GetContentFromCacheAsync(referenceId); - - var (result, remaining) = RuleVariable.GetValue(reference, path); - - if (remaining.Length == 0) - { - return result?.ToString(); - } - - return default; - } - - private Task GetContentFromCacheAsync(Guid referenceId) - { - var cacheKey = $"FORMAT_REFERENCE_{referenceId}"; - - return localCache.GetOrCreate(cacheKey, () => contentLoader.GetAsync(referenceId)); } protected override async Task CreateEnrichedEventAsync(Envelope @event) @@ -183,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return true; } - var context = new ScriptContext + var context = new ScriptVars { ["event"] = @event }; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs index 071837883..27683b69f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs @@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!c.DoNotScript) { c.Data = await context.ExecuteScriptAndTransformAsync(s => s.Create, - new ScriptContext + new ScriptVars { Operation = "Create", Data = c.Data, @@ -81,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!c.DoNotScript && c.Publish) { await context.ExecuteScriptAsync(s => s.Change, - new ScriptContext + new ScriptVars { Operation = "Published", Data = c.Data, @@ -157,7 +157,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!c.DoNotScript) { await context.ExecuteScriptAsync(s => s.Change, - new ScriptContext + new ScriptVars { Operation = change.ToString(), Data = Snapshot.Data, @@ -194,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!c.DoNotScript) { await context.ExecuteScriptAsync(s => s.Delete, - new ScriptContext + new ScriptVars { Operation = "Delete", Data = Snapshot.Data, @@ -236,7 +236,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!command.DoNotScript) { newData = await context.ExecuteScriptAndTransformAsync(s => s.Update, - new ScriptContext + new ScriptVars { Operation = "Create", Data = newData, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index 8742515a2..157fa6db1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public async Task ExecuteScriptAndTransformAsync(Func script, ScriptContext context) + public async Task ExecuteScriptAndTransformAsync(Func script, ScriptVars context) { Enrich(context); @@ -125,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return await scriptEngine.ExecuteAndTransformAsync(context, actualScript); } - public async Task ExecuteScriptAsync(Func script, ScriptContext context) + public async Task ExecuteScriptAsync(Func script, ScriptVars context) { Enrich(context); @@ -139,7 +139,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await scriptEngine.ExecuteAsync(context, GetScript(script)); } - private void Enrich(ScriptContext context) + private void Enrich(ScriptVars context) { context.ContentId = command.ContentId; context.AppId = app.Id; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs similarity index 93% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterScriptExtension.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs index b9b5ff175..21a3f29ed 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs @@ -13,11 +13,11 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Counter { - public sealed class CounterScriptExtension : IScriptExtension + public sealed class CounterJintExtension : IJintExtension { private readonly IGrainFactory grainFactory; - public CounterScriptExtension(IGrainFactory grainFactory) + public CounterJintExtension(IGrainFactory grainFactory) { Guard.NotNull(grainFactory, nameof(grainFactory)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 6a2db1cba..e712f8463 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -115,7 +115,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!string.IsNullOrWhiteSpace(condition?.Expression)) { - var context = new ScriptContext + var context = new ScriptVars { ["data"] = data }; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs index 721e9f7d0..7dad07756 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs @@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps private async Task TransformAsync(Context context, string script, ContentEntity content) { - var scriptContext = new ScriptContext + var scriptContext = new ScriptVars { ContentId = content.Id, Data = content.Data, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs new file mode 100644 index 000000000..9e4bf8511 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs @@ -0,0 +1,99 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Fluid; +using Fluid.Ast; +using Fluid.Tags; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ReferencesFluidExtension : IFluidExtension + { + private readonly IContentQueryService contentQueryService; + private readonly IAppProvider appProvider; + + private sealed class ReferenceTag : ArgumentsTag + { + private readonly IContentQueryService contentQueryService; + private readonly IAppProvider appProvider; + + public ReferenceTag(IContentQueryService contentQueryService, IAppProvider appProvider) + { + this.contentQueryService = contentQueryService; + + this.appProvider = appProvider; + } + + public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context, FilterArgument[] arguments) + { + if (arguments.Length == 2 && context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent) + { + var app = await appProvider.GetAppAsync(enrichedEvent.AppId.Id); + + if (app == null) + { + return Completion.Normal; + } + + var appContext = + Context.Admin() + .WithoutContentEnrichment() + .WithoutCleanup() + .WithUnpublished(); + + appContext.App = app; + + var id = (await arguments[1].Expression.EvaluateAsync(context)).ToStringValue(); + + if (Guid.TryParse(id, out var guid)) + { + var references = await contentQueryService.QueryAsync(appContext, new List { guid }); + var reference = references.FirstOrDefault(); + + if (reference != null) + { + var name = (await arguments[0].Expression.EvaluateAsync(context)).ToStringValue(); + + context.SetValue(name, reference); + } + } + } + + return Completion.Normal; + } + } + + public ReferencesFluidExtension(IContentQueryService contentQueryService, IAppProvider appProvider) + { + Guard.NotNull(contentQueryService, nameof(contentQueryService)); + Guard.NotNull(appProvider, nameof(appProvider)); + + this.contentQueryService = contentQueryService; + + this.appProvider = appProvider; + } + + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + memberAccessStrategy.Register(); + } + + public void RegisterLanguageExtensions(FluidParserFactory factory) + { + factory.RegisterTag("reference", new ReferenceTag(contentQueryService, appProvider)); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Context.cs b/backend/src/Squidex.Domain.Apps.Entities/Context.cs index 7b73acb90..62de01e18 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Context.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Context.cs @@ -13,6 +13,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Shared.Identity; +using P = Squidex.Shared.Permissions; using ClaimsPermissions = Squidex.Infrastructure.Security.PermissionSet; namespace Squidex.Domain.Apps.Entities @@ -48,7 +49,20 @@ namespace Squidex.Domain.Apps.Entities public static Context Anonymous() { - return new Context(new ClaimsPrincipal(new ClaimsIdentity())); + var claimsIdentity = new ClaimsIdentity(); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + + return new Context(claimsPrincipal); + } + + public static Context Admin() + { + var claimsIdentity = new ClaimsIdentity(); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + + claimsIdentity.AddClaim(new Claim(SquidexClaimTypes.Permissions, P.All)); + + return new Context(claimsPrincipal); } public Context Clone() diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs index 706560fe0..b08673f33 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories { Task UpdateAsync(RuleJob job, RuleJobUpdate update); - Task EnqueueAsync(RuleJob job, Instant nextAttempt, CancellationToken ct = default); + Task EnqueueAsync(RuleJob job, Instant? nextAttempt, CancellationToken ct = default); Task EnqueueAsync(Guid id, Instant nextAttempt); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index 40fa07190..b37d36fc3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -76,7 +76,22 @@ namespace Squidex.Domain.Apps.Entities.Rules foreach (var job in jobs) { - await ruleEventRepository.EnqueueAsync(job, job.Created); + if (job.Exception != null) + { + await ruleEventRepository.EnqueueAsync(job, null); + + await ruleEventRepository.UpdateAsync(job, new RuleJobUpdate + { + JobResult = RuleJobResult.Failed, + ExecutionResult = RuleResult.Failed, + ExecutionDump = job.Exception.ToString(), + Finished = job.Created + }); + } + else + { + await ruleEventRepository.EnqueueAsync(job, job.Created); + } } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs index 0712d3b78..0cfd0070a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs @@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas return true; } - var context = new ScriptContext + var context = new ScriptVars { ["event"] = @event }; diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index e03de5779..708d4e541 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -18,6 +18,8 @@ using Squidex.Areas.Api.Controllers.UI; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting.Extensions; using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Core.Templates.Extensions; using Squidex.Domain.Apps.Entities.Contents.Counter; using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Apps.Entities.Tags; @@ -60,17 +62,29 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsOptional(); - services.AddSingletonAs() - .As(); + services.AddSingletonAs() + .As(); - services.AddSingletonAs() - .As(); + services.AddSingletonAs() + .As(); - services.AddSingletonAs() - .As(); + services.AddSingletonAs() + .As(); - services.AddSingletonAs() - .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .AsOptional(); + + services.AddSingletonAs() + .AsOptional(); + + services.AddSingletonAs() + .AsOptional(); + + services.AddSingletonAs() + .AsOptional(); services.AddSingleton>(DomainObjectGrainFormatter.Format); } diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index 250bc5600..8327eb477 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -8,8 +8,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Squidex.Domain.Apps.Core.HandleRules; -using Squidex.Domain.Apps.Core.HandleRules.Scripting; +using Squidex.Domain.Apps.Core.HandleRules.Extensions; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Templates; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Contents; @@ -44,7 +45,10 @@ namespace Squidex.Config.Domain .As(); services.AddSingletonAs() - .As().As(); + .As(); + + services.AddSingletonAs() + .As(); services.AddSingletonAs() .As(); @@ -70,8 +74,11 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As().AsSelf(); - services.AddSingletonAs() - .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); services.AddSingletonAs() .As(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/ExpressionsAttribute.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/ExpressionsAttribute.cs new file mode 100644 index 000000000..7061e54fd --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/ExpressionsAttribute.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Reflection; +using Xunit.Sdk; + +namespace Squidex.Domain.Apps.Core.Operations.HandleRules +{ + public sealed class ExpressionsAttribute : DataAttribute + { + private readonly string? script; + private readonly string? interpolationOld; + private readonly string? interpolationNew; + private readonly string? liquid; + + public ExpressionsAttribute(string? interpolationOld, string? interpolationNew, string? script, string? liquid) + { + this.liquid = liquid; + + this.interpolationOld = interpolationOld; + this.interpolationNew = interpolationNew; + + this.script = script; + } + + public override IEnumerable GetData(MethodInfo testMethod) + { + if (interpolationOld != null) + { + yield return new object[] { interpolationOld }; + } + + if (interpolationNew != null) + { + yield return new object[] { interpolationNew }; + } + + if (script != null) + { + yield return new object[] + { + string.Format("Script(`{0}`)", script) + }; + } + + if (liquid != null) + { + yield return new object[] + { + string.Format("Liquid({0})", liquid) + }; + } + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs new file mode 100644 index 000000000..da2f9fc26 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs @@ -0,0 +1,793 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using NodaTime; +using Squidex.Domain.Apps.Core.Assets; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.Extensions; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Scripting.Extensions; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Core.Templates.Extensions; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Shared.Identity; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.HandleRules +{ + public class RuleEventFormatterCompareTests + { + private readonly IUser user = A.Fake(); + private readonly IUrlGenerator urlGenerator = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); + private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); + private readonly Guid contentId = Guid.NewGuid(); + private readonly Guid assetId = Guid.NewGuid(); + private readonly RuleEventFormatter sut; + + private class FakeContentResolver : IRuleEventFormatter + { + public (bool Match, ValueTask) Format(EnrichedEvent @event, object value, string[] path) + { + if (path[0] == "data" && value is JsonArray _) + { + return (true, GetValueAsync()); + } + + return default; + } + + private async ValueTask GetValueAsync() + { + await Task.Delay(5); + + return "Reference"; + } + } + + public RuleEventFormatterCompareTests() + { + A.CallTo(() => urlGenerator.ContentUI(appId, schemaId, contentId)) + .Returns("content-url"); + + A.CallTo(() => urlGenerator.AssetContent(assetId)) + .Returns("asset-content-url"); + + A.CallTo(() => user.Id) + .Returns("user123"); + + A.CallTo(() => user.Email) + .Returns("me@email.com"); + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "me") }); + + JintScriptEngine scriptEngine = BuildScriptEngine(); + + var formatters = new IRuleEventFormatter[] + { + new PredefinedPatternsFormatter(urlGenerator), + new FakeContentResolver() + }; + + sut = new RuleEventFormatter(TestUtils.DefaultSerializer, formatters, BuildTemplateEngine(), BuildScriptEngine()); + } + + private FluidTemplateEngine BuildTemplateEngine() + { + var extensions = new IFluidExtension[] + { + new ContentFluidExtension(), + new DateTimeFluidExtension(), + new EventFluidExtensions(urlGenerator), + new StringFluidExtension(), + new UserFluidExtension() + }; + + return new FluidTemplateEngine(extensions); + } + + private JintScriptEngine BuildScriptEngine() + { + var extensions = new IJintExtension[] + { + new DateTimeJintExtension(), + new EventJintExtension(urlGenerator), + new StringJintExtension() + }; + + var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + + return new JintScriptEngine(cache, extensions); + } + + [Theory] + [Expressions( + "Name $APP_NAME has id $APP_ID", + "Name ${EVENT_APPID.NAME} has id ${EVENT_APPID.ID}", + "Name ${event.appId.name} has id ${event.appId.id}", + "Name {{event.appId.name}} has id {{event.appId.id}}" + )] + public async Task Should_format_app_information_from_event(string script) + { + var @event = new EnrichedContentEvent { AppId = appId }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal($"Name my-app has id {appId.Id}", result); + } + + [Theory] + [Expressions( + "Name $SCHEMA_NAME has id $SCHEMA_ID", + "Name ${EVENT_SCHEMAID.NAME} has id ${EVENT_SCHEMAID.ID}", + "Name ${event.schemaId.name} has id ${event.schemaId.id}", + "Name {{event.schemaId.name}} has id {{event.schemaId.id}}" + )] + public async Task Should_format_schema_information_from_event(string script) + { + var @event = new EnrichedContentEvent { SchemaId = schemaId }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal($"Name my-schema has id {schemaId.Id}", result); + } + + [Theory] + [Expressions( + "DateTime: $TIMESTAMP_DATETIME", + null, + "DateTime: ${formatDate(event.timestamp, 'yyyy-MM-dd-hh-mm-ss')}", + "DateTime: {{event.timestamp | format_date: 'yyyy-MM-dd-hh-mm-ss'}}" + )] + public async Task Should_format_timestamp_information_from_event(string script) + { + var @event = new EnrichedContentEvent { Timestamp = now }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal($"DateTime: {now:yyyy-MM-dd-hh-mm-ss}", result); + } + + [Theory] + [Expressions( + "Date: $TIMESTAMP_DATE", + null, + "Date: ${formatDate(event.timestamp, 'yyyy-MM-dd')}", + "Date: {{event.timestamp | format_date: 'yyyy-MM-dd'}}" + )] + public async Task Should_format_timestamp_date_information_from_event(string script) + { + var @event = new EnrichedContentEvent { Timestamp = now }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal($"Date: {now:yyyy-MM-dd}", result); + } + + [Theory] + [Expressions( + "From $MENTIONED_NAME ($MENTIONED_EMAIL, $MENTIONED_ID)", + "From ${EVENT_MENTIONEDUSER.NAME} (${EVENT_MENTIONEDUSER.EMAIL}, ${EVENT_MENTIONEDUSER.ID})", + "From ${event.mentionedUser.name} (${event.mentionedUser.email}, ${event.mentionedUser.id})", + "From {{event.mentionedUser.name}} ({{event.mentionedUser.email}}, {{event.mentionedUser.id}})" + )] + public async Task Should_format_email_and_display_name_from_mentioned_user(string script) + { + var @event = new EnrichedCommentEvent { MentionedUser = user }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("From me (me@email.com, user123)", result); + } + + [Theory] + [Expressions( + "From $USER_NAME ($USER_EMAIL, $USER_ID)", + "From ${EVENT_USER.NAME} (${EVENT_USER.EMAIL}, ${EVENT_USER.ID})", + "From ${event.user.name} (${event.user.email}, ${event.user.id})", + "From {{event.user.name}} ({{event.user.email}}, {{event.user.id}})" + )] + public async Task Should_format_email_and_display_name_from_user(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("From me (me@email.com, user123)", result); + } + + [Theory] + [Expressions( + "From $USER_NAME ($USER_EMAIL, $USER_ID)", + "From ${EVENT_USER.NAME} (${EVENT_USER.EMAIL}, ${EVENT_USER.ID})", + "From ${event.user.name} (${event.user.email}, ${event.user.id})", + "From {{event.user.name | default: 'null'}} ({{event.user.email | default: 'null'}}, {{event.user.id | default: 'null'}})" + )] + public async Task Should_return_null_if_user_is_not_found(string script) + { + var @event = new EnrichedContentEvent(); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("From null (null, null)", result); + } + + [Theory] + [Expressions( + "From $USER_NAME ($USER_EMAIL, $USER_ID)", + "From ${EVENT_USER.NAME} (${EVENT_USER.EMAIL}, ${EVENT_USER.ID})", + "From ${event.user.name} (${event.user.email}, ${event.user.id})", + "From {{event.user.name}} ({{event.user.email}}, {{event.user.id}})" + )] + public async Task Should_format_email_and_display_name_from_client(string script) + { + var @event = new EnrichedContentEvent { User = new ClientUser(new RefToken(RefTokenType.Client, "android")) }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("From client:android (client:android, android)", result); + } + + [Theory] + [Expressions( + "Version: $ASSET_VERSION", + "Version: ${EVENT_VERSION}", + "Version: ${event.version}", + "Version: {{event.version}}" + )] + public async Task Should_format_base_property(string script) + { + var @event = new EnrichedAssetEvent { Version = 13 }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Version: 13", result); + } + + [Theory] + [Expressions( + "File: $ASSET_FILENAME", + "File: ${EVENT_FILENAME}", + "File: ${event.fileName}", + "File: {{event.fileName}}" + )] + public async Task Should_format_asset_file_name_from_event(string script) + { + var @event = new EnrichedAssetEvent { FileName = "my-file.png" }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("File: my-file.png", result); + } + + [Theory] + [Expressions( + "Type: $ASSSET_ASSETTYPE", + "Type: ${EVENT_ASSETTYPE}", + "Type: ${event.assetType}", + "Type: {{event.assetType}}" + )] + public async Task Should_format_asset_asset_type_from_event(string script) + { + var @event = new EnrichedAssetEvent { AssetType = AssetType.Audio }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Type: Audio", result); + } + + [Theory] + [Expressions( + "Download at $ASSET_CONTENT_URL", + null, + "Download at ${assetContentUrl()}", + "Download at {{event.id | assetContentUrl}}" + )] + public async Task Should_format_asset_content_url_from_event(string script) + { + var @event = new EnrichedAssetEvent { Id = assetId }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Download at asset-content-url", result); + } + + [Theory] + [Expressions( + "Download at $ASSET_CONTENT_URL", + null, + "Download at ${assetContentUrl()}", + "Download at {{event.id | assetContentUrl | default: 'null'}}" + )] + public async Task Should_return_null_when_asset_content_url_not_found(string script) + { + var @event = new EnrichedContentEvent(); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Download at null", result); + } + + [Theory] + [Expressions( + "Go to $CONTENT_URL", + null, + "Go to ${contentUrl()}", + "Go to {{event.id | contentUrl | default: 'null'}}" + )] + public async Task Should_format_content_url_from_event(string script) + { + var @event = new EnrichedContentEvent { AppId = appId, Id = contentId, SchemaId = schemaId }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Go to content-url", result); + } + + [Theory] + [Expressions( + "Go to $CONTENT_URL", + null, + "Go to ${contentUrl()}", + "Go to {{event.id | contentUrl | default: 'null'}}" + )] + public async Task Should_return_null_when_content_url_when_not_found(string script) + { + var @event = new EnrichedAssetEvent(); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Go to null", result); + } + + [Theory] + [Expressions( + "$CONTENT_STATUS", + "${EVENT_STATUS}", + "${contentAction()}", + "{{event.status}}" + )] + public async Task Should_format_content_status_when_found(string script) + { + var @event = new EnrichedContentEvent { Status = Status.Published }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Published", result); + } + + [Theory] + [Expressions( + "$CONTENT_STATUS", + "${EVENT_STATUS}", + "${contentAction()}", + "{{event.status | default: 'null'}}" + )] + public async Task Should_return_null_when_content_status_not_found(string script) + { + var @event = new EnrichedAssetEvent(); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("null", result); + } + + [Theory] + [Expressions( + "$CONTENT_ACTION", + "${EVENT_TYPE}", + "${event.type}", + "{{event.type}}" + )] + public async Task Should_format_content_actions_when_found(string script) + { + var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Created", result); + } + + [Theory] + [Expressions( + "$CONTENT_STATUS", + "${CONTENT_STATUS}", + "${contentAction()}", + null + )] + public async Task Should_return_null_when_content_action_not_found(string script) + { + var @event = new EnrichedAssetEvent(); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("null", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.country.iv", + "${CONTENT_DATA.country.iv}", + "${event.data.country.iv}", + "{{event.data.country.iv | default: 'null'}}" + )] + public async Task Should_return_null_when_field_not_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddValue("iv", "Berlin")) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("null", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.country.iv", + "${CONTENT_DATA.country.iv}", + "${event.data.country.iv}", + "{{event.data.country.iv | default: 'null'}}" + )] + public async Task Should_return_null_when_partition_not_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddValue("iv", "Berlin")) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("null", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.country.iv.10", + "${CONTENT_DATA.country.iv.10}", + "${event.data.country.iv[10]}", + "{{event.data.country.iv[10] | default: 'null'}}" + )] + public async Task Should_return_null_when_array_item_not_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Array())) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("null", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.country.iv.Location", + "${CONTENT_DATA.country.iv.Location}", + "${event.data.country.iv.Location}", + "{{event.data.country.iv.Location | default: 'null'}}" + )] + public async Task Should_return_null_when_property_not_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("null", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.city.iv", + "${CONTENT_DATA.city.iv}", + "${event.data.city.iv}", + "{{event.data.city.iv}}" + )] + public async Task Should_return_plain_value_when_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddValue("iv", "Berlin")) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Berlin", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.city.iv.0", + "${CONTENT_DATA.city.iv.0}", + "${event.data.city.iv[0]}", + "{{event.data.city.iv[0]}}" + )] + public async Task Should_return_plain_value_from_array_when_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Array("Berlin"))) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Berlin", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.city.iv.name", + "${CONTENT_DATA.city.iv.name}", + "${event.data.city.iv.name}", + "{{event.data.city.iv.name}}" + )] + public async Task Should_return_plain_value_from_object_when_found(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Berlin", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.city.iv", + "${CONTENT_DATA.city.iv}", + "${JSON.stringify(event.data.city.iv)}", + "{{event.data.city.iv}}" + )] + public async Task Should_return_json_string_when_object(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("{\"name\":\"Berlin\"}", result); + } + + [Theory] + [Expressions( + "$CONTENT_DATA.city.iv", + "${CONTENT_DATA.city.iv}", + "${JSON.stringify(event.data.city.iv)}", + "{{event.data.city.iv}}" + )] + public async Task Should_return_json_string_when_array(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(1, 2, 3))) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("[1,2,3]", result?.Replace(" ", string.Empty)); + } + + [Theory] + [Expressions( + null, + "From ${EVENT_ACTOR}", + "From ${event.actor}", + "From {{event.actor}}" + )] + public async Task Should_format_actor(string script) + { + var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("From client:android", result); + } + + [Theory] + [Expressions( + null, + "${ASSET_LASTMODIFIED | timestamp}", + "${event.lastModified.getTime()}", + "{{event.lastModified | timestamp}}" + )] + public async Task Should_transform_timestamp(string script) + { + var @event = new EnrichedAssetEvent { LastModified = Instant.FromUnixTimeSeconds(1590769584) }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("1590769584000", result); + } + + [Theory] + [Expressions( + null, + "${ASSET_LASTMODIFIED | timestamp_sec}", + "${event.lastModified.getTime() / 1000}", + "{{event.lastModified | timestamp_sec}}" + )] + public async Task Should_transform_timestamp_seconds(string script) + { + var @event = new EnrichedAssetEvent { LastModified = Instant.FromUnixTimeSeconds(1590769584) }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("1590769584", result); + } + + [Theory] + [Expressions( + "${USER_NAME | Upper}", + "${EVENT_USER.NAME | Upper}", + "${event.user.name.toUpperCase()}", + "{{event.user.name | upcase}}" + )] + public async Task Should_transform_upper(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "Donald Duck") }); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("DONALD DUCK", result); + } + + [Theory] + [Expressions( + "${USER_NAME | Lower}", + "${EVENT_USER.NAME | Lower}", + "${event.user.name.toLowerCase()}", + "{{event.user.name | downcase}}" + )] + public async Task Should_transform_lower(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "Donald Duck") }); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("donald duck", result); + } + + [Theory] + [Expressions( + "${USER_NAME | Trim}", + "${EVENT_USER.NAME | Trim}", + "${event.user.name.trim()}", + "{{event.user.name | trim}}" + )] + public async Task Should_transform_trimmed(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "Donald Duck ") }); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Donald Duck", result); + } + + [Theory] + [Expressions( + "${USER_NAME | Slugify}", + "${EVENT_USER.NAME | Slugify}", + "${slugify(event.user.name)}", + "{{event.user.name | slugify}}" + )] + public async Task Should_transform_slugify(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "Donald Duck") }); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("donald-duck", result); + } + + [Theory] + [Expressions( + "${USER_NAME | Upper | Trim}", + "${EVENT_USER.NAME | Upper | Trim}", + "${event.user.name.toUpperCase().trim()}", + "{{event.user.name | upcase | trim}}" + )] + public async Task Should_transform_chained(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "Donald Duck ") }); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("DONALD DUCK", result); + } + + [Theory] + [Expressions( + "${USER_NAME | Escape}", + "${EVENT_USER.NAME | Escape}", + null, + "{{event.user.name | escape}}" + )] + public async Task Should_transform_json_escape(string script) + { + var @event = new EnrichedContentEvent { User = user }; + + A.CallTo(() => user.Claims) + .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "Donald\"Duck") }); + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("Donald\\\"Duck", result); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs index cea1bd83b..f57f7d6fb 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs @@ -12,14 +12,14 @@ using System.Threading.Tasks; using FakeItEasy; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using NodaTime; -using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.HandleRules; -using Squidex.Domain.Apps.Core.HandleRules.Scripting; +using Squidex.Domain.Apps.Core.HandleRules.Extensions; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting.Extensions; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Core.Templates.Extensions; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; using Squidex.Shared.Identity; @@ -34,7 +34,6 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules private readonly IUrlGenerator urlGenerator = A.Fake(); private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); - private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); private readonly Guid contentId = Guid.NewGuid(); private readonly Guid assetId = Guid.NewGuid(); private readonly RuleEventFormatter sut; @@ -76,14 +75,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules A.CallTo(() => user.Claims) .Returns(new List { new Claim(SquidexClaimTypes.DisplayName, "me") }); - var extensions = new IScriptExtension[] - { - new DateTimeScriptExtension(), - new EventScriptExtension(urlGenerator), - new StringScriptExtension() - }; - - var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + JintScriptEngine scriptEngine = BuildScriptEngine(); var formatters = new IRuleEventFormatter[] { @@ -91,7 +83,32 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules new FakeContentResolver() }; - sut = new RuleEventFormatter(TestUtils.DefaultSerializer, formatters, new JintScriptEngine(cache, extensions)); + sut = new RuleEventFormatter(TestUtils.DefaultSerializer, formatters, BuildTemplateEngine(), BuildScriptEngine()); + } + + private static FluidTemplateEngine BuildTemplateEngine() + { + var extensions = new IFluidExtension[] + { + new DateTimeFluidExtension(), + new UserFluidExtension() + }; + + return new FluidTemplateEngine(extensions); + } + + private JintScriptEngine BuildScriptEngine() + { + var extensions = new IJintExtension[] + { + new DateTimeJintExtension(), + new EventJintExtension(urlGenerator), + new StringJintExtension() + }; + + var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + + return new JintScriptEngine(cache, extensions); } [Fact] @@ -122,389 +139,6 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Contains("MyEventName", result); } - [Theory] - [InlineData("Name $APP_NAME has id $APP_ID")] - [InlineData("Name ${EVENT_APPID.NAME} has id ${EVENT_APPID.ID}")] - [InlineData("Script(`Name ${event.appId.name} has id ${event.appId.id}`)")] - public async Task Should_format_app_information_from_event(string script) - { - var @event = new EnrichedContentEvent { AppId = appId }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal($"Name my-app has id {appId.Id}", result); - } - - [Theory] - [InlineData("Name $SCHEMA_NAME has id $SCHEMA_ID")] - [InlineData("Script(`Name ${event.schemaId.name} has id ${event.schemaId.id}`)")] - public async Task Should_format_schema_information_from_event(string script) - { - var @event = new EnrichedContentEvent { SchemaId = schemaId }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal($"Name my-schema has id {schemaId.Id}", result); - } - - [Theory] - [InlineData("Full: $TIMESTAMP_DATETIME")] - [InlineData("Script(`Full: ${formatDate(event.timestamp, 'yyyy-MM-dd-hh-mm-ss')}`)")] - public async Task Should_format_timestamp_information_from_event(string script) - { - var @event = new EnrichedContentEvent { Timestamp = now }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal($"Full: {now:yyyy-MM-dd-hh-mm-ss}", result); - } - - [Theory] - [InlineData("Date: $TIMESTAMP_DATE")] - [InlineData("Script(`Date: ${formatDate(event.timestamp, 'yyyy-MM-dd')}`)")] - public async Task Should_format_timestamp_date_information_from_event(string script) - { - var @event = new EnrichedContentEvent { Timestamp = now }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal($"Date: {now:yyyy-MM-dd}", result); - } - - [Theory] - [InlineData("From $MENTIONED_NAME ($MENTIONED_EMAIL, $MENTIONED_ID)")] - [InlineData("From ${COMMENT_MENTIONEDUSER.NAME} (${COMMENT_MENTIONEDUSER.EMAIL}, ${COMMENT_MENTIONEDUSER.ID})")] - [InlineData("Script(`From ${event.mentionedUser.name} (${event.mentionedUser.email}, ${event.mentionedUser.id})`)")] - public async Task Should_format_email_and_display_name_from_mentioned_user(string script) - { - var @event = new EnrichedCommentEvent { MentionedUser = user }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("From me (me@email.com, user123)", result); - } - - [Theory] - [InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")] - [InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")] - public async Task Should_format_email_and_display_name_from_user(string script) - { - var @event = new EnrichedContentEvent { User = user }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("From me (me@email.com, user123)", result); - } - - [Theory] - [InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")] - [InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")] - public async Task Should_return_null_if_user_is_not_found(string script) - { - var @event = new EnrichedContentEvent(); - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("From null (null, null)", result); - } - - [Theory] - [InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")] - [InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")] - public async Task Should_format_email_and_display_name_from_client(string script) - { - var @event = new EnrichedContentEvent { User = new ClientUser(new RefToken(RefTokenType.Client, "android")) }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("From client:android (client:android, android)", result); - } - - [Theory] - [InlineData("Version: $ASSET_VERSION")] - [InlineData("Script(`Version: ${event.version}`)")] - public async Task Should_format_base_property(string script) - { - var @event = new EnrichedAssetEvent { Version = 13 }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Version: 13", result); - } - - [Theory] - [InlineData("File: $ASSET_FILENAME")] - [InlineData("Script(`File: ${event.fileName}`)")] - public async Task Should_format_asset_file_name_from_event(string script) - { - var @event = new EnrichedAssetEvent { FileName = "my-file.png" }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("File: my-file.png", result); - } - - [Theory] - [InlineData("Type: $ASSET_ASSETTYPE")] - [InlineData("Script(`Type: ${event.assetType}`)")] - public async Task Should_format_asset_asset_type_from_event(string script) - { - var @event = new EnrichedAssetEvent { AssetType = AssetType.Audio }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Type: Audio", result); - } - - [Theory] - [InlineData("Download at $ASSET_CONTENT_URL")] - [InlineData("Script(`Download at ${assetContentUrl()}`)")] - public async Task Should_format_asset_content_url_from_event(string script) - { - var @event = new EnrichedAssetEvent { Id = assetId }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Download at asset-content-url", result); - } - - [Theory] - [InlineData("Download at $ASSET_CONTENT_URL")] - [InlineData("Script(`Download at ${assetContentUrl()}`)")] - public async Task Should_return_null_when_asset_content_url_not_found(string script) - { - var @event = new EnrichedContentEvent(); - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Download at null", result); - } - - [Theory] - [InlineData("Go to $CONTENT_URL")] - [InlineData("Script(`Go to ${contentUrl()}`)")] - public async Task Should_format_content_url_from_event(string script) - { - var @event = new EnrichedContentEvent { AppId = appId, Id = contentId, SchemaId = schemaId }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Go to content-url", result); - } - - [Theory] - [InlineData("Go to $CONTENT_URL")] - [InlineData("Script(`Go to ${contentUrl()}`)")] - public async Task Should_return_null_when_content_url_when_not_found(string script) - { - var @event = new EnrichedAssetEvent(); - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Go to null", result); - } - - [Theory] - [InlineData("$CONTENT_STATUS")] - [InlineData("Script(contentAction())")] - [InlineData("Script(`${event.status}`)")] - public async Task Should_format_content_status_when_found(string script) - { - var @event = new EnrichedContentEvent { Status = Status.Published }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Published", result); - } - - [Theory] - [InlineData("$CONTENT_ACTION")] - [InlineData("Script(contentAction())")] - public async Task Should_return_null_when_content_status_not_found(string script) - { - var @event = new EnrichedAssetEvent(); - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("null", result); - } - - [Theory] - [InlineData("$CONTENT_ACTION")] - [InlineData("Script(`${event.type}`)")] - public async Task Should_format_content_actions_when_found(string script) - { - var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Created", result); - } - - [Theory] - [InlineData("$CONTENT_ACTION")] - [InlineData("Script(contentAction())")] - public async Task Should_return_null_when_content_action_not_found(string script) - { - var @event = new EnrichedAssetEvent(); - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("null", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.country.iv")] - [InlineData("Script(`${event.data.country.iv}`)")] - public async Task Should_return_null_when_field_not_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddValue("iv", "Berlin")) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("null", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.de")] - [InlineData("Script(`${event.data.country.iv}`)")] - public async Task Should_return_null_when_partition_not_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddValue("iv", "Berlin")) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("null", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.iv.10")] - [InlineData("Script(`${event.data.country.de[10]}`)")] - public async Task Should_return_null_when_array_item_not_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddJsonValue(JsonValue.Array())) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("null", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.de.Name")] - [InlineData("Script(`${event.data.city.de.Location}`)")] - public async Task Should_return_null_when_property_not_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("null", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.iv")] - [InlineData("Script(`${event.data.city.iv}`)")] - public async Task Should_return_plain_value_when_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddValue("iv", "Berlin")) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Berlin", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.iv.0")] - [InlineData("Script(`${event.data.city.iv[0]}`)")] - public async Task Should_return_plain_value_from_array_when_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddJsonValue(JsonValue.Array("Berlin"))) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Berlin", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.iv.name")] - [InlineData("Script(`${event.data.city.iv.name}`)")] - public async Task Should_return_plain_value_from_object_when_found(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("Berlin", result); - } - - [Theory] - [InlineData("$CONTENT_DATA.city.iv")] - [InlineData("Script(`${JSON.stringify(event.data.city.iv)}`)")] - public async Task Should_return_json_string_when_object(string script) - { - var @event = new EnrichedContentEvent - { - Data = - new NamedContentData() - .AddField("city", - new ContentFieldData() - .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) - }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("{\"name\":\"Berlin\"}", result); - } - [Fact] public async Task Should_resolve_reference() { @@ -522,17 +156,6 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Equal("Reference", result); } - [Theory] - [InlineData("Script(`From ${event.actor}`)")] - public async Task Should_format_actor(string script) - { - var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal("From client:android", result); - } - [Theory] [InlineData("${EVENT_INVALID ? file}", "file")] public async Task Should_provide_fallback_if_path_is_invalid(string script, string expect) @@ -615,18 +238,6 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Equal(expect, result); } - [Theory] - [InlineData("${ASSET_LASTMODIFIED | timestamp_seconds}", "1590769584")] - [InlineData("${ASSET_LASTMODIFIED | timestamp_ms}", "1590769584000")] - public async Task Should_transform_timestamp(string script, string expect) - { - var @event = new EnrichedAssetEvent { LastModified = Instant.FromUnixTimeSeconds(1590769584) }; - - var result = await sut.FormatAsync(script, @event); - - Assert.Equal(expect, result); - } - [Fact] public async Task Should_format_json() { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs index 783a3b508..1cd0a7da4 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs @@ -29,11 +29,11 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting public JintScriptEngineHelperTests() { - var extensions = new IScriptExtension[] + var extensions = new IJintExtension[] { - new DateTimeScriptExtension(), - new HttpScriptExtension(httpClientFactory), - new StringScriptExtension() + new DateTimeJintExtension(), + new HttpJintExtension(httpClientFactory), + new StringJintExtension() }; var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); @@ -51,12 +51,12 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting return toCamelCase(value); "; - var context = new ScriptContext + var vars = new ScriptVars { ["value"] = "Hello World" }; - var result = sut.Interpolate(context, script); + var result = sut.Interpolate(vars, script); Assert.Equal("helloWorld", result); } @@ -68,12 +68,12 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting return toPascalCase(value); "; - var context = new ScriptContext + var vars = new ScriptVars { ["value"] = "Hello World" }; - var result = sut.Interpolate(context, script); + var result = sut.Interpolate(vars, script); Assert.Equal("HelloWorld", result); } @@ -85,12 +85,12 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting return slugify(value); "; - var context = new ScriptContext + var vars = new ScriptVars { ["value"] = "4 Häuser" }; - var result = sut.Interpolate(context, script); + var result = sut.Interpolate(vars, script); Assert.Equal("4-haeuser", result); } @@ -102,12 +102,12 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting return slugify(value, true); "; - var context = new ScriptContext + var vars = new ScriptVars { ["value"] = "4 Häuser" }; - var result = sut.Interpolate(context, script); + var result = sut.Interpolate(vars, script); Assert.Equal("4-hauser", result); } @@ -119,7 +119,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting reject() "; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptContext(), script)); + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script)); Assert.Empty(ex.Errors); } @@ -131,7 +131,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting reject('Not valid') "; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptContext(), script)); + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script)); Assert.Equal("Not valid", ex.Errors.Single().Message); } @@ -143,7 +143,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting disallow() "; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptContext(), script)); + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script)); Assert.Equal("Not allowed", ex.Message); } @@ -155,7 +155,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting disallow('Operation not allowed') "; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptContext(), script)); + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script)); Assert.Equal("Operation not allowed", ex.Message); } @@ -173,7 +173,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting }); "; - var result = await sut.GetAsync(new ScriptContext(), script); + var result = await sut.GetAsync(new ScriptVars(), script); httpHandler.ShouldBeMethod(HttpMethod.Get); httpHandler.ShouldBeUrl("http://squidex.io/"); @@ -201,7 +201,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting }, headers); "; - var result = await sut.GetAsync(new ScriptContext(), script); + var result = await sut.GetAsync(new ScriptVars(), script); httpHandler.ShouldBeMethod(HttpMethod.Get); httpHandler.ShouldBeUrl("http://squidex.io/"); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs index 79427dcbf..54fa6d2a8 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs @@ -29,11 +29,11 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting public JintScriptEngineTests() { - var extensions = new IScriptExtension[] + var extensions = new IJintExtension[] { - new DateTimeScriptExtension(), - new HttpScriptExtension(httpClientFactory), - new StringScriptExtension() + new DateTimeJintExtension(), + new HttpJintExtension(httpClientFactory), + new StringJintExtension() }; var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) @@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting invalid() "; - await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptContext(), script)); + await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script)); } [Fact] @@ -71,14 +71,14 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting throw 'Error'; "; - await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptContext(), script)); + await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script)); } [Fact] public async Task TransformAsync_should_return_original_content_when_script_failed() { var content = new NamedContentData(); - var context = new ScriptContext { Data = content }; + var context = new ScriptVars { Data = content }; const string script = @" x => x @@ -109,7 +109,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting new ContentFieldData() .AddValue("iv", 10.0)); - var context = new ScriptContext { Data = content }; + var context = new ScriptVars { Data = content }; const string script = @" var data = ctx.data; @@ -134,14 +134,14 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting throw 'Error'; "; - await Assert.ThrowsAsync(() => sut.ExecuteAndTransformAsync(new ScriptContext(), script)); + await Assert.ThrowsAsync(() => sut.ExecuteAndTransformAsync(new ScriptVars(), script)); } [Fact] public async Task ExecuteAndTransformAsync_should_throw_when_script_failed() { var content = new NamedContentData(); - var context = new ScriptContext { Data = content }; + var context = new ScriptVars { Data = content }; const string script = @" invalid(); @@ -154,7 +154,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting public async Task ExecuteAndTransformAsync_should_return_original_content_when_not_replaced() { var content = new NamedContentData(); - var context = new ScriptContext { Data = content }; + var context = new ScriptVars { Data = content }; const string script = @" var x = 0; @@ -169,7 +169,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting public async Task ExecuteAndTransformAsync_should_return_original_content_when_not_replaced_async() { var content = new NamedContentData(); - var context = new ScriptContext { Data = content }; + var context = new ScriptVars { Data = content }; const string script = @" async = true; @@ -197,7 +197,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting new ContentFieldData() .AddValue("iv", "MyOperation")); - var context = new ScriptContext { Data = content, Operation = "MyOperation" }; + var context = new ScriptVars { Data = content, Operation = "MyOperation" }; const string script = @" var data = ctx.data; @@ -223,7 +223,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting new ContentFieldData() .AddValue("iv", 42)); - var context = new ScriptContext { Data = content, Operation = "MyOperation" }; + var context = new ScriptVars { Data = content, Operation = "MyOperation" }; const string script = @" async = true; @@ -247,7 +247,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting public async Task ExecuteAndTransformAsync_should_ignore_transformation_when_async_not_set() { var content = new NamedContentData(); - var context = new ScriptContext { Data = content, Operation = "MyOperation" }; + var context = new ScriptVars { Data = content, Operation = "MyOperation" }; const string script = @" var data = ctx.data; @@ -269,7 +269,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting public async Task ExecuteAndTransformAsync_should_timeout_when_replace_never_called() { var content = new NamedContentData(); - var context = new ScriptContext { Data = content, Operation = "MyOperation" }; + var context = new ScriptVars { Data = content, Operation = "MyOperation" }; const string script = @" async = true; @@ -305,7 +305,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting new ContentFieldData() .AddValue("iv", 10.0)); - var context = new ScriptContext { Data = content }; + var context = new ScriptVars { Data = content }; const string script = @" var data = ctx.data; @@ -349,7 +349,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting userIdentity.AddClaim(new Claim(OpenIdClaims.ClientId, "2")); - var context = new ScriptContext { Data = content, DataOld = oldContent, User = userPrincipal }; + var context = new ScriptVars { Data = content, DataOld = oldContent, User = userPrincipal }; const string script = @" ctx.data.number0.iv = ctx.data.number0.iv + ctx.oldData.number0.iv * parseInt(ctx.user.id, 10); @@ -369,7 +369,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting value.i == 2 "; - var context = new ScriptContext + var context = new ScriptVars { ["value"] = new { i = 2 } }; @@ -386,7 +386,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting value.status == 'Published' "; - var context = new ScriptContext + var context = new ScriptVars { ["value"] = new { status = Status.Published } }; @@ -403,7 +403,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting value.i == 3 "; - var context = new ScriptContext + var context = new ScriptVars { ["value"] = new { i = 2 } }; @@ -420,7 +420,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting function(); "; - var context = new ScriptContext + var context = new ScriptVars { ["value"] = new { i = 2 } }; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Templates/FluidTemplateEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Templates/FluidTemplateEngineTests.cs new file mode 100644 index 000000000..72c817403 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Templates/FluidTemplateEngineTests.cs @@ -0,0 +1,129 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Core.Templates.Extensions; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.Templates +{ + public class FluidTemplateEngineTests + { + private readonly FluidTemplateEngine sut; + + public FluidTemplateEngineTests() + { + var extensions = new IFluidExtension[] + { + new DateTimeFluidExtension() + }; + + sut = new FluidTemplateEngine(extensions); + } + + [Theory] + [InlineData("{{ e.user }}", "subject:me")] + [InlineData("{{ e.user.type }}", "subject")] + [InlineData("{{ e.user.identifier }}", "me")] + public async Task Should_render_ref_token(string template, string expected) + { + var value = new + { + User = new RefToken(RefTokenType.Subject, "me") + }; + + var result = await RenderAync(template, value); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("{{ e.id }}", "42,my-app")] + [InlineData("{{ e.id.name}}", "my-app")] + [InlineData("{{ e.id.id }}", "42")] + public async Task Should_render_named_id(string template, string expected) + { + var value = new + { + Id = NamedId.Of("42", "my-app") + }; + + var result = await RenderAync(template, value); + + Assert.Equal(expected, result); + } + + [Fact] + public async Task Should_format_enum() + { + var value = new + { + Type = EnrichedContentEventType.Created + }; + + var template = "{{ e.type }}"; + + var result = await RenderAync(template, value); + + Assert.Equal(value.Type.ToString(), result); + } + + [Fact] + public async Task Should_format_date() + { + var now = DateTime.UtcNow; + + var value = new + { + Timestamp = now + }; + + var template = "{{ e.timestamp | format_date: 'yyyy-MM-dd-hh-mm-ss' }}"; + + var result = await RenderAync(template, value); + + Assert.Equal($"{now:yyyy-MM-dd-hh-mm-ss}", result); + } + + [Fact] + public async Task Should_format_content_data() + { + var template = "{{ e.data.value.en }}"; + + var value = new + { + Data = + new NamedContentData() + .AddField("value", + new ContentFieldData() + .AddValue("en", "Hello")) + }; + + var result = await RenderAync(template, value); + + Assert.Equal("Hello", result); + } + + [Fact] + public async Task Should_throw_exception_when_template_invalid() + { + var template = "{% for x of event %}"; + + await Assert.ThrowsAsync(() => sut.RenderAsync(template, new TemplateVars())); + } + + private Task RenderAync(string template, object value) + { + return sut.RenderAsync(template, new TemplateVars { ["e"] = value }); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs index 38b9e0347..54fb3ad16 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs @@ -32,10 +32,10 @@ namespace Squidex.Domain.Apps.Entities.Assets public AssetChangedTriggerHandlerTests() { - A.CallTo(() => scriptEngine.Evaluate(A._, "true")) + A.CallTo(() => scriptEngine.Evaluate(A._, "true")) .Returns(true); - A.CallTo(() => scriptEngine.Evaluate(A._, "false")) + A.CallTo(() => scriptEngine.Evaluate(A._, "false")) .Returns(false); sut = new AssetChangedTriggerHandler(scriptEngine, assetLoader); @@ -149,12 +149,12 @@ namespace Squidex.Domain.Apps.Entities.Assets if (string.IsNullOrWhiteSpace(condition)) { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustNotHaveHappened(); } else { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustHaveHappened(); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs index f7947ce44..1ce72b4a8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs @@ -33,10 +33,10 @@ namespace Squidex.Domain.Apps.Entities.Comments public CommentTriggerHandlerTests() { - A.CallTo(() => scriptEngine.Evaluate(A._, "true")) + A.CallTo(() => scriptEngine.Evaluate(A._, "true")) .Returns(true); - A.CallTo(() => scriptEngine.Evaluate(A._, "false")) + A.CallTo(() => scriptEngine.Evaluate(A._, "false")) .Returns(false); sut = new CommentTriggerHandler(scriptEngine, userResolver); @@ -290,12 +290,12 @@ namespace Squidex.Domain.Apps.Entities.Comments if (string.IsNullOrWhiteSpace(condition)) { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustNotHaveHappened(); } else { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustHaveHappened(); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs index 376aa73f7..f87f3fa57 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -22,7 +22,6 @@ using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Json.Objects; using Xunit; #pragma warning disable SA1401 // Fields must be private @@ -35,23 +34,20 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IScriptEngine scriptEngine = A.Fake(); private readonly ILocalCache localCache = new AsyncLocalCache(); private readonly IContentLoader contentLoader = A.Fake(); - private readonly ContentChangedTriggerHandler sut; - private readonly IRuleTriggerHandler handler; + private readonly IRuleTriggerHandler sut; private readonly Guid ruleId = Guid.NewGuid(); private static readonly NamedId SchemaMatch = NamedId.Of(Guid.NewGuid(), "my-schema1"); private static readonly NamedId SchemaNonMatch = NamedId.Of(Guid.NewGuid(), "my-schema2"); public ContentChangedTriggerHandlerTests() { - A.CallTo(() => scriptEngine.Evaluate(A._, "true")) + A.CallTo(() => scriptEngine.Evaluate(A._, "true")) .Returns(true); - A.CallTo(() => scriptEngine.Evaluate(A._, "false")) + A.CallTo(() => scriptEngine.Evaluate(A._, "false")) .Returns(false); - sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader, localCache); - - handler = sut; + sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader); } public static IEnumerable TestEvents() @@ -64,58 +60,6 @@ namespace Squidex.Domain.Apps.Entities.Contents yield return new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished }; } - [Fact] - public async Task Should_resolve_reference_if_value_from_content_loader() - { - var referenceId = Guid.NewGuid(); - var referenceValue = JsonValue.Array(referenceId); - - SetupReference(referenceId); - - var (handled, result) = sut.Format(null!, referenceValue, new[] { "data", "field1", "iv" }); - - Assert.True(handled); - Assert.Equal("Hello", await result); - } - - [Fact] - public async Task Should_resolve_reference_only_once() - { - using (localCache.StartContext()) - { - var referenceId = Guid.NewGuid(); - var referenceValue = JsonValue.Array(referenceId); - - SetupReference(referenceId); - - var (handled1, result1) = sut.Format(null!, referenceValue, new[] { "data", "field1", "iv" }); - var (handled2, result2) = sut.Format(null!, referenceValue, new[] { "data", "field2", "iv" }); - - Assert.True(handled1); - Assert.Equal("Hello", await result1); - - Assert.True(handled2); - Assert.Equal("World", await result2); - - A.CallTo(() => contentLoader.GetAsync(A._, A._)) - .MustHaveHappenedOnceExactly(); - } - } - - [Fact] - public async Task Should_not_return_value_if_path_not_found_in_reference() - { - var referenceId = Guid.NewGuid(); - var referenceValue = JsonValue.Array(referenceId); - - SetupReference(referenceId); - - var (handled, result) = sut.Format(null!, referenceValue, new[] { "data", "invalid", "iv" }); - - Assert.True(handled); - Assert.Null(await result); - } - [Theory] [MemberData(nameof(TestEvents))] public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) @@ -125,7 +69,7 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentLoader.GetAsync(@event.ContentId, 12)) .Returns(new ContentEntity { SchemaId = SchemaMatch }); - var result = await handler.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(envelope); var enrichedEvent = result.Single() as EnrichedContentEvent; @@ -148,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentLoader.GetAsync(@event.ContentId, 11)) .Returns(new ContentEntity { SchemaId = SchemaMatch, Version = 11, Data = dataOld }); - var result = await handler.CreateEnrichedEventsAsync(envelope); + var result = await sut.CreateEnrichedEventsAsync(envelope); var enrichedEvent = result.Single() as EnrichedContentEvent; @@ -161,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger => { - var result = handler.Trigger(new AssetCreated(), trigger, ruleId); + var result = sut.Trigger(new AssetCreated(), trigger, ruleId); Assert.False(result); }); @@ -172,7 +116,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => { - var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); + var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); Assert.False(result); }); @@ -183,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger => { - var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); + var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); Assert.True(result); }); @@ -194,7 +138,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger => { - var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); + var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); Assert.True(result); }); @@ -205,7 +149,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger => { - var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); + var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); Assert.False(result); }); @@ -216,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger => { - var result = handler.Trigger(new EnrichedAssetEvent(), trigger); + var result = sut.Trigger(new EnrichedAssetEvent(), trigger); Assert.False(result); }); @@ -227,7 +171,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => { - var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); + var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); Assert.False(result); }); @@ -238,7 +182,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger => { - var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); + var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); Assert.True(result); }); @@ -249,7 +193,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger => { - var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); + var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); Assert.True(result); }); @@ -260,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "true", action: trigger => { - var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); + var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); Assert.True(result); }); @@ -271,7 +215,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger => { - var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); + var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); Assert.False(result); }); @@ -282,7 +226,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "false", action: trigger => { - var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); + var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); Assert.False(result); }); @@ -307,30 +251,14 @@ namespace Squidex.Domain.Apps.Entities.Contents if (string.IsNullOrWhiteSpace(condition)) { - A.CallTo(() => scriptEngine.Evaluate(A._, A._)) + A.CallTo(() => scriptEngine.Evaluate(A._, A._)) .MustNotHaveHappened(); } else { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustHaveHappened(); } } - - private void SetupReference(Guid referenceId) - { - A.CallTo(() => contentLoader.GetAsync(referenceId, EtagVersion.Any)) - .Returns(new ContentEntity - { - Data = - new NamedContentData() - .AddField("field1", - new ContentFieldData() - .AddJsonValue(JsonValue.Create("Hello"))) - .AddField("field2", - new ContentFieldData() - .AddJsonValue(JsonValue.Create("World"))) - }); - } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs index fcf025c35..d6d328f31 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs @@ -100,8 +100,8 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId)) .Returns((app, schema)); - A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, A._)) - .ReturnsLazily(x => Task.FromResult(x.GetArgument(0)!.Data!)); + A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, A._)) + .ReturnsLazily(x => Task.FromResult(x.GetArgument(0)!.Data!)); patched = patch.MergeInto(data); @@ -139,7 +139,7 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(ScriptContext(data, null, Status.Draft), "")) .MustHaveHappened(); - A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) + A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) .MustNotHaveHappened(); } @@ -233,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.Single(LastEvents); - A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, "")) + A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, "")) .MustNotHaveHappened(); } @@ -306,7 +306,7 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.Single(LastEvents); - A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, "")) + A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, "")) .MustNotHaveHappened(); } @@ -424,7 +424,7 @@ namespace Squidex.Domain.Apps.Entities.Contents CreateContentEvent(new ContentStatusScheduled { Status = Status.Published, DueTime = dueTime }) ); - A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) + A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) .MustNotHaveHappened(); } @@ -452,7 +452,7 @@ namespace Squidex.Domain.Apps.Entities.Contents CreateContentEvent(new ContentStatusChanged { Status = Status.Archived }) ); - A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) + A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) .MustHaveHappened(); } @@ -480,7 +480,7 @@ namespace Squidex.Domain.Apps.Entities.Contents CreateContentEvent(new ContentSchedulingCancelled()) ); - A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) + A.CallTo(() => scriptEngine.ExecuteAsync(A._, "")) .MustNotHaveHappened(); } @@ -577,17 +577,17 @@ namespace Squidex.Domain.Apps.Entities.Contents return PublishAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published })); } - private ScriptContext ScriptContext(NamedContentData? newData, NamedContentData? oldData, Status newStatus) + private ScriptVars ScriptContext(NamedContentData? newData, NamedContentData? oldData, Status newStatus) { - return A.That.Matches(x => M(x, newData, oldData, newStatus, default)); + return A.That.Matches(x => M(x, newData, oldData, newStatus, default)); } - private ScriptContext ScriptContext(NamedContentData? newData, NamedContentData? oldData, Status newStatus, Status oldStatus) + private ScriptVars ScriptContext(NamedContentData? newData, NamedContentData? oldData, Status newStatus, Status oldStatus) { - return A.That.Matches(x => M(x, newData, oldData, newStatus, oldStatus)); + return A.That.Matches(x => M(x, newData, oldData, newStatus, oldStatus)); } - private bool M(ScriptContext x, NamedContentData? newData, NamedContentData? oldData, Status newStatus, Status oldStatus) + private bool M(ScriptVars x, NamedContentData? newData, NamedContentData? oldData, Status newStatus, Status oldStatus) { return Equals(x.Data, newData) && diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterScriptExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterJintExtensionTests.cs similarity index 88% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterScriptExtensionTests.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterJintExtensionTests.cs index 54af6a08a..2409b7d7a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterScriptExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterJintExtensionTests.cs @@ -15,17 +15,17 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.Counter { - public class CounterScriptExtensionTests + public class CounterJintExtensionTests { private readonly IGrainFactory grainFactory = A.Fake(); private readonly ICounterGrain counter = A.Fake(); private readonly JintScriptEngine sut; - public CounterScriptExtensionTests() + public CounterJintExtensionTests() { - var extensions = new IScriptExtension[] + var extensions = new IJintExtension[] { - new CounterScriptExtension(grainFactory) + new CounterJintExtension(grainFactory) }; var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); @@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Counter return resetCounter('my', 4); "; - var context = new ScriptContext + var context = new ScriptVars { ["appId"] = appId }; @@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Counter return incrementCounter('my'); "; - var context = new ScriptContext + var context = new ScriptVars { ["appId"] = appId }; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs index 4884dab98..62680f6ae 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs @@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await sut.EnrichAsync(ctx, new[] { content }, schemaProvider); - A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, A._)) + A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, A._)) .MustNotHaveHappened(); } @@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await sut.EnrichAsync(ctx, new[] { content }, schemaProvider); - A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, A._)) + A.CallTo(() => scriptEngine.ExecuteAndTransformAsync(A._, A._)) .MustNotHaveHappened(); } @@ -93,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries var content = new ContentEntity { SchemaId = schemaWithScriptId, Data = oldData }; - A.CallTo(() => scriptEngine.TransformAsync(A._, "my-query")) + A.CallTo(() => scriptEngine.TransformAsync(A._, "my-query")) .Returns(new NamedContentData()); await sut.EnrichAsync(ctx, new[] { content }, schemaProvider); @@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.NotSame(oldData, content.Data); A.CallTo(() => scriptEngine.TransformAsync( - A.That.Matches(x => + A.That.Matches(x => ReferenceEquals(x.User, ctx.User) && ReferenceEquals(x.Data, oldData) && x.ContentId == content.Id), diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs new file mode 100644 index 000000000..a02f7c6b6 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs @@ -0,0 +1,104 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class ReferenceFluidExtensionTests + { + private readonly IContentQueryService contentQuery = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly FluidTemplateEngine sut; + + public ReferenceFluidExtensionTests() + { + var extensions = new IFluidExtension[] + { + new ReferencesFluidExtension(contentQuery, appProvider) + }; + + A.CallTo(() => appProvider.GetAppAsync(appId.Id)) + .Returns(Mocks.App(appId)); + + sut = new FluidTemplateEngine(extensions); + } + + [Fact] + public async Task Should_resolve_references_in_loop() + { + var referenceId1 = Guid.NewGuid(); + var reference1 = CreateReference(referenceId1, 1); + var referenceId2 = Guid.NewGuid(); + var reference2 = CreateReference(referenceId1, 2); + + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("references", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(referenceId1, referenceId2))), + AppId = appId + }; + + A.CallTo(() => contentQuery.QueryAsync(A._, A>.That.Contains(referenceId1))) + .Returns(ResultList.CreateFrom(1, reference1)); + + A.CallTo(() => contentQuery.QueryAsync(A._, A>.That.Contains(referenceId2))) + .Returns(ResultList.CreateFrom(1, reference2)); + + var vars = new TemplateVars + { + ["event"] = @event + }; + + var template = @" +{% for id in event.data.references.iv %} + {% reference 'ref', id %} + Text: {{ ref.data.field1.iv }} {{ ref.data.field2.iv }} +{% endfor %} +"; + + var expected = @" + Text: Hello 1 World 1 + Text: Hello 2 World 2 +"; + + var result = await sut.RenderAsync(template, vars); + + Assert.Equal(expected, result); + } + + private IEnrichedContentEntity CreateReference(Guid referenceId, int index) + { + return new ContentEntity + { + Data = + new NamedContentData() + .AddField("field1", + new ContentFieldData() + .AddJsonValue(JsonValue.Create($"Hello {index}"))) + .AddField("field2", + new ContentFieldData() + .AddJsonValue(JsonValue.Create($"World {index}"))), + Id = referenceId + }; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs index 86019758d..1d806ac83 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs @@ -31,10 +31,10 @@ namespace Squidex.Domain.Apps.Entities.Schemas public SchemaChangedTriggerHandlerTests() { - A.CallTo(() => scriptEngine.Evaluate(A._, "true")) + A.CallTo(() => scriptEngine.Evaluate(A._, "true")) .Returns(true); - A.CallTo(() => scriptEngine.Evaluate(A._, "false")) + A.CallTo(() => scriptEngine.Evaluate(A._, "false")) .Returns(false); sut = new SchemaChangedTriggerHandler(scriptEngine); @@ -136,12 +136,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas if (string.IsNullOrWhiteSpace(condition)) { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustNotHaveHappened(); } else { - A.CallTo(() => scriptEngine.Evaluate(A._, condition)) + A.CallTo(() => scriptEngine.Evaluate(A._, condition)) .MustHaveHappened(); } } diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.scss b/frontend/app/features/rules/pages/events/rule-events-page.component.scss index 678a2764c..b6b104d3a 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.scss +++ b/frontend/app/features/rules/pages/events/rule-events-page.component.scss @@ -2,6 +2,10 @@ h3 { margin-bottom: 1rem; } +.expanded { + border-bottom: 0; +} + .event { &-stats { font-size: .8rem; @@ -17,7 +21,7 @@ h3 { &-header { & { - background: $color-border; + background: $color-table-footer; border: 0; margin: -.75rem -1.25rem; margin-bottom: 1rem; @@ -26,7 +30,7 @@ h3 { } &::before { - @include caret-top($color-border); + @include caret-top($color-table-footer); @include absolute(-1.1rem, 1.8rem, auto, auto); }