Browse Source

Rule Improvements (#424)

* Rule names and statistics
pull/426/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
e8213d43ba
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs
  2. 4
      src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs
  3. 7
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  4. 5
      src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs
  5. 42
      src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs
  6. 90
      src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs
  7. 4
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  8. 2
      src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
  9. 2
      src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
  10. 4
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  11. 2
      src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs
  12. 11
      src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs
  13. 20
      src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs
  14. 19
      src/Squidex.Domain.Apps.Entities/Rules/IRuleEnricher.cs
  15. 17
      src/Squidex.Domain.Apps.Entities/Rules/IRuleQueryService.cs
  16. 80
      src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs
  17. 38
      src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleQueryService.cs
  18. 6
      src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs
  19. 25
      src/Squidex.Domain.Apps.Entities/Rules/Repositories/RuleStatistics.cs
  20. 49
      src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs
  21. 4
      src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs
  22. 46
      src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs
  23. 2
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  24. 6
      src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs
  25. 2
      src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs
  26. 2
      src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs
  27. 2
      src/Squidex.Domain.Apps.Events/Rules/RuleCreated.cs
  28. 2
      src/Squidex.Domain.Apps.Events/Rules/RuleUpdated.cs
  29. 1
      src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs
  30. 3
      src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  31. 7
      src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs
  32. 22
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs
  33. 2
      src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs
  34. 7
      src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs
  35. 21
      src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  36. 16
      src/Squidex/Config/Domain/EntitiesServices.cs
  37. 2
      src/Squidex/app/features/content/pages/content/content-field.component.scss
  38. 4
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss
  39. 14
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts
  40. 12
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html
  41. 6
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss
  42. 74
      src/Squidex/app/features/rules/pages/rules/rule.component.html
  43. 22
      src/Squidex/app/features/rules/pages/rules/rule.component.scss
  44. 42
      src/Squidex/app/features/rules/pages/rules/rule.component.ts
  45. 6
      src/Squidex/app/features/rules/pages/rules/rules-page.component.html
  46. 2
      src/Squidex/app/features/settings/pages/clients/client.component.html
  47. 7
      src/Squidex/app/features/settings/pages/clients/client.component.scss
  48. 2
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  49. 23
      src/Squidex/app/framework/angular/forms/editable-title.component.html
  50. 27
      src/Squidex/app/framework/angular/forms/editable-title.component.scss
  51. 29
      src/Squidex/app/framework/angular/forms/editable-title.component.ts
  52. 14
      src/Squidex/app/shared/services/rules.service.spec.ts
  53. 21
      src/Squidex/app/shared/services/rules.service.ts
  54. 19
      src/Squidex/app/shared/state/rule-events.state.spec.ts
  55. 18
      src/Squidex/app/shared/state/rule-events.state.ts
  56. 15
      src/Squidex/app/shared/state/rules.state.spec.ts
  57. 8
      src/Squidex/app/shared/state/rules.state.ts
  58. 2
      src/Squidex/app/theme/_bootstrap.scss
  59. 4
      src/Squidex/app/theme/_forms.scss
  60. 10
      src/Squidex/app/theme/_lists.scss
  61. 2
      src/Squidex/app/theme/_panels.scss
  62. 2
      src/Squidex/app/theme/_static.scss
  63. 8
      tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs
  64. 2
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  65. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs
  66. 25
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs
  67. 14
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs
  68. 76
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs
  69. 58
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleQueryServiceTests.cs
  70. 96
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleCommandMiddlewareTests.cs
  71. 8
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs
  72. 7
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  73. 6
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs
  74. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs
  75. 1
      tests/Squidex.Infrastructure.Tests/Log/JsonLogWriterTests.cs

15
src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs

@ -15,8 +15,14 @@ namespace Squidex.Domain.Apps.Core.Rules
{ {
private RuleTrigger trigger; private RuleTrigger trigger;
private RuleAction action; private RuleAction action;
private string name;
private bool isEnabled = true; private bool isEnabled = true;
public string Name
{
get { return name; }
}
public RuleTrigger Trigger public RuleTrigger Trigger
{ {
get { return trigger; } get { return trigger; }
@ -44,6 +50,15 @@ namespace Squidex.Domain.Apps.Core.Rules
this.action.Freeze(); this.action.Freeze();
} }
[Pure]
public Rule Rename(string name)
{
return Clone(clone =>
{
clone.name = name;
});
}
[Pure] [Pure]
public Rule Enable() public Rule Enable()
{ {

4
src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs

@ -12,10 +12,12 @@ namespace Squidex.Domain.Apps.Core.Rules
{ {
public sealed class RuleJob public sealed class RuleJob
{ {
public Guid JobId { get; set; } public Guid Id { get; set; }
public Guid AppId { get; set; } public Guid AppId { get; set; }
public Guid RuleId { get; set; }
public string EventName { get; set; } public string EventName { get; set; }
public string ActionName { get; set; } public string ActionName { get; set; }

7
src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs

@ -140,15 +140,16 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var job = new RuleJob var job = new RuleJob
{ {
JobId = Guid.NewGuid(), Id = Guid.NewGuid(),
ActionName = actionName,
ActionData = json, ActionData = json,
ActionName = actionName,
AppId = enrichedEvent.AppId.Id, AppId = enrichedEvent.AppId.Id,
Created = now, Created = now,
Description = actionData.Description,
EventName = enrichedEvent.Name, EventName = enrichedEvent.Name,
ExecutionPartition = enrichedEvent.Partition, ExecutionPartition = enrichedEvent.Partition,
Expires = expires, Expires = expires,
Description = actionData.Description RuleId = ruleId
}; };
return job; return job;

5
src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs

@ -23,6 +23,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
[BsonRepresentation(BsonType.String)] [BsonRepresentation(BsonType.String)]
public Guid AppId { get; set; } public Guid AppId { get; set; }
[BsonIgnoreIfDefault]
[BsonElement]
[BsonRepresentation(BsonType.String)]
public Guid RuleId { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]
[BsonRepresentation(BsonType.String)] [BsonRepresentation(BsonType.String)]

42
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<MongoRuleEventEntity>, IRuleEventRepository public sealed class MongoRuleEventRepository : MongoRepositoryBase<MongoRuleEventEntity>, IRuleEventRepository
{ {
private readonly MongoRuleStatisticsCollection statisticsCollection;
public MongoRuleEventRepository(IMongoDatabase database) public MongoRuleEventRepository(IMongoDatabase database)
: base(database) : base(database)
{ {
statisticsCollection = new MongoRuleStatisticsCollection(database);
} }
protected override string CollectionName() protected override string CollectionName()
@ -32,9 +35,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
return "RuleEvents"; return "RuleEvents";
} }
protected override Task SetupCollectionAsync(IMongoCollection<MongoRuleEventEntity> collection, CancellationToken ct = default) protected override async Task SetupCollectionAsync(IMongoCollection<MongoRuleEventEntity> collection, CancellationToken ct = default)
{ {
return collection.Indexes.CreateManyAsync(new[] await statisticsCollection.InitializeAsync(ct);
await collection.Indexes.CreateManyAsync(new[]
{ {
new CreateIndexModel<MongoRuleEventEntity>(Index.Ascending(x => x.NextAttempt)), new CreateIndexModel<MongoRuleEventEntity>(Index.Ascending(x => x.NextAttempt)),
new CreateIndexModel<MongoRuleEventEntity>(Index.Ascending(x => x.AppId).Descending(x => x.Created)), new CreateIndexModel<MongoRuleEventEntity>(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); return Collection.Find(x => x.NextAttempt < now).ForEachAsync(callback, ct);
} }
public async Task<IReadOnlyList<IRuleEventEntity>> QueryByAppAsync(Guid appId, int skip = 0, int take = 20) public async Task<IReadOnlyList<IRuleEventEntity>> 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 = 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(); .ToListAsync();
return ruleEventEntities; return ruleEventEntities;
@ -83,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
public Task EnqueueAsync(RuleJob job, Instant nextAttempt) 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); return Collection.InsertOneIfNotExistsAsync(entity);
} }
@ -96,15 +108,29 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
.Set(x => x.JobResult, RuleJobResult.Cancelled)); .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)
{
if (result == RuleResult.Success)
{ {
return Collection.UpdateOneAsync(x => x.Id == jobId, 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 Update
.Set(x => x.Result, result) .Set(x => x.Result, result)
.Set(x => x.LastDump, dump) .Set(x => x.LastDump, dump)
.Set(x => x.JobResult, jobResult) .Set(x => x.JobResult, jobResult)
.Set(x => x.NextAttempt, nextAttempt) .Set(x => x.NextAttempt, nextCall)
.Inc(x => x.NumCalls, 1)); .Inc(x => x.NumCalls, 1));
} }
public Task<IReadOnlyList<RuleStatistics>> QueryStatisticsByAppAsync(Guid appId)
{
return statisticsCollection.QueryByAppAsync(appId);
}
} }
} }

90
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<RuleStatistics>
{
static MongoRuleStatisticsCollection()
{
var guidSerializer = new GuidSerializer().WithRepresentation(BsonType.String);
BsonClassMap.RegisterClassMap<RuleStatistics>(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<RuleStatistics> collection, CancellationToken ct = default)
{
return collection.Indexes.CreateOneAsync(
new CreateIndexModel<RuleStatistics>(
Index
.Ascending(x => x.AppId)
.Ascending(x => x.RuleId)),
cancellationToken: ct);
}
public async Task<IReadOnlyList<RuleStatistics>> 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);
}
}
}

4
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities
{ {
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => 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 localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () =>
{ {
return await indexSchemas.GetSchemaAsync(appId, name); return await indexSchemas.GetSchemaByNameAsync(appId, name);
}); });
} }

