Browse Source

Rules implemented.

pull/363/head
Sebastian 7 years ago
parent
commit
a9d061da26
  1. 21
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  2. 28
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  3. 44
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs
  4. 24
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs
  5. 21
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs
  6. 59
      src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs
  7. 55
      src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  8. 2
      src/Squidex/app/features/administration/pages/users/user-page.component.html
  9. 8
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  10. 2
      src/Squidex/app/features/administration/state/event-consumers.state.spec.ts
  11. 18
      src/Squidex/app/features/administration/state/users.state.spec.ts
  12. 10
      src/Squidex/app/features/administration/state/users.state.ts
  13. 4
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html
  14. 25
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts
  15. 26
      src/Squidex/app/features/rules/pages/rules/rules-page.component.html
  16. 7
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html
  17. 3
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts
  18. 4
      src/Squidex/app/framework/angular/forms/toggle.component.ts
  19. 18
      src/Squidex/app/framework/angular/http/hateos.pipes.ts
  20. 3
      src/Squidex/app/framework/module.ts
  21. 10
      src/Squidex/app/framework/utils/hateos.ts
  22. 198
      src/Squidex/app/shared/services/rules.service.spec.ts
  23. 132
      src/Squidex/app/shared/services/rules.service.ts
  24. 4
      src/Squidex/app/shared/state/asset-uploader.state.spec.ts
  25. 56
      src/Squidex/app/shared/state/assets.state.spec.ts
  26. 8
      src/Squidex/app/shared/state/rule-events.state.spec.ts
  27. 4
      src/Squidex/app/shared/state/rule-events.state.ts
  28. 107
      src/Squidex/app/shared/state/rules.state.spec.ts
  29. 102
      src/Squidex/app/shared/state/rules.state.ts
  30. 6
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  31. 6
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs

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

@ -41,32 +41,40 @@ namespace Squidex.Domain.Apps.Entities.Rules
switch (command)
{
case CreateRule createRule:
return CreateAsync(createRule, async c =>
return CreateReturnAsync(createRule, async c =>
{
await GuardRule.CanCreate(c, appProvider);
Create(c);
return await GetRawStateAsync();
});
case UpdateRule updateRule:
return UpdateAsync(updateRule, async c =>
return UpdateReturnAsync(updateRule, async c =>
{
await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider);
Update(c);
return await GetRawStateAsync();
});
case EnableRule enableRule:
return UpdateAsync(enableRule, c =>
return UpdateReturnAsync(enableRule, async c =>
{
GuardRule.CanEnable(c, Snapshot.RuleDef);
Enable(c);
return await GetRawStateAsync();
});
case DisableRule disableRule:
return UpdateAsync(disableRule, c =>
return UpdateReturnAsync(disableRule, async c =>
{
GuardRule.CanDisable(c, Snapshot.RuleDef);
Disable(c);
return await GetRawStateAsync();
});
case DeleteRule deleteRule:
return UpdateAsync(deleteRule, c =>
@ -123,6 +131,11 @@ namespace Squidex.Domain.Apps.Entities.Rules
}
}
public Task<IRuleEntity> GetRawStateAsync()
{
return Task.FromResult<IRuleEntity>(Snapshot);
}
public Task<J<IRuleEntity>> GetStateAsync()
{
return J.AsTask<IRuleEntity>(Snapshot);

28
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -178,12 +178,10 @@ namespace Squidex.Areas.Api.Controllers.Assets
var assetFile = await CheckAssetFileAsync(file);
var command = new CreateAsset { File = assetFile };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetCreatedResult>();
var response = AssetDto.FromAsset(result.Asset, this, app, result.IsDuplicate);
var response = await InvokeCommand(app, command);
return StatusCode(201, response);
return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response);
}
/// <summary>
@ -202,6 +200,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpPut]
[Route("apps/{app}/assets/{id}/content/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(AssetDto), 200)]
[ApiPermission(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]
@ -210,10 +209,8 @@ namespace Squidex.Areas.Api.Controllers.Assets
var assetFile = await CheckAssetFileAsync(file);
var command = new UpdateAsset { File = assetFile, AssetId = id };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAssetEntity>();
var response = AssetDto.FromAsset(result, this, app);
var response = await InvokeCommand(app, command);
return Ok(response);
}
@ -225,12 +222,13 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// <param name="id">The id of the asset.</param>
/// <param name="request">The asset object that needs to updated.</param>
/// <returns>
/// 204 => Asset updated.
/// 200 => Asset updated.
/// 400 => Asset name not valid.
/// 404 => Asset or app not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(AssetDto), 200)]
[AssetRequestSizeLimit]
[ApiPermission(Permissions.AppAssetsUpdate)]
@ -238,10 +236,8 @@ namespace Squidex.Areas.Api.Controllers.Assets
public async Task<IActionResult> PutAsset(string app, Guid id, [FromBody] AnnotateAssetDto request)
{
var command = request.ToCommand(id);
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAssetEntity>();
var response = AssetDto.FromAsset(result, this, app);
var response = await InvokeCommand(app, command);
return Ok(response);
}
@ -266,6 +262,16 @@ namespace Squidex.Areas.Api.Controllers.Assets
return NoContent();
}
private async Task<AssetDto> InvokeCommand(string app, AssetCommand command)
{
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAssetEntity>();
var response = AssetDto.FromAsset(result, this, app);
return response;
}
private async Task<AssetFile> CheckAssetFileAsync(IReadOnlyList<IFormFile> file)
{
if (file.Count != 1)

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

@ -14,11 +14,12 @@ using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
public sealed class RuleDto : IGenerateETag
public sealed class RuleDto : Resource, IGenerateETag
{
/// <summary>
/// The id of the rule.
@ -70,19 +71,48 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
[JsonConverter(typeof(RuleActionConverter))]
public RuleAction Action { get; set; }
public static RuleDto FromRule(IRuleEntity rule)
public static RuleDto FromRule(IRuleEntity rule, ApiController controller, string app)
{
var response = new RuleDto();
var result = new RuleDto();
SimpleMapper.Map(rule, response);
SimpleMapper.Map(rule.RuleDef, response);
SimpleMapper.Map(rule, result);
SimpleMapper.Map(rule.RuleDef, result);
if (rule.RuleDef.Trigger != null)
{
response.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger);
result.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger);
}
return response;
return CreateLinks(result, controller, app);
}
private static RuleDto CreateLinks(RuleDto result, ApiController controller, string app)
{
var values = new { app, id = result.Id };
if (controller.HasPermission(Permissions.AppRulesDisable, app))
{
if (result.IsEnabled)
{
result.AddPutLink("disable", controller.Url<RulesController>(x => nameof(x.DisableRule), values));
}
else
{
result.AddPutLink("enable", controller.Url<RulesController>(x => nameof(x.EnableRule), values));
}
}
if (controller.HasPermission(Permissions.AppRulesUpdate))
{
result.AddPutLink("update", controller.Url<RulesController>(x => nameof(x.PutRule), values));
}
if (controller.HasPermission(Permissions.AppRulesDelete))
{
result.AddPutLink("delete", controller.Url<RulesController>(x => nameof(x.DeleteRule), values));
}
return result;
}
}
}

24
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs

@ -11,10 +11,11 @@ using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Infrastructure.Reflection;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
public sealed class RuleEventDto
public sealed class RuleEventDto : Resource
{
/// <summary>
/// The id of the event.
@ -63,14 +64,25 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// </summary>
public RuleJobResult JobResult { get; set; }
public static RuleEventDto FromRuleEvent(IRuleEventEntity ruleEvent)
public static RuleEventDto FromRuleEvent(IRuleEventEntity ruleEvent, ApiController controller, string app)
{
var response = new RuleEventDto();
var result = new RuleEventDto();
SimpleMapper.Map(ruleEvent, response);
SimpleMapper.Map(ruleEvent.Job, response);
SimpleMapper.Map(ruleEvent, result);
SimpleMapper.Map(ruleEvent.Job, result);
return response;
return CreateLinks(result, controller, app);
}
private static RuleEventDto CreateLinks(RuleEventDto result, ApiController controller, string app)
{
var values = new { app, id = result.Id };
result.AddPutLink("update", controller.Url<RulesController>(x => nameof(x.PutEvent), values));
result.AddDeleteLink("delete", controller.Url<RulesController>(x => nameof(x.DeleteEvent), values));
return result;
}
}
}

21
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs

@ -5,14 +5,16 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
public sealed class RuleEventsDto
public sealed class RuleEventsDto : Resource
{
/// <summary>
/// The rule events.
@ -25,9 +27,22 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
/// </summary>
public long Total { get; set; }
public static RuleEventsDto FromRuleEvents(IReadOnlyList<IRuleEventEntity> items, long total)
public static RuleEventsDto FromRuleEvents(IReadOnlyList<IRuleEventEntity> items, long total, ApiController controller, string app)
{
return new RuleEventsDto { Total = total, Items = items.Select(RuleEventDto.FromRuleEvent).ToArray() };
var result = new RuleEventsDto
{
Total = total,
Items = items.Select(x => RuleEventDto.FromRuleEvent(x, controller, app)).ToArray()
};
return CreateLinks(result, controller, app);
}
private static RuleEventsDto CreateLinks(RuleEventsDto result, ApiController controller, string app)
{
result.AddSelfLink(controller.Url<RulesController>(x => nameof(x.GetEvents), new { app }));
return result;
}
}
}

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

@ -0,0 +1,59 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
public sealed class RulesDto : Resource
{
/// <summary>
/// The rules.
/// </summary>
[Required]
public RuleDto[] Items { get; set; }
public string GenerateEtag()
{
return Items.ToManyEtag(0);
}
public static RulesDto FromRules(IEnumerable<IRuleEntity> items, ApiController controller, string app)
{
var result = new RulesDto
{
Items = items.Select(x => RuleDto.FromRule(x, controller, app)).ToArray()
};
return CreateLinks(result, controller, app);
}
private static RulesDto CreateLinks(RulesDto result, ApiController controller, string app)
{
var values = new { app };
result.AddSelfLink(controller.Url<RulesController>(x => nameof(x.GetRules), values));
if (controller.HasPermission(Permissions.AppRulesCreate, app))
{
result.AddPostLink("create", controller.Url<RulesController>(x => nameof(x.PostRule), values));
}
if (controller.HasPermission(Permissions.AppRulesEvents, app))
{
result.AddGetLink("events", controller.Url<RulesController>(x => nameof(x.GetEvents), values));
}
return result;
}
}
}

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

