diff --git a/backend/src/Migrations/OldTriggers/AssetChangedTrigger.cs b/backend/src/Migrations/OldTriggers/AssetChangedTrigger.cs index 06da56498..366a0ee17 100644 --- a/backend/src/Migrations/OldTriggers/AssetChangedTrigger.cs +++ b/backend/src/Migrations/OldTriggers/AssetChangedTrigger.cs @@ -24,7 +24,7 @@ public sealed record AssetChangedTrigger : RuleTrigger, IMigrated public bool SendDelete { get; set; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { throw new NotSupportedException(); } diff --git a/backend/src/Migrations/OldTriggers/ContentChangedTrigger.cs b/backend/src/Migrations/OldTriggers/ContentChangedTrigger.cs index c6aec8079..cc549f1c7 100644 --- a/backend/src/Migrations/OldTriggers/ContentChangedTrigger.cs +++ b/backend/src/Migrations/OldTriggers/ContentChangedTrigger.cs @@ -20,7 +20,7 @@ public sealed record ContentChangedTrigger : RuleTrigger, IMigrated public bool HandleAll { get; set; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { throw new NotSupportedException(); } diff --git a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj index 110964ded..fe4649109 100644 --- a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj +++ b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj @@ -36,17 +36,17 @@ - - - + + + - - - - + + + + diff --git a/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj b/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj index c0040f5aa..bb323fc7b 100644 --- a/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj +++ b/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj @@ -25,12 +25,12 @@ - - - - - - + + + + + + diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedManualEvent.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedManualEvent.cs index 2c72aa475..516a9920a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedManualEvent.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedManualEvent.cs @@ -5,10 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure.Json.Objects; + namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents; public sealed class EnrichedManualEvent : EnrichedUserEventBase { + public JsonValue Value { get; set; } + public override long Partition { get => 0; diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs index a70dc9f90..b1245db11 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs @@ -9,17 +9,17 @@ using Squidex.Domain.Apps.Core.Rules.Triggers; namespace Squidex.Domain.Apps.Core.Rules; -public interface IRuleTriggerVisitor +public interface IRuleTriggerVisitor { - T Visit(AssetChangedTriggerV2 trigger); + T Visit(AssetChangedTriggerV2 trigger, TArgs args); - T Visit(ContentChangedTriggerV2 trigger); + T Visit(ContentChangedTriggerV2 trigger, TArgs args); - T Visit(CommentTrigger trigger); + T Visit(CommentTrigger trigger, TArgs args); - T Visit(ManualTrigger trigger); + T Visit(ManualTrigger trigger, TArgs args); - T Visit(SchemaChangedTrigger trigger); + T Visit(SchemaChangedTrigger trigger, TArgs args); - T Visit(UsageTrigger trigger); + T Visit(UsageTrigger trigger, TArgs args); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs index f4234aae2..7ae4e5dd5 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs @@ -9,5 +9,5 @@ namespace Squidex.Domain.Apps.Core.Rules; public abstract record RuleTrigger { - public abstract T Accept(IRuleTriggerVisitor visitor); + public abstract T Accept(IRuleTriggerVisitor visitor, TArgs args); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs index 5b7183e2e..f2b9226c8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs @@ -14,8 +14,8 @@ public sealed record AssetChangedTriggerV2 : RuleTrigger { public string Condition { get; init; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CommentTrigger.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CommentTrigger.cs index c1246884b..3210f1679 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CommentTrigger.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CommentTrigger.cs @@ -14,8 +14,8 @@ public sealed record CommentTrigger : RuleTrigger { public string Condition { get; init; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs index bdd285d82..5a8a70e09 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs @@ -19,8 +19,8 @@ public sealed record ContentChangedTriggerV2 : RuleTrigger public bool HandleAll { get; init; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ManualTrigger.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ManualTrigger.cs index 11848ee08..b8ce6f17a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ManualTrigger.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ManualTrigger.cs @@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers; [TypeName(nameof(ManualTrigger))] public sealed record ManualTrigger : RuleTrigger { - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaChangedTrigger.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaChangedTrigger.cs index 72f73426a..ed1fd8c6f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaChangedTrigger.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaChangedTrigger.cs @@ -14,8 +14,8 @@ public sealed record SchemaChangedTrigger : RuleTrigger { public string Condition { get; init; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs index 4f27f7b96..7d8e74a01 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs @@ -16,8 +16,8 @@ public sealed record UsageTrigger : RuleTrigger public int? NumDays { get; init; } - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index 0788c99b2..5afcc1ccd 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -20,7 +20,7 @@ - + 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 19f834695..959afd32f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Security.Claims; -using NodaTime; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Templates; @@ -44,7 +43,19 @@ public sealed class RuleEventFormatter( return false; } - return scriptEngine.Evaluate(new ScriptVars { ["@event"] = value }, expression); + if (value is not EnrichedEvent @event) + { + if (value is not FlowEventContext context) + { + return false; + } + + @event = context.Event; + } + + var vars = CreateScriptVars(value, @event); + + return scriptEngine.Evaluate(vars, expression); } public async ValueTask RenderAsync(string? expression, T value, ExpressionFallback fallback = default) @@ -64,13 +75,11 @@ public sealed class RuleEventFormatter( switch (fallback) { case ExpressionFallback.Context: - // Provide this overload with object to serialize the derived type and not the static type. return serializer.Serialize(value, true); case ExpressionFallback.Event: - // Provide this overload with object to serialize the derived type and not the static type. return serializer.Serialize(@event, true); case ExpressionFallback.Envelope: - return ToEnvelope(@event.Name, @event, @event.Timestamp); + return serializer.Serialize(new { type = @event.Name, payload = @event, timestamp = @event.Timestamp }, true); default: return expression; } @@ -78,26 +87,16 @@ public sealed class RuleEventFormatter( if (TryGetTemplate(expression.Trim(), out var template)) { - var vars = new TemplateVars - { - ["event"] = @event, - }; + var input = CreateTemplateVars(value, @event); + var result = await templateEngine.RenderAsync(template, input); - return await templateEngine.RenderAsync(template, vars); + return result; } if (TryGetScript(expression.Trim(), out var script)) { - // Script vars are just wrappers over dictionaries for better performance. - var vars = new EventScriptVars - { - Event = @event, - AppId = @event.AppId.Id, - AppName = @event.AppId.Name, - User = Admin(), - }; - - var result = (await scriptEngine.ExecuteAsync(vars, script)).ToString(); + var input = CreateScriptVars(value, @event); + var result = (await scriptEngine.ExecuteAsync(input, script)).ToString(); if (result == "undefined") { @@ -110,12 +109,6 @@ public sealed class RuleEventFormatter( return await simpleFormatter.Format(expression, @event, GlobalFallback); } - private string ToEnvelope(string type, object payload, Instant timestamp) - { - // Provide this overload with object to serialize the derived type and not the static type. - return serializer.Serialize(new { type, payload, timestamp }, true); - } - private static ClaimsPrincipal Admin() { var claimsIdentity = new ClaimsIdentity(); @@ -126,6 +119,30 @@ public sealed class RuleEventFormatter( return claimsPrincipal; } + private static EventScriptVars CreateScriptVars(T value, EnrichedEvent @event) + { + return new EventScriptVars + { + AppId = @event.AppId.Id, + AppName = @event.AppId.Name, + Event = @event, + User = Admin(), + UserData = (value as FlowEventContext)?.UserData ?? [], + }; + } + + private static TemplateVars CreateTemplateVars(T value, EnrichedEvent @event) + { + return new TemplateVars + { + ["appId"] = @event.AppId.Id, + ["appName"] = @event.AppId.Name, + ["event"] = @event, + ["user"] = new { displayName = "FlowUser" }, + ["userData"] = (value as FlowEventContext)?.UserData ?? [], + }; + } + private static bool TryGetScript(string text, out string script) { const string ScriptSuffix = ")"; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs index ade92811d..97fd4df5e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs @@ -32,4 +32,9 @@ public sealed class EventScriptVars : ScriptVars { set => SetInitial(value); } + + public Dictionary UserData + { + set => SetInitial(value); + } } 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 932ae57fc..b1179d54a 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 @@ -29,8 +29,8 @@ - - + + diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/TriggerRule.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/TriggerRule.cs index 1b0151d5d..660efb151 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/TriggerRule.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/TriggerRule.cs @@ -5,8 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure.Json.Objects; + namespace Squidex.Domain.Apps.Entities.Rules.Commands; public sealed class TriggerRule : RuleCommand { + public JsonValue Value { get; set; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs index 13e74e446..7a0e50668 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs @@ -5,19 +5,16 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules.Commands; -using Squidex.Flows; using Squidex.Infrastructure; -using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Validation; namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards; public static class GuardRule { - public static Task CanCreate(CreateRule command, IAppProvider appProvider, IFlowManager flowManager, + public static Task CanCreate(CreateRule command, IRuleValidator validator, CancellationToken ct) { Guard.NotNull(command); @@ -30,9 +27,8 @@ public static class GuardRule } else { - var errors = await RuleTriggerValidator.ValidateAsync(command.AppId.Id, command.Trigger, appProvider, ct); - - errors.Foreach((x, _) => x.AddTo(e)); + await validator.ValidateTriggerAsync(command.Trigger, command.AppId.Id, + Validate.WithPrefix(nameof(command.Trigger), e), ct); } if (command.Flow == null) @@ -41,12 +37,13 @@ public static class GuardRule } else { - await flowManager.ValidateAsync(command.Flow, ConvertError(e), ct); + await validator.ValidateFlowAsync(command.Flow, + Validate.WithPrefix(nameof(command.Flow), e), ct); } }); } - public static Task CanUpdate(UpdateRule command, Rule rule, IAppProvider appProvider, IFlowManager flowManager, + public static Task CanUpdate(UpdateRule command, Rule rule, IRuleValidator validator, CancellationToken ct) { Guard.NotNull(command); @@ -55,45 +52,14 @@ public static class GuardRule { if (command.Trigger != null) { - var errors = await RuleTriggerValidator.ValidateAsync(rule.AppId.Id, command.Trigger, appProvider, ct); - - errors.Foreach((x, _) => x.AddTo(e)); + await validator.ValidateTriggerAsync(command.Trigger, command.AppId.Id, + Validate.WithPrefix(nameof(command.Trigger), e), ct); } if (command.Flow != null) { - await flowManager.ValidateAsync(command.Flow, ConvertError(e), ct); - } - }); - } - - private static AddError ConvertError(AddValidation addError) - { - return new AddError((path, type, message) => - { - var actualPath = nameof(CreateRule.Flow); - if (!string.IsNullOrWhiteSpace(path)) - { - actualPath = $"{actualPath}.{path}"; - } - - switch (type) - { - case ValidationErrorType.NoSteps: - addError(T.Get("rules.validation.noSteps"), actualPath); - break; - case ValidationErrorType.NoStartStep: - addError(T.Get("rules.validation.noStartStep"), actualPath); - break; - case ValidationErrorType.InvalidNextStepId: - addError(T.Get("rules.validation.invalidNextStepId"), actualPath); - break; - case ValidationErrorType.InvalidStepId: - addError(T.Get("rules.validation.invalidStepId"), actualPath); - break; - case ValidationErrorType.InvalidProperty when !string.IsNullOrWhiteSpace(message): - addError(message, actualPath); - break; + await validator.ValidateFlowAsync(command.Flow, + Validate.WithPrefix(nameof(command.Flow), e), ct); } }); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs deleted file mode 100644 index 58349051d..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs +++ /dev/null @@ -1,85 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Triggers; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Translations; -using Squidex.Infrastructure.Validation; - -namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards; - -public sealed class RuleTriggerValidator(DomainId appId, IAppProvider appProvider, CancellationToken ct) - : IRuleTriggerVisitor>> -{ - public static Task> ValidateAsync(DomainId appId, RuleTrigger trigger, IAppProvider appProvider, - CancellationToken ct) - { - Guard.NotNull(trigger); - Guard.NotNull(appProvider); - - var visitor = new RuleTriggerValidator(appId, appProvider, ct); - - return trigger.Accept(visitor); - } - - public Task> Visit(CommentTrigger trigger) - { - return Task.FromResult(Enumerable.Empty()); - } - - public Task> Visit(AssetChangedTriggerV2 trigger) - { - return Task.FromResult(Enumerable.Empty()); - } - - public Task> Visit(ManualTrigger trigger) - { - return Task.FromResult(Enumerable.Empty()); - } - - public Task> Visit(SchemaChangedTrigger trigger) - { - return Task.FromResult(Enumerable.Empty()); - } - - public Task> Visit(UsageTrigger trigger) - { - var errors = new List(); - - if (trigger.NumDays is < 1 or > 30) - { - errors.Add(new ValidationError(Not.Between(nameof(trigger.NumDays), 1, 30), nameof(trigger.NumDays))); - } - - return Task.FromResult>(errors); - } - - public async Task> Visit(ContentChangedTriggerV2 trigger) - { - var errors = new List(); - - if (trigger.Schemas == null) - { - return errors; - } - - foreach (var schema in trigger.Schemas) - { - if (schema.SchemaId == DomainId.Empty) - { - errors.Add(new ValidationError(Not.Defined("SchemaId"), nameof(trigger.Schemas))); - } - else if (await appProvider.GetSchemaAsync(appId, schema.SchemaId, false, ct) == null) - { - errors.Add(new ValidationError(T.Get("schemas.notFoundId", new { id = schema.SchemaId }), nameof(trigger.Schemas))); - } - } - - return errors; - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs index 6a1088f44..5f526e1f2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs @@ -7,13 +7,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Rules; -using Squidex.Flows; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; @@ -64,7 +62,7 @@ public partial class RuleDomainObject( case CreateRule createRule: return ApplyReturnAsync(createRule, async (c, ct) => { - await GuardRule.CanCreate(c, AppProvider(), FlowManager(), ct); + await GuardRule.CanCreate(c, Validator(), ct); Create(c); @@ -74,7 +72,7 @@ public partial class RuleDomainObject( case UpdateRule updateRule: return ApplyReturnAsync(updateRule, async (c, ct) => { - await GuardRule.CanUpdate(c, Snapshot, AppProvider(), FlowManager(), ct); + await GuardRule.CanUpdate(c, Snapshot, Validator(), ct); Update(c); @@ -124,12 +122,7 @@ public partial class RuleDomainObject( SimpleMapper.Map(command, @event); SimpleMapper.Map(Snapshot, @event); - await RuleEnqueuer().EnqueueAsync(Snapshot.Id, Snapshot, Envelope.Create(@event)); - } - - private IRuleEnqueuer RuleEnqueuer() - { - return serviceProvider.GetRequiredService(); + await Enqueuer().EnqueueAsync(Snapshot.Id, Snapshot, Envelope.Create(@event)); } private void Create(CreateRule command) @@ -162,13 +155,13 @@ public partial class RuleDomainObject( RaiseEvent(Envelope.Create(SimpleMapper.Map(command, @event))); } - private IAppProvider AppProvider() + private IRuleEnqueuer Enqueuer() { - return serviceProvider.GetRequiredService(); + return serviceProvider.GetRequiredService(); } - private IFlowManager FlowManager() + private IRuleValidator Validator() { - return serviceProvider.GetRequiredService>(); + return serviceProvider.GetRequiredService(); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleValidator.cs new file mode 100644 index 000000000..b18436c33 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleValidator.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Flows; +using Squidex.Flows.Internal; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Entities.Rules; + +public interface IRuleValidator +{ + Task ValidateTriggerAsync(RuleTrigger trigger, DomainId appId, AddValidation addError, + CancellationToken ct = default); + + Task ValidateFlowAsync(FlowDefinition flow, AddValidation addError, + CancellationToken ct = default); + + Task ValidateStepAsync(FlowStep step, AddValidation addError, + CancellationToken ct = default); +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs index 777152064..99991cb3a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs @@ -34,7 +34,6 @@ public sealed class ManualTriggerHandler : IRuleTriggerHandler SimpleMapper.Map((RuleManuallyTriggered)@event.Payload, result); await Task.Yield(); - yield return result; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs new file mode 100644 index 000000000..589e4428d --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs @@ -0,0 +1,141 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Flows; +using Squidex.Flows.Internal; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Translations; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Entities.Rules; + +public sealed class RuleValidator(IFlowManager flowManager, IAppProvider appProvider) : IRuleValidator +{ + public async Task ValidateTriggerAsync(RuleTrigger trigger, DomainId appId, AddValidation addError, + CancellationToken ct = default) + { + Guard.NotNull(trigger); + Guard.NotNull(addError); + + var context = new TriggerValidationContext(appId, addError, appProvider, ct); + + await trigger.Accept(RuleTriggerValidator.Instance, context); + } + + public async Task ValidateFlowAsync(FlowDefinition flow, AddValidation addError, + CancellationToken ct = default) + { + Guard.NotNull(flow); + Guard.NotNull(addError); + + await flowManager.ValidateAsync(flow, ConvertError(addError), ct); + } + + public async Task ValidateStepAsync(FlowStep step, AddValidation addError, + CancellationToken ct = default) + { + Guard.NotNull(step); + Guard.NotNull(addError); + + await flowManager.ValidateAsync(step, ConvertError(addError), ct); + } + + private static AddError ConvertError(AddValidation addError) + { + return new AddError((path, type, message) => + { + switch (type) + { + case ValidationErrorType.NoSteps: + addError(T.Get("rules.validation.noSteps"), path); + break; + case ValidationErrorType.NoStartStep: + addError(T.Get("rules.validation.noStartStep"), path); + break; + case ValidationErrorType.InvalidNextStepId: + addError(T.Get("rules.validation.invalidNextStepId"), path); + break; + case ValidationErrorType.InvalidStepId: + addError(T.Get("rules.validation.invalidStepId"), path); + break; + case ValidationErrorType.InvalidProperty when !string.IsNullOrWhiteSpace(message): + addError(message, path); + break; + } + }); + } + + private sealed class RuleTriggerValidator : IRuleTriggerVisitor, TriggerValidationContext> + { + public static readonly RuleTriggerValidator Instance = new RuleTriggerValidator(); + + public ValueTask Visit(CommentTrigger trigger, TriggerValidationContext args) + { + return default; + } + + public ValueTask Visit(AssetChangedTriggerV2 trigger, TriggerValidationContext args) + { + return default; + } + + public ValueTask Visit(ManualTrigger trigger, TriggerValidationContext args) + { + return default; + } + + public ValueTask Visit(SchemaChangedTrigger trigger, TriggerValidationContext args) + { + return default; + } + + public ValueTask Visit(UsageTrigger trigger, TriggerValidationContext args) + { + if (trigger.NumDays is < 1 or > 30) + { + args.AddError(Not.Between(nameof(trigger.NumDays), 1, 30), nameof(trigger.NumDays)); + } + + return default; + } + + public async ValueTask Visit(ContentChangedTriggerV2 trigger, TriggerValidationContext args) + { + if (trigger.Schemas == null) + { + return None.Value; + } + + foreach (var schema in trigger.Schemas) + { + if (schema.SchemaId == DomainId.Empty) + { + args.AddError(Not.Defined("SchemaId"), nameof(trigger.Schemas)); + } + else if (await args.AppProvider.GetSchemaAsync(args.AppId, schema.SchemaId, false, args.CancellationToken) == null) + { + args.AddError(T.Get("schemas.notFoundId", new { id = schema.SchemaId }), nameof(trigger.Schemas)); + } + } + + return None.Value; + } + } + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter +#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it + private record struct TriggerValidationContext( + DomainId AppId, + AddValidation AddError, + IAppProvider AppProvider, + CancellationToken CancellationToken); +#pragma warning restore RECS0082 // Parameter has the same name as a member and hides it +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter +} diff --git a/backend/src/Squidex.Domain.Apps.Events/Rules/RuleManuallyTriggered.cs b/backend/src/Squidex.Domain.Apps.Events/Rules/RuleManuallyTriggered.cs index 124c3fddf..79df550a3 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Rules/RuleManuallyTriggered.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Rules/RuleManuallyTriggered.cs @@ -5,8 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure.Json.Objects; + namespace Squidex.Domain.Apps.Events.Rules; public sealed class RuleManuallyTriggered : RuleEvent { + public JsonValue Value { get; set; } } diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs index 47fff786d..9504d80f7 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs @@ -144,6 +144,36 @@ public sealed class ConstantWithTypeVisitor : QueryNodeVisitor return default!; } + public override ClrValue Visit(SingleValueFunctionCallNode nodeIn) + { + var parameterNode = nodeIn.Parameters.ElementAtOrDefault(0); + if (parameterNode == null) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + var value = Visit(parameterNode); + if (value.Value is not string text) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + if (string.Equals(nodeIn.Name, "tolower", StringComparison.OrdinalIgnoreCase)) + { + return text.ToLowerInvariant(); + } + + if (string.Equals(nodeIn.Name, "toupper", StringComparison.OrdinalIgnoreCase)) + { + return text.ToUpperInvariant(); + } + + ThrowHelper.NotSupportedException(); + return default!; + } + private static Instant ParseInstant(object value) { if (value is DateTimeOffset dateTimeOffset) diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs index 6ed15028d..b14a06461 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text.RegularExpressions; +using Google.Protobuf; using Microsoft.OData.UriParser; using Microsoft.Spatial; @@ -48,7 +50,12 @@ public sealed class FilterVisitor : QueryNodeVisitor> public override FilterNode Visit(SingleValueFunctionCallNode nodeIn) { - var fieldNode = nodeIn.Parameters.ElementAt(0); + var fieldNode = nodeIn.Parameters.ElementAtOrDefault(0); + if (fieldNode == null) + { + ThrowHelper.NotSupportedException(); + return default!; + } if (string.Equals(nodeIn.Name, "empty", StringComparison.OrdinalIgnoreCase)) { @@ -65,7 +72,12 @@ public sealed class FilterVisitor : QueryNodeVisitor> return ClrFilter.Exists(PropertyPathVisitor.Visit(fieldNode)); } - var valueNode = nodeIn.Parameters.ElementAt(1); + var valueNode = nodeIn.Parameters.ElementAtOrDefault(1); + if (valueNode == null) + { + ThrowHelper.NotSupportedException(); + return default!; + } if (string.Equals(nodeIn.Name, "matchs", StringComparison.OrdinalIgnoreCase)) { @@ -113,42 +125,33 @@ public sealed class FilterVisitor : QueryNodeVisitor> if (nodeIn.Left is SingleValueFunctionCallNode functionNode) { - if (string.Equals(functionNode.Name, "geo.distance", StringComparison.OrdinalIgnoreCase) && nodeIn.OperatorKind == BinaryOperatorKind.LessThan) + if (string.Equals(functionNode.Name, "geo.distance", StringComparison.OrdinalIgnoreCase)) { - var valueDistance = (double)ConstantWithTypeVisitor.Visit(nodeIn.Right).Value!; - - if (functionNode.Parameters.ElementAt(1) is not ConstantNode constantNode) - { - ThrowHelper.NotSupportedException(); - return default!; - } - - if (constantNode.Value is not GeographyPoint geographyPoint) - { - ThrowHelper.NotSupportedException(); - return default!; - } - - var property = PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0)); + return ParseGeoDistance(nodeIn, functionNode); + } - return ClrFilter.Lt(property, new FilterSphere(geographyPoint.Longitude, geographyPoint.Latitude, valueDistance)); + if (string.Equals(functionNode.Name, "toupper", StringComparison.OrdinalIgnoreCase)) + { + return ParseMatch(nodeIn, functionNode, c => !char.IsLetter(c) || char.IsUpper(c)); } - else + + if (string.Equals(functionNode.Name, "tolower", StringComparison.OrdinalIgnoreCase)) { - var regexFilter = Visit(functionNode); + return ParseMatch(nodeIn, functionNode, c => !char.IsLetter(c) || char.IsLower(c)); + } - var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + var innerFunction = Visit(functionNode); - if (value.ValueType == ClrValueType.Boolean && value.Value is bool booleanRight) + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + if (value.ValueType == ClrValueType.Boolean && value.Value is bool booleanRight) + { + if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || + (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) { - if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || - (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) - { - regexFilter = ClrFilter.Not(regexFilter); - } - - return regexFilter; + innerFunction = ClrFilter.Not(innerFunction); } + + return innerFunction; } } else @@ -199,4 +202,59 @@ public sealed class FilterVisitor : QueryNodeVisitor> ThrowHelper.NotSupportedException(); return default!; } + + private static FilterNode ParseMatch(BinaryOperatorNode nodeIn, SingleValueFunctionCallNode functionNode, Func condition) + { + if (nodeIn.OperatorKind is not BinaryOperatorKind.Equal and not BinaryOperatorKind.NotEqual) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + if (value.Value is not string text || !text.All(condition)) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + FilterNode filter = + ClrFilter.Matchs( + PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0)), + $"/^{Regex.Escape(text)}$/i"); + + if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual) + { + filter = ClrFilter.Not(filter); + } + + return filter; + } + + private static FilterNode ParseGeoDistance(BinaryOperatorNode nodeIn, SingleValueFunctionCallNode functionNode) + { + if (nodeIn.OperatorKind != BinaryOperatorKind.LessThan) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + var valueDistance = (double)ConstantWithTypeVisitor.Visit(nodeIn.Right).Value!; + + if (functionNode.Parameters.ElementAt(1) is not ConstantNode constantNode) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + if (constantNode.Value is not GeographyPoint geographyPoint) + { + ThrowHelper.NotSupportedException(); + return default!; + } + + var property = PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0)); + + return ClrFilter.Lt(property, new FilterSphere(geographyPoint.Longitude, geographyPoint.Latitude, valueDistance)); + } } diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index abd16d5bb..133abddce 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + diff --git a/backend/src/Squidex.Infrastructure/Validation/Validate.cs b/backend/src/Squidex.Infrastructure/Validation/Validate.cs index 3862a46d2..8f5df7ef7 100644 --- a/backend/src/Squidex.Infrastructure/Validation/Validate.cs +++ b/backend/src/Squidex.Infrastructure/Validation/Validate.cs @@ -46,6 +46,29 @@ public static class Validate throw new ValidationException(errors); } } + + public static AddValidation WithPrefix(string prefix, AddValidation inner) + { + return (message, propertyNames) => + { + if (propertyNames.Length == 0) + { + inner(message, propertyNames); + } + + var withPrefix = propertyNames.Select(path => + { + if (!string.IsNullOrWhiteSpace(path)) + { + return $"{prefix}.{path}"; + } + + return path; + }).ToArray(); + + inner(message, withPrefix); + }; + } } public delegate void AddValidation(string message, params string[] propertyNames); diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs index 2c97ec279..b43a0ce31 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs @@ -8,8 +8,9 @@ using GraphQL.Utilities; using NJsonSchema; using NJsonSchema.Generation; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Infrastructure.Reflection; -using System.Reflection; +using System.Diagnostics; namespace Squidex.Areas.Api.Config.OpenApi; @@ -22,9 +23,12 @@ public sealed class DiscriminatorProcessor(TypeRegistry typeRegistry) : ISchemaP return; } - if (!typeRegistry.TryGetConfig(context.ContextualType.Type, out var config) || - config.IsEmpty || - config.DiscriminatorProperty == null) + if (!typeRegistry.TryGetConfig(context.ContextualType.Type, out var config)) + { + return; + } + + if (config.IsEmpty || config.DiscriminatorProperty == null) { return; } @@ -37,8 +41,6 @@ public sealed class DiscriminatorProcessor(TypeRegistry typeRegistry) : ISchemaP var schema = context.Schema; - var isObsolete = context.ContextualType.Type.GetCustomAttribute(); - foreach (var (derivedType, typeName) in config.DerivedTypes().OrderBy(x => x.TypeName)) { var derivedSchema = context.Generator.Generate(derivedType, context.Resolver); diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs index efbe4d1b2..89c4d226d 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs @@ -16,6 +16,7 @@ using NSwag.Generation.Processors.Contexts; using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; @@ -90,6 +91,7 @@ public static class OpenApiServices services.AddOpenApiDocument((settings, services) => { ConfigureSchemaSettings(settings.SchemaSettings, services.GetRequiredService(), false); + settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor()); settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor()); settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor()); settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor()); diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/SchemaNameGenerator.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/SchemaNameGenerator.cs index 5c25b071f..84e8d7dcf 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/SchemaNameGenerator.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/SchemaNameGenerator.cs @@ -7,6 +7,7 @@ using NJsonSchema.Generation; using Squidex.Domain.Apps.Core.Rules.Deprecated; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Flows; using Squidex.Infrastructure.Reflection; @@ -16,28 +17,31 @@ public sealed class SchemaNameGenerator : DefaultSchemaNameGenerator { public override string Generate(Type type) { - if (type.BaseType == typeof(FlowStep)) - { - return $"{type.TypeName(false, "FlowStep")}FlowStepDto"; - } +#pragma warning disable CS0618 // Type or member is obsolete + var result = + GenerateTypeName(type, "FlowStep") ?? + GenerateTypeName(type, "Event") ?? + GenerateTypeName(type, "Action", "RuleAction") ?? + base.Generate(type); +#pragma warning restore CS0618 // Type or member is obsolete - if (type == typeof(FlowStep)) - { - return $"FlowStepDto"; - } + return result; + } -#pragma warning disable CS0618 // Type or member is obsolete - if (type.BaseType == typeof(RuleAction)) + private static string? GenerateTypeName(Type type, string suffix, string? newSuffix = null) + { + newSuffix ??= suffix; + + if (type == typeof(T)) { - return $"{type.TypeName(false, "Action")}RuleActionDto"; + return $"{type.TypeName(false)}Dto"; } - if (type == typeof(RuleAction)) + if (type.IsAssignableTo(typeof(T))) { - return $"RuleActionDto"; + return $"{type.TypeName(false, suffix)}{newSuffix}Dto"; } -#pragma warning restore CS0618 // Type or member is obsolete - return base.Generate(type); + return null; } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs index ecbd308bc..589c9735b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs @@ -8,10 +8,11 @@ using Squidex.Areas.Api.Controllers.Rules.Models.Triggers; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Infrastructure; namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters; -public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor +public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor { private static readonly RuleTriggerDtoFactory Instance = new RuleTriggerDtoFactory(); @@ -21,35 +22,35 @@ public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor public static RuleTriggerDto Create(RuleTrigger properties) { - return properties.Accept(Instance); + return properties.Accept(Instance, None.Value); } - public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger) + public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger, None args) { return AssetChangedRuleTriggerDto.FromDomain(trigger); } - public RuleTriggerDto Visit(CommentTrigger trigger) + public RuleTriggerDto Visit(CommentTrigger trigger, None args) { return CommentRuleTriggerDto.FromDomain(trigger); } - public RuleTriggerDto Visit(ManualTrigger trigger) + public RuleTriggerDto Visit(ManualTrigger trigger, None args) { return ManualRuleTriggerDto.FromDomain(trigger); } - public RuleTriggerDto Visit(SchemaChangedTrigger trigger) + public RuleTriggerDto Visit(SchemaChangedTrigger trigger, None args) { return SchemaChangedRuleTriggerDto.FromDomain(trigger); } - public RuleTriggerDto Visit(UsageTrigger trigger) + public RuleTriggerDto Visit(UsageTrigger trigger, None args) { return UsageRuleTriggerDto.FromDomain(trigger); } - public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger) + public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger, None args) { return ContentChangedRuleTriggerDto.FromDomain(trigger); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index 803353b50..aa61a95fb 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -8,6 +8,7 @@ using NodaTime; using Squidex.Areas.Api.Controllers.Rules.Models.Converters; using Squidex.Domain.Apps.Core.Rules.Deprecated; +using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Runner; using Squidex.Infrastructure; @@ -143,8 +144,11 @@ public sealed class RuleDto : Resource if (resources.CanRunRuleEvents) { - AddPutLink("trigger", - resources.Url(x => nameof(x.TriggerRule), values)); + if (rule.Trigger is ManualTrigger) + { + AddPutLink("trigger", + resources.Url(x => nameof(x.TriggerRule), values)); + } if (canRun && ruleRunnerService.CanRunRule(rule)) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/TriggerRuleDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/TriggerRuleDto.cs new file mode 100644 index 000000000..bd3357163 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/TriggerRuleDto.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Rules.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Reflection; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Rules.Models; + +[OpenApiRequest] +public class TriggerRuleDto +{ + /// + /// The optional value to send to the flow. + /// + public JsonValue Value { get; set; } + + public TriggerRule ToCommand(DomainId id) + { + return SimpleMapper.Map(this, new TriggerRule { RuleId = id }); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index af3bb4a24..7edd7d8cf 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -20,6 +20,7 @@ using Squidex.Flows; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.Validation; using Squidex.Shared; using Squidex.Web; @@ -36,6 +37,7 @@ public sealed class RulesController( IFlowManager flowManager, IRuleQueryService ruleQuery, IRuleRunnerService ruleRunnerService, + IRuleValidator ruleValidator, EventJsonSchemaGenerator eventJsonSchemaGenerator) : ApiController(commandBus) { @@ -194,6 +196,7 @@ public sealed class RulesController( /// /// The name of the app. /// The ID of the rule to disable. + /// The arguments for the rule flow. /// Rule triggered. /// Rule or app not found. [HttpPut] @@ -201,9 +204,9 @@ public sealed class RulesController( [ProducesResponseType(StatusCodes.Status204NoContent)] [ApiPermissionOrAnonymous(PermissionIds.AppRulesEventsRun)] [ApiCosts(1)] - public async Task TriggerRule(string app, DomainId id) + public async Task TriggerRule(string app, DomainId id, [FromBody] TriggerRuleDto request) { - var command = new TriggerRule { RuleId = id }; + var command = request.ToCommand(id); await CommandBus.PublishAsync(command, HttpContext.RequestAborted); @@ -247,6 +250,54 @@ public sealed class RulesController( return NoContent(); } + /// + /// Validates a rule trigger. + /// + /// The name of the app. + /// The rule trigger that needs to be validate. + /// Rule trigger validated. + /// Rule trigger not valid. + /// Rule or app not found. + [HttpPost] + [Route("apps/{app}/rules/validate/trigger/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermission] + [ApiCosts(0)] + public async Task ValidateTrigger(string app, [FromBody] RuleTriggerDto request) + { + await Validate.It(async e => + { + await ruleValidator.ValidateTriggerAsync(request.ToTrigger(), AppId, e, + HttpContext.RequestAborted); + }); + + return NoContent(); + } + + /// + /// Validates a rule step. + /// + /// The name of the app. + /// The rule step that needs to be validate. + /// Rule step validated. + /// Rule step not valid. + /// Rule or app not found. + [HttpPost] + [Route("apps/{app}/rules/validate/step/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermission] + [ApiCosts(0)] + public async Task ValidateStep(string app, [FromBody] FlowStep request) + { + await Validate.It(async e => + { + await ruleValidator.ValidateStepAsync(request, e, + HttpContext.RequestAborted); + }); + + return NoContent(); + } + /// /// Simulate a rule. /// @@ -290,9 +341,8 @@ public sealed class RulesController( return NotFound(); } - var simulation = await ruleRunnerService.SimulateAsync(rule, HttpContext.RequestAborted); - - var response = SimulatedRuleEventsDto.FromDomain(simulation); + var result = await ruleRunnerService.SimulateAsync(rule, HttpContext.RequestAborted); + var response = SimulatedRuleEventsDto.FromDomain(result); return Ok(response); } @@ -334,7 +384,9 @@ public sealed class RulesController( [ApiCosts(0)] public async Task GetEvents(string app, [FromQuery] DomainId? ruleId = null, [FromQuery] int skip = 0, [FromQuery] int take = 20) { - var (states, total) = await flowManager.QueryInstancesByOwnerAsync(AppId.ToString(), ruleId?.ToString(), skip, take, HttpContext.RequestAborted); + var (states, total) = + await flowManager.QueryInstancesByOwnerAsync(AppId.ToString(), ruleId?.ToString(), skip, take, + HttpContext.RequestAborted); var response = RuleEventsDto.FromDomain(ResultList.Create(total, states), Resources, ruleId); @@ -373,7 +425,7 @@ public sealed class RulesController( [HttpDelete] [Route("apps/{app}/rules/events/{id}/")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ApiPermissionOrAnonymous(PermissionIds.AppRulesEventsDelete)] + [ApiPermission] [ApiCosts(0)] public async Task DeleteEvent(string app, Guid id) { diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index d94ba8113..222d57b35 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -75,6 +75,9 @@ public static class RuleServices services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As().As(); diff --git a/backend/src/Squidex/Config/Domain/SerializationServices.cs b/backend/src/Squidex/Config/Domain/SerializationServices.cs index 32fa325ed..61c2490d9 100644 --- a/backend/src/Squidex/Config/Domain/SerializationServices.cs +++ b/backend/src/Squidex/Config/Domain/SerializationServices.cs @@ -112,7 +112,7 @@ public static class SerializationServices new AssemblyTypeProvider("triggerType")); services.AddSingleton( - new AssemblyTypeProvider()); + new AssemblyTypeProvider("$type")); services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 82ce4c04c..9e03fae1b 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -59,17 +59,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -83,11 +83,11 @@ - + - + diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs index 44b3c0254..8f32d9bd6 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs @@ -31,7 +31,7 @@ public class RuleTests public sealed record OtherTrigger : RuleTrigger { - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { throw new NotSupportedException(); } @@ -39,7 +39,7 @@ public class RuleTests public sealed record MigratedTrigger : RuleTrigger, IMigrated { - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { throw new NotSupportedException(); } 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 349c4111c..9148d07ee 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 @@ -280,6 +280,30 @@ public class RuleEventFormatterTests Assert.Equal("Created", actual); } + [Fact] + public void Should_evaluate_expression() + { + var @event = new EnrichedManualEvent { Value = 1, AppId = appId }; + + var actual1 = sut.Evaluate("event.value == 1", @event); + var actual2 = sut.Evaluate("event.value == 2", @event); + + Assert.True(actual1); + Assert.False(actual2); + } + + [Fact] + public void Should_evaluate_expression_with_context() + { + var context = new FlowEventContext { Event = new EnrichedManualEvent { Value = 1, AppId = appId } }; + + var actual1 = sut.Evaluate("event.value == 1", context); + var actual2 = sut.Evaluate("event.value == 2", context); + + Assert.True(actual1); + Assert.False(actual2); + } + [Fact] public async Task Should_return_json_string_if_array() { @@ -293,16 +317,25 @@ public class RuleEventFormatterTests .AddInvariant(JsonValue.Array("ref1", "ref2", "ref3"))), }; - var script = @" - Script(JSON.stringify( - { - 'categories': event.data.categories.iv - }))"; + var actual = await sut.RenderAsync(@"Script(JSON.stringify({ categories: event.data.categories.iv }))", @event); - var actual = await sut.RenderAsync(script, @event); + Assert.Equal("{'categories':['ref1','ref2','ref3']}", + actual? + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace('"', '\'')); + } + + [Fact] + public async Task Should_add_to_context() + { + var context = new FlowEventContext + { + Event = new EnrichedManualEvent { Value = 1, AppId = appId }, + UserData = [], + }; + + await sut.RenderAsync(@"Script(userData['myValue'] = '42')", context); - Assert.Equal("{'categories':['ref1','ref2','ref3']}", actual? - .Replace(" ", string.Empty, StringComparison.Ordinal) - .Replace('"', '\'')); + Assert.Equal("42", context.UserData["myValue"]); } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs index 992facc6c..16fe228b8 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs @@ -35,9 +35,9 @@ public class RuleServiceTests public sealed record InvalidTrigger : RuleTrigger { - public override T Accept(IRuleTriggerVisitor visitor) + public override T Accept(IRuleTriggerVisitor visitor, TArgs args) { - return default!; + throw new NotSupportedException(); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs index 9a377eabb..d38c9b66d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs @@ -8,6 +8,7 @@ using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Flows; @@ -19,9 +20,15 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards; public class GuardRuleTests : GivenContext, IClassFixture { private readonly IFlowManager flowManager = A.Fake>(); + private readonly IRuleValidator validator; + + public GuardRuleTests() + { + validator = new RuleValidator(flowManager, AppProvider); + } [Fact] - public async Task CanCreate_should_throw_exception_if_trigger_null() + public async Task CanCreate_should_throw_exception_if_trigger_is_null() { var command = CreateCommand(new CreateRule { @@ -32,12 +39,12 @@ public class GuardRuleTests : GivenContext, IClassFixture Trigger = null!, }); - await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, AppProvider, flowManager, CancellationToken), + await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, validator, CancellationToken), new ValidationError("Trigger is required.", "Trigger")); } [Fact] - public async Task CanCreate_should_throw_exception_if_flow_null() + public async Task CanCreate_should_throw_exception_if_flow_is_null() { var command = CreateCommand(new CreateRule { @@ -48,7 +55,7 @@ public class GuardRuleTests : GivenContext, IClassFixture Flow = null!, }); - await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, AppProvider, flowManager, CancellationToken), + await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, validator, CancellationToken), new ValidationError("Flow is required.", "Flow")); } @@ -67,7 +74,7 @@ public class GuardRuleTests : GivenContext, IClassFixture }, }); - await GuardRule.CanCreate(command, AppProvider, flowManager, CancellationToken); + await GuardRule.CanCreate(command, validator, CancellationToken); } [Fact] @@ -75,7 +82,7 @@ public class GuardRuleTests : GivenContext, IClassFixture { var command = new UpdateRule(); - await GuardRule.CanUpdate(command, CreateRule(), AppProvider, flowManager, CancellationToken); + await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken); } [Fact] @@ -83,13 +90,13 @@ public class GuardRuleTests : GivenContext, IClassFixture { var command = new UpdateRule { Name = "MyName" }; - await GuardRule.CanUpdate(command, CreateRule(), AppProvider, flowManager, CancellationToken); + await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken); } [Fact] public async Task CanUpdate_should_not_throw_exception_if_trigger_flow_and_name_are_valid() { - var command = new UpdateRule + var command = CreateCommand(new UpdateRule { Trigger = new ContentChangedTriggerV2 { @@ -100,12 +107,12 @@ public class GuardRuleTests : GivenContext, IClassFixture InitialStepId = Guid.NewGuid(), }, Name = "NewName", - }; + }); - await GuardRule.CanUpdate(command, CreateRule(), AppProvider, flowManager, CancellationToken); + await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken); } - private CreateRule CreateCommand(CreateRule command) + private T CreateCommand(T command) where T : IAppCommand { command.AppId = AppId; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs index 8e162c77e..8b8a36a3e 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.TestHelpers; @@ -17,6 +18,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards.Triggers; public class ContentChangedTriggerTests : GivenContext, IClassFixture { + private readonly RuleValidator validator; + + public ContentChangedTriggerTests() + { + validator = new RuleValidator(null!, AppProvider); + } + [Fact] public async Task Should_add_error_if_schema_id_is_not_defined() { @@ -25,7 +33,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture> ValidateAsync(RuleTrigger trigger) + { + var errors = new List(); + + await validator.ValidateTriggerAsync(trigger, AppId.Id, (m, p) => errors.Add(new ValidationError(m, p)), CancellationToken); + return errors; + } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs index b281b5332..73e8d3741 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers; @@ -14,12 +15,19 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards.Triggers; public class UsageTriggerValidationTests : GivenContext, IClassFixture { + private readonly IRuleValidator validator; + + public UsageTriggerValidationTests() + { + validator = new RuleValidator(null!, AppProvider); + } + [Fact] public async Task Should_add_error_if_num_days_less_than_1() { var trigger = new UsageTrigger { NumDays = 0 }; - var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken); + var errors = await ValidateAsync(trigger); errors.Should().BeEquivalentTo( [ @@ -32,7 +40,7 @@ public class UsageTriggerValidationTests : GivenContext, IClassFixture> ValidateAsync(RuleTrigger trigger) + { + var errors = new List(); + + await validator.ValidateTriggerAsync(trigger, AppId.Id, (m, p) => errors.Add(new ValidationError(m, p)), CancellationToken); + return errors; + } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs index c48873b91..220b49b60 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs @@ -7,13 +7,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Rules; -using Squidex.Flows; using Squidex.Flows.Internal; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -24,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject; public class RuleDomainObjectTests : HandlerTestBase { private readonly IRuleEnqueuer ruleEnqueuer = A.Fake(); - private readonly IFlowManager flowManager = A.Fake>(); + private readonly IRuleValidator ruleValidator = A.Fake(); private readonly DomainId ruleId = DomainId.NewGuid(); private readonly RuleDomainObject sut; @@ -40,7 +38,7 @@ public class RuleDomainObjectTests : HandlerTestBase var serviceProvider = new ServiceCollection() .AddSingleton(AppProvider) - .AddSingleton(flowManager) + .AddSingleton(ruleValidator) .AddSingleton(ruleEnqueuer) .BuildServiceProvider(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs new file mode 100644 index 000000000..24fab1b4e --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Flows; +using Squidex.Flows.Internal; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Entities.Rules; + +public class RuleValidatorTests : GivenContext, IClassFixture +{ + private readonly IFlowManager flowManager = A.Fake>(); + private readonly RuleValidator sut; + + private sealed record TestFlowStep : FlowStep + { + public override ValueTask ExecuteAsync(FlowExecutionContext executionContext, + CancellationToken ct) + { + return default; + } + } + + public RuleValidatorTests() + { + sut = new RuleValidator(flowManager, AppProvider); + } + + [Fact] + public async Task Should_validate_trigger() + { + var trigger = new UsageTrigger { NumDays = 50 }; + + var errors = new List(); + + await sut.ValidateTriggerAsync( + trigger, + AppId.Id, + (message, properties) => errors.Add(new ValidationError(message, properties)), + CancellationToken); + + errors.Should().BeEquivalentTo( + [ + new ValidationError("Num days must be between 1 and 30.", "NumDays"), + ]); + } + + [Fact] + public async Task Should_validate_step() + { + var step = new TestFlowStep(); + + A.CallTo(() => flowManager.ValidateAsync(step, A._, CancellationToken)) + .Invokes(x => + { + x.GetArgument(1)?.Invoke("Path", ValidationErrorType.InvalidProperty, "Invalid property."); + }); + + var errors = new List(); + + await sut.ValidateStepAsync( + step, + (message, properties) => errors.Add(new ValidationError(message, properties)), + CancellationToken); + + errors.Should().BeEquivalentTo( + [ + new ValidationError("Invalid property.", "Path"), + ]); + } + + [Theory] + [InlineData(ValidationErrorType.NoSteps, "Flow has no step")] + [InlineData(ValidationErrorType.NoStartStep, "Flow has no start step")] + [InlineData(ValidationErrorType.InvalidNextStepId, "Invalid next step ID")] + [InlineData(ValidationErrorType.InvalidStepId, "Invalid step ID")] + public async Task Should_validate_flow(ValidationErrorType type, string expectedMessage) + { + var flow = new FlowDefinition(); + + A.CallTo(() => flowManager.ValidateAsync(flow, A._, CancellationToken)) + .Invokes(x => + { + x.GetArgument(1)?.Invoke("Path", type); + }); + + var errors = new List(); + + await sut.ValidateFlowAsync(flow, + (message, properties) => errors.Add(new ValidationError(message, properties)), + CancellationToken); + + errors.Should().BeEquivalentTo( + [ + new ValidationError(expectedMessage, "Path"), + ]); + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs index 4468fb563..0917dfde3 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs @@ -79,8 +79,8 @@ public class QueryFromODataTests [InlineData("properties/nested/datetime")] public void Should_parse_filter_if_type_is_datetime(string field) { - var i = _Q($"$filter={field} eq 1988-01-19T12:00:00Z"); - var o = _C($"Filter: {field} == 1988-01-19T12:00:00Z"); + var i = _o($"$filter={field} eq 1988-01-19T12:00:00Z"); + var o = _q($"Filter: {field} == 1988-01-19T12:00:00Z"); Assert.Equal(o, i); } @@ -92,8 +92,8 @@ public class QueryFromODataTests [InlineData("properties/nested/datetime")] public void Should_parse_filter_if_type_is_datetime_and_value_is_null(string field) { - var i = _Q($"$filter={field} eq null"); - var o = _C($"Filter: {field} == null"); + var i = _o($"$filter={field} eq null"); + var o = _q($"Filter: {field} == null"); Assert.Equal(o, i); } @@ -101,8 +101,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_datetime_list() { - var i = _Q("$filter=created in ('1988-01-19T12:00:00Z')"); - var o = _C("Filter: created in [1988-01-19T12:00:00Z]"); + var i = _o("$filter=created in ('1988-01-19T12:00:00Z')"); + var o = _q("Filter: created in [1988-01-19T12:00:00Z]"); Assert.Equal(o, i); } @@ -110,8 +110,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_datetime_and_and_value_is_date() { - var i = _Q("$filter=created eq 1988-01-19"); - var o = _C("Filter: created == 1988-01-19T00:00:00Z"); + var i = _o("$filter=created eq 1988-01-19"); + var o = _q("Filter: created == 1988-01-19T00:00:00Z"); Assert.Equal(o, i); } @@ -123,8 +123,8 @@ public class QueryFromODataTests [InlineData("properties/nested/guid")] public void Should_parse_filter_if_type_is_guid(string field) { - var i = _Q($"$filter={field} eq B5FE25E3-B262-4B17-91EF-B3772A6B62BB"); - var o = _C($"Filter: {field} == b5fe25e3-b262-4b17-91ef-b3772a6b62bb"); + var i = _o($"$filter={field} eq B5FE25E3-B262-4B17-91EF-B3772A6B62BB"); + var o = _q($"Filter: {field} == b5fe25e3-b262-4b17-91ef-b3772a6b62bb"); Assert.Equal(o, i); } @@ -136,8 +136,8 @@ public class QueryFromODataTests [InlineData("properties/nested/guid")] public void Should_parse_filter_if_type_is_guid_and_value_is_null(string field) { - var i = _Q($"$filter={field} eq null"); - var o = _C($"Filter: {field} == null"); + var i = _o($"$filter={field} eq null"); + var o = _q($"Filter: {field} == null"); Assert.Equal(o, i); } @@ -145,8 +145,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_guid_list() { - var i = _Q("$filter=id in ('B5FE25E3-B262-4B17-91EF-B3772A6B62BB')"); - var o = _C("Filter: id in [b5fe25e3-b262-4b17-91ef-b3772a6b62bb]"); + var i = _o("$filter=id in ('B5FE25E3-B262-4B17-91EF-B3772A6B62BB')"); + var o = _q("Filter: id in [b5fe25e3-b262-4b17-91ef-b3772a6b62bb]"); Assert.Equal(o, i); } @@ -154,8 +154,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_null() { - var i = _Q("$filter=firstName eq null"); - var o = _C("Filter: firstName == null"); + var i = _o("$filter=firstName eq null"); + var o = _q("Filter: firstName == null"); Assert.Equal(o, i); } @@ -167,8 +167,8 @@ public class QueryFromODataTests [InlineData("properties/nested/string")] public void Should_parse_filter_if_type_is_string(string field) { - var i = _Q($"$filter={field} eq 'Dagobert'"); - var o = _C($"Filter: {field} == 'Dagobert'"); + var i = _o($"$filter={field} eq 'Dagobert'"); + var o = _q($"Filter: {field} == 'Dagobert'"); Assert.Equal(o, i); } @@ -176,8 +176,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_string_list() { - var i = _Q("$filter=firstName in ('Dagobert')"); - var o = _C("Filter: firstName in ['Dagobert']"); + var i = _o("$filter=firstName in ('Dagobert')"); + var o = _q("Filter: firstName in ['Dagobert']"); Assert.Equal(o, i); } @@ -189,8 +189,8 @@ public class QueryFromODataTests [InlineData("properties/nested/boolean")] public void Should_parse_filter_if_type_is_boolean(string field) { - var i = _Q($"$filter={field} eq true"); - var o = _C($"Filter: {field} == True"); + var i = _o($"$filter={field} eq true"); + var o = _q($"Filter: {field} == True"); Assert.Equal(o, i); } @@ -202,8 +202,8 @@ public class QueryFromODataTests [InlineData("properties/nested/boolean")] public void Should_parse_filter_if_type_is_boolean_and_value_is_null(string field) { - var i = _Q($"$filter={field} eq null"); - var o = _C($"Filter: {field} == null"); + var i = _o($"$filter={field} eq null"); + var o = _q($"Filter: {field} == null"); Assert.Equal(o, i); } @@ -211,8 +211,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_boolean_list() { - var i = _Q("$filter=isComicFigure in (true)"); - var o = _C("Filter: isComicFigure in [True]"); + var i = _o("$filter=isComicFigure in (true)"); + var o = _q("Filter: isComicFigure in [True]"); Assert.Equal(o, i); } @@ -224,8 +224,8 @@ public class QueryFromODataTests [InlineData("properties/nested/double")] public void Should_parse_filter_if_type_is_double(string field) { - var i = _Q($"$filter={field} eq 5634474356.1233"); - var o = _C($"Filter: {field} == 5634474356.1233"); + var i = _o($"$filter={field} eq 5634474356.1233"); + var o = _q($"Filter: {field} == 5634474356.1233"); Assert.Equal(o, i); } @@ -237,8 +237,8 @@ public class QueryFromODataTests [InlineData("properties/nested/geo")] public void Should_parse_filter_if_type_is_geograph(string field) { - var i = _Q($"$filter=geo.distance({field}, geography'POINT(10 20)') lt 30.0"); - var o = _C($"Filter: {field} < Radius(10, 20, 30)"); + var i = _o($"$filter=geo.distance({field}, geography'POINT(10 20)') lt 30.0"); + var o = _q($"Filter: {field} < Radius(10, 20, 30)"); Assert.Equal(o, i); } @@ -246,8 +246,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_if_type_is_double_list() { - var i = _Q("$filter=incomeMio in (5634474356.1233)"); - var o = _C("Filter: incomeMio in [5634474356.1233]"); + var i = _o("$filter=incomeMio in (5634474356.1233)"); + var o = _q("Filter: incomeMio in [5634474356.1233]"); Assert.Equal(o, i); } @@ -255,8 +255,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_negation() { - var i = _Q("$filter=not endswith(lastName, 'Duck')"); - var o = _C("Filter: !(endsWith(lastName, 'Duck'))"); + var i = _o("$filter=not endswith(lastName, 'Duck')"); + var o = _q("Filter: !(endsWith(lastName, 'Duck'))"); Assert.Equal(o, i); } @@ -264,8 +264,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_startswith() { - var i = _Q("$filter=startswith(lastName, 'Duck')"); - var o = _C("Filter: startsWith(lastName, 'Duck')"); + var i = _o("$filter=startswith(lastName, 'Duck')"); + var o = _q("Filter: startsWith(lastName, 'Duck')"); Assert.Equal(o, i); } @@ -273,8 +273,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_endswith() { - var i = _Q("$filter=endswith(lastName, 'Duck')"); - var o = _C("Filter: endsWith(lastName, 'Duck')"); + var i = _o("$filter=endswith(lastName, 'Duck')"); + var o = _q("Filter: endsWith(lastName, 'Duck')"); Assert.Equal(o, i); } @@ -282,8 +282,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_matchs() { - var i = _Q("$filter=matchs(lastName, 'Duck')"); - var o = _C("Filter: matchs(lastName, 'Duck')"); + var i = _o("$filter=matchs(lastName, 'Duck')"); + var o = _q("Filter: matchs(lastName, 'Duck')"); Assert.Equal(o, i); } @@ -291,8 +291,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_empty() { - var i = _Q("$filter=empty(lastName)"); - var o = _C("Filter: empty(lastName)"); + var i = _o("$filter=empty(lastName)"); + var o = _q("Filter: empty(lastName)"); Assert.Equal(o, i); } @@ -300,8 +300,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_empty_to_true() { - var i = _Q("$filter=empty(lastName) eq true"); - var o = _C("Filter: empty(lastName)"); + var i = _o("$filter=empty(lastName) eq true"); + var o = _q("Filter: empty(lastName)"); Assert.Equal(o, i); } @@ -309,8 +309,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_exists() { - var i = _Q("$filter=exists(lastName)"); - var o = _C("Filter: exists(lastName)"); + var i = _o("$filter=exists(lastName)"); + var o = _q("Filter: exists(lastName)"); Assert.Equal(o, i); } @@ -318,8 +318,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_exists_to_true() { - var i = _Q("$filter=exists(lastName) eq true"); - var o = _C("Filter: exists(lastName)"); + var i = _o("$filter=exists(lastName) eq true"); + var o = _q("Filter: exists(lastName)"); Assert.Equal(o, i); } @@ -327,8 +327,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_exists_to_false() { - var i = _Q("$filter=exists(lastName) eq false"); - var o = _C("Filter: !(exists(lastName))"); + var i = _o("$filter=exists(lastName) eq false"); + var o = _q("Filter: !(exists(lastName))"); Assert.Equal(o, i); } @@ -336,8 +336,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_contains() { - var i = _Q("$filter=contains(lastName, 'Duck')"); - var o = _C("Filter: contains(lastName, 'Duck')"); + var i = _o("$filter=contains(lastName, 'Duck')"); + var o = _q("Filter: contains(lastName, 'Duck')"); Assert.Equal(o, i); } @@ -345,8 +345,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_contains_to_true() { - var i = _Q("$filter=contains(lastName, 'Duck') eq true"); - var o = _C("Filter: contains(lastName, 'Duck')"); + var i = _o("$filter=contains(lastName, 'Duck') eq true"); + var o = _q("Filter: contains(lastName, 'Duck')"); Assert.Equal(o, i); } @@ -354,8 +354,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_contains_to_false() { - var i = _Q("$filter=contains(lastName, 'Duck') eq false"); - var o = _C("Filter: !(contains(lastName, 'Duck'))"); + var i = _o("$filter=contains(lastName, 'Duck') eq false"); + var o = _q("Filter: !(contains(lastName, 'Duck'))"); Assert.Equal(o, i); } @@ -363,8 +363,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_equals() { - var i = _Q("$filter=age eq 1"); - var o = _C("Filter: age == 1"); + var i = _o("$filter=age eq 1"); + var o = _q("Filter: age == 1"); Assert.Equal(o, i); } @@ -372,8 +372,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_notequals() { - var i = _Q("$filter=age ne 1"); - var o = _C("Filter: age != 1"); + var i = _o("$filter=age ne 1"); + var o = _q("Filter: age != 1"); Assert.Equal(o, i); } @@ -381,8 +381,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_lessthan() { - var i = _Q("$filter=age lt 1"); - var o = _C("Filter: age < 1"); + var i = _o("$filter=age lt 1"); + var o = _q("Filter: age < 1"); Assert.Equal(o, i); } @@ -390,8 +390,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_lessthanorequal() { - var i = _Q("$filter=age le 1"); - var o = _C("Filter: age <= 1"); + var i = _o("$filter=age le 1"); + var o = _q("Filter: age <= 1"); Assert.Equal(o, i); } @@ -399,8 +399,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_greaterthan() { - var i = _Q("$filter=age gt 1"); - var o = _C("Filter: age > 1"); + var i = _o("$filter=age gt 1"); + var o = _q("Filter: age > 1"); Assert.Equal(o, i); } @@ -408,8 +408,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_greaterthanorequal() { - var i = _Q("$filter=age ge 1"); - var o = _C("Filter: age >= 1"); + var i = _o("$filter=age ge 1"); + var o = _q("Filter: age >= 1"); Assert.Equal(o, i); } @@ -417,8 +417,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_conjunction_and_contains() { - var i = _Q("$filter=contains(firstName, 'Sebastian') eq false and isComicFigure eq true"); - var o = _C("Filter: (!(contains(firstName, 'Sebastian')) && isComicFigure == True)"); + var i = _o("$filter=contains(firstName, 'Sebastian') eq false and isComicFigure eq true"); + var o = _q("Filter: (!(contains(firstName, 'Sebastian')) && isComicFigure == True)"); Assert.Equal(o, i); } @@ -426,8 +426,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_conjunction() { - var i = _Q("$filter=age eq 1 and age eq 2"); - var o = _C("Filter: (age == 1 && age == 2)"); + var i = _o("$filter=age eq 1 and age eq 2"); + var o = _q("Filter: (age == 1 && age == 2)"); Assert.Equal(o, i); } @@ -435,17 +435,83 @@ public class QueryFromODataTests [Fact] public void Should_parse_filter_with_disjunction() { - var i = _Q("$filter=age eq 1 or age eq 2"); - var o = _C("Filter: (age == 1 || age == 2)"); + var i = _o("$filter=age eq 1 or age eq 2"); + var o = _q("Filter: (age == 1 || age == 2)"); Assert.Equal(o, i); } + [Fact] + public void Should_compare_constant_to_lowercase() + { + var i = _o("$filter=firstName eq tolower('DONALD')"); + var o = _q("Filter: firstName == 'donald'"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_compare_constant_to_uppercase() + { + var i = _o("$filter=firstName eq toupper('donald')"); + var o = _q("Filter: firstName == 'DONALD'"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_convert_lowercase_eq_comparison_to_regex() + { + var i = _o("$filter=tolower(firstName) eq tolower('DONALD.*42')"); + var o = _q("Filter: matchs(firstName, '//^donald\\.\\*42$//i')"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_convert_lowercase_ne_comparison_to_regex() + { + var i = _o("$filter=tolower(firstName) ne tolower('DONALD.*42')"); + var o = _q("Filter: !(matchs(firstName, '//^donald\\.\\*42$//i'))"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_convert_uppercase_eq_comparison_to_regex() + { + var i = _o("$filter=toupper(firstName) eq toupper('donald.*42')"); + var o = _q("Filter: matchs(firstName, '//^DONALD\\.\\*42$//i')"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_convert_uppercase_ne_comparison_to_regex() + { + var i = _o("$filter=toupper(firstName) ne toupper('donald.*42')"); + var o = _q("Filter: !(matchs(firstName, '//^DONALD\\.\\*42$//i'))"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_not_convert_lowercase_to_regex_if_right_side_is_mixed() + { + Assert.Throws(() => _o("$filter=tolower(firstName) ne 'Donald.*42'")); + } + + [Fact] + public void Should_not_convert_uppercase_to_regex_if_right_side_is_mixed() + { + Assert.Throws(() => _o("$filter=toupper(firstName) ne 'Donald.*42'")); + } + [Fact] public void Should_full_text() { - var i = _Q("$search=Duck"); - var o = _C("FullText: 'Duck'"); + var i = _o("$search=Duck"); + var o = _q("FullText: 'Duck'"); Assert.Equal(o, i); } @@ -453,8 +519,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_collation1() { - var i = _Q("collation=Collation"); - var o = _C("Collation: 'Collation'"); + var i = _o("collation=Collation"); + var o = _q("Collation: 'Collation'"); Assert.Equal(o, i); } @@ -462,8 +528,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_collation2() { - var i = _Q("$collation=Collation"); - var o = _C("Collation: 'Collation'"); + var i = _o("$collation=Collation"); + var o = _q("Collation: 'Collation'"); Assert.Equal(o, i); } @@ -471,8 +537,8 @@ public class QueryFromODataTests [Fact] public void Should_text_and_multiple_terms() { - var i = _Q("$search=Dagobert or Donald"); - var o = _C("FullText: 'Dagobert or Donald'"); + var i = _o("$search=Dagobert or Donald"); + var o = _q("FullText: 'Dagobert or Donald'"); Assert.Equal(o, i); } @@ -480,8 +546,8 @@ public class QueryFromODataTests [Fact] public void Should_full_text_numbers() { - var i = _Q("$search=\"33k\""); - var o = _C("FullText: '33k'"); + var i = _o("$search=\"33k\""); + var o = _q("FullText: '33k'"); Assert.Equal(o, i); } @@ -489,8 +555,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_orderby() { - var i = _Q("$orderby=age desc"); - var o = _C("Sort: age Descending"); + var i = _o("$orderby=age desc"); + var o = _q("Sort: age Descending"); Assert.Equal(o, i); } @@ -498,8 +564,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_orderby_with_multiple_field() { - var i = _Q("$orderby=age, incomeMio desc"); - var o = _C("Sort: age Ascending, incomeMio Descending"); + var i = _o("$orderby=age, incomeMio desc"); + var o = _q("Sort: age Ascending, incomeMio Descending"); Assert.Equal(o, i); } @@ -507,8 +573,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_top() { - var i = _Q("$top=3"); - var o = _C("Take: 3"); + var i = _o("$top=3"); + var o = _q("Take: 3"); Assert.Equal(o, i); } @@ -516,8 +582,8 @@ public class QueryFromODataTests [Fact] public void Should_parse_skip() { - var i = _Q("$skip=4"); - var o = _C("Skip: 4"); + var i = _o("$skip=4"); + var o = _q("Skip: 4"); Assert.Equal(o, i); } @@ -525,18 +591,18 @@ public class QueryFromODataTests [Fact] public void Should_parse_random() { - var i = _Q("$random=4"); - var o = _C("Random: 4"); + var i = _o("$random=4"); + var o = _q("Random: 4"); Assert.Equal(o, i); } - private static string _C(string value) + private static string _q(string value) { - return value.Replace('/', '.'); + return value.Replace('/', '.').Replace("..", "/", StringComparison.OrdinalIgnoreCase); } - private static string? _Q(string value) + private static string? _o(string value) { var parser = EdmModel.ParseQuery(value); diff --git a/frontend/src/app/features/rules/pages/rule/rule-page.component.ts b/frontend/src/app/features/rules/pages/rule/rule-page.component.ts index c7c20d093..625e67258 100644 --- a/frontend/src/app/features/rules/pages/rule/rule-page.component.ts +++ b/frontend/src/app/features/rules/pages/rule/rule-page.component.ts @@ -134,7 +134,7 @@ export class RulePageComponent implements OnInit { public save() { const { flow, isEditable, isEnabled, name, trigger } = this.editableRule.value; - if (!isEditable || !trigger || !flow || !flow.dto.steps || Object.entries(flow.dto.steps).length === 0) { + if (!isEditable || !trigger || !flow?.dto.steps || Object.entries(flow.dto.steps).length === 0) { return; } @@ -155,6 +155,9 @@ export class RulePageComponent implements OnInit { this.rulesState.create(request) .subscribe({ + next: rule => { + this.router.navigate(['../', rule.id], { relativeTo: this.route, replaceUrl: true }); + }, complete: () => { this.dialogs.notifyInfo('i18n:rules.created'); }, @@ -183,9 +186,14 @@ export class RulePageComponent implements OnInit { })); } - public startAddStep(target: FlowStepAdd) { - this.stepToUpsert = { target }; - this.stepDialog.show(); + public startUpdateTrigger(update: RuleTriggerDto) { + const metadata = this.availableTriggers[update.triggerType]; + if (!metadata || !metadata.hasProperties) { + return; + } + + this.triggerToEdit = update; + this.triggerDialog.show(); } public startUpdateStep(update: FlowStepUpdate) { @@ -193,9 +201,9 @@ export class RulePageComponent implements OnInit { this.stepDialog.show(); } - public startUpdateTrigger(update: RuleTriggerDto) { - this.triggerToEdit = update; - this.triggerDialog.show(); + public startAddStep(target: FlowStepAdd) { + this.stepToUpsert = { target }; + this.stepDialog.show(); } public changeTrigger(trigger: RuleTriggerDto) { diff --git a/frontend/src/app/features/rules/pages/rule/step-dialog.component.html b/frontend/src/app/features/rules/pages/rule/step-dialog.component.html index cb3f469d3..5c9fd86c2 100644 --- a/frontend/src/app/features/rules/pages/rule/step-dialog.component.html +++ b/frontend/src/app/features/rules/pages/rule/step-dialog.component.html @@ -20,7 +20,7 @@
- +
{{ "rules.stepIgnoreErrorHint" | sqxTranslate }} @@ -28,6 +28,8 @@
+ + @for (property of currentStep.editableProperties; track property.name) {
- @if (isManual) { + @if (rule.canTrigger) {