2
src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs

@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
} }
} }
public async Task<IAppEntity> GetAppAsync(string name) public async Task<IAppEntity> GetAppByNameAsync(string name)
{ {
using (Profiler.TraceMethod<AppsIndex>()) using (Profiler.TraceMethod<AppsIndex>())
{ {

2
src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions); Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions);
Task<IAppEntity> GetAppAsync(string name); Task<IAppEntity> GetAppByNameAsync(string name);
Task<IAppEntity> GetAppAsync(Guid appId); Task<IAppEntity> GetAppAsync(Guid appId);

4
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -28,12 +28,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Instant LastModified { get; set; } public Instant LastModified { get; set; }
public ScheduleJob ScheduleJob { get; set; }
public RefToken CreatedBy { get; set; } public RefToken CreatedBy { get; set; }
public RefToken LastModifiedBy { get; set; } public RefToken LastModifiedBy { get; set; }
public ScheduleJob ScheduleJob { get; set; }
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
public NamedContentData DataDraft { get; set; } public NamedContentData DataDraft { get; set; }

2
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 RuleTrigger Trigger { get; set; }
public RuleAction Action { get; set; } public RuleAction Action { get; set; }
public string Name { get; set; }
} }
} }

11
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)); Guard.NotNull(command, nameof(command));
return Validate.It(() => "Cannot update rule.", async e => 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) if (command.Trigger != null)
@ -70,6 +70,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
errors.Foreach(x => x.AddTo(e)); errors.Foreach(x => x.AddTo(e));
} }
if (command.Name != null && string.Equals(rule.Name, command.Name))
{
e(Not.New("Rule", "name"), nameof(command.Name));
}
}); });
} }

20
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; }
}
}

19
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<IEnrichedRuleEntity> EnrichAsync(IRuleEntity rule, Context context);
Task<IReadOnlyList<IEnrichedRuleEntity>> EnrichAsync(IEnumerable<IRuleEntity> rules, Context context);
}
}

17
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<IReadOnlyList<IEnrichedRuleEntity>> QueryAsync(Context context);
}
}

80
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<IEnrichedRuleEntity> 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<IReadOnlyList<IEnrichedRuleEntity>> EnrichAsync(IEnumerable<IRuleEntity> rules, Context context)
{
Guard.NotNull(rules, nameof(rules));
Guard.NotNull(context, nameof(context));
using (Profiler.TraceMethod<RuleEnricher>())
{
var results = new List<RuleEntity>();
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<object>
{
statistic.LastExecuted
};
}
}
}
return results;
}
}
}
}

38
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<IReadOnlyList<IEnrichedRuleEntity>> QueryAsync(Context context)
{
var rules = await rulesIndex.GetRulesAsync(context.App.Id);
var enriched = await ruleEnricher.EnrichAsync(rules, context);
return enriched;
}
}
}

6
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 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<IRuleEventEntity, Task> callback, CancellationToken ct = default); Task QueryPendingAsync(Instant now, Func<IRuleEventEntity, Task> callback, CancellationToken ct = default);
Task<int> CountByAppAsync(Guid appId); Task<int> CountByAppAsync(Guid appId);
Task<IReadOnlyList<IRuleEventEntity>> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); Task<IReadOnlyList<RuleStatistics>> QueryStatisticsByAppAsync(Guid appId);
Task<IReadOnlyList<IRuleEventEntity>> QueryByAppAsync(Guid appId, Guid? ruleId = null, int skip = 0, int take = 20);
Task<IRuleEventEntity> FindAsync(Guid id); Task<IRuleEventEntity> FindAsync(Guid id);
} }

25
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; }
}
}

49
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<RuleCommand, IRuleGrain>
{
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<Task> 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);
}
}
}

4
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 jobInvoke = ComputeJobInvoke(response.Status, @event, job);
var jobResult = ComputeJobResult(response.Status, jobInvoke); 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) catch (Exception ex)
{ {

46
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<Guid> AppId { get; set; }
public NamedId<Guid> 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<object> CacheDependencies { get; set; }
}
}

