Browse Source

Fixes to rule server and rule system.

* Generate debug jobs only for simulation path.
* Fix rule simulator.
pull/760/head
Sebastian 4 years ago
parent
commit
a64ab9e3d5
  1. 22
      backend/i18n/frontend_en.json
  2. 22
      backend/i18n/frontend_it.json
  3. 22
      backend/i18n/frontend_nl.json
  4. 22
      backend/i18n/frontend_zh.json
  5. 22
      backend/i18n/source/frontend_en.json
  6. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs
  7. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs
  8. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs
  9. 72
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  10. 22
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs
  11. 1
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  12. 13
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs
  14. 19
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs
  15. 449
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  16. 19
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  17. 1
      frontend/app/features/rules/declarations.ts
  18. 3
      frontend/app/features/rules/module.ts
  19. 9
      frontend/app/features/rules/pages/simulator/rule-transition.component.html
  20. 32
      frontend/app/features/rules/pages/simulator/rule-transition.component.scss
  21. 42
      frontend/app/features/rules/pages/simulator/rule-transition.component.ts
  22. 85
      frontend/app/features/rules/pages/simulator/simulated-rule-event.component.html
  23. 33
      frontend/app/features/rules/pages/simulator/simulated-rule-event.component.scss
  24. 31
      frontend/app/features/rules/pages/simulator/simulated-rule-event.component.ts
  25. 4
      frontend/app/shared/services/rules.service.spec.ts
  26. 4
      frontend/app/shared/services/rules.service.ts
  27. 5
      frontend/app/theme/_vars.scss

22
backend/i18n/frontend_en.json

@ -696,19 +696,21 @@
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job will be taken from the queue and executed.",
"rules.simulation.conditionEvaluated": "Event is evaluated, whether it matches the conditions in the tigger. ",
"rules.simulation.errorConditionDoesNotMatch": "Condition does not match to the trigger.",
"rules.simulation.errorDisabled": "Rule is dissabled.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javescript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
"rules.simulation.errorFailed": "Internal Error.",
"rules.simulation.errorFromRule": "Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "Event is too old.",
"rules.simulation.errorWrongEvent": "Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "Event does not match to the trigger.",
"rules.simulation.errorFromRule": "STOP: Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "STOP: Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "STOP: Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matches to the conditions and javascript expressions in the tigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "Simulator",
"rules.stop": "Rule will stop soon.",
"rules.triggerConfirmText": "Do you really want to trigger the rule?",

22
backend/i18n/frontend_it.json

@ -696,19 +696,21 @@
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job will be taken from the queue and executed.",
"rules.simulation.conditionEvaluated": "Event is evaluated, whether it matches the conditions in the tigger. ",
"rules.simulation.errorConditionDoesNotMatch": "Condition does not match to the trigger.",
"rules.simulation.errorDisabled": "Rule is dissabled.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javescript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
"rules.simulation.errorFailed": "Internal Error.",
"rules.simulation.errorFromRule": "Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "Event is too old.",
"rules.simulation.errorWrongEvent": "Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "Event does not match to the trigger.",
"rules.simulation.errorFromRule": "STOP: Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "STOP: Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "STOP: Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matches to the conditions and javascript expressions in the tigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "Simulator",
"rules.stop": "La regola si fermerà al più presto.",
"rules.triggerConfirmText": "Sei sicuro che voler attivare la regola?",

22
backend/i18n/frontend_nl.json

@ -696,19 +696,21 @@
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job will be taken from the queue and executed.",
"rules.simulation.conditionEvaluated": "Event is evaluated, whether it matches the conditions in the tigger. ",
"rules.simulation.errorConditionDoesNotMatch": "Condition does not match to the trigger.",
"rules.simulation.errorDisabled": "Rule is dissabled.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javescript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
"rules.simulation.errorFailed": "Internal Error.",
"rules.simulation.errorFromRule": "Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "Event is too old.",
"rules.simulation.errorWrongEvent": "Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "Event does not match to the trigger.",
"rules.simulation.errorFromRule": "STOP: Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "STOP: Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "STOP: Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matches to the conditions and javascript expressions in the tigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "Simulator",
"rules.stop": "Regel stopt binnenkort.",
"rules.triggerConfirmText": "Wil je echt de regel activeren?",

22
backend/i18n/frontend_zh.json

@ -696,19 +696,21 @@
"rules.simulate": "模拟",
"rules.simulateTooltip": "使用最近 100 个事件模拟此规则。",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job will be taken from the queue and executed.",
"rules.simulation.conditionEvaluated": "Event is evaluated, whether it matches the conditions in the tigger. ",
"rules.simulation.errorConditionDoesNotMatch": "Condition does not match to the trigger.",
"rules.simulation.errorDisabled": "Rule is dissabled.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javescript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
"rules.simulation.errorFailed": "Internal Error.",
"rules.simulation.errorFromRule": "Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "Event is too old.",
"rules.simulation.errorWrongEvent": "Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "Event does not match to the trigger.",
"rules.simulation.errorFromRule": "STOP: Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "STOP: Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "STOP: Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matches to the conditions and javascript expressions in the tigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "模拟器",
"rules.stop": "规则很快就会停止。",
"rules.triggerConfirmText": "你真的要触发规则吗?",

22
backend/i18n/source/frontend_en.json

