diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs index e0f3b3618..7517186a4 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs @@ -15,8 +15,14 @@ namespace Squidex.Domain.Apps.Core.Rules { private RuleTrigger trigger; private RuleAction action; + private string name; private bool isEnabled = true; + public string Name + { + get { return name; } + } + public RuleTrigger Trigger { get { return trigger; } @@ -44,6 +50,15 @@ namespace Squidex.Domain.Apps.Core.Rules this.action.Freeze(); } + [Pure] + public Rule Rename(string name) + { + return Clone(clone => + { + clone.name = name; + }); + } + [Pure] public Rule Enable() { diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs index cbda50c4e..106358450 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs @@ -12,10 +12,12 @@ namespace Squidex.Domain.Apps.Core.Rules { public sealed class RuleJob { - public Guid JobId { get; set; } + public Guid Id { get; set; } public Guid AppId { get; set; } + public Guid RuleId { get; set; } + public string EventName { get; set; } public string ActionName { get; set; } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index ab44b3c0b..21319f7de 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -140,15 +140,16 @@ namespace Squidex.Domain.Apps.Core.HandleRules var job = new RuleJob { - JobId = Guid.NewGuid(), - ActionName = actionName, + Id = Guid.NewGuid(), ActionData = json, + ActionName = actionName, AppId = enrichedEvent.AppId.Id, Created = now, + Description = actionData.Description, EventName = enrichedEvent.Name, ExecutionPartition = enrichedEvent.Partition, Expires = expires, - Description = actionData.Description + RuleId = ruleId }; return job; diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs index 4b8071eac..7f9c094f2 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs @@ -23,6 +23,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules [BsonRepresentation(BsonType.String)] public Guid AppId { get; set; } + [BsonIgnoreIfDefault] + [BsonElement] + [BsonRepresentation(BsonType.String)] + public Guid RuleId { get; set; } + [BsonRequired] [BsonElement] [BsonRepresentation(BsonType.String)] diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index 308afd735..fed408874 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -22,9 +22,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules { public sealed class MongoRuleEventRepository : MongoRepositoryBase, IRuleEventRepository { + private readonly MongoRuleStatisticsCollection statisticsCollection; + public MongoRuleEventRepository(IMongoDatabase database) : base(database) { + statisticsCollection = new MongoRuleStatisticsCollection(database); } protected override string CollectionName() @@ -32,9 +35,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules return "RuleEvents"; } - protected override Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) + protected override async Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) { - return collection.Indexes.CreateManyAsync(new[] + await statisticsCollection.InitializeAsync(ct); + + await collection.Indexes.CreateManyAsync(new[] { new CreateIndexModel(Index.Ascending(x => x.NextAttempt)), new CreateIndexModel(Index.Ascending(x => x.AppId).Descending(x => x.Created)), @@ -53,10 +58,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules return Collection.Find(x => x.NextAttempt < now).ForEachAsync(callback, ct); } - public async Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20) + public async Task> QueryByAppAsync(Guid appId, Guid? ruleId = null, int skip = 0, int take = 20) { + var filter = Filter.Eq(x => x.AppId, appId); + + if (ruleId.HasValue) + { + filter = Filter.And(filter, Filter.Eq(x => x.RuleId, ruleId)); + } + var ruleEventEntities = - await Collection.Find(x => x.AppId == appId).Skip(skip).Limit(take).SortByDescending(x => x.Created) + await Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.Created) .ToListAsync(); return ruleEventEntities; @@ -83,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules public Task EnqueueAsync(RuleJob job, Instant nextAttempt) { - var entity = SimpleMapper.Map(job, new MongoRuleEventEntity { Id = job.JobId, Job = job, Created = nextAttempt, NextAttempt = nextAttempt }); + var entity = SimpleMapper.Map(job, new MongoRuleEventEntity { Job = job, Created = nextAttempt, NextAttempt = nextAttempt }); return Collection.InsertOneIfNotExistsAsync(entity); } @@ -96,15 +108,29 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules .Set(x => x.JobResult, RuleJobResult.Cancelled)); } - public Task MarkSentAsync(Guid jobId, string dump, RuleResult result, RuleJobResult jobResult, TimeSpan elapsed, Instant? nextAttempt) + public async Task MarkSentAsync(RuleJob job, string dump, RuleResult result, RuleJobResult jobResult, TimeSpan elapsed, Instant finished, Instant? nextCall) { - return Collection.UpdateOneAsync(x => x.Id == jobId, + if (result == RuleResult.Success) + { + await statisticsCollection.IncrementSuccess(job.AppId, job.RuleId, finished); + } + else + { + await statisticsCollection.IncrementFailed(job.AppId, job.RuleId, finished); + } + + await Collection.UpdateOneAsync(x => x.Id == job.Id, Update .Set(x => x.Result, result) .Set(x => x.LastDump, dump) .Set(x => x.JobResult, jobResult) - .Set(x => x.NextAttempt, nextAttempt) + .Set(x => x.NextAttempt, nextCall) .Inc(x => x.NumCalls, 1)); } + + public Task> QueryStatisticsByAppAsync(Guid appId) + { + return statisticsCollection.QueryByAppAsync(appId); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs new file mode 100644 index 000000000..d46f569e3 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs @@ -0,0 +1,90 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver; +using NodaTime; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Rules +{ + public sealed class MongoRuleStatisticsCollection : MongoRepositoryBase + { + static MongoRuleStatisticsCollection() + { + var guidSerializer = new GuidSerializer().WithRepresentation(BsonType.String); + + BsonClassMap.RegisterClassMap(map => + { + map.AutoMap(); + + map.MapProperty(x => x.AppId).SetSerializer(guidSerializer); + map.MapProperty(x => x.RuleId).SetSerializer(guidSerializer); + + map.SetIgnoreExtraElements(true); + }); + } + + public MongoRuleStatisticsCollection(IMongoDatabase database) + : base(database) + { + } + + protected override string CollectionName() + { + return "RuleStatistics"; + } + + protected override Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) + { + return collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Index + .Ascending(x => x.AppId) + .Ascending(x => x.RuleId)), + cancellationToken: ct); + } + + public async Task> QueryByAppAsync(Guid appId) + { + var statistics = await Collection.Find(x => x.AppId == appId).ToListAsync(); + + return statistics; + } + + public Task IncrementSuccess(Guid appId, Guid ruleId, Instant now) + { + return Collection.UpdateOneAsync( + x => x.AppId == appId && x.RuleId == ruleId, + Update + .Inc(x => x.NumSucceeded, 1) + .Set(x => x.LastExecuted, now) + .SetOnInsert(x => x.AppId, appId) + .SetOnInsert(x => x.RuleId, ruleId), + Upsert); + } + + public Task IncrementFailed(Guid appId, Guid ruleId, Instant now) + { + return Collection.UpdateOneAsync( + x => x.AppId == appId && x.RuleId == ruleId, + Update + .Inc(x => x.NumFailed, 1) + .Set(x => x.LastExecuted, now) + .SetOnInsert(x => x.AppId, appId) + .SetOnInsert(x => x.RuleId, ruleId), + Upsert); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index d1a38f395..f4b76508f 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities { return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => { - return await indexForApps.GetAppAsync(appName); + return await indexForApps.GetAppByNameAsync(appName); }); } @@ -90,7 +90,7 @@ namespace Squidex.Domain.Apps.Entities { return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () => { - return await indexSchemas.GetSchemaAsync(appId, name); + return await indexSchemas.GetSchemaByNameAsync(appId, name); }); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs index 07ea8ac59..025f33ee6 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes } } - public async Task GetAppAsync(string name) + public async Task GetAppByNameAsync(string name) { using (Profiler.TraceMethod()) { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs index 505b519a9..17b9f9aeb 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes Task> GetAppsForUserAsync(string userId, PermissionSet permissions); - Task GetAppAsync(string name); + Task GetAppByNameAsync(string name); Task GetAppAsync(Guid appId); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 974094d57..b8c0ded1c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -28,12 +28,12 @@ namespace Squidex.Domain.Apps.Entities.Contents public Instant LastModified { get; set; } - public ScheduleJob ScheduleJob { get; set; } - public RefToken CreatedBy { get; set; } public RefToken LastModifiedBy { get; set; } + public ScheduleJob ScheduleJob { get; set; } + public NamedContentData Data { get; set; } public NamedContentData DataDraft { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs index e461ff8ac..2a2f8d13f 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs @@ -14,5 +14,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Commands public RuleTrigger Trigger { get; set; } public RuleAction Action { get; set; } + + public string Name { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs index d0bf9a90e..3ea443623 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs @@ -46,15 +46,15 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards }); } - public static Task CanUpdate(UpdateRule command, Guid appId, IAppProvider appProvider) + public static Task CanUpdate(UpdateRule command, Guid appId, IAppProvider appProvider, Rule rule) { Guard.NotNull(command, nameof(command)); return Validate.It(() => "Cannot update rule.", async e => { - if (command.Trigger == null && command.Action == null) + if (command.Trigger == null && command.Action == null && command.Name == null) { - e(Not.Defined("Either trigger or action"), nameof(command.Trigger), nameof(command.Action)); + e(Not.Defined("Either trigger, action or name"), nameof(command.Trigger), nameof(command.Action)); } if (command.Trigger != null) @@ -70,6 +70,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards errors.Foreach(x => x.AddTo(e)); } + + if (command.Name != null && string.Equals(rule.Name, command.Name)) + { + e(Not.New("Rule", "name"), nameof(command.Name)); + } }); } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs b/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs new file mode 100644 index 000000000..059327dcf --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IEnrichedRuleEntity : IRuleEntity, IEntityWithCacheDependencies + { + int NumSucceeded { get; } + + int NumFailed { get; } + + Instant? LastExecuted { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnricher.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnricher.cs new file mode 100644 index 000000000..bbf1f6e45 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnricher.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IRuleEnricher + { + Task EnrichAsync(IRuleEntity rule, Context context); + + Task> EnrichAsync(IEnumerable rules, Context context); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleQueryService.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleQueryService.cs new file mode 100644 index 000000000..f3b4d501f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleQueryService.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IRuleQueryService + { + Task> QueryAsync(Context context); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs b/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs new file mode 100644 index 000000000..01a0024e2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Rules.Queries +{ + public sealed class RuleEnricher : IRuleEnricher + { + private readonly IRuleEventRepository ruleEventRepository; + + public RuleEnricher(IRuleEventRepository ruleEventRepository) + { + Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); + + this.ruleEventRepository = ruleEventRepository; + } + + public async Task EnrichAsync(IRuleEntity rule, Context context) + { + Guard.NotNull(rule, nameof(rule)); + + var enriched = await EnrichAsync(Enumerable.Repeat(rule, 1), context); + + return enriched[0]; + } + + public async Task> EnrichAsync(IEnumerable rules, Context context) + { + Guard.NotNull(rules, nameof(rules)); + Guard.NotNull(context, nameof(context)); + + using (Profiler.TraceMethod()) + { + var results = new List(); + + foreach (var rule in rules) + { + var result = SimpleMapper.Map(rule, new RuleEntity()); + + results.Add(result); + } + + foreach (var group in results.GroupBy(x => x.AppId.Id)) + { + var statistics = await ruleEventRepository.QueryStatisticsByAppAsync(group.Key); + + foreach (var rule in group) + { + var statistic = statistics.FirstOrDefault(x => x.RuleId == rule.Id); + + if (statistic != null) + { + rule.LastExecuted = statistic.LastExecuted; + rule.NumFailed = statistic.NumFailed; + rule.NumSucceeded = statistic.NumSucceeded; + + rule.CacheDependencies = new HashSet + { + statistic.LastExecuted + }; + } + } + } + + return results; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleQueryService.cs b/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleQueryService.cs new file mode 100644 index 000000000..a470206e1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleQueryService.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Rules.Indexes; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Rules.Queries +{ + public sealed class RuleQueryService : IRuleQueryService + { + private readonly IRulesIndex rulesIndex; + private readonly IRuleEnricher ruleEnricher; + + public RuleQueryService(IRulesIndex rulesIndex, IRuleEnricher ruleEnricher) + { + Guard.NotNull(rulesIndex, nameof(rulesIndex)); + Guard.NotNull(ruleEnricher, nameof(ruleEnricher)); + + this.rulesIndex = rulesIndex; + this.ruleEnricher = ruleEnricher; + } + + public async Task> QueryAsync(Context context) + { + var rules = await rulesIndex.GetRulesAsync(context.App.Id); + + var enriched = await ruleEnricher.EnrichAsync(rules, context); + + return enriched; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs index 58b9ea511..e6979e9bc 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -23,13 +23,15 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories Task CancelAsync(Guid id); - Task MarkSentAsync(Guid jobId, string dump, RuleResult result, RuleJobResult jobResult, TimeSpan elapsed, Instant? nextCall); + Task MarkSentAsync(RuleJob job, string dump, RuleResult result, RuleJobResult jobResult, TimeSpan elapsed, Instant finished, Instant? nextCall); Task QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default); Task CountByAppAsync(Guid appId); - Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); + Task> QueryStatisticsByAppAsync(Guid appId); + + Task> QueryByAppAsync(Guid appId, Guid? ruleId = null, int skip = 0, int take = 20); Task FindAsync(Guid id); } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs new file mode 100644 index 000000000..b4a8f78bb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Rules.Repositories +{ + public class RuleStatistics + { + public Guid AppId { get; set; } + + public Guid RuleId { get; set; } + + public int NumSucceeded { get; set; } + + public int NumFailed { get; set; } + + public Instant? LastExecuted { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs new file mode 100644 index 000000000..705cf8e05 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Rules.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class RuleCommandMiddleware : GrainCommandMiddleware + { + private readonly IRuleEnricher ruleEnricher; + private readonly IContextProvider contextProvider; + + public RuleCommandMiddleware(IGrainFactory grainFactory, IRuleEnricher ruleEnricher, IContextProvider contextProvider) + : base(grainFactory) + { + Guard.NotNull(ruleEnricher, nameof(ruleEnricher)); + Guard.NotNull(contextProvider, nameof(contextProvider)); + + this.ruleEnricher = ruleEnricher; + this.contextProvider = contextProvider; + } + + public override async Task HandleAsync(CommandContext context, Func next) + { + await base.HandleAsync(context, next); + + if (context.PlainResult is IRuleEntity rule && NotEnriched(context)) + { + var enriched = await ruleEnricher.EnrichAsync(rule, contextProvider.Context); + + context.Complete(enriched); + } + } + + private static bool NotEnriched(CommandContext context) + { + return !(context.PlainResult is IEnrichedRuleEntity); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs index 1a8715309..be304a805 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs @@ -103,7 +103,9 @@ namespace Squidex.Domain.Apps.Entities.Rules var jobInvoke = ComputeJobInvoke(response.Status, @event, job); var jobResult = ComputeJobResult(response.Status, jobInvoke); - await ruleEventRepository.MarkSentAsync(@event.Id, response.Dump, response.Status, jobResult, elapsed, jobInvoke); + var now = clock.GetCurrentInstant(); + + await ruleEventRepository.MarkSentAsync(@event.Job, response.Dump, response.Status, jobResult, elapsed, now, jobInvoke); } catch (Exception ex) { diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs new file mode 100644 index 000000000..373f9db95 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using NodaTime; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class RuleEntity : IEnrichedRuleEntity + { + public Guid Id { get; set; } + + public NamedId AppId { get; set; } + + public NamedId SchemaId { get; set; } + + public long Version { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public Rule RuleDef { get; set; } + + public bool IsDeleted { get; set; } + + public int NumSucceeded { get; set; } + + public int NumFailed { get; set; } + + public Instant? LastExecuted { get; set; } + + public HashSet CacheDependencies { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs index 41d1db26d..40a986c67 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs @@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Rules case UpdateRule updateRule: return UpdateReturnAsync(updateRule, async c => { - await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider); + await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider, Snapshot.RuleDef); Update(c); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs index bd18acab7..bf45aead8 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs @@ -36,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.State case RuleCreated e: { RuleDef = new Rule(e.Trigger, e.Action); + RuleDef = RuleDef.Rename(e.Name); AppId = e.AppId; @@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.State RuleDef = RuleDef.Update(e.Action); } + if (e.Name != null) + { + RuleDef = RuleDef.Rename(e.Name); + } + break; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs index a47dc6019..f750b9935 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs @@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); - Task GetSchemaAsync(Guid appId, string name, bool allowDeleted = false); + Task GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false); Task> GetSchemasAsync(Guid appId, bool allowDeleted = false); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs index 49ad16bd9..6a09a538f 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs @@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes } } - public async Task GetSchemaAsync(Guid appId, string name, bool allowDeleted = false) + public async Task GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false) { using (Profiler.TraceMethod()) { diff --git a/src/Squidex.Domain.Apps.Events/Rules/RuleCreated.cs b/src/Squidex.Domain.Apps.Events/Rules/RuleCreated.cs index f0b7e9c93..a12e520ab 100644 --- a/src/Squidex.Domain.Apps.Events/Rules/RuleCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Rules/RuleCreated.cs @@ -18,6 +18,8 @@ namespace Squidex.Domain.Apps.Events.Rules public RuleAction Action { get; set; } + public string Name { get; set; } + public IEvent Migrate() { if (Trigger is IMigrated migrated) diff --git a/src/Squidex.Domain.Apps.Events/Rules/RuleUpdated.cs b/src/Squidex.Domain.Apps.Events/Rules/RuleUpdated.cs index e9016300e..db798c2f3 100644 --- a/src/Squidex.Domain.Apps.Events/Rules/RuleUpdated.cs +++ b/src/Squidex.Domain.Apps.Events/Rules/RuleUpdated.cs @@ -18,6 +18,8 @@ namespace Squidex.Domain.Apps.Events.Rules public RuleAction Action { get; set; } + public string Name { get; set; } + public IEvent Migrate() { if (Trigger is IMigrated migrated) diff --git a/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs b/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs index bb4e4c240..35325b41e 100644 --- a/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs +++ b/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs @@ -26,6 +26,7 @@ namespace Squidex.Domain.Users.MongoDb cm.AutoMap(); cm.MapMember(x => x.Id).SetSerializer(new StringSerializer(BsonType.ObjectId)); + cm.UnmapMember(x => x.ConcurrencyStamp); }); } diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs index 3ad148137..0c03ebbe0 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs @@ -26,7 +26,8 @@ namespace Squidex.Infrastructure.EventSourcing Guard.NotNullOrEmpty(property, nameof(property)); return Collection.Indexes.CreateOneAsync( - new CreateIndexModel(Index.Ascending(CreateIndexPath(property)))); + new CreateIndexModel( + Index.Ascending(CreateIndexPath(property)))); } public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter = null, string position = null) diff --git a/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs b/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs index b6e633fa5..421862d29 100644 --- a/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs +++ b/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs @@ -32,7 +32,12 @@ namespace Squidex.Infrastructure.UsageTracking protected override Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) { return collection.Indexes.CreateOneAsync( - new CreateIndexModel(Index.Ascending(x => x.Key).Ascending(x => x.Category).Ascending(x => x.Date)), cancellationToken: ct); + new CreateIndexModel( + Index + .Ascending(x => x.Key) + .Ascending(x => x.Category) + .Ascending(x => x.Date)), + cancellationToken: ct); } public async Task TrackUsagesAsync(UsageUpdate update) diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index e625f2f35..0b9159022 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -58,6 +58,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// public bool IsEnabled { get; set; } + /// + /// Optional rule name. + /// + public string Name { get; set; } + /// /// The trigger properties. /// @@ -71,7 +76,22 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [JsonConverter(typeof(RuleActionConverter))] public RuleAction Action { get; set; } - public static RuleDto FromRule(IRuleEntity rule, ApiController controller, string app) + /// + /// The number of completed executions. + /// + public int NumSucceeded { get; set; } + + /// + /// The number of failed executions. + /// + public int NumFailed { get; set; } + + /// + /// The date and time when the rule was executed the last time. + /// + public Instant? LastExecuted { get; set; } + + public static RuleDto FromRule(IEnrichedRuleEntity rule, ApiController controller, string app) { var result = new RuleDto(); diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs index 7379e019a..e65e773c7 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs @@ -22,7 +22,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [Required] public RuleDto[] Items { get; set; } - public static RulesDto FromRules(IEnumerable items, ApiController controller, string app) + public static RulesDto FromRules(IEnumerable items, ApiController controller, string app) { var result = new RulesDto { diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs index adfe9099b..8833d330b 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs @@ -14,6 +14,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models { public sealed class UpdateRuleDto { + /// + /// Optional rule name. + /// + public string Name { get; set; } + /// /// The trigger properties. /// @@ -27,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models public UpdateRule ToCommand(Guid id) { - var command = new UpdateRule { RuleId = id, Action = Action }; + var command = new UpdateRule { RuleId = id, Action = Action, Name = Name }; if (Trigger != null) { diff --git a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index c34e8c117..a9f158b91 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -14,7 +14,6 @@ using Microsoft.Net.Http.Headers; using NodaTime; using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Domain.Apps.Core.HandleRules; -using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Repositories; @@ -31,17 +30,18 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiExplorerSettings(GroupName = nameof(Rules))] public sealed class RulesController : ApiController { - private readonly IAppProvider appProvider; + private readonly IRuleQueryService ruleQuery; private readonly IRuleEventRepository ruleEventsRepository; private readonly RuleRegistry ruleRegistry; - public RulesController(ICommandBus commandBus, IAppProvider appProvider, - IRuleEventRepository ruleEventsRepository, RuleRegistry ruleRegistry) + public RulesController(ICommandBus commandBus, + IRuleEventRepository ruleEventsRepository, + IRuleQueryService ruleQuery, + RuleRegistry ruleRegistry) : base(commandBus) { - this.appProvider = appProvider; - this.ruleEventsRepository = ruleEventsRepository; + this.ruleQuery = ruleQuery; this.ruleRegistry = ruleRegistry; } @@ -85,7 +85,7 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(1)] public async Task GetRules(string app) { - var rules = await appProvider.GetRulesAsync(AppId); + var rules = await ruleQuery.QueryAsync(Context); var response = Deferred.Response(() => { @@ -221,6 +221,7 @@ namespace Squidex.Areas.Api.Controllers.Rules /// Get rule events. /// /// The name of the app. + /// The optional rule id to filter to events. /// The number of events to skip. /// The number of events to take. /// @@ -232,9 +233,9 @@ namespace Squidex.Areas.Api.Controllers.Rules [ProducesResponseType(typeof(RuleEventsDto), 200)] [ApiPermission(Permissions.AppRulesRead)] [ApiCosts(0)] - public async Task GetEvents(string app, [FromQuery] int skip = 0, [FromQuery] int take = 20) + public async Task GetEvents(string app, [FromQuery] Guid? ruleId = null, [FromQuery] int skip = 0, [FromQuery] int take = 20) { - var taskForItems = ruleEventsRepository.QueryByAppAsync(AppId, skip, take); + var taskForItems = ruleEventsRepository.QueryByAppAsync(AppId, ruleId, skip, take); var taskForCount = ruleEventsRepository.CountByAppAsync(AppId); await Task.WhenAll(taskForItems, taskForCount); @@ -302,7 +303,7 @@ namespace Squidex.Areas.Api.Controllers.Rules { var context = await CommandBus.PublishAsync(command); - var result = context.Result(); + var result = context.Result(); var response = RuleDto.FromRule(result, this, app); return response; diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 628977f62..cc8cb02f8 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -38,8 +38,8 @@ using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.History.Notifications; using Squidex.Domain.Apps.Entities.Rules; -using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Indexes; +using Squidex.Domain.Apps.Entities.Rules.Queries; using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; @@ -105,9 +105,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs() - .As(); - services.AddSingletonAs() .AsSelf(); @@ -123,6 +120,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -162,6 +162,12 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsOptional(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs>() .AsSelf(); @@ -269,7 +275,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .As(); - services.AddSingletonAs>() + services.AddSingletonAs() .As(); services.AddSingletonAs() diff --git a/src/Squidex/app/features/content/pages/content/content-field.component.scss b/src/Squidex/app/features/content/pages/content/content-field.component.scss index 821dd5465..c826c0c18 100644 --- a/src/Squidex/app/features/content/pages/content/content-field.component.scss +++ b/src/Squidex/app/features/content/pages/content/content-field.component.scss @@ -8,7 +8,7 @@ } .languages-buttons { - @include absolute(.7rem, 1.25rem, auto, auto); + @include absolute(.75rem, 1.25rem, auto, auto); } .row { diff --git a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss index 0828441b3..da3352398 100644 --- a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss +++ b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss @@ -22,11 +22,11 @@ h3 { &-header { & { - padding: 1rem 1.25rem; + padding: .75rem 1.25rem; position: relative; background: $color-border; border: 0; - margin: -.7rem -1.25rem; + margin: -.75rem -1.25rem; margin-bottom: 1rem; } diff --git a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts index 7a91b0d22..f03ddbc24 100644 --- a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts +++ b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts @@ -6,8 +6,10 @@ */ import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { + ResourceOwner, RuleEventDto, RuleEventsState } from '@app/shared'; @@ -17,15 +19,23 @@ import { styleUrls: ['./rule-events-page.component.scss'], templateUrl: './rule-events-page.component.html' }) -export class RuleEventsPageComponent implements OnInit { +export class RuleEventsPageComponent extends ResourceOwner implements OnInit { public selectedEventId: string | null = null; constructor( - public readonly ruleEventsState: RuleEventsState + public readonly ruleEventsState: RuleEventsState, + private readonly route: ActivatedRoute ) { + super(); } public ngOnInit() { + this.own( + this.route.queryParams + .subscribe(x => { + this.ruleEventsState.filterByRule(x.ruleId); + })); + this.ruleEventsState.load(); } diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index bdc63a202..52f544f3f 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html @@ -26,10 +26,8 @@
-

- - - {{triggerElement.display}} +

+ {{triggerElement.display}}

@@ -80,10 +78,8 @@ -

- - - {{actionElement.display}} +

+ {{actionElement.display}}

+
+
+
+ + +
+
+ +
+
+
+
+
+
+

If

+
+
+ + + +
+
+

then

+
+
+ + + +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/rules/rule.component.scss b/src/Squidex/app/features/rules/pages/rules/rule.component.scss new file mode 100644 index 000000000..b779dbfe4 --- /dev/null +++ b/src/Squidex/app/features/rules/pages/rules/rule.component.scss @@ -0,0 +1,22 @@ +@import '_vars'; +@import '_mixins'; + +.card { + & { + @include border-radius(0); + border-bottom-width: 2px; + border-top-width: 0; + margin-bottom: .25rem; + } + + &-header, + &-body { + padding: .75rem 1.25rem; + } + + &-footer { + background: $color-border; + font-weight: normal; + font-size: 90%; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/rules/rule.component.ts b/src/Squidex/app/features/rules/pages/rules/rule.component.ts index f4ce4d4e3..64808fe9a 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/rule.component.ts @@ -17,39 +17,9 @@ import { } from '@app/shared'; @Component({ - selector: '[sqxRule]', - template: ` - - -

If

- - - - - - - -

then

- - - - - - - - - - - - - - `, + selector: 'sqx-rule', + styleUrls: ['./rule.component.scss'], + templateUrl: './rule.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class RuleComponent { @@ -65,7 +35,7 @@ export class RuleComponent { @Input() public ruleActions: ActionsDto; - @Input('sqxRule') + @Input() public rule: RuleDto; constructor( @@ -77,6 +47,10 @@ export class RuleComponent { this.rulesState.delete(this.rule); } + public rename(name: string) { + this.rulesState.rename(this.rule, name); + } + public toggle() { if (this.rule.isEnabled) { this.rulesState.disable(this.rule); diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index f13600229..d885b700e 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -32,15 +32,15 @@ - - +
diff --git a/src/Squidex/app/features/settings/pages/clients/client.component.html b/src/Squidex/app/features/settings/pages/clients/client.component.html index 1ea8ea399..e80844bcb 100644 --- a/src/Squidex/app/features/settings/pages/clients/client.component.html +++ b/src/Squidex/app/features/settings/pages/clients/client.component.html @@ -4,7 +4,7 @@
diff --git a/src/Squidex/app/features/settings/pages/clients/client.component.scss b/src/Squidex/app/features/settings/pages/clients/client.component.scss index b48af0c03..3af74219b 100644 --- a/src/Squidex/app/features/settings/pages/clients/client.component.scss +++ b/src/Squidex/app/features/settings/pages/clients/client.component.scss @@ -11,10 +11,9 @@ $color-editor: #eceeef; margin-bottom: .25rem; } - &-header { - & { - margin-bottom: .5rem; - } + &-header, + &-body { + padding: .75rem 1.25rem; } } diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html index 52ae9dba2..339049415 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html @@ -19,7 +19,7 @@
diff --git a/src/Squidex/app/framework/angular/forms/editable-title.component.html b/src/Squidex/app/framework/angular/forms/editable-title.component.html index 1d3220c26..8184027cd 100644 --- a/src/Squidex/app/framework/angular/forms/editable-title.component.html +++ b/src/Squidex/app/framework/angular/forms/editable-title.component.html @@ -1,23 +1,30 @@
- -
- - - + +
+
+
+ + + +
+
+
+ + + +
- - - - -

- {{name}} -

+
+

+ {{name || fallback}} +

- + +
\ No newline at end of file diff --git a/src/Squidex/app/framework/angular/forms/editable-title.component.scss b/src/Squidex/app/framework/angular/forms/editable-title.component.scss index e01faeb6a..8546568ef 100644 --- a/src/Squidex/app/framework/angular/forms/editable-title.component.scss +++ b/src/Squidex/app/framework/angular/forms/editable-title.component.scss @@ -2,6 +2,10 @@ @import '_mixins'; .title { + & { + position: relative; + } + &-edit { color: $color-border-dark; display: none; @@ -14,20 +18,31 @@ } &-name { - padding: .375rem 0; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 1.2rem; font-weight: normal; - line-height: 1.5rem; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - display: inline-block; + display: inline; margin: 0; } + &-view { + @include truncate; + padding: .375rem 0; + position: absolute; + border-top: 0; + border-bottom: 1px solid transparent; + line-height: 1.5rem; + } + &:hover { .title-edit { - display: inline-block; + display: inline; } } +} + +h3 { + &.fallback { + color: $color-text-decent; + } } \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/forms/editable-title.component.ts b/src/Squidex/app/framework/angular/forms/editable-title.component.ts index c8d47fbcd..8d8d30300 100644 --- a/src/Squidex/app/framework/angular/forms/editable-title.component.ts +++ b/src/Squidex/app/framework/angular/forms/editable-title.component.ts @@ -17,16 +17,31 @@ const ESCAPE_KEY = 27; }) export class EditableTitleComponent { @Output() - public nameChanged = new EventEmitter(); + public nameChange = new EventEmitter(); @Input() public disabled = false; + @Input() + public fallback: string; + @Input() public name: string; - public isRenaming = false; + @Input() + public maxLength = 20; + + @Input() + public set isRequired(value: boolean) { + const validator = + value ? + Validators.required : + Validators.nullValidator; + this.renameForm.controls['name'].setValidators(validator); + } + + public renaming = false; public renameForm = this.formBuilder.group({ name: ['', [ @@ -51,9 +66,8 @@ export class EditableTitleComponent { return; } - this.renameForm.setValue({ name: this.name }); - - this.isRenaming = !this.isRenaming; + this.renameForm.setValue({ name: this.name || '' }); + this.renaming = !this.renaming; } public rename() { @@ -64,9 +78,10 @@ export class EditableTitleComponent { if (this.renameForm.valid) { const value = this.renameForm.value; - this.nameChanged.emit(value.name); + this.nameChange.emit(value.name); + this.name = value.name; - this.toggleRename(); + this.renaming = false; } } } \ No newline at end of file diff --git a/src/Squidex/app/shared/services/rules.service.spec.ts b/src/Squidex/app/shared/services/rules.service.spec.ts index 7a4fb381b..2163a44df 100644 --- a/src/Squidex/app/shared/services/rules.service.spec.ts +++ b/src/Squidex/app/shared/services/rules.service.spec.ts @@ -274,11 +274,11 @@ describe('RulesService', () => { let rules: RuleEventsDto; - rulesService.getEvents('my-app', 10, 20).subscribe(result => { + rulesService.getEvents('my-app', 10, 20, '12').subscribe(result => { rules = result; }); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events?take=10&skip=20'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events?take=10&skip=20&ruleId=12'); expect(req.request.method).toEqual('GET'); @@ -359,6 +359,10 @@ describe('RulesService', () => { createdBy: `creator${id}`, lastModified: `${id % 1000 + 2000}-11-11T10:10`, lastModifiedBy: `modifier${id}`, + name: `Name${id}${suffix}`, + numSucceeded: id * 3, + numFailed: id * 4, + lastExecuted: `${id % 1000 + 2000}-10-10T10:10:00`, isEnabled: id % 2 === 0, trigger: { param1: 1, @@ -416,5 +420,9 @@ export function createRule(id: number, suffix = '') { param4: 4, actionType: `Webhook${id}${suffix}` }, - `Webhook${id}${suffix}`); + `Webhook${id}${suffix}`, + `Name${id}${suffix}`, + id * 3, + id * 4, + DateTime.parseISO_UTC(`${id % 1000 + 2000}-10-10T10:10:00`)); } \ No newline at end of file diff --git a/src/Squidex/app/shared/services/rules.service.ts b/src/Squidex/app/shared/services/rules.service.ts index ce66f417b..c569cf33f 100644 --- a/src/Squidex/app/shared/services/rules.service.ts +++ b/src/Squidex/app/shared/services/rules.service.ts @@ -127,7 +127,11 @@ export class RuleDto { public readonly trigger: any, public readonly triggerType: string, public readonly action: any, - public readonly actionType: string + public readonly actionType: string, + public readonly name: string, + public readonly numSucceeded: number, + public readonly numFailed: number, + public readonly lastExecuted?: DateTime ) { this._links = links; @@ -169,8 +173,9 @@ export class RuleEventDto extends Model { } export interface UpsertRuleDto { - readonly trigger: RuleAction; - readonly action: RuleAction; + readonly trigger?: RuleTrigger; + readonly action?: RuleAction; + readonly name?: string; } export type RuleAction = { actionType: string } & any; @@ -304,8 +309,8 @@ export class RulesService { pretifyError('Failed to delete rule. Please reload.')); } - public getEvents(appName: string, take: number, skip: number): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events?take=${take}&skip=${skip}`); + public getEvents(appName: string, take: number, skip: number, ruleId?: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events?take=${take}&skip=${skip}&ruleId=${ruleId || ''}`); return HTTP.getVersioned(this.http, url).pipe( map(({ payload }) => { @@ -365,5 +370,9 @@ function parseRule(response: any) { response.trigger, response.trigger.triggerType, response.action, - response.action.actionType); + response.action.actionType, + response.name, + response.numSucceeded, + response.numFailed, + response.lastExecuted ? DateTime.parseISO_UTC(response.lastExecuted) : undefined); } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/rule-events.state.spec.ts b/src/Squidex/app/shared/state/rule-events.state.spec.ts index 55a87ff1e..f993c303d 100644 --- a/src/Squidex/app/shared/state/rule-events.state.spec.ts +++ b/src/Squidex/app/shared/state/rule-events.state.spec.ts @@ -39,7 +39,7 @@ describe('RuleEventsState', () => { rulesService = Mock.ofType(); - rulesService.setup(x => x.getEvents(app, 10, 0)) + rulesService.setup(x => x.getEvents(app, 10, 0, undefined)) .returns(() => of(new RuleEventsDto(200, oldRuleEvents))); ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object); @@ -63,7 +63,7 @@ describe('RuleEventsState', () => { }); it('should load next page and prev page when paging', () => { - rulesService.setup(x => x.getEvents(app, 10, 10)) + rulesService.setup(x => x.getEvents(app, 10, 10, undefined)) .returns(() => of(new RuleEventsDto(200, []))); ruleEventsState.goNext().subscribe(); @@ -71,8 +71,19 @@ describe('RuleEventsState', () => { expect().nothing(); - rulesService.verify(x => x.getEvents(app, 10, 10), Times.once()); - rulesService.verify(x => x.getEvents(app, 10, 0), Times.exactly(2)); + rulesService.verify(x => x.getEvents(app, 10, 10, undefined), Times.once()); + rulesService.verify(x => x.getEvents(app, 10, 0, undefined), Times.exactly(2)); + }); + + it('should load with rule id when filtered', () => { + rulesService.setup(x => x.getEvents(app, 10, 0, '12')) + .returns(() => of(new RuleEventsDto(200, []))); + + ruleEventsState.filterByRule('12').subscribe(); + + expect().nothing(); + + rulesService.verify(x => x.getEvents(app, 10, 0, '12'), Times.exactly(1)); }); it('should call service when enqueuing event', () => { diff --git a/src/Squidex/app/shared/state/rule-events.state.ts b/src/Squidex/app/shared/state/rule-events.state.ts index 14da5c57e..0afa20466 100644 --- a/src/Squidex/app/shared/state/rule-events.state.ts +++ b/src/Squidex/app/shared/state/rule-events.state.ts @@ -6,7 +6,7 @@ */ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { empty, Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { @@ -29,6 +29,9 @@ interface Snapshot { // Indicates if the rule events are loaded. isLoaded?: boolean; + + // The current rule id. + ruleId?: string; } @Injectable() @@ -61,7 +64,8 @@ export class RuleEventsState extends State { private loadInternal(isReload = false): Observable { return this.rulesService.getEvents(this.appName, this.snapshot.ruleEventsPager.pageSize, - this.snapshot.ruleEventsPager.skip).pipe( + this.snapshot.ruleEventsPager.skip, + this.snapshot.ruleId).pipe( tap(({ total, items: ruleEvents }) => { if (isReload) { this.dialogs.notifyInfo('RuleEvents reloaded.'); @@ -96,6 +100,16 @@ export class RuleEventsState extends State { shareSubscribed(this.dialogs)); } + public filterByRule(ruleId?: string) { + if (ruleId === this.snapshot.ruleId) { + return empty(); + } + + this.next(s => ({ ...s, ruleEventsPager: new Pager(0), ruleId })); + + return this.loadInternal(); + } + public goNext(): Observable { this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.goNext() })); diff --git a/src/Squidex/app/shared/state/rules.state.spec.ts b/src/Squidex/app/shared/state/rules.state.spec.ts index 41ca3c150..2100e387e 100644 --- a/src/Squidex/app/shared/state/rules.state.spec.ts +++ b/src/Squidex/app/shared/state/rules.state.spec.ts @@ -124,6 +124,21 @@ describe('RulesState', () => { expect(rule1New).toEqual(updated); }); + it('should update rule when renamed', () => { + const newName = 'NewName'; + + const updated = createRule(1, '_new'); + + rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) + .returns(() => of(updated)).verifiable(); + + rulesState.rename(rule1, newName).subscribe(); + + const rule1New = rulesState.snapshot.rules[0]; + + expect(rule1New).toEqual(updated); + }); + it('should update rule when enabled', () => { const updated = createRule(1, '_new'); diff --git a/src/Squidex/app/shared/state/rules.state.ts b/src/Squidex/app/shared/state/rules.state.ts index a4f844191..6df8de2a9 100644 --- a/src/Squidex/app/shared/state/rules.state.ts +++ b/src/Squidex/app/shared/state/rules.state.ts @@ -124,6 +124,14 @@ export class RulesState extends State { shareSubscribed(this.dialogs)); } + public rename(rule: RuleDto, name: string): Observable { + return this.rulesService.putRule(this.appName, rule, { name }, rule.version).pipe( + tap(updated => { + this.replaceRule(updated); + }), + shareSubscribed(this.dialogs)); + } + public enable(rule: RuleDto): Observable { return this.rulesService.enableRule(this.appName, rule, rule.version).pipe( tap(updated => { diff --git a/src/Squidex/app/theme/_bootstrap.scss b/src/Squidex/app/theme/_bootstrap.scss index e886dfdc6..c785394e0 100644 --- a/src/Squidex/app/theme/_bootstrap.scss +++ b/src/Squidex/app/theme/_bootstrap.scss @@ -535,7 +535,7 @@ a { &-tabs { background: $color-border; position: relative; - padding: 1rem 1.25rem; + padding: .75rem 1.25rem; height: 70px; } diff --git a/src/Squidex/app/theme/_forms.scss b/src/Squidex/app/theme/_forms.scss index f25da337c..90cb21331 100644 --- a/src/Squidex/app/theme/_forms.scss +++ b/src/Squidex/app/theme/_forms.scss @@ -32,7 +32,7 @@ // Small triangle under the error tooltip with the border trick. &::after { - @include absolute(auto, auto, -.7rem, .625rem); + @include absolute(auto, auto, -.75rem, .625rem); content: ''; height: 0; border-style: solid; @@ -166,7 +166,7 @@ label { // Search icon that is placed within the form control. .icon-search { - @include absolute(.7rem, .7rem, auto, auto); + @include absolute(.75rem, .75rem, auto, auto); color: $color-dark2-focus-foreground; font-size: 1.1rem; font-weight: lighter; diff --git a/src/Squidex/app/theme/_lists.scss b/src/Squidex/app/theme/_lists.scss index c6778d3c4..756958614 100644 --- a/src/Squidex/app/theme/_lists.scss +++ b/src/Squidex/app/theme/_lists.scss @@ -13,7 +13,7 @@ td { // Unified padding for all table cells. & { - padding: .7rem; + padding: .75rem; } // Additional padding for the first column. @@ -95,7 +95,7 @@ // &-row { & { - padding: 1rem 1.25rem; + padding: .75rem 1.25rem; background: $color-table-background; border: 1px solid $color-border; border-top: 0; @@ -105,7 +105,7 @@ // Summary row for expandable rows. &-summary { - padding: 1rem 1.25rem; + padding: .75rem 1.25rem; position: relative; line-height: 2.5rem; } @@ -131,7 +131,7 @@ } &-tab { - padding: 1rem 1.25rem 1.25rem; + padding: .75rem 1.25rem 1.25rem; } &-tabs { @@ -146,7 +146,7 @@ // Footer that typically contains an add-item-form. &-header, &-footer { - padding: 1rem 1.25rem; + padding: .75rem 1.25rem; background: $color-table-footer; border: 1px solid $color-border; border-bottom-width: 2px; diff --git a/src/Squidex/app/theme/_panels.scss b/src/Squidex/app/theme/_panels.scss index 4eb061ed4..cfba56e73 100644 --- a/src/Squidex/app/theme/_panels.scss +++ b/src/Squidex/app/theme/_panels.scss @@ -393,7 +393,7 @@ a { } th { - padding: .7rem; + padding: .75rem; } .table-items { diff --git a/src/Squidex/app/theme/_static.scss b/src/Squidex/app/theme/_static.scss index 49d30864c..909b3dfe9 100644 --- a/src/Squidex/app/theme/_static.scss +++ b/src/Squidex/app/theme/_static.scss @@ -86,7 +86,7 @@ noscript { display: inline-block; background: $color-dark-foreground; border: 0; - bottom: -.7rem; + bottom: -.75rem; position: relative; padding: 0 1rem; } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs index 3f0804427..eeaf343b7 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs @@ -97,6 +97,14 @@ namespace Squidex.Domain.Apps.Core.Model.Rules Assert.False(rule_2.IsEnabled); } + [Fact] + public void Should_replace_name_when_renaming() + { + var rule_1 = rule_0.Rename("MyName"); + + Assert.Equal("MyName", rule_1.Name); + } + [Fact] public void Should_replace_trigger_when_updating() { diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs index e4c41dc3e..636c85caa 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs @@ -250,7 +250,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Equal(enrichedEvent.AppId.Id, job.AppId); - Assert.NotEqual(Guid.Empty, job.JobId); + Assert.NotEqual(Guid.Empty, job.Id); A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, A>.That.Matches(x => x.Payload == @event.Payload))) .MustHaveHappened(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs index 4eb06a70e..e253c5ddc 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs @@ -89,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes A.CallTo(() => indexByName.GetIdAsync(appId.Name)) .Returns(appId.Id); - var actual = await sut.GetAppAsync(appId.Name); + var actual = await sut.GetAppByNameAsync(appId.Name); Assert.Same(expected, actual); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs index e88a90d91..263b27a7a 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs @@ -24,7 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards public class GuardRuleTests { private readonly Uri validUrl = new Uri("https://squidex.io"); - private readonly Rule rule_0 = new Rule(new ContentChangedTriggerV2(), new TestAction()); + private readonly Rule rule_0 = new Rule(new ContentChangedTriggerV2(), new TestAction()).Rename("MyName"); private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); private readonly IAppProvider appProvider = A.Fake(); @@ -95,12 +95,24 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards { var command = new UpdateRule(); - await ValidationAssert.ThrowsAsync(() => GuardRule.CanUpdate(command, appId.Id, appProvider), - new ValidationError("Either trigger or action is required.", "Trigger", "Action")); + await ValidationAssert.ThrowsAsync(() => GuardRule.CanUpdate(command, appId.Id, appProvider, rule_0), + new ValidationError("Either trigger, action or name is required.", "Trigger", "Action")); } [Fact] - public async Task CanUpdate_should_not_throw_exception_if_trigger_and_action_valid() + public async Task CanUpdate_should_throw_exception_if_rule_has_already_this_name() + { + var command = new UpdateRule + { + Name = "MyName" + }; + + await ValidationAssert.ThrowsAsync(() => GuardRule.CanUpdate(command, appId.Id, appProvider, rule_0), + new ValidationError("Rule has already this name.", "Name")); + } + + [Fact] + public async Task CanUpdate_should_not_throw_exception_if_trigger_action__and_name_are_valid() { var command = new UpdateRule { @@ -111,10 +123,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards Action = new TestAction { Url = validUrl - } + }, + Name = "NewName" }; - await GuardRule.CanUpdate(command, appId.Id, appProvider); + await GuardRule.CanUpdate(command, appId.Id, appProvider, rule_0); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs index f547607cc..b1fb9cf12 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs @@ -116,23 +116,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes private IRuleEntity SetupRule(long version, bool deleted) { - var ruleEntity = A.Fake(); - var ruleId = Guid.NewGuid(); - A.CallTo(() => ruleEntity.Id) - .Returns(ruleId); - A.CallTo(() => ruleEntity.AppId) - .Returns(appId); - A.CallTo(() => ruleEntity.Version) - .Returns(version); - A.CallTo(() => ruleEntity.IsDeleted) - .Returns(deleted); - + var ruleEntity = new RuleEntity { Id = ruleId, AppId = appId, Version = version, IsDeleted = deleted }; var ruleGrain = A.Fake(); A.CallTo(() => ruleGrain.GetStateAsync()) - .Returns(J.Of(ruleEntity)); + .Returns(J.Of(ruleEntity)); A.CallTo(() => grainFactory.GetGrain(ruleId, null)) .Returns(ruleGrain); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs new file mode 100644 index 000000000..5aec8d677 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs @@ -0,0 +1,76 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FakeItEasy; +using NodaTime; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Rules.Queries +{ + public class RuleEnricherTests + { + private readonly IRuleEventRepository ruleEventRepository = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly Context requestContext = Context.Anonymous(); + private readonly RuleEnricher sut; + + public RuleEnricherTests() + { + sut = new RuleEnricher(ruleEventRepository); + } + + [Fact] + public async Task Should_not_enrich_if_statistics_not_found() + { + var source = new RuleEntity { AppId = appId }; + + var result = await sut.EnrichAsync(source, requestContext); + + Assert.Equal(0, result.NumFailed); + Assert.Equal(0, result.NumSucceeded); + Assert.Null(result.LastExecuted); + } + + [Fact] + public async Task Should_enrich_rules_with_found_statistics() + { + var source1 = new RuleEntity { AppId = appId, Id = Guid.NewGuid() }; + var source2 = new RuleEntity { AppId = appId, Id = Guid.NewGuid() }; + + var stats = new RuleStatistics + { + RuleId = source1.Id, + NumFailed = 12, + NumSucceeded = 17, + LastExecuted = SystemClock.Instance.GetCurrentInstant() + }; + + A.CallTo(() => ruleEventRepository.QueryStatisticsByAppAsync(appId.Id)) + .Returns(new List { stats }); + + var result = await sut.EnrichAsync(new[] { source1, source2 }, requestContext); + + var enriched1 = result.ElementAt(0); + + Assert.Equal(12, enriched1.NumFailed); + Assert.Equal(17, enriched1.NumSucceeded); + Assert.Equal(stats.LastExecuted, enriched1.LastExecuted); + + var enriched2 = result.ElementAt(1); + + Assert.Equal(0, enriched2.NumFailed); + Assert.Equal(0, enriched2.NumSucceeded); + Assert.Null(enriched2.LastExecuted); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleQueryServiceTests.cs new file mode 100644 index 000000000..6c04345c2 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleQueryServiceTests.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Rules.Indexes; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Rules.Queries +{ + public class RuleQueryServiceTests + { + private readonly IRulesIndex rulesIndex = A.Fake(); + private readonly IRuleEnricher ruleEnricher = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly Context requestContext = Context.Anonymous(); + private readonly RuleQueryService sut; + + public RuleQueryServiceTests() + { + requestContext.App = Mocks.App(appId); + + sut = new RuleQueryService(rulesIndex, ruleEnricher); + } + + [Fact] + public async Task Should_get_rules_from_index_and_enrich() + { + var original = new List + { + new RuleEntity() + }; + + var enriched = new List + { + new RuleEntity() + }; + + A.CallTo(() => rulesIndex.GetRulesAsync(appId.Id)) + .Returns(original); + + A.CallTo(() => ruleEnricher.EnrichAsync(original, requestContext)) + .Returns(enriched); + + var result = await sut.QueryAsync(requestContext); + + Assert.Same(enriched, result); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleCommandMiddlewareTests.cs new file mode 100644 index 000000000..0aa91b14a --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleCommandMiddlewareTests.cs @@ -0,0 +1,96 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Orleans; +using Squidex.Domain.Apps.Entities.Rules.State; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure.Commands; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class RuleCommandMiddlewareTests : HandlerTestBase + { + private readonly IRuleEnricher ruleEnricher = A.Fake(); + private readonly IContextProvider contextProvider = A.Fake(); + private readonly Guid ruleId = Guid.NewGuid(); + private readonly Context requestContext = Context.Anonymous(); + private readonly RuleCommandMiddleware sut; + + public sealed class MyCommand : SquidexCommand + { + } + + protected override Guid Id + { + get { return ruleId; } + } + + public RuleCommandMiddlewareTests() + { + A.CallTo(() => contextProvider.Context) + .Returns(requestContext); + + sut = new RuleCommandMiddleware(A.Fake(), ruleEnricher, contextProvider); + } + + [Fact] + public async Task Should_not_invoke_enricher_for_other_result() + { + var command = CreateCommand(new MyCommand()); + var context = CreateContextForCommand(command); + + context.Complete(12); + + await sut.HandleAsync(context); + + A.CallTo(() => ruleEnricher.EnrichAsync(A.Ignored, requestContext)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_invoke_enricher_if_already_enriched() + { + var result = new RuleEntity(); + + var command = CreateCommand(new MyCommand()); + var context = CreateContextForCommand(command); + + context.Complete(result); + + await sut.HandleAsync(context); + + Assert.Same(result, context.Result()); + + A.CallTo(() => ruleEnricher.EnrichAsync(A.Ignored, requestContext)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_enrich_rule_result() + { + var result = A.Fake(); + + var command = CreateCommand(new MyCommand()); + var context = CreateContextForCommand(command); + + context.Complete(result); + + var enriched = new RuleEntity(); + + A.CallTo(() => ruleEnricher.EnrichAsync(result, requestContext)) + .Returns(enriched); + + await sut.HandleAsync(context); + + Assert.Same(enriched, context.Result()); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs index bb5984aad..8b8ce419d 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs @@ -56,16 +56,18 @@ namespace Squidex.Domain.Apps.Entities.Rules A.CallTo(() => ruleService.InvokeAsync(@event.Job.ActionName, @event.Job.ActionData)) .Returns((Result.Create(requestDump, result), requestElapsed)); + var now = clock.GetCurrentInstant(); + Instant? nextCall = null; if (minutes > 0) { - nextCall = clock.GetCurrentInstant().Plus(Duration.FromMinutes(minutes)); + nextCall = now.Plus(Duration.FromMinutes(minutes)); } await sut.HandleAsync(@event); - A.CallTo(() => ruleEventRepository.MarkSentAsync(@event.Id, requestDump, result, jobResult, requestElapsed, nextCall)) + A.CallTo(() => ruleEventRepository.MarkSentAsync(@event.Job, requestDump, result, jobResult, requestElapsed, now, nextCall)) .MustHaveHappened(); } @@ -75,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var job = new RuleJob { - JobId = Guid.NewGuid(), + Id = Guid.NewGuid(), ActionData = actionData, ActionName = actionName, Created = clock.GetCurrentInstant() diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs index 363b8f1ef..ed212c188 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs @@ -75,11 +75,8 @@ namespace Squidex.Domain.Apps.Entities.Rules var job1 = new RuleJob { Created = now }; - var ruleEntity1 = A.Fake(); - var ruleEntity2 = A.Fake(); - - A.CallTo(() => ruleEntity1.RuleDef).Returns(rule1); - A.CallTo(() => ruleEntity2.RuleDef).Returns(rule2); + var ruleEntity1 = new RuleEntity { RuleDef = rule1 }; + var ruleEntity2 = new RuleEntity { RuleDef = rule2 }; A.CallTo(() => appProvider.GetRulesAsync(appId.Id)) .Returns(new List { ruleEntity1, ruleEntity2 }); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs index 4f3773f9b..cd7a64140 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs @@ -87,9 +87,11 @@ namespace Squidex.Domain.Apps.Entities.Rules Assert.Same(command.Trigger, sut.Snapshot.RuleDef.Trigger); Assert.Same(command.Action, sut.Snapshot.RuleDef.Action); + Assert.Equal(command.Name, sut.Snapshot.RuleDef.Name); + LastEvents .ShouldHaveSameEvents( - CreateRuleEvent(new RuleUpdated { Trigger = command.Trigger, Action = command.Action }) + CreateRuleEvent(new RuleUpdated { Trigger = command.Trigger, Action = command.Action, Name = "NewName" }) ); } @@ -214,7 +216,7 @@ namespace Squidex.Domain.Apps.Entities.Rules Url = new Uri("https://squidex.io/v2") }; - return new UpdateRule { Trigger = newTrigger, Action = newAction }; + return new UpdateRule { Trigger = newTrigger, Action = newAction, Name = "NewName" }; } } } \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs index 7d171fe9e..7f73232cf 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs @@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name)) .Returns(schema.Id); - var actual = await sut.GetSchemaAsync(appId.Id, schema.SchemaDef.Name); + var actual = await sut.GetSchemaByNameAsync(appId.Id, schema.SchemaDef.Name); Assert.Same(actual, schema); } diff --git a/tests/Squidex.Infrastructure.Tests/Log/JsonLogWriterTests.cs b/tests/Squidex.Infrastructure.Tests/Log/JsonLogWriterTests.cs index 364a6d3a5..3d4ad0ecd 100644 --- a/tests/Squidex.Infrastructure.Tests/Log/JsonLogWriterTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Log/JsonLogWriterTests.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using Newtonsoft.Json; using NodaTime; using Xunit;