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 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()
{

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

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

5
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)]

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
{
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<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.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<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 =
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)
{
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
.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<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 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);
});
}

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>())
{

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<IAppEntity> GetAppAsync(string name);
Task<IAppEntity> GetAppByNameAsync(string name);
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 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; }

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

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

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 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)
{

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:
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);

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

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, string name, bool allowDeleted = false);
Task<ISchemaEntity> GetSchemaByNameAsync(Guid appId, string name, 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>())
{

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 string Name { get; set; }
public IEvent Migrate()
{
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 string Name { get; set; }
public IEvent Migrate()
{
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.MapMember(x => x.Id).SetSerializer(new StringSerializer(BsonType.ObjectId));
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));
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)

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)
{
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)

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

@ -58,6 +58,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Optional rule name.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The trigger properties.
/// </summary>
@ -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)
/// <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();

2
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<IRuleEntity> items, ApiController controller, string app)
public static RulesDto FromRules(IEnumerable<IEnrichedRuleEntity> items, ApiController controller, string app)
{
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
{
/// <summary>
/// Optional rule name.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The trigger properties.
/// </summary>
@ -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)
{

21
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<IActionResult> 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.
/// </summary>
/// <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="take">The number of events to take.</param>
/// <returns>
@ -232,9 +233,9 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ProducesResponseType(typeof(RuleEventsDto), 200)]
[ApiPermission(Permissions.AppRulesRead)]
[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);
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<IRuleEntity>();
var result = context.Result<IEnrichedRuleEntity>();
var response = RuleDto.FromRule(result, this, app);
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.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<AssetEnricher>()
.As<IAssetEnricher>();
services.AddSingletonAs<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<AssetQueryParser>()
.AsSelf();
@ -123,6 +120,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<ContentQueryParser>()
.AsSelf();
services.AddSingletonAs<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>();
@ -162,6 +162,12 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<JintScriptEngine>()
.AsOptional<IScriptEngine>();
services.AddSingletonAs<RuleQueryService>()
.As<IRuleQueryService>();
services.AddSingletonAs<RuleEnricher>()
.As<IRuleEnricher>();
services.AddSingletonAs<GrainBootstrap<IContentSchedulerGrain>>()
.AsSelf();
@ -269,7 +275,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<RuleCommand, IRuleGrain>>()
services.AddSingletonAs<RuleCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<SingletonCommandMiddleware>()

2
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 {

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

14
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();
}

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

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

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

@ -2,14 +2,14 @@
@import '_mixins';
.rule-element {
padding-right: .25rem;
margin: .25rem;
}
.wizard-title {
color: $color-dark-foreground;
background: $color-border;
margin: -1.5rem -1.75rem;
margin-bottom: 1rem;
font-weight: 400;
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';
@Component({
selector: '[sqxRule]',
template: `
<tr>
<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>`,
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);

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

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

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

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

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

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

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

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

@ -1,23 +1,30 @@
<div class="title">
<form *ngIf="isRenaming; else noRenaming" class="form-inline" [formGroup]="renameForm" (ngSubmit)="rename()">
<div class="form-group mr-1">
<form *ngIf="renaming; else noRenaming" [formGroup]="renameForm" (ngSubmit)="rename()">
<div class="row no-gutters">
<div class="col">
<div class="form-group mr-2">
<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 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" (click)="toggleRename()">
<button type="button" class="btn btn-text-secondary btn-cancel mr-4" (click)="toggleRename()">
<i class="icon-close"></i>
</button>
</div>
</div>
</form>
<ng-template #noRenaming>
<h3 class="title-name" (dblclick)="toggleRename()">
{{name}}
<div class="title-view">
<h3 class="title-name" [class.fallback]="!name" (dblclick)="toggleRename()">
{{name || fallback}}
</h3>
<i class="title-edit icon-pencil" *ngIf="!disabled" (click)="toggleRename()"></i>
</div>
</ng-template>
</div>

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

29
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<string>();
public nameChange = new EventEmitter<string>();
@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;
}
}
}

14
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`));
}

21
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<RuleEventDto> {
}
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<RuleEventsDto> {
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<RuleEventsDto> {
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);
}

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

@ -39,7 +39,7 @@ describe('RuleEventsState', () => {
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)));
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', () => {

18
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<Snapshot> {
private loadInternal(isReload = false): Observable<any> {
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<Snapshot> {
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> {
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);
});
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');

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

@ -124,6 +124,14 @@ export class RulesState extends State<Snapshot> {
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> {
return this.rulesService.enableRule(this.appName, rule, rule.version).pipe(
tap(updated => {

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

4
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;

10
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;

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

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

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

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);
}
[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()
{

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.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)))
.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))
.Returns(appId.Id);
var actual = await sut.GetAppAsync(appId.Name);
var actual = await sut.GetAppByNameAsync(appId.Name);
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
{
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> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
@ -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]

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)
{
var ruleEntity = A.Fake<IRuleEntity>();
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<IRuleGrain>();
A.CallTo(() => ruleGrain.GetStateAsync())
.Returns(J.Of(ruleEntity));
.Returns(J.Of<IRuleEntity>(ruleEntity));
A.CallTo(() => grainFactory.GetGrain<IRuleGrain>(ruleId, null))
.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))
.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()

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 ruleEntity1 = A.Fake<IRuleEntity>();
var ruleEntity2 = A.Fake<IRuleEntity>();
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<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.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" };
}
}
}

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))
.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);
}

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

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

Loading…
Cancel
Save