@ -696,19 +696,21 @@
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job will be taken from the queue and executed.",
"rules.simulation.conditionEvaluated": "Event is evaluated, whether it matches the conditions in the tigger. ",
"rules.simulation.errorConditionDoesNotMatch": "Condition does not match to the trigger.",
"rules.simulation.errorDisabled": "Rule is dissabled.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javescript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
"rules.simulation.errorFailed": "Internal Error.",
"rules.simulation.errorFromRule": "Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "Event is too old.",
"rules.simulation.errorWrongEvent": "Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "Event does not match to the trigger.",
"rules.simulation.errorFromRule": "STOP: Event has been created from another rule and will be skipped to prevent endless loops.",
"rules.simulation.errorNoAction": "STOP: Action type is obsolete and has been removed.",
"rules.simulation.errorNoTrigger": "STOP: Trigger type is obsolete and has been removed.",
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matches to the conditions and javascript expressions in the tigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "Simulator",
"rules.stop": "Rule will stop soon.",
"rules.triggerConfirmText": "Do you really want to trigger the rule?",

2
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs

@ -26,4 +26,4 @@ namespace Squidex.Domain.Apps.Core.HandleRules
Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job);
}
}
}

9
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs

@ -18,6 +18,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules
SkipReason = SkipReason.ConditionDoesNotMatch
};
public static readonly JobResult ConditionPrecheckDoesNotMatch = new JobResult
{
SkipReason = SkipReason.ConditionPrecheckDoesNotMatch
};
public static readonly JobResult Disabled = new JobResult
{
SkipReason = SkipReason.Disabled
@ -61,13 +66,13 @@ namespace Squidex.Domain.Apps.Core.HandleRules
public SkipReason SkipReason { get; init; }
public static JobResult Failed(Exception error, EnrichedEvent? enrichedEvent = null, RuleJob? job = null)
public static JobResult Failed(Exception exception, EnrichedEvent? enrichedEvent = null, RuleJob? job = null)
{
return new JobResult
{
Job = job,
EnrichedEvent = enrichedEvent,
EnrichmentError = error,
EnrichmentError = exception,
SkipReason = SkipReason.Failed
};
}

4
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs

@ -18,6 +18,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules
public Rule Rule { get; init; }
public bool IgnoreStale { get; init; }
public bool IncludeSkipped { get; init; }
public bool IncludeStale { get; init; }
}
}

72
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs

@ -149,12 +149,22 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
try
{
var skipReason = SkipReason.None;
var rule = context.Rule;
if (!rule.IsEnabled)
{
jobs.Add(JobResult.Disabled);
return;
// For the simulation we want to proceed as much as possible.
if (context.IncludeSkipped)
{
skipReason |= SkipReason.Disabled;
}
else
{
jobs.Add(JobResult.Disabled);
return;
}
}
if (@event.Payload is not AppEvent)
@ -167,8 +177,16 @@ namespace Squidex.Domain.Apps.Core.HandleRules
if (typed.Payload.FromRule)
{
jobs.Add(JobResult.FromRule);
return;
// For the simulation we want to proceed as much as possible.
if (context.IncludeSkipped)
{
skipReason |= SkipReason.FromRule;
}
else
{
jobs.Add(JobResult.FromRule);
return;
}
}
var actionType = rule.Action.GetType();
@ -198,17 +216,32 @@ namespace Squidex.Domain.Apps.Core.HandleRules
@event.Headers.Timestamp() :
now;
if (context.IgnoreStale && eventTime.Plus(Constants.StaleTime) < now)
if (!context.IncludeStale && eventTime.Plus(Constants.StaleTime) < now)
{
jobs.Add(JobResult.TooOld);
return;
// For the simulation we want to proceed as much as possible.
if (context.IncludeSkipped)
{
skipReason |= SkipReason.TooOld;
}
else
{
jobs.Add(JobResult.TooOld);
return;
}
}
var skipReason = SkipReason.None;
if (!triggerHandler.Trigger(typed, context))
{
skipReason = SkipReason.ConditionDoesNotMatch;
// For the simulation we want to proceed as much as possible.
if (context.IncludeSkipped)
{
skipReason |= SkipReason.ConditionPrecheckDoesNotMatch;
}
else
{
jobs.Add(JobResult.ConditionPrecheckDoesNotMatch);
return;
}
}
await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(typed, context, ct))
@ -224,12 +257,27 @@ namespace Squidex.Domain.Apps.Core.HandleRules
if (!triggerHandler.Trigger(enrichedEvent, context))
{
skipReason = SkipReason.ConditionDoesNotMatch;
// For the simulation we want to proceed as much as possible.
if (context.IncludeSkipped)
{
skipReason |= SkipReason.ConditionDoesNotMatch;
}
else
{
jobs.Add(JobResult.ConditionDoesNotMatch);
return;
}
}
var job = await CreateJobAsync(actionHandler, enrichedEvent, context, now);
jobs.Add(job with { SkipReason = skipReason });
// If the conditions matchs, we can skip creating a new object and save a few allocation.s
if (skipReason != SkipReason.None)
{
job = job with { SkipReason = skipReason };
}
jobs.Add(job);
}
catch (Exception ex)
{

22
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs

@ -5,19 +5,23 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Domain.Apps.Core.HandleRules
{
[Flags]
public enum SkipReason
{
None,
ConditionDoesNotMatch,
Disabled,
WrongEvent,
Failed,
FromRule,
NoAction,
NoTrigger,
TooOld,
WrongEventForTrigger
ConditionDoesNotMatch = 1 << 0,
ConditionPrecheckDoesNotMatch = 1 << 1,
Disabled = 1 << 2,
Failed = 1 << 3,
FromRule = 1 << 4,
NoAction = 1 << 5,
NoTrigger = 1 << 6,
TooOld = 1 << 7,
WrongEvent = 1 << 8,
WrongEventForTrigger = 1 << 9
}
}

1
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -54,7 +54,6 @@ namespace Squidex.Domain.Apps.Entities.Rules
{
Rule = rule,
RuleId = ruleId,
IgnoreStale = false
};
var jobs = ruleService.CreateJobsAsync(@event, ruleContext);

13
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs

@ -41,7 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
{
Guard.NotNull(rule, nameof(rule));
var context = GetContext(rule);
var context = new RuleContext
{
AppId = rule.AppId,
Rule = rule.RuleDef,
RuleId = rule.Id,
IncludeSkipped = true,
IncludeStale = true
};
var simulatedEvents = new List<SimulatedRuleEvent>(MaxSimulatedEvents);
@ -53,6 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
if (@event?.Payload is AppEvent appEvent)
{
// Also create jobs for rules with failing conditions because we want to show them in th table.
await foreach (var result in ruleService.CreateJobsAsync(@event, context, ct))
{
var eventName = result.Job?.EventName;
@ -120,8 +128,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
{
AppId = rule.AppId,
Rule = rule.RuleDef,
RuleId = rule.Id,
IgnoreStale = false
RuleId = rule.Id
};
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs

@ -167,7 +167,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
AppId = rule.AppId,
Rule = rule.RuleDef,
RuleId = rule.Id,
IgnoreStale = true
IncludeStale = true
};
if (currentState.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(context))

19
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
@ -50,11 +52,24 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// The reason why the event has been skipped.
/// </summary>
[Required]
public SkipReason SkipReason { get; set; }
public List<SkipReason> SkipReasons { get; set; }
public static SimulatedRuleEventDto FromSimulatedRuleEvent(SimulatedRuleEvent ruleEvent)
{
return SimpleMapper.Map(ruleEvent, new SimulatedRuleEventDto());
var result = SimpleMapper.Map(ruleEvent, new SimulatedRuleEventDto
{
SkipReasons = new List<SkipReason>()
});
foreach (var reason in Enum.GetValues<SkipReason>())
{
if (reason != SkipReason.None && ruleEvent.SkipReason.HasFlag(reason))
{
result.SkipReasons.Add(reason);
}
}
return result;
}
}
}

449
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs

@ -96,15 +96,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public void Should_calculate_event_name_from_trigger_handler()
{
var @event = new ContentCreated();
var eventEnvelope = new ContentCreated();
A.CallTo(() => ruleTriggerHandler.Handles(@event))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
A.CallTo(() => ruleTriggerHandler.GetName(eventEnvelope))
.Returns("custom-name");
var name = sut.GetName(@event);
var name = sut.GetName(eventEnvelope);
Assert.Equal("custom-name", name);
}
@ -112,42 +112,44 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public void Should_calculate_default_name_if_trigger_handler_returns_no_name()
{
var @event = new ContentCreated();
var eventEnvelope = new ContentCreated();
A.CallTo(() => ruleTriggerHandler.Handles(@event))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
A.CallTo(() => ruleTriggerHandler.GetName(eventEnvelope))
.Returns(null);
var name = sut.GetName(@event);
var name = sut.GetName(eventEnvelope);
Assert.Equal("ContentCreated", name);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
A.CallTo(() => ruleTriggerHandler.GetName(eventEnvelope))
.MustHaveHappened();
}
[Fact]
public void Should_calculate_default_name_if_trigger_handler_cannot_not_handle_event()
{
var @event = new ContentCreated();
var eventEnvelope = new ContentCreated();
A.CallTo(() => ruleTriggerHandler.Handles(@event))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope))
.Returns(false);
var name = sut.GetName(@event);
var name = sut.GetName(eventEnvelope);
Assert.Equal("ContentCreated", name);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
A.CallTo(() => ruleTriggerHandler.GetName(eventEnvelope))
.MustNotHaveHappened();
}
[Fact]
public void Should_not_run_from_snapshots_if_no_trigger_handler_registered()
{
var result = sut.CanCreateSnapshotEvents(RuleInvalidTrigger());
var context = RuleInvalidTrigger();
var result = sut.CanCreateSnapshotEvents(context);
Assert.False(result);
}
@ -155,10 +157,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public void Should_not_run_from_snapshots_if_trigger_handler_does_not_support_it()
{
var context = Rule();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(false);
var result = sut.CanCreateSnapshotEvents(Rule());
var result = sut.CanCreateSnapshotEvents(context);
Assert.False(result);
}
@ -166,10 +170,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public void Should_run_from_snapshots_if_trigger_handler_does_support_it()
{
var context = Rule();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true);
var result = sut.CanCreateSnapshotEvents(Rule());
var result = sut.CanCreateSnapshotEvents(context);
Assert.True(result);
}
@ -177,10 +183,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public async Task Should_not_create_job_from_snapshots_if_trigger_handler_does_not_support_it()
{
var context = Rule();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(false);
var jobs = await sut.CreateSnapshotJobsAsync(Rule()).ToListAsync();
var jobs = await sut.CreateSnapshotJobsAsync(context).ToListAsync();
Assert.Empty(jobs);
@ -191,10 +199,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public async Task Should_not_create_job_from_snapshots_if_rule_disabled()
{
var context = Rule(disable: true);
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true);
var jobs = await sut.CreateSnapshotJobsAsync(Rule(disable: true)).ToListAsync();
var jobs = await sut.CreateSnapshotJobsAsync(context).ToListAsync();
Assert.Empty(jobs);
@ -205,10 +215,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public async Task Should_not_create_job_from_snapshots_if_no_trigger_handler_registered()
{
var context = RuleInvalidTrigger();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true);
var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidTrigger()).ToListAsync();
var jobs = await sut.CreateSnapshotJobsAsync(context).ToListAsync();
Assert.Empty(jobs);
@ -219,10 +231,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public async Task Should_not_create_job_from_snapshots_if_no_action_handler_registered()
{
var context = RuleInvalidAction();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true);
var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidAction()).ToListAsync();
var jobs = await sut.CreateSnapshotJobsAsync(context).ToListAsync();
Assert.Empty(jobs);
@ -276,85 +290,118 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal(2, jobs.Count(x => x.Job == null && x.EnrichmentError != null));
}
[Fact]
public async Task Should_create_debug_rob_if_rule_disabled()
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Should_create_debug_job_for_invalid_event(bool includeSkipped)
{
var @event = Envelope.Create(new ContentCreated());
var context = Rule(includeSkipped: includeSkipped);
var result = await sut.CreateJobsAsync(@event, Rule(disable: true)).SingleAsync();
var eventEnvelope = CreateEnvelope(new InvalidEvent());
Assert.Equal(SkipReason.Disabled, result.SkipReason);
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.WrongEvent, result.SkipReason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_debug_job_for_invalid_event()
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Should_create_debug_job_if_no_trigger_handler_registered(bool includeSkipped)
{
var @event = Envelope.Create(new InvalidEvent());
var context = RuleInvalidTrigger(includeSkipped);
var result = await sut.CreateJobsAsync(@event, Rule()).SingleAsync();
var eventEnvelope = CreateEnvelope(new ContentCreated());
Assert.Equal(SkipReason.WrongEvent, result.SkipReason);
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.NoTrigger, job.SkipReason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_debug_job_if_no_trigger_handler_registered()
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Should_create_debug_job_if_trigger_handler_does_not_handle_event(bool includeSkipped)
{
var @event = Envelope.Create(new ContentCreated());
var context = Rule(includeSkipped: includeSkipped);
var job = await sut.CreateJobsAsync(@event, RuleInvalidTrigger()).SingleAsync();
var eventEnvelope = CreateEnvelope(new ContentCreated());
Assert.Equal(SkipReason.NoTrigger, job.SkipReason);
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.WrongEventForTrigger, job.SkipReason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_debug_job_if_trigger_handler_does_not_handle_event()
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Should_create_debug_job_if_no_action_handler_registered(bool includeSkipped)
{
var @event = Envelope.Create(new ContentCreated());
var context = RuleInvalidAction(includeSkipped);
var job = await sut.CreateJobsAsync(@event, Rule()).SingleAsync();
var eventEnvelope = CreateEnvelope(new ContentCreated());
Assert.Equal(SkipReason.WrongEventForTrigger, job.SkipReason);
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.NoAction, job.SkipReason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_debug_job_if_no_action_handler_registered()
public async Task Should_create_debug_job_if_rule_disabled()
{
var @event = Envelope.Create(new ContentCreated());
var context = Rule(disable: true);
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
var eventEnvelope = CreateEnvelope(new ContentCreated());
var job = await sut.CreateJobsAsync(@event, RuleInvalidAction()).SingleAsync();
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.NoAction, job.SkipReason);
Assert.Equal(SkipReason.Disabled, result.SkipReason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_job_if_rule_disabled_and_skipped_included()
{
var context = Rule(disable: true, includeSkipped: true);
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = SetupFullFlow(context, eventEnvelope);
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
AssertJob(eventEnriched, result, SkipReason.Disabled);
}
[Fact]
public async Task Should_create_debug_job_if_too_old()
{
var @event =
var context = Rule();
var eventEnvelope =
Envelope.Create(new ContentCreated())
.SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3)));
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
var job = await sut.CreateJobsAsync(@event, Rule(ignoreStale: true)).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.TooOld, job.SkipReason);
@ -363,29 +410,33 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
}
[Fact]
public async Task Should_create_job_if_too_old_but_stale_events_are_not_ignored()
public async Task Should_create_job_if_too_old_but_stale_events_are_included()
{
var context = Rule(ignoreStale: false);
var context = Rule(includeStale: true);
var @event =
var eventEnvelope =
Envelope.Create(new ContentCreated())
.SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3)));
var eventEnriched = SetupFullFlow(context, eventEnvelope);
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, context))
.Returns(true);
AssertJob(eventEnriched, result, SkipReason.None);
}
A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, context))
.Returns(true);
[Fact]
public async Task Should_create_job_if_too_old_but_skipped_are_included()
{
var context = Rule(includeSkipped: true);
var jobs = await sut.CreateJobsAsync(@event, context).ToListAsync();
var eventEnvelope =
Envelope.Create(new ContentCreated())
.SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3)));
var eventEnriched = SetupFullFlow(context, eventEnvelope);
Assert.Empty(jobs);
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustHaveHappened();
AssertJob(eventEnriched, result, SkipReason.TooOld);
}
[Fact]
@ -393,9 +444,9 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{
var context = Rule();
var @event = Envelope.Create(new ContentCreated { FromRule = true });
var eventEnvelope = CreateEnvelope(new ContentCreated { FromRule = true });
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.FromRule, job.SkipReason);
@ -406,28 +457,57 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.MustNotHaveHappened();
}
[Fact]
public async Task Should_job_if_event_created_by_rule_but_skipped_are_included()
{
var context = Rule(includeSkipped: true);
var eventEnvelope = CreateEnvelope(new ContentCreated { FromRule = true });
var eventEnriched = SetupFullFlow(context, eventEnvelope);
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
AssertJob(eventEnriched, result, SkipReason.FromRule);
}
[Fact]
public async Task Should_create_debug_job_if_not_triggered_with_precheck()
{
var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = new EnrichedContentEvent { AppId = appId };
var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(false);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default))
.Returns(new List<EnrichedEvent> { eventEnriched }.ToAsyncEnumerable());
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.ConditionDoesNotMatch, job.SkipReason);
Assert.Equal(enrichedEvent, job.EnrichedEvent);
Assert.Equal(SkipReason.ConditionPrecheckDoesNotMatch, job.SkipReason);
Assert.Null(job.EnrichedEvent);
Assert.Null(job.Job);
}
[Fact]
public async Task Should_create_job_if_not_triggered_with_precheck_but_skipped_are_included()
{
var context = Rule(includeSkipped: true);
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = SetupFullFlow(context, eventEnvelope);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(false);
var result = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
AssertJob(eventEnriched, result, SkipReason.ConditionPrecheckDoesNotMatch);
}
[Fact]
@ -435,15 +515,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{
var context = Rule();
var @event = Envelope.Create(new ContentCreated());
var eventEnvelope = CreateEnvelope(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Throws(new InvalidOperationException());
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.Failed, job.SkipReason);
}
@ -453,18 +533,18 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{
var context = Rule();
var @event = Envelope.Create(new ContentCreated());
var eventEnvelope = CreateEnvelope(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default))
.Returns(AsyncEnumerable.Empty<EnrichedEvent>());
var job = await sut.CreateJobsAsync(@event, context).ToListAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).ToListAsync();
Assert.Empty(job);
}
@ -472,51 +552,62 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public async Task Should_create_debug_job_if_not_triggered()
{
var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var context = Rule(includeSkipped: true);
var @event = Envelope.Create(new ContentCreated());
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = new EnrichedContentEvent { AppId = appId };
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context))
A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context))
.Returns(false);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default))
.Returns(new List<EnrichedEvent> { eventEnriched }.ToAsyncEnumerable());
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.ConditionDoesNotMatch, job.SkipReason);
Assert.Equal(enrichedEvent, job.EnrichedEvent);
Assert.Equal(eventEnriched, job.EnrichedEvent);
}
[Fact]
public async Task Should_create_debug_job_if_enrichment_failed()
public async Task Should_debug_job_if_not_triggered_but_skipped_included()
{
var now = clock.GetCurrentInstant();
var context = Rule(includeSkipped: true);
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = SetupFullFlow(context, eventEnvelope);
A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context))
.Returns(false);
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
AssertJob(eventEnriched, job, SkipReason.ConditionDoesNotMatch);
}
[Fact]
public async Task Should_create_debug_job_if_enrichment_failed()
{
var context = Rule();
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
var eventEnvelope = CreateEnvelope(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default))
.Throws(new InvalidOperationException());
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.Equal(SkipReason.Failed, job.SkipReason);
}
@ -524,122 +615,80 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public async Task Should_create_job_if_triggered()
{
var now = clock.GetCurrentInstant();
var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = SetupFullFlow(context, eventEnvelope);
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(true);
AssertJob(eventEnriched, job, SkipReason.None);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 }));
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
AssertJob(now, enrichedEvent, job);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event)))
A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched, MatchPayload(eventEnvelope)))
.MustHaveHappened();
}
[Fact]
public async Task Should_create_job_with_exception_if_trigger_failed()
{
var now = clock.GetCurrentInstant();
var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched = SetupFullFlow(context, eventEnvelope);
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action))
A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched, context.Rule.Action))
.Throws(new InvalidOperationException());
var job = await sut.CreateJobsAsync(@event, context).SingleAsync();
var job = await sut.CreateJobsAsync(eventEnvelope, context).SingleAsync();
Assert.NotNull(job.EnrichmentError);
Assert.NotNull(job.Job?.ActionData);
Assert.NotNull(job.Job?.Description);
Assert.Equal(enrichedEvent, job.EnrichedEvent);
Assert.Equal(eventEnriched, job.EnrichedEvent);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event)))
A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched, MatchPayload(eventEnvelope)))
.MustHaveHappened();
}
[Fact]
public async Task Should_create_multiple_jobs_if_triggered()
{
var now = clock.GetCurrentInstant();
var context = Rule();
var enrichedEvent1 = new EnrichedContentEvent { AppId = appId };
var enrichedEvent2 = new EnrichedContentEvent { AppId = appId };
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
var eventEnvelope = CreateEnvelope(new ContentCreated());
var eventEnriched1 = new EnrichedContentEvent { AppId = appId };
var eventEnriched2 = new EnrichedContentEvent { AppId = appId };
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent1, context))
A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched1, context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent2, context))
A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched2, context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent1, enrichedEvent2 }.ToAsyncEnumerable());
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default))
.Returns(new List<EnrichedEvent> { eventEnriched1, eventEnriched2 }.ToAsyncEnumerable());
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent1, context.Rule.Action))
A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched1, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 }));
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent2, context.Rule.Action))
A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched2, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 }));
var jobs = await sut.CreateJobsAsync(@event, context, default).ToListAsync();
var jobs = await sut.CreateJobsAsync(eventEnvelope, context, default).ToListAsync();
AssertJob(now, enrichedEvent1, jobs[0]);
AssertJob(now, enrichedEvent2, jobs[1]);
AssertJob(eventEnriched1, jobs[0], SkipReason.None);
AssertJob(eventEnriched2, jobs[1], SkipReason.None);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent1, MatchPayload(@event)))
A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched1, MatchPayload(eventEnvelope)))
.MustHaveHappened();
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent2, MatchPayload(@event)))
A.CallTo(() => eventEnricher.EnrichAsync(eventEnriched2, MatchPayload(eventEnvelope)))
.MustHaveHappened();
}
@ -700,27 +749,51 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal(ex, result.Result.Exception);
}
private RuleContext RuleInvalidAction()
private EnrichedContentEvent SetupFullFlow<T>(RuleContext context, Envelope<T> eventEnvelope) where T : AppEvent
{
var eventEnriched = new EnrichedContentEvent { AppId = appId };
A.CallTo(() => ruleTriggerHandler.Handles(eventEnvelope.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(eventEnvelope), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(eventEnriched, context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(eventEnvelope), context, default))
.Returns(new List<EnrichedEvent> { eventEnriched }.ToAsyncEnumerable());
A.CallTo(() => ruleActionHandler.CreateJobAsync(eventEnriched, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 }));
return eventEnriched;
}
private RuleContext RuleInvalidAction(bool includeSkipped = false)
{
return new RuleContext
{
AppId = appId,
Rule = new Rule(new ContentChangedTriggerV2(), new InvalidAction()),
RuleId = ruleId
RuleId = ruleId,
IncludeSkipped = includeSkipped
};
}
private RuleContext RuleInvalidTrigger()
private RuleContext RuleInvalidTrigger(bool includeSkipped = false)
{
return new RuleContext
{
AppId = appId,
Rule = new Rule(new InvalidTrigger(), new ValidAction()),
RuleId = ruleId
RuleId = ruleId,
IncludeSkipped = includeSkipped
};
}
private RuleContext Rule(bool disable = false, bool ignoreStale = true)
private RuleContext Rule(bool disable = false, bool includeStale = false, bool includeSkipped = false)
{
var rule = new Rule(new ContentChangedTriggerV2(), new ValidAction());
@ -734,21 +807,31 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
AppId = appId,
Rule = rule,
RuleId = ruleId,
IgnoreStale = ignoreStale
IncludeStale = includeStale,
IncludeSkipped = includeSkipped
};
}
private static Envelope<AppEvent> MatchPayload(Envelope<IEvent> @event)
private Envelope<T> CreateEnvelope<T>(T @event) where T : class, IEvent
{
return Envelope.Create(@event).SetTimestamp(clock.GetCurrentInstant());
}
private static Envelope<AppEvent> MatchPayload(Envelope<IEvent> eventEnvelope)
{
return A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload);
return A<Envelope<AppEvent>>.That.Matches(x => x.Payload == eventEnvelope.Payload);
}
private void AssertJob(Instant now, EnrichedContentEvent enrichedEvent, JobResult result)
private void AssertJob(EnrichedContentEvent eventEnriched, JobResult result, SkipReason skipped)
{
var now = clock.GetCurrentInstant();
var job = result.Job!;
Assert.Equal(enrichedEvent, result.EnrichedEvent);
Assert.Equal(enrichedEvent.AppId.Id, job.AppId);
Assert.Equal(skipped, result.SkipReason);
Assert.Equal(eventEnriched, result.EnrichedEvent);
Assert.Equal(eventEnriched.AppId.Id, job.AppId);
Assert.Equal(actionData, job.ActionData);
Assert.Equal(actionName, job.ActionName);

19
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs

@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
Created = now
};
A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule.RuleDef), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
.Returns(new List<JobResult> { new JobResult() }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event);
@ -108,7 +108,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
Created = now
};
A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule.RuleDef), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
.Returns(new List<JobResult> { new JobResult { Job = job, SkipReason = SkipReason.TooOld } }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event);
@ -129,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
Created = now
};
A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule.RuleDef), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
.Returns(new List<JobResult> { new JobResult { Job = job } }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event);
@ -179,10 +179,10 @@ namespace Squidex.Domain.Apps.Entities.Rules
A.CallTo(() => appProvider.GetRulesAsync(appId.Id))
.Returns(new List<IRuleEntity> { rule1, rule2 });
A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule1.RuleDef), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule1), default))
.Returns(new List<JobResult> { new JobResult { Job = job1 } }.ToAsyncEnumerable());
A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule2.RuleDef), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule2), default))
.Returns(new List<JobResult>().ToAsyncEnumerable());
}
@ -192,5 +192,14 @@ namespace Squidex.Domain.Apps.Entities.Rules
return new RuleEntity { RuleDef = rule, Id = DomainId.NewGuid() };
}
private static RuleContext MatchingContext(RuleEntity rule)
{
// These two properties must not be set to true for performance reasons.
return A<RuleContext>.That.Matches(x =>
x.Rule == rule.RuleDef &&
!x.IncludeSkipped &&
!x.IncludeStale);
}
}
}