@ -15,6 +15,7 @@ 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;
using Squidex.Infrastructure;
@ -76,16 +77,16 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpGet]
[Route("apps/{app}/rules/")]
[ProducesResponseType(typeof(RuleDto[]), 200)]
[ProducesResponseType(typeof(RulesDto), 200)]
[ApiPermission(Permissions.AppRulesRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetRules(string app)
{
var entities = await appProvider.GetRulesAsync(AppId);
var response = entities.Select(RuleDto.FromRule).ToArray();
var response = RulesDto.FromRules(entities, this, app);
Response.Headers[HeaderNames.ETag] = response.ToManyEtag(0);
Response.Headers[HeaderNames.ETag] = response.GenerateEtag();
return Ok(response);
}
@ -108,10 +109,9 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ApiCosts(1)]
public async Task<IActionResult> PostRule(string app, [FromBody] CreateRuleDto request)
{
var context = await CommandBus.PublishAsync(request.ToCommand());
var command = request.ToCommand();
var result = context.Result<EntityCreatedResult<Guid>>();
var response = EntityCreatedDto.FromResult(result);
var response = await InvokeCommand(app, command);
return CreatedAtAction(nameof(GetRules), new { app }, response);
}
@ -123,7 +123,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// <param name="id">The id of the rule to update.</param>
/// <param name="request">The rule object that needs to be added to the app.</param>
/// <returns>
/// 204 => Rule updated.
/// 200 => Rule updated.
/// 400 => Rule is not valid.
/// 404 => Rule or app not found.
/// </returns>
@ -132,14 +132,17 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </remarks>
[HttpPut]
[Route("apps/{app}/rules/{id}/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(ErrorDto), 200)]
[ProducesResponseType(typeof(RuleDto), 400)]
[ApiPermission(Permissions.AppRulesUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutRule(string app, Guid id, [FromBody] UpdateRuleDto request)
{
await CommandBus.PublishAsync(request.ToCommand(id));
var command = request.ToCommand(id);
return NoContent();
var response = await InvokeCommand(app, command);
return Ok(response);
}
/// <summary>
@ -148,19 +151,23 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the rule to enable.</param>
/// <returns>
/// 204 => Rule enabled.
/// 200 => Rule enabled.
/// 400 => Rule already enabled.
/// 404 => Rule or app not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/rules/{id}/enable/")]
[ProducesResponseType(typeof(ErrorDto), 200)]
[ProducesResponseType(typeof(RuleDto), 400)]
[ApiPermission(Permissions.AppRulesDisable)]
[ApiCosts(1)]
public async Task<IActionResult> EnableRule(string app, Guid id)
{
await CommandBus.PublishAsync(new EnableRule { RuleId = id });
var command = new EnableRule { RuleId = id };
return NoContent();
var response = await InvokeCommand(app, command);
return Ok(response);
}
/// <summary>
@ -169,19 +176,23 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the rule to disable.</param>
/// <returns>
/// 204 => Rule disabled.
/// 200 => Rule disabled.
/// 400 => Rule already disabled.
/// 404 => Rule or app not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/rules/{id}/disable/")]
[ProducesResponseType(typeof(ErrorDto), 200)]
[ProducesResponseType(typeof(RuleDto), 400)]
[ApiPermission(Permissions.AppRulesDisable)]
[ApiCosts(1)]
public async Task<IActionResult> DisableRule(string app, Guid id)
{
await CommandBus.PublishAsync(new DisableRule { RuleId = id });
var command = new DisableRule { RuleId = id };
return NoContent();
var response = await InvokeCommand(app, command);
return Ok(response);
}
/// <summary>
@ -226,7 +237,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
await Task.WhenAll(taskForItems, taskForCount);
var response = RuleEventsDto.FromRuleEvents(taskForItems.Result, taskForCount.Result);
var response = RuleEventsDto.FromRuleEvents(taskForItems.Result, taskForCount.Result, this, app);
return Ok(response);
}
@ -284,5 +295,15 @@ namespace Squidex.Areas.Api.Controllers.Rules
return NoContent();
}
private async Task<RuleDto> InvokeCommand(string app, RuleCommand command)
{
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IRuleEntity>();
var response = RuleDto.FromRule(result, this, app);
return response;
}
}
}

2
src/Squidex/app/features/administration/pages/users/user-page.component.html

@ -16,7 +16,7 @@
<ng-container menu>
<ng-container *ngIf="usersState.selectedUser | async; let user; else noUserMenu">
<ng-container *ngIf="!isReadOnly">
<ng-container *ngIf="canUpdate">
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>

8
src/Squidex/app/features/administration/pages/users/user-page.component.ts

@ -29,8 +29,6 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
public user?: UserDto;
public userForm = new UserForm(this.formBuilder);
public isReadOnly = false;
constructor(
public readonly usersState: UsersState,
private readonly formBuilder: FormBuilder,
@ -49,9 +47,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
if (selectedUser) {
this.userForm.load(selectedUser);
this.isReadOnly = !hasLink(this.user, 'update');
this.canUpdate = hasLink(this.user, 'update');
if (this.isReadOnly) {
if (!this.canUpdate) {
this.userForm.form.disable();
}
}
@ -59,7 +57,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
}
public save() {
if (this.isReadOnly) {
if (!this.canUpdate) {
return;
}

2
src/Squidex/app/features/administration/state/event-consumers.state.spec.ts

@ -79,7 +79,7 @@ describe('EventConsumersState', () => {
eventConsumersState.load().subscribe();
});
it('should update evnet consumer when started', () => {
it('should update event consumer when started', () => {
const updated = createEventConsumer(2, '_new');
eventConsumersService.setup(x => x.putStart(eventConsumer2))

18
src/Squidex/app/features/administration/state/users.state.spec.ts

@ -168,7 +168,7 @@ describe('UsersState', () => {
expect(usersState.snapshot.selectedUser).toBeNull();
});
it('should update user selected user when locked', () => {
it('should update user and selected user when locked', () => {
const updated = createUser(2, '_new');
usersService.setup(x => x.lockUser(user2))
@ -177,9 +177,9 @@ describe('UsersState', () => {
usersState.select(user2.id).subscribe();
usersState.lock(user2).subscribe();
const newUser2 = usersState.snapshot.users.at(1);
const user2New = usersState.snapshot.users.at(1);
expect(newUser2).toBe(usersState.snapshot.selectedUser!);
expect(user2New).toBe(usersState.snapshot.selectedUser!);
});
it('should update user and selected user when unlocked', () => {
@ -191,10 +191,10 @@ describe('UsersState', () => {
usersState.select(user2.id).subscribe();
usersState.unlock(user2).subscribe();
const newUser2 = usersState.snapshot.users.at(1);
const user2New = usersState.snapshot.users.at(1);
expect(newUser2).toEqual(updated);
expect(newUser2).toBe(usersState.snapshot.selectedUser!);
expect(user2New).toEqual(updated);
expect(user2New).toBe(usersState.snapshot.selectedUser!);
});
it('should update user and selected user when updated', () => {
@ -208,10 +208,10 @@ describe('UsersState', () => {
usersState.select(user2.id).subscribe();
usersState.update(user2, request).subscribe();
const newUser2 = usersState.snapshot.users.at(1);
const user2New = usersState.snapshot.users.at(1);
expect(newUser2).toEqual(updated);
expect(newUser2).toBe(usersState.snapshot.selectedUser!);
expect(user2New).toEqual(updated);
expect(user2New).toBe(usersState.snapshot.selectedUser!);
});
it('should add user to snapshot when created', () => {

10
src/Squidex/app/features/administration/state/users.state.ts

@ -60,10 +60,6 @@ export class UsersState extends State<Snapshot> {
this.changes.pipe(map(x => x.usersPager),
distinctUntilChanged());
public links =
this.changes.pipe(map(x => x.links),
distinctUntilChanged());
public selectedUser =
this.changes.pipe(map(x => x.selectedUser),
distinctUntilChanged());
@ -72,6 +68,10 @@ export class UsersState extends State<Snapshot> {
this.changes.pipe(map(x => !!x.isLoaded),
distinctUntilChanged());
public links =
this.changes.pipe(map(x => x.links),
distinctUntilChanged());
constructor(
private readonly dialogs: DialogService,
private readonly usersService: UsersService
@ -131,7 +131,7 @@ export class UsersState extends State<Snapshot> {
selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser;
}
return { ...s, users, usersPager, links, selectedUser, isLoaded: true };
return { ...s, users, usersPager, selectedUser, isLoaded: true, links };
});
}),
shareSubscribed(this.dialogs));

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

@ -106,12 +106,12 @@
<ng-container *ngIf="mode !== 'Wizard' && step === 2">
<button type="reset" class="float-left btn btn-secondary" (click)="emitComplete()">Cancel</button>
<button type="submit" class="float-right btn btn-primary" (click)="saveTrigger()">Save</button>
<button type="submit" class="float-right btn btn-primary" (click)="saveTrigger()" *ngIf="canUpdate">Save</button>
</ng-container>
<ng-container *ngIf="step === 4">
<button type="reset" class="float-left btn btn-secondary" (click)="emitComplete()">Cancel</button>
<button type="submit" class="float-right btn btn-primary" (click)="saveAction()">Save</button>
<button type="submit" class="float-right btn btn-primary" (click)="saveAction()" *ngIf="canUpdate">Save</button>
</ng-container>
</div>
</ng-container>

25
src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts

@ -5,11 +5,12 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
Form,
hasLink,
ImmutableArray,
RuleDto,
RuleElementDto,
@ -26,7 +27,7 @@ export const MODE_EDIT_ACTION = 'EditAction';
styleUrls: ['./rule-wizard.component.scss'],
templateUrl: './rule-wizard.component.html'
})
export class RuleWizardComponent implements OnInit {
export class RuleWizardComponent implements AfterViewInit, OnInit {
public actionForm = new Form<FormGroup, any>(new FormGroup({}));
public actionType: string;
public action: any = {};
@ -35,6 +36,8 @@ export class RuleWizardComponent implements OnInit {
public triggerType: string;
public trigger: any = {};
public canUpdate: boolean;
public step = 1;
@Output()
@ -61,6 +64,8 @@ export class RuleWizardComponent implements OnInit {
}
public ngOnInit() {
this.canUpdate = !this.rule || hasLink(this.rule, 'update');
if (this.mode === MODE_EDIT_ACTION) {
this.step = 4;
@ -74,6 +79,14 @@ export class RuleWizardComponent implements OnInit {
}
}
public ngAfterViewInit() {
if (!this.canUpdate) {
this.actionForm.form.disable();
this.triggerForm.form.disable();
}
}
public emitComplete() {
this.complete.emit();
}
@ -132,6 +145,10 @@ export class RuleWizardComponent implements OnInit {
}
private updateTrigger() {
if (!this.canUpdate) {
return;
}
this.rulesState.updateTrigger(this.rule, this.trigger)
.subscribe(() => {
this.emitComplete();
@ -143,6 +160,10 @@ export class RuleWizardComponent implements OnInit {
}
private updateAction() {
if (!this.canUpdate) {
return;
}
this.rulesState.updateAction(this.rule, this.action)
.subscribe(() => {
this.emitComplete();

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

@ -6,16 +6,19 @@
</ng-container>
<ng-container menu>
<button type="button" class="btn btn-text-secondary mr-1" (click)="reload()" title="Refresh Assets (CTRL + SHIFT + R)">
<button type="button" class="btn btn-text-secondary mr-1" (click)="reload()" title="Refresh Rules (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh
</button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+shift+g" (trigger)="buttonNew.click()"></sqx-shortcut>
<button type="button" class="btn btn-success" #buttonNew (click)="createNew()" title="New Rule (CTRL + M)">
<i class="icon-plus"></i> New
</button>
<ng-container *ngIf="rulesState.links | async | sqxHasLink:'create'">
<sqx-shortcut keys="ctrl+shift+g" (trigger)="buttonNew.click()"></sqx-shortcut>
<button type="button" class="btn btn-success" #buttonNew (click)="createNew()" title="New Rule (CTRL + M)">
<i class="icon-plus"></i> New
</button>
</ng-container>
</ng-container>
<ng-container content>
@ -23,7 +26,7 @@
<div class="table-items-row table-items-row-empty" *ngIf="rules.length === 0">
No rule created yet.
<button type="button" class="btn btn-success btn-sm ml-2" (click)="createNew()">
<button type="button" class="btn btn-success btn-sm ml-2" (click)="createNew()" *ngIf="rulesState.links | async | sqxHasLink:'create'">
<i class="icon icon-plus"></i> Add Rule
</button>
</div>
@ -48,10 +51,11 @@
</span>
</td>
<td class="cell-actions">
<sqx-toggle [ngModel]="rule.isEnabled" (ngModelChange)="toggle(rule)"></sqx-toggle>
<sqx-toggle [disabled]="rule | sqxHasNoLink:'delete'" [ngModel]="rule.isEnabled" (ngModelChange)="toggle(rule)"></sqx-toggle>
</td>
<td class="cell-actions">
<button type="button" class="btn btn-text-danger"
[disabled]="rule | sqxHasNoLink:'delete'"
(sqxConfirmClick)="delete(rule)"
confirmTitle="Delete rule"
confirmText="Do you really want to delete the rule?">
@ -78,9 +82,11 @@
<ng-container sidebar>
<div class="panel-nav">
<a class="panel-link panel-link-gray" routerLink="events" routerLinkActive="active" title="History" titlePosition="left">
<i class="icon-time"></i>
</a>
<ng-container *ngIf="rulesState.links | async | sqxHasLink:'create'">
<a class="panel-link panel-link-gray" routerLink="events" routerLinkActive="active" title="History" titlePosition="left">
<i class="icon-time"></i>
</a>
</ng-container>
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left" #helpLink>
<i class="icon-help"></i>

7
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html

@ -22,12 +22,13 @@
</td>
<td class="text-center">
<input type="text" class="form-control code" placeholder="Optional condition as javascript expression"
[disabled]="!canUpdate"
[ngModelOptions]="{ updateOn: 'blur' }"
[ngModel]="triggerSchema.condition"
(ngModelChange)="updateCondition(triggerSchema.schema, $event)" />
</td>
<td class="text-center">
<button type="button" class="btn btn-text-secondary" (click)="removeSchema(triggerSchema)">
<button type="button" class="btn btn-text-secondary" (click)="removeSchema(triggerSchema)" [disabled]="!canUpdate">
<i class="icon-close"></i>
</button>
</td>
@ -37,12 +38,12 @@
<div class="section" *ngIf="schemasToAdd.length > 0">
<form class="form-inline" (ngSubmit)="addSchema()">
<div class="form-group mr-1">
<select class="form-control schemas-control" [(ngModel)]="schemaToAdd" name="schema">
<select class="form-control schemas-control" [disabled]="!canUpdate" [(ngModel)]="schemaToAdd" name="schema">
<option *ngFor="let schema of schemasToAdd; trackBy: trackBySchema" [ngValue]="schema">{{schema.displayName}}</option>
</select>
</div>
<button type="submit" class="btn btn-success" [disabled]="!hasSchema">Add Schema</button>
<button type="submit" class="btn btn-success" [disabled]="!canUpdate">Add Schema</button>
</form>
</div>

3
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts

@ -29,6 +29,9 @@ export class ContentChangedTriggerComponent implements OnInit {
@Input()
public schemas: ImmutableArray<SchemaDto>;
@Input()
public canUpdate: boolean;
@Input()
public trigger: any;

4
src/Squidex/app/framework/angular/forms/toggle.component.ts

@ -28,6 +28,10 @@ export class ToggleComponent extends StatefulControlComponent<State, boolean | n
@Input()
public threeStates = false;
public set disabled(value: boolean) {
this.setDisabledState(value);
}
constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, {
isChecked: null

18
src/Squidex/app/framework/angular/http/hateos.pipes.ts

@ -8,7 +8,7 @@
import { Pipe, PipeTransform } from '@angular/core';
import {
hasLink,
hasAnyLink,
Resource,
ResourceLinks
} from '@app/framework/internal';
@ -19,12 +19,16 @@ import {
})
export class HasLinkPipe implements PipeTransform {
public transform(value: Resource | ResourceLinks, ...rels: string[]) {
for (let rel of rels) {
if (hasLink(value, rel)) {
return true;
}
}
return hasAnyLink(value, ...rels);
}
}
return false;
@Pipe({
name: 'sqxHasNoLink',
pure: true
})
export class HasNoLinkPipe implements PipeTransform {
public transform(value: Resource | ResourceLinks, ...rels: string[]) {
return !hasAnyLink(value, ...rels);
}
}

3
src/Squidex/app/framework/module.ts

@ -43,6 +43,7 @@ import {
FromNowPipe,
FullDateTimePipe,
HasLinkPipe,
HasNoLinkPipe,
HoverBackgroundDirective,
IFrameEditorComponent,
IgnoreScrollbarDirective,
@ -124,6 +125,7 @@ import {
FromNowPipe,
FullDateTimePipe,
HasLinkPipe,
HasNoLinkPipe,
HoverBackgroundDirective,
IFrameEditorComponent,
IgnoreScrollbarDirective,
@ -191,6 +193,7 @@ import {
FromNowPipe,
FullDateTimePipe,
HasLinkPipe,
HasNoLinkPipe,
HoverBackgroundDirective,
IFrameEditorComponent,
IgnoreScrollbarDirective,

10
src/Squidex/app/framework/utils/hateos.ts

@ -54,6 +54,16 @@ export function hasLink(value: Resource | ResourceLinks, rel: string): boolean {
return !!(link && link.method && link.href);
}
export function hasAnyLink(value: Resource | ResourceLinks, ...rels: string[]) {
for (let rel of rels) {
if (hasLink(value, rel)) {
return true;
}
}
return false;
}
export function getLink(value: Resource | ResourceLinks, rel: string): ResourceLink {
return value ? (value._links ? value._links[rel] : value[rel]) : undefined;
}

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

@ -13,16 +13,16 @@ import {
AnalyticsService,
ApiUrlConfig,
DateTime,
Resource,
RuleDto,
RuleElementDto,
RuleElementPropertyDto,
RuleEventDto,
RuleEventsDto,
RulesDto,
RulesService,
Version,
Versioned
Version
} from '@app/shared/internal';
import { RuleCreatedDto } from './rules.service';
describe('RulesService', () => {
const version = new Version('1');
@ -107,7 +107,7 @@ describe('RulesService', () => {
it('should make get request to get app rules',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
let rules: RuleDto[];
let rules: RulesDto;
rulesService.getRules('my-app').subscribe(result => {
rules = result;
@ -118,49 +118,18 @@ describe('RulesService', () => {
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush([
{
id: 'id1',
created: '2016-12-12T10:10',
createdBy: 'CreatedBy1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
url: 'http://squidex.io/hook',
version: '1',
trigger: {
param1: 1,
param2: 2,
triggerType: 'ContentChanged'
},
action: {
param3: 3,
param4: 4,
actionType: 'Webhook'
},
isEnabled: true
}
]);
req.flush({
items: [
ruleResponse(12),
ruleResponse(13)
]
});
expect(rules!).toEqual(
[
new RuleDto('id1', 'CreatedBy1', 'LastModifiedBy1',
DateTime.parseISO_UTC('2016-12-12T10:10'),
DateTime.parseISO_UTC('2017-12-12T10:10'),
version,
true,
{
param1: 1,
param2: 2,
triggerType: 'ContentChanged'
},
'ContentChanged',
{
param3: 3,
param4: 4,
actionType: 'Webhook'
},
'Webhook')
]);
new RulesDto(2, [
createRule(12),
createRule(13)
]));
}));
it('should make post request to create rule',
@ -179,7 +148,7 @@ describe('RulesService', () => {
}
};
let rule: Versioned<RuleCreatedDto>;
let rule: RuleDto;
rulesService.postRule('my-app', dto).subscribe(result => {
rule = result;
@ -190,18 +159,13 @@ describe('RulesService', () => {
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ id: 'id1' }, {
req.flush(ruleResponse(12), {
headers: {
etag: '1'
}
});
expect(rule!).toEqual({
payload: {
id: 'id1'
},
version
});
expect(rule!).toEqual(createRule(12));
}));
it('should make put request to update rule',
@ -216,46 +180,88 @@ describe('RulesService', () => {
}
};
rulesService.putRule('my-app', '123', dto, version).subscribe();
const resource: Resource = {
_links: {
update: { method: 'PUT', href: '/api/apps/my-app/rules/123' }
}
};
let rule: RuleDto;
rulesService.putRule('my-app', resource, dto, version).subscribe(result => {
rule = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
req.flush(ruleResponse(123));
expect(rule!).toEqual(createRule(123));
}));
it('should make put request to enable rule',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
rulesService.enableRule('my-app', '123', version).subscribe();
const resource: Resource = {
_links: {
enable: { method: 'PUT', href: '/api/apps/my-app/rules/123/enable' }
}
};
let rule: RuleDto;
rulesService.enableRule('my-app', resource, version).subscribe(result => {
rule = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/enable');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
req.flush(ruleResponse(123));
expect(rule!).toEqual(createRule(123));
}));
it('should make put request to disable rule',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
rulesService.disableRule('my-app', '123', version).subscribe();
const resource: Resource = {
_links: {
disable: { method: 'PUT', href: '/api/apps/my-app/rules/123/disable' }
}
};
let rule: RuleDto;
rulesService.disableRule('my-app', resource, version).subscribe(result => {
rule = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/disable');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
req.flush(ruleResponse(123));
expect(rule!).toEqual(createRule(123));
}));
it('should make delete request to delete rule',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
rulesService.deleteRule('my-app', '123', version).subscribe();
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: '/api/apps/my-app/rules/123' }
}
};
rulesService.deleteRule('my-app', resource, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123');
@ -322,7 +328,13 @@ describe('RulesService', () => {
it('should make put request to enqueue rule event',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
rulesService.enqueueEvent('my-app', '123').subscribe();
const resource: Resource = {
_links: {
update: { method: 'PUT', href: '/api/apps/my-app/rules/events/123' }
}
};
rulesService.enqueueEvent('my-app', resource).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123');
@ -335,7 +347,13 @@ describe('RulesService', () => {
it('should make delete request to cancel rule event',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
rulesService.cancelEvent('my-app', '123').subscribe();
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: '/api/apps/my-app/rules/events/123' }
}
};
rulesService.cancelEvent('my-app', resource).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123');
@ -344,4 +362,58 @@ describe('RulesService', () => {
req.flush({});
}));
});
function ruleResponse(id: number, suffix = '') {
return {
id: `id${id}`,
created: `${id % 1000 + 2000}-12-12T10:10`,
createdBy: `creator-${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10`,
lastModifiedBy: `modifier-${id}`,
isEnabled: id % 2 === 0,
trigger: {
param1: 1,
param2: 2,
triggerType: `ContentChanged${id}${suffix}`
},
action: {
param3: 3,
param4: 4,
actionType: `Webhook${id}${suffix}`
},
version: id,
_links: {
update: { method: 'PUT', href: `/rules/${id}` }
}
};
}
});
export function createRule(id: number, suffix = '') {
const result = new RuleDto(
`id${id}`,
`creator-${id}`,
`modifier-${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10`),
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10`),
new Version(`${id}`),
id % 2 === 0,
{
param1: 1,
param2: 2,
triggerType: `ContentChanged${id}${suffix}`
},
`ContentChanged${id}${suffix}`,
{
param3: 3,
param4: 4,
actionType: `Webhook${id}${suffix}`
},
`Webhook${id}${suffix}`);
result._links['update'] = {
method: 'PUT', href: `/rules/${id}`
};
return result;
}

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

@ -15,12 +15,13 @@ import {
ApiUrlConfig,
DateTime,
HTTP,
mapVersioned,
Model,
pretifyError,
Resource,
ResourceLinks,
ResultSet,
Version,
Versioned
withLinks
} from '@app/framework';
export const ALL_TRIGGERS = {
@ -74,7 +75,13 @@ export class RuleElementPropertyDto {
}
}
export class RulesDto extends ResultSet<RuleDto> {
public readonly _links: ResourceLinks = {};
}
export class RuleDto extends Model<RuleDto> {
public readonly _links: ResourceLinks = {};
constructor(
public readonly id: string,
public readonly createdBy: string,
@ -92,9 +99,13 @@ export class RuleDto extends Model<RuleDto> {
}
}
export class RuleEventsDto extends ResultSet<RuleEventDto> { }
export class RuleEventsDto extends ResultSet<RuleEventDto> {
public readonly _links: ResourceLinks = {};
}
export class RuleEventDto extends Model<RuleEventDto> {
public readonly _links: ResourceLinks = {};
constructor(
public readonly id: string,
public readonly created: DateTime,
@ -115,10 +126,6 @@ export interface UpsertRuleDto {
readonly action: RuleAction;
}
export interface RuleCreatedDto {
readonly id: string;
}
export type RuleAction = { actionType: string } & any;
export type RuleTrigger = { triggerType: string } & any;
@ -167,77 +174,87 @@ export class RulesService {
pretifyError('Failed to load Rules. Please reload.'));
}
public getRules(appName: string): Observable<RuleDto[]> {
public getRules(appName: string): Observable<RulesDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules`);
return HTTP.getVersioned<any>(this.http, url).pipe(
map(({ payload }) => {
const items: any[] = payload.body;
const items: any[] = payload.body.items;
const rules = items.map(item =>
new RuleDto(
item.id,
item.createdBy,
item.lastModifiedBy,
DateTime.parseISO_UTC(item.created),
DateTime.parseISO_UTC(item.lastModified),
new Version(item.version.toString()),
item.isEnabled,
item.trigger,
item.trigger.triggerType,
item.action,
item.action.actionType));
return rules;
const rules = items.map(item => parseRule(item));
return withLinks(new RulesDto(rules.length, rules), payload.body);
}),
pretifyError('Failed to load Rules. Please reload.'));
}
public postRule(appName: string, dto: UpsertRuleDto): Observable<Versioned<RuleCreatedDto>> {
public postRule(appName: string, dto: UpsertRuleDto): Observable<RuleDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules`);
return HTTP.postVersioned<RuleCreatedDto>(this.http, url, dto).pipe(
mapVersioned(({ body }) => body!),
return HTTP.postVersioned<any>(this.http, url, dto).pipe(
map(({ payload }) => {
return parseRule(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Rule', 'Created', appName);
}),
pretifyError('Failed to create rule. Please reload.'));
}
public putRule(appName: string, id: string, dto: Partial<UpsertRuleDto>, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}`);
public putRule(appName: string, resource: Resource, dto: Partial<UpsertRuleDto>, version: Version): Observable<RuleDto> {
const link = resource._links['update'];
return HTTP.putVersioned(this.http, url, dto, version).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseRule(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Rule', 'Updated', appName);
}),
pretifyError('Failed to update rule. Please reload.'));
}
public enableRule(appName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}/enable`);
public enableRule(appName: string, resource: Resource, version: Version): Observable<RuleDto> {
const link = resource._links['enable'];
return HTTP.putVersioned(this.http, url, {}, version).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ payload }) => {
return parseRule(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Rule', 'Updated', appName);
this.analytics.trackEvent('Rule', 'Enabled', appName);
}),
pretifyError('Failed to enable rule. Please reload.'));
}
public disableRule(appName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}/disable`);
public disableRule(appName: string, resource: Resource, version: Version): Observable<RuleDto> {
const link = resource._links['disable'];
return HTTP.putVersioned(this.http, url, {}, version).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ payload }) => {
return parseRule(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Rule', 'Updated', appName);
this.analytics.trackEvent('Rule', 'Disabled', appName);
}),
pretifyError('Failed to disable rule. Please reload.'));
}
public deleteRule(appName: string, id: string, version: Version): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}`);
public deleteRule(appName: string, resource: Resource, version: Version): Observable<any> {
const link = resource._links['delete'];
return HTTP.deleteVersioned(this.http, url, version).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
map(({ payload }) => {
return parseRule(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Rule', 'Deleted', appName);
}),
@ -270,23 +287,44 @@ export class RulesService {
pretifyError('Failed to load events. Please reload.'));
}
public enqueueEvent(appName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events/${id}`);
public enqueueEvent(appName: string, resource: Resource): Observable<any> {
const link = resource._links['update'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.putVersioned(this.http, url, {}).pipe(
return HTTP.requestVersioned(this.http, link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'EventEnqueued', appName);
}),
pretifyError('Failed to enqueue rule event. Please reload.'));
}
public cancelEvent(appName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events/${id}`);
public cancelEvent(appName: string, resource: Resource): Observable<any> {
const link = resource._links['delete'];
return HTTP.deleteVersioned(this.http, url).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'EventDequeued', appName);
}),
pretifyError('Failed to cancel rule event. Please reload.'));
}
}
function parseRule(resource: any) {
return withLinks(
new RuleDto(
resource.id,
resource.createdBy,
resource.lastModifiedBy,
DateTime.parseISO_UTC(resource.created),
DateTime.parseISO_UTC(resource.lastModified),
new Version(resource.version.toString()),
resource.isEnabled,
resource.trigger,
resource.trigger.triggerType,
resource.action,
resource.action.actionType),
resource);
}

4
src/Squidex/app/shared/state/asset-uploader.state.spec.ts

@ -22,7 +22,7 @@ import { createAsset } from './../services/assets.service.spec';
import { TestValues } from './_test-helpers';
describe('AssetsState', () => {
describe('AssetUploaderState', () => {
const {
app,
appsState
@ -155,7 +155,7 @@ describe('AssetsState', () => {
it('should update status when uploading asset completes', () => {
const file: File = <any>{ name: 'my-file' };
let updated = createAsset(1, undefined, '-new');
let updated = createAsset(1, undefined, '_new');
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version))
.returns(() => of(10, 20, updated)).verifiable();

56
src/Squidex/app/shared/state/assets.state.spec.ts

@ -41,6 +41,9 @@ describe('AssetsState', () => {
dialogs = Mock.ofType<DialogService>();
assetsService = Mock.ofType<AssetsService>();
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(Times.atLeastOnce());
assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object);
});
@ -50,12 +53,9 @@ describe('AssetsState', () => {
describe('Loading', () => {
it('should load assets', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable();
assetsState.load().subscribe();
expect(assetsState.snapshot.assets.values).toEqual([asset1, asset2]);
@ -66,11 +66,8 @@ describe('AssetsState', () => {
});
it('should show notification on load when reload is true', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(200, [asset1, asset2])));
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable();
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsState.load(true).subscribe();
@ -80,20 +77,20 @@ describe('AssetsState', () => {
});
it('should load with tags when tag toggled', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1']))
.returns(() => of(new AssetsDto(0, [])));
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1'])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.toggleTag('tag1').subscribe();
expect(assetsState.isTagSelected('tag1')).toBeTruthy();
});
it('should load without tags when tag toggled', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1']))
.returns(() => of(new AssetsDto(0, [])));
it('should load without tags when tag untoggled', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1'])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(0, [])));
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.toggleTag('tag1').subscribe();
assetsState.toggleTag('tag1').subscribe();
@ -102,8 +99,8 @@ describe('AssetsState', () => {
});
it('should load with tags when tags selected', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1', 'tag2']))
.returns(() => of(new AssetsDto(0, [])));
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1', 'tag2'])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.selectTags(['tag1', 'tag2']).subscribe();
@ -111,8 +108,8 @@ describe('AssetsState', () => {
});
it('should load without tags when tags reset', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(0, [])));
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.resetTags().subscribe();
@ -120,9 +117,13 @@ describe('AssetsState', () => {
});
it('should load next page and prev page when paging', () => {
assetsService.setup(x => x.getAssets(app, 30, 30, undefined, []))
.returns(() => of(new AssetsDto(200, [])));
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(200, []))).verifiable(Times.exactly(2));
assetsService.setup(x => x.getAssets(app, 30, 30, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.load().subscribe();
assetsState.goNext().subscribe();
assetsState.goPrev().subscribe();
@ -130,8 +131,8 @@ describe('AssetsState', () => {
});
it('should load with query when searching', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', []))
.returns(() => of(new AssetsDto(0, [])));
assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', It.isValue([])))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.search('my-query').subscribe();
@ -141,12 +142,9 @@ describe('AssetsState', () => {
describe('Updates', () => {
beforeEach(() => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([])))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable();
assetsState.load(true).subscribe();
});
@ -165,7 +163,7 @@ describe('AssetsState', () => {
});
it('should update asset when updated', () => {
const update = createAsset(1, ['new'], '-new');
const update = createAsset(1, ['new'], '_new');
assetsState.update(update);

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

@ -76,24 +76,24 @@ describe('RuleEventsState', () => {
});
it('should call service when enqueuing event', () => {
rulesService.setup(x => x.enqueueEvent(app, oldRuleEvents[0].id))
rulesService.setup(x => x.enqueueEvent(app, oldRuleEvents[0]))
.returns(() => of({}));
ruleEventsState.enqueue(oldRuleEvents[0]).subscribe();
expect().nothing();
rulesService.verify(x => x.enqueueEvent(app, oldRuleEvents[0].id), Times.once());
rulesService.verify(x => x.enqueueEvent(app, oldRuleEvents[0]), Times.once());
});
it('should call service when cancelling event', () => {
rulesService.setup(x => x.cancelEvent(app, oldRuleEvents[0].id))
rulesService.setup(x => x.cancelEvent(app, oldRuleEvents[0]))
.returns(() => of({}));
ruleEventsState.cancel(oldRuleEvents[0]).subscribe();
expect().nothing();
rulesService.verify(x => x.cancelEvent(app, oldRuleEvents[0].id), Times.once());
rulesService.verify(x => x.cancelEvent(app, oldRuleEvents[0]), Times.once());
});
});

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

@ -82,7 +82,7 @@ export class RuleEventsState extends State<Snapshot> {
}
public enqueue(event: RuleEventDto): Observable<any> {
return this.rulesService.enqueueEvent(this.appsState.appName, event.id).pipe(
return this.rulesService.enqueueEvent(this.appsState.appName, event).pipe(
tap(() => {
this.dialogs.notifyInfo('Events enqueued. Will be resend in a few seconds.');
}),
@ -90,7 +90,7 @@ export class RuleEventsState extends State<Snapshot> {
}
public cancel(event: RuleEventDto): Observable<any> {
return this.rulesService.cancelEvent(this.appsState.appName, event.id).pipe(
return this.rulesService.cancelEvent(this.appsState.appName, event).pipe(
tap(() => {
return this.next(s => {
const ruleEvents = s.ruleEvents.replaceBy('id', setCancelled(event));

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

@ -12,30 +12,27 @@ import { RulesState } from './rules.state';
import {
DialogService,
RuleDto,
RulesDto,
RulesService,
versioned
} from '@app/shared/internal';
import { createRule } from '../services/rules.service.spec';
import { TestValues } from './_test-helpers';
describe('RulesState', () => {
const {
app,
appsState,
authService,
creation,
creator,
modified,
modifier,
newVersion,
version
} = TestValues;
const oldRules = [
new RuleDto('id1', creator, creator, creation, creation, version, false, {}, 'trigger1', {}, 'action1'),
new RuleDto('id2', creator, creator, creation, creation, version, true, {}, 'trigger2', {}, 'action2')
];
const rule1 = createRule(1);
const rule2 = createRule(2);
const newRule = createRule(3);
let dialogs: IMock<DialogService>;
let rulesService: IMock<RulesService>;
@ -45,7 +42,7 @@ describe('RulesState', () => {
dialogs = Mock.ofType<DialogService>();
rulesService = Mock.ofType<RulesService>();
rulesState = new RulesState(appsState.object, authService.object, dialogs.object, rulesService.object);
rulesState = new RulesState(appsState.object, dialogs.object, rulesService.object);
});
afterEach(() => {
@ -55,11 +52,11 @@ describe('RulesState', () => {
describe('Loading', () => {
it('should load rules', () => {
rulesService.setup(x => x.getRules(app))
.returns(() => of(oldRules)).verifiable();
.returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable();
rulesState.load().subscribe();
expect(rulesState.snapshot.rules.values).toEqual(oldRules);
expect(rulesState.snapshot.rules.values).toEqual([rule1, rule2]);
expect(rulesState.snapshot.isLoaded).toBeTruthy();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -67,7 +64,7 @@ describe('RulesState', () => {
it('should show notification on load when reload is true', () => {
rulesService.setup(x => x.getRules(app))
.returns(() => of(oldRules)).verifiable();
.returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable();
rulesState.load(true).subscribe();
@ -81,89 +78,85 @@ describe('RulesState', () => {
describe('Updates', () => {
beforeEach(() => {
rulesService.setup(x => x.getRules(app))
.returns(() => of(oldRules)).verifiable();
.returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable();
rulesState.load().subscribe();
});
it('should add rule to snapshot when created', () => {
const newRule = new RuleDto('id3', modifier, modifier, modified, modified, version, true, { value: 3 }, 'trigger3', { value: 1 }, 'action3');
const request = { trigger: { triggerType: 'trigger3', value: 3 }, action: { actionType: 'action3', value: 1 } };
rulesService.setup(x => x.postRule(app, request))
.returns(() => of(versioned(version, { id: 'id3' })));
.returns(() => of(newRule));
rulesState.create(request, modified).subscribe();
rulesState.create(request).subscribe();
expect(rulesState.snapshot.rules.values).toEqual([...oldRules, newRule]);
expect(rulesState.snapshot.rules.values).toEqual([rule1, rule2, newRule]);
});
it('should update action and update and user info when updated action', () => {
it('should update rule when updated action', () => {
const newAction = {};
rulesService.setup(x => x.putRule(app, oldRules[0].id, It.isAny(), version))
.returns(() => of(versioned(newVersion))).verifiable();
const updated = createRule(1, 'new');
rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version))
.returns(() => of(updated)).verifiable();
rulesState.updateAction(oldRules[0], newAction, modified).subscribe();
rulesState.updateAction(rule1, newAction).subscribe();
const rule_1 = rulesState.snapshot.rules.at(0);
const newRule1 = rulesState.snapshot.rules.at(0);
expect(rule_1.action).toBe(newAction);
expectToBeModified(rule_1);
expect(newRule1).toEqual(updated);
});
it('should update trigger and update and user info when updated trigger', () => {
it('should update rule when updated trigger', () => {
const newTrigger = {};
rulesService.setup(x => x.putRule(app, oldRules[0].id, It.isAny(), version))
.returns(() => of(versioned(newVersion))).verifiable();
const updated = createRule(1, 'new');
rulesState.updateTrigger(oldRules[0], newTrigger, modified).subscribe();
rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version))
.returns(() => of(updated)).verifiable();
const rule_1 = rulesState.snapshot.rules.at(0);
rulesState.updateTrigger(rule1, newTrigger).subscribe();
expect(rule_1.trigger).toBe(newTrigger);
expectToBeModified(rule_1);
const rule1New = rulesState.snapshot.rules.at(0);
expect(rule1New).toEqual(updated);
});
it('should mark as enabled and update and user info when enabled', () => {
rulesService.setup(x => x.enableRule(app, oldRules[0].id, version))
.returns(() => of(versioned(newVersion))).verifiable();
it('should update rule when enabled', () => {
const updated = createRule(1, 'new');
rulesService.setup(x => x.enableRule(app, rule1, version))
.returns(() => of(updated)).verifiable();
rulesState.enable(oldRules[0], modified).subscribe();
rulesState.enable(rule1).subscribe();
const rule_1 = rulesState.snapshot.rules.at(0);
const rule1New = rulesState.snapshot.rules.at(0);
expect(rule_1.isEnabled).toBeTruthy();
expectToBeModified(rule_1);
expect(rule1New).toEqual(updated);
});
it('should mark as disabled and update and user info when disabled', () => {
rulesService.setup(x => x.disableRule(app, oldRules[1].id, version))
.returns(() => of(versioned(newVersion))).verifiable();
it('should update rule when disabled', () => {
const updated = createRule(1, 'new');
rulesService.setup(x => x.disableRule(app, rule1, version))
.returns(() => of(updated)).verifiable();
rulesState.disable(oldRules[1], modified).subscribe();
rulesState.disable(rule1).subscribe();
const rule_1 = rulesState.snapshot.rules.at(1);
const rule1New = rulesState.snapshot.rules.at(0);
expect(rule_1.isEnabled).toBeFalsy();
expectToBeModified(rule_1);
expect(rule1New).toEqual(updated);
});
it('should remove rule from snapshot when deleted', () => {
rulesService.setup(x => x.deleteRule(app, oldRules[0].id, version))
rulesService.setup(x => x.deleteRule(app, rule1, version))
.returns(() => of(versioned(newVersion))).verifiable();
rulesState.delete(oldRules[0]).subscribe();
rulesState.delete(rule1).subscribe();
expect(rulesState.snapshot.rules.values).toEqual([oldRules[1]]);
expect(rulesState.snapshot.rules.values).toEqual([rule2]);
});
function expectToBeModified(rule_1: RuleDto) {
expect(rule_1.lastModified).toEqual(modified);
expect(rule_1.lastModifiedBy).toEqual(modifier);
expect(rule_1.version).toEqual(newVersion);
}
});
});

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

@ -10,20 +10,16 @@ import { Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
DateTime,
DialogService,
ImmutableArray,
ResourceLinks,
shareSubscribed,
State,
Version,
Versioned
State
} from '@app/framework';
import { AuthService} from './../services/auth.service';
import { AppsState } from './apps.state';
import {
RuleCreatedDto,
RuleDto,
RulesService,
UpsertRuleDto
@ -33,6 +29,9 @@ interface Snapshot {
// The current rules.
rules: RulesList;
// The resource links.
links: ResourceLinks;
// Indicates if the rules are loaded.
isLoaded?: boolean;
}
@ -49,13 +48,16 @@ export class RulesState extends State<Snapshot> {
this.changes.pipe(map(x => !!x.isLoaded),
distinctUntilChanged());
public links =
this.changes.pipe(map(x => x.links),
distinctUntilChanged());
constructor(
private readonly appsState: AppsState,
private readonly authState: AuthService,
private readonly dialogs: DialogService,
private readonly rulesService: RulesService
) {
super({ rules: ImmutableArray.empty() });
super({ rules: ImmutableArray.empty(), links: {} });
}
public load(isReload = false): Observable<any> {
@ -64,23 +66,22 @@ export class RulesState extends State<Snapshot> {
}
return this.rulesService.getRules(this.appName).pipe(
tap(payload => {
tap(({ items, _links: links }) => {
if (isReload) {
this.dialogs.notifyInfo('Rules reloaded.');
}
this.next(s => {
const rules = ImmutableArray.of(payload);
const rules = ImmutableArray.of(items);
return { ...s, rules, isLoaded: true };
return { ...s, rules, isLoaded: true, links };
});
}),
shareSubscribed(this.dialogs));
}
public create(request: UpsertRuleDto, now?: DateTime): Observable<RuleDto> {
public create(request: UpsertRuleDto): Observable<RuleDto> {
return this.rulesService.postRule(this.appName, request).pipe(
map(payload => createRule(request, payload, this.user, now)),
tap(created => {
this.next(s => {
const rules = s.rules.push(created);
@ -92,7 +93,7 @@ export class RulesState extends State<Snapshot> {
}
public delete(rule: RuleDto): Observable<any> {
return this.rulesService.deleteRule(this.appName, rule.id, rule.version).pipe(
return this.rulesService.deleteRule(this.appName, rule, rule.version).pipe(
tap(() => {
this.next(s => {
const rules = s.rules.removeAll(x => x.id === rule.id);
@ -103,36 +104,32 @@ export class RulesState extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
public updateAction(rule: RuleDto, action: any, now?: DateTime): Observable<any> {
return this.rulesService.putRule(this.appName, rule.id, { action }, rule.version).pipe(
map(({ version }) => updateAction(rule, action, this.user, version, now)),
public updateAction(rule: RuleDto, action: any): Observable<RuleDto> {
return this.rulesService.putRule(this.appName, rule, { action }, rule.version).pipe(
tap(updated => {
this.replaceRule(updated);
}),
shareSubscribed(this.dialogs));
}
public updateTrigger(rule: RuleDto, trigger: any, now?: DateTime): Observable<any> {
return this.rulesService.putRule(this.appName, rule.id, { trigger }, rule.version).pipe(
map(({ version }) => updateTrigger(rule, trigger, this.user, version, now)),
public updateTrigger(rule: RuleDto, trigger: any): Observable<RuleDto> {
return this.rulesService.putRule(this.appName, rule, { trigger }, rule.version).pipe(
tap(updated => {
this.replaceRule(updated);
}),
shareSubscribed(this.dialogs));
}
public enable(rule: RuleDto, now?: DateTime): Observable<any> {
return this.rulesService.enableRule(this.appName, rule.id, rule.version).pipe(
map(({ version }) => setEnabled(rule, true, this.user, version, now)),
public enable(rule: RuleDto): Observable<any> {
return this.rulesService.enableRule(this.appName, rule, rule.version).pipe(
tap(updated => {
this.replaceRule(updated);
}),
shareSubscribed(this.dialogs));
}
public disable(rule: RuleDto, now?: DateTime): Observable<any> {
return this.rulesService.disableRule(this.appName, rule.id, rule.version).pipe(
map(({ version }) => setEnabled(rule, false, this.user, version, now)),
public disable(rule: RuleDto): Observable<any> {
return this.rulesService.disableRule(this.appName, rule, rule.version).pipe(
tap(updated => {
this.replaceRule(updated);
}),
@ -150,57 +147,4 @@ export class RulesState extends State<Snapshot> {
private get appName() {
return this.appsState.appName;
}
private get user() {
return this.authState.user!.token;
}
}
const updateTrigger = (rule: RuleDto, trigger: any, user: string, version: Version, now?: DateTime) =>
rule.with({
trigger,
triggerType: trigger.triggerType,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const updateAction = (rule: RuleDto, action: any, user: string, version: Version, now?: DateTime) =>
rule.with({
action,
actionType: action.actionType,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const setEnabled = (rule: RuleDto, isEnabled: boolean, user: string, version: Version, now?: DateTime) =>
rule.with({
isEnabled,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
function createRule(request: UpsertRuleDto, { payload, version }: Versioned<RuleCreatedDto>, user: string, now?: DateTime) {
now = now || DateTime.now();
const { triggerType, ...trigger } = request.trigger;
const { actionType, ...action } = request.action;
const rule = new RuleDto(
payload.id,
user,
user,
now,
now,
version,
true,
trigger,
triggerType,
action,
actionType);
return rule;
}

6
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs

@ -76,9 +76,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = context.Result<AssetCreatedResult>();
Assert.Equal(assetId, result.IdOrValue);
Assert.Contains("tag1", result.Tags);
Assert.Contains("tag2", result.Tags);
Assert.Equal(assetId, result.Asset.Id);
Assert.Contains("tag1", result.Asset.Tags);
Assert.Contains("tag2", result.Asset.Tags);
AssertAssetHasBeenUploaded(0, context.ContextId);
AssertAssetImageChecked();

6
tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs

@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
var result = await sut.ExecuteAsync(CreateRuleCommand(command));
result.ShouldBeEquivalent(EntityCreatedResult.Create(Id, 0));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(AppId, sut.Snapshot.AppId.Id);
@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
var result = await sut.ExecuteAsync(CreateRuleCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(2));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.True(sut.Snapshot.RuleDef.IsEnabled);
@ -129,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
var result = await sut.ExecuteAsync(CreateRuleCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(1));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.False(sut.Snapshot.RuleDef.IsEnabled);

Loading…
Cancel
Save