2
src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs

@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
case UpdateRule updateRule: case UpdateRule updateRule:
return UpdateReturnAsync(updateRule, async c => 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); Update(c);

6
src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs

@ -36,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.State
case RuleCreated e: case RuleCreated e:
{ {
RuleDef = new Rule(e.Trigger, e.Action); RuleDef = new Rule(e.Trigger, e.Action);
RuleDef = RuleDef.Rename(e.Name);
AppId = e.AppId; AppId = e.AppId;
@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.State
RuleDef = RuleDef.Update(e.Action); RuleDef = RuleDef.Update(e.Action);
} }
if (e.Name != null)
{
RuleDef = RuleDef.Rename(e.Name);
}
break; break;
} }

2
src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
{ {
Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false);
Task<ISchemaEntity> GetSchemaAsync(Guid appId, string name, bool allowDeleted = false); Task<ISchemaEntity> GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false);
Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId, bool allowDeleted = false); Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId, bool allowDeleted = false);

2
src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs

@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
} }
} }
public async Task<ISchemaEntity> GetSchemaAsync(Guid appId, string name, bool allowDeleted = false) public async Task<ISchemaEntity> GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false)
{ {
using (Profiler.TraceMethod<SchemasIndex>()) using (Profiler.TraceMethod<SchemasIndex>())
{ {

2
src/Squidex.Domain.Apps.Events/Rules/RuleCreated.cs

@ -18,6 +18,8 @@ namespace Squidex.Domain.Apps.Events.Rules
public RuleAction Action { get; set; } public RuleAction Action { get; set; }
public string Name { get; set; }
public IEvent Migrate() public IEvent Migrate()
{ {
if (Trigger is IMigrated<RuleTrigger> migrated) if (Trigger is IMigrated<RuleTrigger> migrated)

2
src/Squidex.Domain.Apps.Events/Rules/RuleUpdated.cs

@ -18,6 +18,8 @@ namespace Squidex.Domain.Apps.Events.Rules
public RuleAction Action { get; set; } public RuleAction Action { get; set; }
public string Name { get; set; }
public IEvent Migrate() public IEvent Migrate()
{ {
if (Trigger is IMigrated<RuleTrigger> migrated) if (Trigger is IMigrated<RuleTrigger> migrated)

1
src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs

@ -26,6 +26,7 @@ namespace Squidex.Domain.Users.MongoDb
cm.AutoMap(); cm.AutoMap();
cm.MapMember(x => x.Id).SetSerializer(new StringSerializer(BsonType.ObjectId)); cm.MapMember(x => x.Id).SetSerializer(new StringSerializer(BsonType.ObjectId));
cm.UnmapMember(x => x.ConcurrencyStamp); cm.UnmapMember(x => x.ConcurrencyStamp);
}); });
} }

3
src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs

@ -26,7 +26,8 @@ namespace Squidex.Infrastructure.EventSourcing
Guard.NotNullOrEmpty(property, nameof(property)); Guard.NotNullOrEmpty(property, nameof(property));
return Collection.Indexes.CreateOneAsync( return Collection.Indexes.CreateOneAsync(
new CreateIndexModel<MongoEventCommit>(Index.Ascending(CreateIndexPath(property)))); new CreateIndexModel<MongoEventCommit>(
Index.Ascending(CreateIndexPath(property))));
} }
public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter = null, string position = null) public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter = null, string position = null)

7
src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageRepository.cs

@ -32,7 +32,12 @@ namespace Squidex.Infrastructure.UsageTracking
protected override Task SetupCollectionAsync(IMongoCollection<MongoUsage> collection, CancellationToken ct = default) protected override Task SetupCollectionAsync(IMongoCollection<MongoUsage> collection, CancellationToken ct = default)
{ {
return collection.Indexes.CreateOneAsync( return collection.Indexes.CreateOneAsync(
new CreateIndexModel<MongoUsage>(Index.Ascending(x => x.Key).Ascending(x => x.Category).Ascending(x => x.Date)), cancellationToken: ct); new CreateIndexModel<MongoUsage>(
Index
.Ascending(x => x.Key)
.Ascending(x => x.Category)
.Ascending(x => x.Date)),
cancellationToken: ct);
} }
public async Task TrackUsagesAsync(UsageUpdate update) public async Task TrackUsagesAsync(UsageUpdate update)

22
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs

@ -58,6 +58,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// </summary> /// </summary>
public bool IsEnabled { get; set; } public bool IsEnabled { get; set; }
/// <summary>
/// Optional rule name.
/// </summary>
public string Name { get; set; }
/// <summary> /// <summary>
/// The trigger properties. /// The trigger properties.
/// </summary> /// </summary>
@ -71,7 +76,22 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
[JsonConverter(typeof(RuleActionConverter))] [JsonConverter(typeof(RuleActionConverter))]
public RuleAction Action { get; set; } public RuleAction Action { get; set; }
public static RuleDto FromRule(IRuleEntity rule, ApiController controller, string app) /// <summary>
/// The number of completed executions.
/// </summary>
public int NumSucceeded { get; set; }
/// <summary>
/// The number of failed executions.
/// </summary>
public int NumFailed { get; set; }
/// <summary>
/// The date and time when the rule was executed the last time.
/// </summary>
public Instant? LastExecuted { get; set; }
public static RuleDto FromRule(IEnrichedRuleEntity rule, ApiController controller, string app)
{ {
var result = new RuleDto(); var result = new RuleDto();

2
src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs

@ -22,7 +22,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
[Required] [Required]
public RuleDto[] Items { get; set; } public RuleDto[] Items { get; set; }
public static RulesDto FromRules(IEnumerable<IRuleEntity> items, ApiController controller, string app) public static RulesDto FromRules(IEnumerable<IEnrichedRuleEntity> items, ApiController controller, string app)
{ {
var result = new RulesDto var result = new RulesDto
{ {

7
src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs

@ -14,6 +14,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
{ {
public sealed class UpdateRuleDto public sealed class UpdateRuleDto
{ {
/// <summary>
/// Optional rule name.
/// </summary>
public string Name { get; set; }
/// <summary> /// <summary>
/// The trigger properties. /// The trigger properties.
/// </summary> /// </summary>
@ -27,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
public UpdateRule ToCommand(Guid id) 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) if (Trigger != null)
{ {

21
src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -14,7 +14,6 @@ using Microsoft.Net.Http.Headers;
using NodaTime; using NodaTime;
using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Rules.Repositories;
@ -31,17 +30,18 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ApiExplorerSettings(GroupName = nameof(Rules))] [ApiExplorerSettings(GroupName = nameof(Rules))]
public sealed class RulesController : ApiController public sealed class RulesController : ApiController
{ {
private readonly IAppProvider appProvider; private readonly IRuleQueryService ruleQuery;
private readonly IRuleEventRepository ruleEventsRepository; private readonly IRuleEventRepository ruleEventsRepository;
private readonly RuleRegistry ruleRegistry; private readonly RuleRegistry ruleRegistry;
public RulesController(ICommandBus commandBus, IAppProvider appProvider, public RulesController(ICommandBus commandBus,
IRuleEventRepository ruleEventsRepository, RuleRegistry ruleRegistry) IRuleEventRepository ruleEventsRepository,
IRuleQueryService ruleQuery,
RuleRegistry ruleRegistry)
: base(commandBus) : base(commandBus)
{ {
this.appProvider = appProvider;
this.ruleEventsRepository = ruleEventsRepository; this.ruleEventsRepository = ruleEventsRepository;
this.ruleQuery = ruleQuery;
this.ruleRegistry = ruleRegistry; this.ruleRegistry = ruleRegistry;
} }
@ -85,7 +85,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetRules(string app) public async Task<IActionResult> GetRules(string app)
{ {
var rules = await appProvider.GetRulesAsync(AppId); var rules = await ruleQuery.QueryAsync(Context);
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
@ -221,6 +221,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// Get rule events. /// Get rule events.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="ruleId">The optional rule id to filter to events.</param>
/// <param name="skip">The number of events to skip.</param> /// <param name="skip">The number of events to skip.</param>
/// <param name="take">The number of events to take.</param> /// <param name="take">The number of events to take.</param>
/// <returns> /// <returns>
@ -232,9 +233,9 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ProducesResponseType(typeof(RuleEventsDto), 200)] [ProducesResponseType(typeof(RuleEventsDto), 200)]
[ApiPermission(Permissions.AppRulesRead)] [ApiPermission(Permissions.AppRulesRead)]
[ApiCosts(0)] [ApiCosts(0)]
public async Task<IActionResult> GetEvents(string app, [FromQuery] int skip = 0, [FromQuery] int take = 20) public async Task<IActionResult> 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); var taskForCount = ruleEventsRepository.CountByAppAsync(AppId);
await Task.WhenAll(taskForItems, taskForCount); await Task.WhenAll(taskForItems, taskForCount);
@ -302,7 +303,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
{ {
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IRuleEntity>(); var result = context.Result<IEnrichedRuleEntity>();
var response = RuleDto.FromRule(result, this, app); var response = RuleDto.FromRule(result, this, app);
return response; return response;

16
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;
using Squidex.Domain.Apps.Entities.History.Notifications; using Squidex.Domain.Apps.Entities.History.Notifications;
using Squidex.Domain.Apps.Entities.Rules; 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.Indexes;
using Squidex.Domain.Apps.Entities.Rules.Queries;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
@ -105,9 +105,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetEnricher>() services.AddSingletonAs<AssetEnricher>()
.As<IAssetEnricher>(); .As<IAssetEnricher>();
services.AddSingletonAs<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<AssetQueryParser>() services.AddSingletonAs<AssetQueryParser>()
.AsSelf(); .AsSelf();
@ -123,6 +120,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<ContentQueryParser>() services.AddSingletonAs<ContentQueryParser>()
.AsSelf(); .AsSelf();
services.AddSingletonAs<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<ContentQueryService>() services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>(); .As<IContentQueryService>();
@ -162,6 +162,12 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<JintScriptEngine>() services.AddSingletonAs<JintScriptEngine>()
.AsOptional<IScriptEngine>(); .AsOptional<IScriptEngine>();
services.AddSingletonAs<RuleQueryService>()
.As<IRuleQueryService>();
services.AddSingletonAs<RuleEnricher>()
.As<IRuleEnricher>();
services.AddSingletonAs<GrainBootstrap<IContentSchedulerGrain>>() services.AddSingletonAs<GrainBootstrap<IContentSchedulerGrain>>()
.AsSelf(); .AsSelf();
@ -269,7 +275,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>() services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<RuleCommand, IRuleGrain>>() services.AddSingletonAs<RuleCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<SingletonCommandMiddleware>() services.AddSingletonAs<SingletonCommandMiddleware>()

2
src/Squidex/app/features/content/pages/content/content-field.component.scss

@ -8,7 +8,7 @@
} }
.languages-buttons { .languages-buttons {
@include absolute(.7rem, 1.25rem, auto, auto); @include absolute(.75rem, 1.25rem, auto, auto);
} }
.row { .row {

4
src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss

@ -22,11 +22,11 @@ h3 {
&-header { &-header {
& { & {
padding: 1rem 1.25rem; padding: .75rem 1.25rem;
position: relative; position: relative;
background: $color-border; background: $color-border;
border: 0; border: 0;
margin: -.7rem -1.25rem; margin: -.75rem -1.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }

14
src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts

@ -6,8 +6,10 @@
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { import {
ResourceOwner,
RuleEventDto, RuleEventDto,
RuleEventsState RuleEventsState
} from '@app/shared'; } from '@app/shared';
@ -17,15 +19,23 @@ import {
styleUrls: ['./rule-events-page.component.scss'], styleUrls: ['./rule-events-page.component.scss'],
templateUrl: './rule-events-page.component.html' templateUrl: './rule-events-page.component.html'
}) })
export class RuleEventsPageComponent implements OnInit { export class RuleEventsPageComponent extends ResourceOwner implements OnInit {
public selectedEventId: string | null = null; public selectedEventId: string | null = null;
constructor( constructor(
public readonly ruleEventsState: RuleEventsState public readonly ruleEventsState: RuleEventsState,
private readonly route: ActivatedRoute
) { ) {
super();
} }
public ngOnInit() { public ngOnInit() {
this.own(
this.route.queryParams
.subscribe(x => {
this.ruleEventsState.filterByRule(x.ruleId);
}));
this.ruleEventsState.load(); this.ruleEventsState.load();
} }

12
src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html

@ -26,10 +26,8 @@
<ng-container *ngIf="step === 2 && schemas"> <ng-container *ngIf="step === 2 && schemas">
<form [formGroup]="triggerForm.form" (ngSubmit)="saveTrigger()"> <form [formGroup]="triggerForm.form" (ngSubmit)="saveTrigger()">
<h3 class="wizard-title" [style.background]="triggerElement.iconColor"> <h3 class="wizard-title">
<sqx-rule-icon size="sm" [element]="triggerElement"></sqx-rule-icon> {{triggerElement.display}}
<span class="ml-2">{{triggerElement.display}}</span>
</h3> </h3>
<ng-container [ngSwitch]="triggerType"> <ng-container [ngSwitch]="triggerType">
@ -80,10 +78,8 @@
<ng-container *ngIf="step === 4"> <ng-container *ngIf="step === 4">
<form [formGroup]="actionForm.form" (ngSubmit)="saveAction()"> <form [formGroup]="actionForm.form" (ngSubmit)="saveAction()">
<h3 class="wizard-title" [style.background]="actionElement.iconColor"> <h3 class="wizard-title">
<sqx-rule-icon size="sm" [element]="actionElement"></sqx-rule-icon> {{actionElement.display}}
<span class="ml-2">{{actionElement.display}}</span>
</h3> </h3>
<sqx-generic-action <sqx-generic-action

6
src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss

@ -2,14 +2,14 @@
@import '_mixins'; @import '_mixins';
.rule-element { .rule-element {
padding-right: .25rem; margin: .25rem;
} }
.wizard-title { .wizard-title {
color: $color-dark-foreground; background: $color-border;
margin: -1.5rem -1.75rem; margin: -1.5rem -1.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
font-weight: 400; font-weight: 400;
font-size: 1.05rem; font-size: 1.05rem;
padding: 1rem 1.75rem; padding: 1rem;
} }

74
src/Squidex/app/features/rules/pages/rules/rule.component.html

@ -0,0 +1,74 @@
<div class="card">
<div class="card-header">
<div class="row">
<div class="col col-name">
<sqx-editable-title
fallback="Unnamed Rule"
[name]="rule.name"
(nameChange)="rename($event)"
[maxLength]="60"
[isRequired]="false"
[disabled]="!rule.canUpdate">
</sqx-editable-title>
</div>
<div class="col-auto">
<button type="button" class="btn btn-text-danger"
[disabled]="!rule.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="Delete rule"
confirmText="Do you really want to delete the rule?">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col col-auto">
<h3>If</h3>
</div>
<div class="col">
<span (click)="editTrigger.emit()">
<sqx-rule-element [type]="rule.triggerType" [element]="ruleTriggers[rule.triggerType]"></sqx-rule-element>
</span>
</div>
<div class="col col-auto">
<h3>then</h3>
</div>
<div class="col">
<span (click)="editAction.emit()">
<sqx-rule-element [type]="rule.actionType" [element]="ruleActions[rule.actionType]"></sqx-rule-element>
</span>
</div>
<div class="col col-auto">
<sqx-toggle [disabled]="!rule.canDisable && !rule.canEnable" [ngModel]="rule.isEnabled" (ngModelChange)="toggle()"></sqx-toggle>
</div>
</div>
</div>
<div class="card-footer">
<div class="row">
<div class="col-3">
Succeeded: <strong>{{rule.numSucceeded}}</strong>
</div>
<div class="col-3">
Failed: <strong>{{rule.numFailed}}</strong>
</div>
<div class="col">
Last Executed:
<ng-container *ngIf="rule.lastExecuted; else notExecuted">
{{rule.lastExecuted | sqxFromNow}}
</ng-container>
<ng-template #notExecuted>
-
</ng-template>
</div>
<div class="col-auto">
<a routerLink="events" [queryParams]="{ ruleId: rule.id }">
Logs
</a>
</div>
</div>
</div>
</div>

22
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%;
}
}

42
src/Squidex/app/features/rules/pages/rules/rule.component.ts

@ -17,39 +17,9 @@ import {
} from '@app/shared'; } from '@app/shared';
@Component({ @Component({
selector: '[sqxRule]', selector: 'sqx-rule',
template: ` styleUrls: ['./rule.component.scss'],
<tr> templateUrl: './rule.component.html',
<td class="cell-separator">
<h3>If</h3>
</td>
<td class="cell-auto">
<span (click)="editTrigger.emit()">
<sqx-rule-element [type]="rule.triggerType" [element]="ruleTriggers[rule.triggerType]"></sqx-rule-element>
</span>
</td>
<td class="cell-separator">
<h3>then</h3>
</td>
<td class="cell-auto">
<span (click)="editAction.emit()">
<sqx-rule-element [type]="rule.actionType" [element]="ruleActions[rule.actionType]"></sqx-rule-element>
</span>
</td>
<td class="cell-actions">
<sqx-toggle [disabled]="!rule.canDisable && !rule.canEnable" [ngModel]="rule.isEnabled" (ngModelChange)="toggle()"></sqx-toggle>
</td>
<td class="cell-actions">
<button type="button" class="btn btn-text-danger"
[disabled]="!rule.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="Delete rule"
confirmText="Do you really want to delete the rule?">
<i class="icon-bin2"></i>
</button>
</td>
</tr>
<tr class="spacer"></tr>`,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RuleComponent { export class RuleComponent {
@ -65,7 +35,7 @@ export class RuleComponent {
@Input() @Input()
public ruleActions: ActionsDto; public ruleActions: ActionsDto;
@Input('sqxRule') @Input()
public rule: RuleDto; public rule: RuleDto;
constructor( constructor(
@ -77,6 +47,10 @@ export class RuleComponent {
this.rulesState.delete(this.rule); this.rulesState.delete(this.rule);
} }
public rename(name: string) {
this.rulesState.rename(this.rule, name);
}
public toggle() { public toggle() {
if (this.rule.isEnabled) { if (this.rule.isEnabled) {
this.rulesState.disable(this.rule); this.rulesState.disable(this.rule);

6
src/Squidex/app/features/rules/pages/rules/rules-page.component.html

@ -32,15 +32,15 @@
</div> </div>
<table class="table table-items table-fixed"> <table class="table table-items table-fixed">
<tbody *ngFor="let rule of rules; trackBy: trackByRule" <sqx-rule *ngFor="let rule of rules; trackBy: trackByRule"
[sqxRule]="rule" [rule]="rule"
[ruleActions]="ruleActions" [ruleActions]="ruleActions"
[ruleTriggers]="ruleTriggers" [ruleTriggers]="ruleTriggers"
(delete)="delete(rule)" (delete)="delete(rule)"
(editAction)="editAction(rule)" (editAction)="editAction(rule)"
(editTrigger)="editTrigger(rule)" (editTrigger)="editTrigger(rule)"
(toggle)="toggle(rule)"> (toggle)="toggle(rule)">
</tbody> </sqx-rule>
</table> </table>
<ng-container *sqxModal="addRuleDialog"> <ng-container *sqxModal="addRuleDialog">

2
src/Squidex/app/features/settings/pages/clients/client.component.html

@ -4,7 +4,7 @@
<div class="col col-name"> <div class="col col-name">
<sqx-editable-title <sqx-editable-title
[name]="client.name" [name]="client.name"
(nameChanged)="rename($event)" (nameChange)="rename($event)"
[disabled]="!client.canUpdate"> [disabled]="!client.canUpdate">
</sqx-editable-title> </sqx-editable-title>
</div> </div>

7
src/Squidex/app/features/settings/pages/clients/client.component.scss

@ -11,10 +11,9 @@ $color-editor: #eceeef;
margin-bottom: .25rem; margin-bottom: .25rem;
} }
&-header { &-header,
& { &-body {
margin-bottom: .5rem; padding: .75rem 1.25rem;
}
} }
} }

2
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html

@ -19,7 +19,7 @@
<div class="col"> <div class="col">
<sqx-editable-title <sqx-editable-title
[name]="step.name" [name]="step.name"
(nameChanged)="changeName($event)" (nameChange)="changeName($event)"
[disabled]="step.isLocked || disabled"> [disabled]="step.isLocked || disabled">
</sqx-editable-title> </sqx-editable-title>
</div> </div>

23
src/Squidex/app/framework/angular/forms/editable-title.component.html

@ -1,23 +1,30 @@
<div class="title"> <div class="title">
<form *ngIf="isRenaming; else noRenaming" class="form-inline" [formGroup]="renameForm" (ngSubmit)="rename()"> <form *ngIf="renaming; else noRenaming" [formGroup]="renameForm" (ngSubmit)="rename()">
<div class="form-group mr-1"> <div class="row no-gutters">
<div class="col">
<div class="form-group mr-2">
<sqx-control-errors for="name"></sqx-control-errors> <sqx-control-errors for="name"></sqx-control-errors>
<input type="text" class="form-control form-underlined" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" spellcheck="false" /> <input type="text" class="form-control form-underlined" formControlName="name" [maxLength]="maxLength" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" spellcheck="false" />
</div> </div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary mr-1" [disabled]="!renameForm.valid || !renameForm.dirty">Save</button>
<button type="submit" class="btn btn-primary" [disabled]="!renameForm.valid || !renameForm.dirty">Save</button> <button type="button" class="btn btn-text-secondary btn-cancel mr-4" (click)="toggleRename()">
<button type="button" class="btn btn-text-secondary btn-cancel" (click)="toggleRename()">
<i class="icon-close"></i> <i class="icon-close"></i>
</button> </button>
</div>
</div>
</form> </form>
<ng-template #noRenaming> <ng-template #noRenaming>
<h3 class="title-name" (dblclick)="toggleRename()"> <div class="title-view">
{{name}} <h3 class="title-name" [class.fallback]="!name" (dblclick)="toggleRename()">
{{name || fallback}}
</h3> </h3>
<i class="title-edit icon-pencil" *ngIf="!disabled" (click)="toggleRename()"></i> <i class="title-edit icon-pencil" *ngIf="!disabled" (click)="toggleRename()"></i>
</div>
</ng-template> </ng-template>
</div> </div>

27
src/Squidex/app/framework/angular/forms/editable-title.component.scss

@ -2,6 +2,10 @@
@import '_mixins'; @import '_mixins';
.title { .title {
& {
position: relative;
}
&-edit { &-edit {
color: $color-border-dark; color: $color-border-dark;
display: none; display: none;
@ -14,20 +18,31 @@
} }
&-name { &-name {
padding: .375rem 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: normal; font-weight: normal;
line-height: 1.5rem; display: inline;
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
display: inline-block;
margin: 0; margin: 0;
} }
&-view {
@include truncate;
padding: .375rem 0;
position: absolute;
border-top: 0;
border-bottom: 1px solid transparent;
line-height: 1.5rem;
}
&:hover { &:hover {
.title-edit { .title-edit {
display: inline-block; display: inline;
} }
} }
} }
h3 {
&.fallback {
color: $color-text-decent;
}
}

29
src/Squidex/app/framework/angular/forms/editable-title.component.ts

@ -17,16 +17,31 @@ const ESCAPE_KEY = 27;
}) })
export class EditableTitleComponent { export class EditableTitleComponent {
@Output() @Output()
public nameChanged = new EventEmitter<string>(); public nameChange = new EventEmitter<string>();
@Input() @Input()
public disabled = false; public disabled = false;
@Input()
public fallback: string;
@Input() @Input()
public name: string; 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({ public renameForm = this.formBuilder.group({
name: ['', name: ['',
[ [
@ -51,9 +66,8 @@ export class EditableTitleComponent {
return; return;
} }
this.renameForm.setValue({ name: this.name }); this.renameForm.setValue({ name: this.name || '' });
this.renaming = !this.renaming;
this.isRenaming = !this.isRenaming;
} }
public rename() { public rename() {
@ -64,9 +78,10 @@ export class EditableTitleComponent {
if (this.renameForm.valid) { if (this.renameForm.valid) {
const value = this.renameForm.value; const value = this.renameForm.value;
this.nameChanged.emit(value.name); this.nameChange.emit(value.name);
this.name = value.name;
this.toggleRename(); this.renaming = false;
} }
} }
} }

14
src/Squidex/app/shared/services/rules.service.spec.ts

@ -274,11 +274,11 @@ describe('RulesService', () => {
let rules: RuleEventsDto; let rules: RuleEventsDto;
rulesService.getEvents('my-app', 10, 20).subscribe(result => { rulesService.getEvents('my-app', 10, 20, '12').subscribe(result => {
rules = 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'); expect(req.request.method).toEqual('GET');
@ -359,6 +359,10 @@ describe('RulesService', () => {
createdBy: `creator${id}`, createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10`, lastModified: `${id % 1000 + 2000}-11-11T10:10`,
lastModifiedBy: `modifier${id}`, 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, isEnabled: id % 2 === 0,
trigger: { trigger: {
param1: 1, param1: 1,
@ -416,5 +420,9 @@ export function createRule(id: number, suffix = '') {
param4: 4, param4: 4,
actionType: `Webhook${id}${suffix}` 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`));
} }

21
src/Squidex/app/shared/services/rules.service.ts

@ -127,7 +127,11 @@ export class RuleDto {
public readonly trigger: any, public readonly trigger: any,
public readonly triggerType: string, public readonly triggerType: string,
public readonly action: any, 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; this._links = links;
@ -169,8 +173,9 @@ export class RuleEventDto extends Model<RuleEventDto> {
} }
export interface UpsertRuleDto { export interface UpsertRuleDto {
readonly trigger: RuleAction; readonly trigger?: RuleTrigger;
readonly action: RuleAction; readonly action?: RuleAction;
readonly name?: string;
} }
export type RuleAction = { actionType: string } & any; export type RuleAction = { actionType: string } & any;
@ -304,8 +309,8 @@ export class RulesService {
pretifyError('Failed to delete rule. Please reload.')); pretifyError('Failed to delete rule. Please reload.'));
} }
public getEvents(appName: string, take: number, skip: number): Observable<RuleEventsDto> { public getEvents(appName: string, take: number, skip: number, ruleId?: string): Observable<RuleEventsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events?take=${take}&skip=${skip}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events?take=${take}&skip=${skip}&ruleId=${ruleId || ''}`);
return HTTP.getVersioned(this.http, url).pipe( return HTTP.getVersioned(this.http, url).pipe(
map(({ payload }) => { map(({ payload }) => {
@ -365,5 +370,9 @@ function parseRule(response: any) {
response.trigger, response.trigger,
response.trigger.triggerType, response.trigger.triggerType,
response.action, response.action,
response.action.actionType); response.action.actionType,
response.name,
response.numSucceeded,
response.numFailed,
response.lastExecuted ? DateTime.parseISO_UTC(response.lastExecuted) : undefined);
} }

19
src/Squidex/app/shared/state/rule-events.state.spec.ts

@ -39,7 +39,7 @@ describe('RuleEventsState', () => {
rulesService = Mock.ofType<RulesService>(); rulesService = Mock.ofType<RulesService>();
rulesService.setup(x => x.getEvents(app, 10, 0)) rulesService.setup(x => x.getEvents(app, 10, 0, undefined))
.returns(() => of(new RuleEventsDto(200, oldRuleEvents))); .returns(() => of(new RuleEventsDto(200, oldRuleEvents)));
ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object); 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', () => { 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, []))); .returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.goNext().subscribe(); ruleEventsState.goNext().subscribe();
@ -71,8 +71,19 @@ describe('RuleEventsState', () => {
expect().nothing(); expect().nothing();
rulesService.verify(x => x.getEvents(app, 10, 10), Times.once()); rulesService.verify(x => x.getEvents(app, 10, 10, undefined), Times.once());
rulesService.verify(x => x.getEvents(app, 10, 0), Times.exactly(2)); 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', () => { it('should call service when enqueuing event', () => {

18
src/Squidex/app/shared/state/rule-events.state.ts

@ -6,7 +6,7 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { empty, Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { import {
@ -29,6 +29,9 @@ interface Snapshot {
// Indicates if the rule events are loaded. // Indicates if the rule events are loaded.
isLoaded?: boolean; isLoaded?: boolean;
// The current rule id.
ruleId?: string;
} }
@Injectable() @Injectable()
@ -61,7 +64,8 @@ export class RuleEventsState extends State<Snapshot> {
private loadInternal(isReload = false): Observable<any> { private loadInternal(isReload = false): Observable<any> {
return this.rulesService.getEvents(this.appName, return this.rulesService.getEvents(this.appName,
this.snapshot.ruleEventsPager.pageSize, this.snapshot.ruleEventsPager.pageSize,
this.snapshot.ruleEventsPager.skip).pipe( this.snapshot.ruleEventsPager.skip,
this.snapshot.ruleId).pipe(
tap(({ total, items: ruleEvents }) => { tap(({ total, items: ruleEvents }) => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('RuleEvents reloaded.'); this.dialogs.notifyInfo('RuleEvents reloaded.');
@ -96,6 +100,16 @@ export class RuleEventsState extends State<Snapshot> {
shareSubscribed(this.dialogs)); 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<any> { public goNext(): Observable<any> {
this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.goNext() })); this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.goNext() }));

15
src/Squidex/app/shared/state/rules.state.spec.ts

@ -124,6 +124,21 @@ describe('RulesState', () => {
expect(rule1New).toEqual(updated); 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', () => { it('should update rule when enabled', () => {
const updated = createRule(1, '_new'); const updated = createRule(1, '_new');

8
src/Squidex/app/shared/state/rules.state.ts

@ -124,6 +124,14 @@ export class RulesState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public rename(rule: RuleDto, name: string): Observable<RuleDto> {
return this.rulesService.putRule(this.appName, rule, { name }, rule.version).pipe(
tap(updated => {
this.replaceRule(updated);
}),
shareSubscribed(this.dialogs));
}
public enable(rule: RuleDto): Observable<any> { public enable(rule: RuleDto): Observable<any> {
return this.rulesService.enableRule(this.appName, rule, rule.version).pipe( return this.rulesService.enableRule(this.appName, rule, rule.version).pipe(
tap(updated => { tap(updated => {

2
src/Squidex/app/theme/_bootstrap.scss

@ -535,7 +535,7 @@ a {
&-tabs { &-tabs {
background: $color-border; background: $color-border;
position: relative; position: relative;
padding: 1rem 1.25rem; padding: .75rem 1.25rem;
height: 70px; height: 70px;
} }

4
src/Squidex/app/theme/_forms.scss

@ -32,7 +32,7 @@
// Small triangle under the error tooltip with the border trick. // Small triangle under the error tooltip with the border trick.
&::after { &::after {
@include absolute(auto, auto, -.7rem, .625rem); @include absolute(auto, auto, -.75rem, .625rem);
content: ''; content: '';
height: 0; height: 0;
border-style: solid; border-style: solid;
@ -166,7 +166,7 @@ label {
// Search icon that is placed within the form control. // Search icon that is placed within the form control.
.icon-search { .icon-search {
@include absolute(.7rem, .7rem, auto, auto); @include absolute(.75rem, .75rem, auto, auto);
color: $color-dark2-focus-foreground; color: $color-dark2-focus-foreground;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: lighter; font-weight: lighter;

10
src/Squidex/app/theme/_lists.scss

@ -13,7 +13,7 @@
td { td {
// Unified padding for all table cells. // Unified padding for all table cells.
& { & {
padding: .7rem; padding: .75rem;
} }
// Additional padding for the first column. // Additional padding for the first column.
@ -95,7 +95,7 @@
// //
&-row { &-row {
& { & {
padding: 1rem 1.25rem; padding: .75rem 1.25rem;
background: $color-table-background; background: $color-table-background;
border: 1px solid $color-border; border: 1px solid $color-border;
border-top: 0; border-top: 0;
@ -105,7 +105,7 @@
// Summary row for expandable rows. // Summary row for expandable rows.
&-summary { &-summary {
padding: 1rem 1.25rem; padding: .75rem 1.25rem;
position: relative; position: relative;
line-height: 2.5rem; line-height: 2.5rem;
} }
@ -131,7 +131,7 @@
} }
&-tab { &-tab {
padding: 1rem 1.25rem 1.25rem; padding: .75rem 1.25rem 1.25rem;
} }
&-tabs { &-tabs {
@ -146,7 +146,7 @@
// Footer that typically contains an add-item-form. // Footer that typically contains an add-item-form.
&-header, &-header,
&-footer { &-footer {
padding: 1rem 1.25rem; padding: .75rem 1.25rem;
background: $color-table-footer; background: $color-table-footer;
border: 1px solid $color-border; border: 1px solid $color-border;
border-bottom-width: 2px; border-bottom-width: 2px;

2
src/Squidex/app/theme/_panels.scss

@ -393,7 +393,7 @@ a {
} }
th { th {
padding: .7rem; padding: .75rem;
} }
.table-items { .table-items {

2
src/Squidex/app/theme/_static.scss

@ -86,7 +86,7 @@ noscript {
display: inline-block; display: inline-block;
background: $color-dark-foreground; background: $color-dark-foreground;
border: 0; border: 0;
bottom: -.7rem; bottom: -.75rem;
position: relative; position: relative;
padding: 0 1rem; padding: 0 1rem;
} }

8
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); 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] [Fact]
public void Should_replace_trigger_when_updating() public void Should_replace_trigger_when_updating()
{ {

2
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.Equal(enrichedEvent.AppId.Id, job.AppId);
Assert.NotEqual(Guid.Empty, job.JobId); Assert.NotEqual(Guid.Empty, job.Id);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload))) A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload)))
.MustHaveHappened(); .MustHaveHappened();

2
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)) A.CallTo(() => indexByName.GetIdAsync(appId.Name))
.Returns(appId.Id); .Returns(appId.Id);
var actual = await sut.GetAppAsync(appId.Name); var actual = await sut.GetAppByNameAsync(appId.Name);
Assert.Same(expected, actual); Assert.Same(expected, actual);
} }

25
tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs

@ -24,7 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
public class GuardRuleTests public class GuardRuleTests
{ {
private readonly Uri validUrl = new Uri("https://squidex.io"); 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<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
@ -95,12 +95,24 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
{ {
var command = new UpdateRule(); var command = new UpdateRule();
await ValidationAssert.ThrowsAsync(() => GuardRule.CanUpdate(command, appId.Id, appProvider), await ValidationAssert.ThrowsAsync(() => GuardRule.CanUpdate(command, appId.Id, appProvider, rule_0),
new ValidationError("Either trigger or action is required.", "Trigger", "Action")); new ValidationError("Either trigger, action or name is required.", "Trigger", "Action"));
} }
[Fact] [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 var command = new UpdateRule
{ {
@ -111,10 +123,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
Action = new TestAction Action = new TestAction
{ {
Url = validUrl Url = validUrl
} },
Name = "NewName"
}; };
await GuardRule.CanUpdate(command, appId.Id, appProvider); await GuardRule.CanUpdate(command, appId.Id, appProvider, rule_0);
} }
[Fact] [Fact]

14
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) private IRuleEntity SetupRule(long version, bool deleted)
{ {
var ruleEntity = A.Fake<IRuleEntity>();
var ruleId = Guid.NewGuid(); var ruleId = Guid.NewGuid();
A.CallTo(() => ruleEntity.Id) var ruleEntity = new RuleEntity { Id = ruleId, AppId = appId, Version = version, IsDeleted = deleted };
.Returns(ruleId);
A.CallTo(() => ruleEntity.AppId)
.Returns(appId);
A.CallTo(() => ruleEntity.Version)
.Returns(version);
A.CallTo(() => ruleEntity.IsDeleted)
.Returns(deleted);
var ruleGrain = A.Fake<IRuleGrain>(); var ruleGrain = A.Fake<IRuleGrain>();
A.CallTo(() => ruleGrain.GetStateAsync()) A.CallTo(() => ruleGrain.GetStateAsync())
.Returns(J.Of(ruleEntity)); .Returns(J.Of<IRuleEntity>(ruleEntity));
A.CallTo(() => grainFactory.GetGrain<IRuleGrain>(ruleId, null)) A.CallTo(() => grainFactory.GetGrain<IRuleGrain>(ruleId, null))
.Returns(ruleGrain); .Returns(ruleGrain);

76
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<IRuleEventRepository>();
private readonly NamedId<Guid> 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<RuleStatistics> { 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);
}
}
}

58
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<IRulesIndex>();
private readonly IRuleEnricher ruleEnricher = A.Fake<IRuleEnricher>();
private readonly NamedId<Guid> 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<IRuleEntity>
{
new RuleEntity()
};
var enriched = new List<IEnrichedRuleEntity>
{
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);
}
}
}

96
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<RuleState>
{
private readonly IRuleEnricher ruleEnricher = A.Fake<IRuleEnricher>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
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<IGrainFactory>(), 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<IEnrichedRuleEntity>.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<IEnrichedRuleEntity>());
A.CallTo(() => ruleEnricher.EnrichAsync(A<IEnrichedRuleEntity>.Ignored, requestContext))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_enrich_rule_result()
{
var result = A.Fake<IRuleEntity>();
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<IEnrichedRuleEntity>());
}
}
}

8
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)) A.CallTo(() => ruleService.InvokeAsync(@event.Job.ActionName, @event.Job.ActionData))
.Returns((Result.Create(requestDump, result), requestElapsed)); .Returns((Result.Create(requestDump, result), requestElapsed));
var now = clock.GetCurrentInstant();
Instant? nextCall = null; Instant? nextCall = null;
if (minutes > 0) if (minutes > 0)
{ {
nextCall = clock.GetCurrentInstant().Plus(Duration.FromMinutes(minutes)); nextCall = now.Plus(Duration.FromMinutes(minutes));
} }
await sut.HandleAsync(@event); 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(); .MustHaveHappened();
} }
@ -75,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
var job = new RuleJob var job = new RuleJob
{ {
JobId = Guid.NewGuid(), Id = Guid.NewGuid(),
ActionData = actionData, ActionData = actionData,
ActionName = actionName, ActionName = actionName,
Created = clock.GetCurrentInstant() Created = clock.GetCurrentInstant()

7
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 job1 = new RuleJob { Created = now };
var ruleEntity1 = A.Fake<IRuleEntity>(); var ruleEntity1 = new RuleEntity { RuleDef = rule1 };
var ruleEntity2 = A.Fake<IRuleEntity>(); var ruleEntity2 = new RuleEntity { RuleDef = rule2 };
A.CallTo(() => ruleEntity1.RuleDef).Returns(rule1);
A.CallTo(() => ruleEntity2.RuleDef).Returns(rule2);
A.CallTo(() => appProvider.GetRulesAsync(appId.Id)) A.CallTo(() => appProvider.GetRulesAsync(appId.Id))
.Returns(new List<IRuleEntity> { ruleEntity1, ruleEntity2 }); .Returns(new List<IRuleEntity> { ruleEntity1, ruleEntity2 });

6
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.Trigger, sut.Snapshot.RuleDef.Trigger);
Assert.Same(command.Action, sut.Snapshot.RuleDef.Action); Assert.Same(command.Action, sut.Snapshot.RuleDef.Action);
Assert.Equal(command.Name, sut.Snapshot.RuleDef.Name);
LastEvents LastEvents
.ShouldHaveSameEvents( .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") Url = new Uri("https://squidex.io/v2")
}; };
return new UpdateRule { Trigger = newTrigger, Action = newAction }; return new UpdateRule { Trigger = newTrigger, Action = newAction, Name = "NewName" };
} }
} }
} }

2
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)) A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name))
.Returns(schema.Id); .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); Assert.Same(actual, schema);
} }

1
tests/Squidex.Infrastructure.Tests/Log/JsonLogWriterTests.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System; using System;
using Newtonsoft.Json;
using NodaTime; using NodaTime;
using Xunit; using Xunit;

Loading…
Cancel
Save