1
frontend/app/features/rules/declarations.ts

@ -11,6 +11,7 @@ export * from './pages/rule/rule-page.component';
export * from './pages/rules/rule.component';
export * from './pages/rules/rules-page.component';
export * from './pages/simulator/rule-simulator-page.component';
export * from './pages/simulator/rule-transition.component';
export * from './pages/simulator/simulated-rule-event.component';
export * from './shared/actions/formattable-input.component';
export * from './shared/actions/generic-action.component';

3
frontend/app/features/rules/module.ts

@ -8,7 +8,7 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HelpComponent, RuleMustExistGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { AssetChangedTriggerComponent, CommentTriggerComponent, ContentChangedTriggerComponent, GenericActionComponent, RuleComponent, RuleElementComponent, RuleEventsPageComponent, RuleIconComponent, RuleSimulatorPageComponent, RulesPageComponent, SchemaChangedTriggerComponent, UsageTriggerComponent } from './declarations';
import { AssetChangedTriggerComponent, CommentTriggerComponent, ContentChangedTriggerComponent, GenericActionComponent, RuleComponent, RuleElementComponent, RuleEventsPageComponent, RuleIconComponent, RuleSimulatorPageComponent, RulesPageComponent, RuleTransitionComponent, SchemaChangedTriggerComponent, UsageTriggerComponent } from './declarations';
import { RuleEventComponent } from './pages/events/rule-event.component';
import { RulePageComponent } from './pages/rule/rule-page.component';
import { SimulatedRuleEventComponent } from './pages/simulator/simulated-rule-event.component';
@ -78,6 +78,7 @@ const routes: Routes = [
RuleIconComponent,
RulePageComponent,
RuleSimulatorPageComponent,
RuleTransitionComponent,
RulesPageComponent,
SchemaChangedTriggerComponent,
SimulatedRuleEventComponent,

9
frontend/app/features/rules/pages/simulator/rule-transition.component.html

@ -0,0 +1,9 @@
<div class="history-transition" *ngIf="text">
{{ text | sqxTranslate }}
</div>
<ng-container *ngIf="filteredErrors; let errors">
<div class="history-transition text-danger" *ngFor="let error of errors">
{{ error | sqxTranslate }}
</div>
</ng-container>

32
frontend/app/features/rules/pages/simulator/rule-transition.component.scss

@ -0,0 +1,32 @@
.history {
&-transition {
font-size: 85%;
font-weight: normal;
margin-bottom: 1rem;
margin-top: 1rem;
position: relative;
&::before {
@include circle($history-dot-sm-size);
@include absolute(.5rem, null, null, -1.5rem);
background: $color-border;
content: '';
margin: 0;
margin-left: $history-dot-sm-offset-x;
}
}
}
:host {
&:first-child {
.history-transition {
margin-top: 0;
}
}
&:last-child {
.history-transition {
margin-bottom: 0;
}
}
}

42
frontend/app/features/rules/pages/simulator/rule-transition.component.ts

@ -0,0 +1,42 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { SimulatedRuleEventDto } from '@app/shared';
@Component({
selector: 'sqx-rule-transition',
styleUrls: ['./rule-transition.component.scss'],
templateUrl: './rule-transition.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RuleTransitionComponent {
@Input()
public event: SimulatedRuleEventDto | undefined | null;
@Input()
public errors: ReadonlyArray<string> | undefined | null;
@Input()
public text: string | undefined | null;
public get filteredErrors() {
const errors = this.errors;
if (!errors) {
return null;
}
const result = this.event?.skipReasons.filter(x => errors.indexOf(x) >= 0).map(x => `rules.simulation.error${x}`);
if (result?.length === 0) {
return null;
}
return result;
}
}

85
frontend/app/features/rules/pages/simulator/simulated-rule-event.component.html

@ -6,7 +6,7 @@
<span class="truncate">{{event.eventName}}</span>
</td>
<td class="cell-40">
<small class="truncate">{{event.skipReason}}</small>
<small class="truncate">{{event.skipReasons.join(', ')}}</small>
</td>
<td class="cell-actions">
<button type="button" class="btn btn-outline-secondary btn-expand" [class.expanded]="expanded" (click)="expandedChange.emit()">
@ -24,67 +24,44 @@
<div class="history">
<div class="history-start"></div>
<div>
<div class="history-transition">
{{ 'rules.simulation.eventQueried' | sqxTranslate }}
</div>
<div>
<ng-container *ngIf="event.event">
<sqx-rule-transition text='rules.simulation.eventQueried'></sqx-rule-transition>
<div class="history-state" *ngIf="event.event">
<label>{{ 'common.event' | sqxTranslate }}</label>
<sqx-code-editor [ngModel]="event.event" valueMode="Json" [disabled]="true" [wordWrap]="false" height="auto" [maxLines]="20"></sqx-code-editor>
</div>
<div class="history-state">
<label>{{ 'common.event' | sqxTranslate }}</label>
<ng-container *ngIf="event.enrichedEvent; else error1">
<div class="history-transition">
{{ 'rules.simulation.eventEnriched' | sqxTranslate }}
</div>
<div class="history-state" *ngIf="event.enrichedEvent">
<label>{{ 'rules.enrichedEvent' | sqxTranslate }}</label>
<sqx-code-editor [ngModel]="event.enrichedEvent" valueMode="Json" [disabled]="true" [wordWrap]="false" height="auto" [maxLines]="20"></sqx-code-editor>
<sqx-code-editor [ngModel]="event.event" valueMode="Json" [disabled]="true" [wordWrap]="false" height="auto" [maxLines]="20"></sqx-code-editor>
</div>
<ng-container *ngIf="event.enrichedEvent; else error2">
<ng-container *ngIf="event.skipReason == 'ConditionDoesNotMatch'"; else valid>
<div class="history-transition text-danger">
{{ 'rules.simulation.errorConditionDoesNotMatch' | sqxTranslate }}
</div>
</ng-container>
<ng-template #valid>
<div class="history-transition">
{{ 'rules.simulation.conditionEvaluated' | sqxTranslate }}
</div>
</ng-template>
<div class="history-transition">
{{ 'rules.simulation.actionCreated' | sqxTranslate }}
<sqx-rule-transition [event]="event" text='rules.simulation.eventTriggerChecked' [errors]="errorsAfterEvent"></sqx-rule-transition>
<ng-container *ngIf="event.enrichedEvent">
<sqx-rule-transition text='rules.simulation.eventEnriched'></sqx-rule-transition>
<div class="history-state" *ngIf="event.enrichedEvent">
<label>{{ 'rules.enrichedEvent' | sqxTranslate }}</label>
<sqx-code-editor [ngModel]="event.enrichedEvent" valueMode="Json" [disabled]="true" [wordWrap]="false" height="auto" [maxLines]="20"></sqx-code-editor>
</div>
<sqx-rule-transition [event]="event" text='rules.simulation.eventConditionEvaluated' [errors]="errorsAfterEnrichedEvent"></sqx-rule-transition>
<div class="history-state" *ngIf="event.actionData">
<label>{{ 'rules.actionData' | sqxTranslate }}</label>
<sqx-code-editor [ngModel]="data" [disabled]="true" [wordWrap]="true" height="auto" [maxLines]="20"></sqx-code-editor>
</div>
<div class="history-transition">
{{ 'rules.simulation.actionExecuted' | sqxTranslate }}
</div>
<ng-container *ngIf="event.actionData">
<sqx-rule-transition text='rules.simulation.actionCreated'></sqx-rule-transition>
<div class="history-state">
<label>{{ 'rules.actionData' | sqxTranslate }}</label>
<sqx-code-editor [ngModel]="data" [disabled]="true" [wordWrap]="true" height="auto" [maxLines]="20"></sqx-code-editor>
</div>
<sqx-rule-transition text='rules.simulation.actionExecuted'></sqx-rule-transition>
</ng-container>
</ng-container>
<ng-template #error2>
<div class="history-transition text-danger">
{{ errorText | sqxTranslate }}
</div>
</ng-template>
</ng-container>
<ng-template #error1>
<div class="history-transition text-danger">
{{ errorText | sqxTranslate }}
</div>
</ng-template>
<sqx-rule-transition [event]="event" [errors]="errorsFailed"></sqx-rule-transition>
<div class="history-state" *ngIf="event.error">
<label>{{ 'common.error' | sqxTranslate }}</label>
@ -93,7 +70,7 @@
</div>
</div>
<div class="history-end"></div>
<div class="history-stop"></div>
</div>
</div>
</td>

33
frontend/app/features/rules/pages/simulator/simulated-rule-event.component.scss

@ -37,11 +37,6 @@ td {
}
}
$history-dot-size: 10px;
$history-dot-offset-x: -($history-dot-size * .5 + 1px);
$history-dot-sm-size: 6px;
$history-dot-sm-offset-x: -($history-dot-sm-size * .5 + 1px);
.history {
border-left: 2px dashed $color-border;
margin: .5rem 0;
@ -62,32 +57,8 @@ $history-dot-sm-offset-x: -($history-dot-sm-size * .5 + 1px);
}
}
&-transition {
font-size: 85%;
font-weight: normal;
margin: 1rem 0;
position: relative;
&::before {
@include circle($history-dot-sm-size);
@include absolute(.5rem, null, null, -1.5rem);
background: $color-border;
content: '';
margin: 0;
margin-left: $history-dot-sm-offset-x;
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
&-start,
&-end {
&-stop {
@include circle($history-dot-size);
background: $color-border;
}
@ -96,7 +67,7 @@ $history-dot-sm-offset-x: -($history-dot-sm-size * .5 + 1px);
@include absolute(-$history-dot-size * .5, null, null, $history-dot-offset-x);
}
&-end {
&-stop {
@include absolute(null, null, -$history-dot-size * .5, $history-dot-offset-x);
}
}

31
frontend/app/features/rules/pages/simulator/simulated-rule-event.component.ts

@ -8,6 +8,25 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { SimulatedRuleEventDto } from '@app/shared';
const ERRORS_AFTER_EVENT = [
'ConditionPrecheckDoesNotMatch',
'Disabled',
'FromRule',
'NoAction',
'NoTrigger',
'TooOld',
'WrongEvent',
'WrongEventForTrigger',
];
const ERRORS_AFTER_ENRICHED_EVENT = [
'ConditionDoesNotMatch',
];
const ERRORS_FAILED = [
'Failed',
];
@Component({
selector: '[sqxSimulatedRuleEvent]',
styleUrls: ['./simulated-rule-event.component.scss'],
@ -24,6 +43,10 @@ export class SimulatedRuleEventComponent {
@Output()
public expandedChange = new EventEmitter<any>();
public errorsAfterEvent = ERRORS_AFTER_EVENT;
public errorsAfterEnrichedEvent = ERRORS_AFTER_ENRICHED_EVENT;
public errorsFailed = ERRORS_FAILED;
public get data() {
let result = this.event.actionData;
@ -38,14 +61,10 @@ export class SimulatedRuleEventComponent {
return result;
}
public get errorText() {
return `rules.simulation.error${this.event.skipReason}`;
}
public get status() {
if (this.event.error) {
return 'Failed';
} else if (this.event.skipReason !== 'None') {
} else if (this.event.skipReasons.length > 0) {
return 'Skipped';
} else {
return 'Success';
@ -55,7 +74,7 @@ export class SimulatedRuleEventComponent {
public get statusClass() {
if (this.event.error) {
return 'danger';
} else if (this.event.skipReason !== 'None') {
} else if (this.event.skipReasons.length > 0) {
return 'warning';
} else {
return 'success';

4
frontend/app/shared/services/rules.service.spec.ts

@ -421,7 +421,7 @@ describe('RulesService', () => {
actionName: `action-name${key}`,
actionData: `action-data${key}`,
error: `error${key}`,
skipReason: `reason${key}`,
skipReasons: [`reason${key}`],
_links: {},
};
}
@ -486,5 +486,5 @@ export function createSimulatedRuleEvent(id: number, suffix = '') {
`action-name${key}`,
`action-data${key}`,
`error${key}`,
`reason${key}`);
[`reason${key}`]);
}

4
frontend/app/shared/services/rules.service.ts

@ -204,7 +204,7 @@ export class SimulatedRuleEventDto {
public readonly actionName: string | undefined,
public readonly actionData: string | undefined,
public readonly error: string | undefined,
public readonly skipReason: string,
public readonly skipReasons: ReadonlyArray<string>,
) {
this._links = links;
}
@ -458,5 +458,5 @@ function parseSimulatedRuleEvent(response: any) {
response.actionName,
response.actionData,
response.error,
response.skipReason);
response.skipReasons);
}

5
frontend/app/theme/_vars.scss

@ -76,6 +76,11 @@ $panel-sidebar: 3.75rem;
$font-small: 85%;
$font-smallest: 80%;
$history-dot-size: 10px;
$history-dot-offset-x: -($history-dot-size * .5 + 1px);
$history-dot-sm-size: 6px;
$history-dot-sm-offset-x: -($history-dot-sm-size * .5 + 1px);
$asset-width: 14.5rem;
$asset-folder-height: 4rem;
$asset-height: 19rem;

Loading…
Cancel
Save