Browse Source

Enable multi-select of roles for prevent updates. (#444)

* Enable multi-select of roles for prevent updates.
pull/445/head
Mittul Madaan 6 years ago
committed by Sebastian Stehle
parent
commit
98a62a248e
  1. 19
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs
  2. 24
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/NoUpdate.cs
  3. 16
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  4. 29
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs
  5. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs
  6. 19
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  8. 22
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  9. 11
      backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  11. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs
  12. 29
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs
  13. 10
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs
  14. 13
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs
  15. 25
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs
  16. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs
  17. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs
  18. 67
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs
  19. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs
  20. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs
  21. 52
      frontend/app/features/settings/pages/workflows/workflow-step.component.html
  22. 47
      frontend/app/features/settings/pages/workflows/workflow-step.component.scss
  23. 11
      frontend/app/features/settings/pages/workflows/workflow-step.component.ts
  24. 2
      frontend/app/features/settings/pages/workflows/workflow-transition.component.html
  25. 5
      frontend/app/features/settings/pages/workflows/workflow-transition.component.scss
  26. 6
      frontend/app/features/settings/pages/workflows/workflow-transition.component.ts
  27. 3
      frontend/app/features/settings/pages/workflows/workflow.component.ts
  28. 2
      frontend/app/features/settings/pages/workflows/workflows-page.component.html
  29. 12
      frontend/app/features/settings/pages/workflows/workflows-page.component.ts
  30. 12
      frontend/app/shared/services/workflows.service.ts

19
backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs

@ -1,12 +1,10 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt) // Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
@ -21,7 +19,7 @@ namespace Squidex.Domain.Apps.Core.Contents.Json
public string Role { get; set; } public string Role { get; set; }
[JsonProperty] [JsonProperty]
public List<string> Roles { get; } public string[] Roles { get; }
public JsonWorkflowTransition() public JsonWorkflowTransition()
{ {
@ -34,18 +32,11 @@ namespace Squidex.Domain.Apps.Core.Contents.Json
public WorkflowTransition ToTransition() public WorkflowTransition ToTransition()
{ {
var rolesList = Roles; var roles = Roles;
if (!string.IsNullOrEmpty(Role)) if (!string.IsNullOrEmpty(Role))
{ {
rolesList = new List<string> { Role }; roles = new[] { Role };
}
ReadOnlyCollection<string>? roles = null;
if (rolesList != null && rolesList.Count > 0)
{
roles = new ReadOnlyCollection<string>(rolesList);
} }
return new WorkflowTransition(Expression, roles); return new WorkflowTransition(Expression, roles);

24
backend/src/Squidex.Domain.Apps.Core.Model/Contents/NoUpdate.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschr�nkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class NoUpdate : WorkflowCondition
{
public static readonly NoUpdate Always = new NoUpdate(null, null);
public NoUpdate(string expression, params string[] roles)
: base(expression, roles)
{
}
public static NoUpdate When(string expression, params string[] roles)
{
return new NoUpdate(expression, roles);
}
}
}

16
backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -55,23 +55,23 @@ namespace Squidex.Domain.Apps.Core.Contents
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Draft] = new WorkflowTransition() [Status.Draft] = WorkflowTransition.Always
}, },
StatusColors.Archived, true), StatusColors.Archived, NoUpdate.Always),
[Status.Draft] = [Status.Draft] =
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition(), [Status.Archived] = WorkflowTransition.Always,
[Status.Published] = new WorkflowTransition() [Status.Published] = WorkflowTransition.Always
}, },
StatusColors.Draft), StatusColors.Draft),
[Status.Published] = [Status.Published] =
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition(), [Status.Archived] = WorkflowTransition.Always,
[Status.Draft] = new WorkflowTransition() [Status.Draft] = WorkflowTransition.Always
}, },
StatusColors.Published) StatusColors.Published)
}, null, name); }, null, name);
@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Core.Contents
} }
else if (TryGetStep(Initial, out var initial)) else if (TryGetStep(Initial, out var initial))
{ {
yield return (Initial, initial, WorkflowTransition.Default); yield return (Initial, initial, WorkflowTransition.Always);
} }
} }
@ -105,7 +105,7 @@ namespace Squidex.Domain.Apps.Core.Contents
} }
else if (to == Initial) else if (to == Initial)
{ {
transition = WorkflowTransition.Default; transition = WorkflowTransition.Always;
return true; return true;
} }

29
backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs

@ -0,0 +1,29 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.ObjectModel;
using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Core.Contents
{
public abstract class WorkflowCondition
{
public string Expression { get; }
public ReadOnlyCollection<string> Roles { get; }
protected WorkflowCondition(string expression, params string[] roles)
{
Expression = expression;
if (roles != null)
{
Roles = ReadOnlyCollection.Create(roles);
}
}
}
}

6
backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs

@ -15,11 +15,11 @@ namespace Squidex.Domain.Apps.Core.Contents
public IReadOnlyDictionary<Status, WorkflowTransition> Transitions { get; } public IReadOnlyDictionary<Status, WorkflowTransition> Transitions { get; }
public string? Color { get; } public string Color { get; }
public bool NoUpdate { get; } public NoUpdate NoUpdate { get; }
public WorkflowStep(IReadOnlyDictionary<Status, WorkflowTransition>? transitions = null, string? color = null, bool noUpdate = false) public WorkflowStep(IReadOnlyDictionary<Status, WorkflowTransition>? transitions = null, string? color = null, NoUpdate? noUpdate = null)
{ {
Transitions = transitions ?? EmptyTransitions; Transitions = transitions ?? EmptyTransitions;

19
backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs

@ -5,23 +5,20 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.ObjectModel;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
{ {
public sealed class WorkflowTransition public sealed class WorkflowTransition : WorkflowCondition
{ {
public static readonly WorkflowTransition Default = new WorkflowTransition(); public static readonly WorkflowTransition Always = new WorkflowTransition(null, null);
public string? Expression { get; }
public ReadOnlyCollection<string>? Roles { get; }
public WorkflowTransition(string? expression = null, ReadOnlyCollection<string>? roles = null) public WorkflowTransition(string expression, params string[] roles)
: base(expression, roles)
{ {
Expression = expression; }
Roles = roles; public static WorkflowTransition When(string expression, params string[] roles)
{
return new WorkflowTransition(expression, roles);
} }
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result); return Task.FromResult(result);
} }
public Task<bool> CanUpdateAsync(IContentEntity content) public Task<bool> CanUpdateAsync(IContentEntity content, ClaimsPrincipal user)
{ {
var result = content.Status != Status.Archived; var result = content.Status != Status.Archived;

22
backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -43,23 +43,23 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content.DataDraft, user); return workflow.TryGetTransition(content.Status, next, out var transition) && IsTrue(transition, content.DataDraft, user);
} }
public async Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) public async Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id);
return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && CanUse(transition, data, user); return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && IsTrue(transition, data, user);
} }
public async Task<bool> CanUpdateAsync(IContentEntity content) public async Task<bool> CanUpdateAsync(IContentEntity content, ClaimsPrincipal user)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
if (workflow.TryGetStep(content.Status, out var step)) if (workflow.TryGetStep(content.Status, out var step))
{ {
return !step.NoUpdate; return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.DataDraft, user);
} }
return true; return true;
@ -94,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) foreach (var (to, step, transition) in workflow.GetTransitions(content.Status))
{ {
if (CanUse(transition, content.DataDraft, user)) if (IsTrue(transition, content.DataDraft, user))
{ {
result.Add(new StatusInfo(to, GetColor(step))); result.Add(new StatusInfo(to, GetColor(step)));
} }
@ -103,19 +103,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result.ToArray(); return result.ToArray();
} }
private bool CanUse(WorkflowTransition transition, NamedContentData data, ClaimsPrincipal user) private bool IsTrue(WorkflowCondition condition, NamedContentData data, ClaimsPrincipal user)
{ {
if (transition.Roles != null) if (condition?.Roles != null)
{ {
if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && transition.Roles.Contains(x.Value))) if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && condition.Roles.Contains(x.Value)))
{ {
return false; return false;
} }
} }
if (!string.IsNullOrWhiteSpace(transition.Expression)) if (!string.IsNullOrWhiteSpace(condition?.Expression))
{ {
return scriptEngine.Evaluate("data", data, transition.Expression); var result = false;
result = scriptEngine.Evaluate("data", data, condition.Expression);
return result;
} }
return true; return true;

11
backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
@ -48,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
if (!isProposal) if (!isProposal)
{ {
await ValidateCanUpdate(content, contentWorkflow); await ValidateCanUpdate(content, contentWorkflow, command.User);
} }
} }
@ -63,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
if (!isProposal) if (!isProposal)
{ {
await ValidateCanUpdate(content, contentWorkflow); await ValidateCanUpdate(content, contentWorkflow, command.User);
} }
} }
@ -121,13 +122,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
if (command.Data == null) if (command.Data == null)
{ {
e(Not.Defined("Data"), nameof(command.Data)); e(Not.Defined("Data"), nameof(command.Data));
} }
} }
private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow) private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, ClaimsPrincipal user)
{ {
if (!await contentWorkflow.CanUpdateAsync(content)) if (!await contentWorkflow.CanUpdateAsync(content, user))
{ {
throw new DomainException($"The workflow does not allow updates at status {content.Status}"); throw new DomainException($"The workflow does not allow updates at status {content.Status}");
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content); Task<bool> CanUpdateAsync(IContentEntity content, ClaimsPrincipal user);
Task<StatusInfo> GetInfoAsync(IContentEntity content); Task<StatusInfo> GetInfoAsync(IContentEntity content);

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs

@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
if (ShouldEnrichWithStatuses(context)) if (ShouldEnrichWithStatuses(context))
{ {
await EnrichNextsAsync(content, result, context); await EnrichNextsAsync(content, result, context);
await EnrichCanUpdateAsync(content, result); await EnrichCanUpdateAsync(content, result, context);
} }
results.Add(result); results.Add(result);
@ -327,9 +327,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
return assets.ToLookup(x => x.Id); return assets.ToLookup(x => x.Id);
} }
private async Task EnrichCanUpdateAsync(IContentEntity content, ContentEntity result) private async Task EnrichCanUpdateAsync(IContentEntity content, ContentEntity result, Context context)
{ {
result.CanUpdate = await contentWorkflow.CanUpdateAsync(content); result.CanUpdate = await contentWorkflow.CanUpdateAsync(content, context.User);
} }
private async Task EnrichNextsAsync(IContentEntity content, ContentEntity result, Context context) private async Task EnrichNextsAsync(IContentEntity content, ContentEntity result, Context context)

29
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs

@ -10,6 +10,7 @@ using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using NoUpdateType = Squidex.Domain.Apps.Core.Contents.NoUpdate;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
@ -31,6 +32,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary> /// </summary>
public bool NoUpdate { get; set; } public bool NoUpdate { get; set; }
/// <summary>
/// Optional expression that must evaluate to true when you want to prevent updates.
/// </summary>
public string NoUpdateExpression { get; set; }
/// <summary>
/// Optional list of roles to restrict the updates for users with these roles.
/// </summary>
public string[] NoUpdateRoles { get; set; }
public static WorkflowStepDto? FromWorkflowStep(WorkflowStep step) public static WorkflowStepDto? FromWorkflowStep(WorkflowStep step)
{ {
if (step == null) if (step == null)
@ -38,12 +49,21 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
return null; return null;
} }
return SimpleMapper.Map(step, new WorkflowStepDto var response = SimpleMapper.Map(step, new WorkflowStepDto
{ {
Transitions = step.Transitions.ToDictionary( Transitions = step.Transitions.ToDictionary(
y => y.Key, y => y.Key,
y => WorkflowTransitionDto.FromWorkflowTransition(y.Value)!) y => WorkflowTransitionDto.FromWorkflowTransition(y.Value))
}); });
if (step.NoUpdate != null)
{
response.NoUpdate = true;
response.NoUpdateExpression = step.NoUpdate.Expression;
response.NoUpdateRoles = step.NoUpdate.Roles?.ToArray();
}
return response;
} }
public WorkflowStep ToStep() public WorkflowStep ToStep()
@ -52,7 +72,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
Transitions?.ToDictionary( Transitions?.ToDictionary(
y => y.Key, y => y.Key,
y => y.Value?.ToTransition()!), y => y.Value?.ToTransition()!),
Color, NoUpdate); Color,
NoUpdate ?
NoUpdateType.When(NoUpdateExpression, NoUpdateRoles) :
null);
} }
} }
} }

10
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.ObjectModel; using System.Linq;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
@ -15,21 +15,21 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary> /// <summary>
/// The optional expression. /// The optional expression.
/// </summary> /// </summary>
public string? Expression { get; set; } public string Expression { get; set; }
/// <summary> /// <summary>
/// The optional restricted role. /// The optional restricted role.
/// </summary> /// </summary>
public ReadOnlyCollection<string>? Roles { get; set; } public string[] Roles { get; set; }
public static WorkflowTransitionDto? FromWorkflowTransition(WorkflowTransition transition) public static WorkflowTransitionDto FromWorkflowTransition(WorkflowTransition transition)
{ {
if (transition == null) if (transition == null)
{ {
return null; return null;
} }
return new WorkflowTransitionDto { Expression = transition.Expression, Roles = transition.Roles }; return new WorkflowTransitionDto { Expression = transition.Expression, Roles = transition.Roles?.ToArray() };
} }
public WorkflowTransition ToTransition() public WorkflowTransition ToTransition()

13
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Linq;
using FluentAssertions; using FluentAssertions;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Contents.Json; using Squidex.Domain.Apps.Core.Contents.Json;
@ -24,6 +25,16 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
serialized.Should().BeEquivalentTo(workflow); serialized.Should().BeEquivalentTo(workflow);
} }
[Fact]
public void Should_serialize_and_deserialize_no_update_condition()
{
var step = new WorkflowStep(noUpdate: NoUpdate.When("Expression", "Role1", "Role2"));
var serialized = step.SerializeAndDeserialize();
serialized.Should().BeEquivalentTo(step);
}
[Fact] [Fact]
public void Should_verify_roles_mapping_in_workflow_transition() public void Should_verify_roles_mapping_in_workflow_transition()
{ {
@ -33,7 +44,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
var result = serialized.ToTransition(); var result = serialized.ToTransition();
Assert.Equal(new string[] { "role_1" }, result.Roles); Assert.Equal(source.Role, result?.Roles.Single());
} }
} }
} }

25
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs

@ -22,8 +22,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition("ToArchivedExpr", ReadOnlyCollection.Create("ToArchivedRole" )), [Status.Archived] = WorkflowTransition.When("ToArchivedExpr", "ToArchivedRole"),
[Status.Published] = new WorkflowTransition("ToPublishedExpr", ReadOnlyCollection.Create("ToPublishedRole" )) [Status.Published] = WorkflowTransition.When("ToPublishedExpr", "ToPublishedRole")
}, },
StatusColors.Draft), StatusColors.Draft),
[Status.Archived] = [Status.Archived] =
@ -74,8 +74,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
var found = workflow.TryGetTransition(Status.Draft, Status.Archived, out var transition); var found = workflow.TryGetTransition(Status.Draft, Status.Archived, out var transition);
Assert.True(found); Assert.True(found);
Assert.Equal("ToArchivedExpr", transition!.Expression); Assert.Equal("ToArchivedExpr", transition?.Expression);
Assert.Equal(new[] { "ToArchivedRole" }, transition!.Roles); Assert.Equal("ToArchivedRole", transition?.Roles.Single());
} }
[Fact] [Fact]
@ -84,8 +84,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
var found = workflow.TryGetTransition(new Status("Other"), Status.Draft, out var transition); var found = workflow.TryGetTransition(new Status("Other"), Status.Draft, out var transition);
Assert.True(found); Assert.True(found);
Assert.Null(transition!.Expression); Assert.Null(transition?.Expression);
Assert.Null(transition!.Roles); Assert.Null(transition?.Roles);
} }
[Fact] [Fact]
@ -116,16 +116,15 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
var (status1, step1, transition1) = transitions[0]; var (status1, step1, transition1) = transitions[0];
Assert.Equal(Status.Archived, status1); Assert.Equal(Status.Archived, status1);
Assert.Equal("ToArchivedExpr", transition1.Expression); Assert.Equal("ToArchivedExpr", transition1?.Expression);
Assert.Equal("ToArchivedRole", transition1?.Roles.Single());
Assert.Equal(new[] { "ToArchivedRole" }, transition1.Roles);
Assert.Same(workflow.Steps[status1], step1); Assert.Same(workflow.Steps[status1], step1);
var (status2, step2, transition2) = transitions[1]; var (status2, step2, transition2) = transitions[1];
Assert.Equal(Status.Published, status2); Assert.Equal(Status.Published, status2);
Assert.Equal("ToPublishedExpr", transition2.Expression); Assert.Equal("ToPublishedExpr", transition2?.Expression);
Assert.Equal(new[] { "ToPublishedRole" }, transition2.Roles); Assert.Equal("ToPublishedRole", transition2?.Roles.Single());
Assert.Same(workflow.Steps[status2], step2); Assert.Same(workflow.Steps[status2], step2);
} }
@ -139,8 +138,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
var (status1, step1, transition1) = transitions[0]; var (status1, step1, transition1) = transitions[0];
Assert.Equal(Status.Draft, status1); Assert.Equal(Status.Draft, status1);
Assert.Null(transition1.Expression); Assert.Null(transition1?.Expression);
Assert.Null(transition1.Roles); Assert.Null(transition1?.Roles);
Assert.Same(workflow.Steps[status1], step1); Assert.Same(workflow.Steps[status1], step1);
} }
} }

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs

@ -150,7 +150,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition() [Status.Archived] = WorkflowTransition.Always
}), }),
[Status.Draft] = new WorkflowStep() [Status.Draft] = new WorkflowStep()
}), }),

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs

@ -49,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var content = new ContentEntity { Status = Status.Published }; var content = new ContentEntity { Status = Status.Published };
var result = await sut.CanUpdateAsync(content); var result = await sut.CanUpdateAsync(content, null!);
Assert.True(result); Assert.True(result);
} }
@ -59,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var content = new ContentEntity { Status = Status.Published }; var content = new ContentEntity { Status = Status.Published };
var result = await sut.CanUpdateAsync(content); var result = await sut.CanUpdateAsync(content, null!);
Assert.True(result); Assert.True(result);
} }
@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var content = new ContentEntity { Status = Status.Archived }; var content = new ContentEntity { Status = Status.Archived };
var result = await sut.CanUpdateAsync(content); var result = await sut.CanUpdateAsync(content, null!);
Assert.False(result); Assert.False(result);
} }

67
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using FluentAssertions; using FluentAssertions;
@ -37,25 +38,25 @@ namespace Squidex.Domain.Apps.Entities.Contents
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Draft] = new WorkflowTransition() [Status.Draft] = WorkflowTransition.Always
}, },
StatusColors.Archived, true), StatusColors.Archived, NoUpdate.Always),
[Status.Draft] = [Status.Draft] =
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition(), [Status.Archived] = WorkflowTransition.Always,
[Status.Published] = new WorkflowTransition("data.field.iv === 2", ReadOnlyCollection.Create("Owner", "Editor")) [Status.Published] = WorkflowTransition.When("data.field.iv === 2", "Editor")
}, },
StatusColors.Draft), StatusColors.Draft),
[Status.Published] = [Status.Published] =
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition(), [Status.Archived] = WorkflowTransition.Always,
[Status.Draft] = new WorkflowTransition() [Status.Draft] = WorkflowTransition.Always
}, },
StatusColors.Published) StatusColors.Published, NoUpdate.When("data.field.iv === 2", "Owner", "Editor"))
}); });
public DynamicContentWorkflowTests() public DynamicContentWorkflowTests()
@ -70,14 +71,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Published] = new WorkflowTransition() [Status.Published] = WorkflowTransition.Always
}, },
StatusColors.Draft), StatusColors.Draft),
[Status.Published] = [Status.Published] =
new WorkflowStep( new WorkflowStep(
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Draft] = new WorkflowTransition() [Status.Draft] = WorkflowTransition.Always
}, },
StatusColors.Published) StatusColors.Published)
}, },
@ -179,7 +180,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var content = CreateContent(Status.Published, 2); var content = CreateContent(Status.Published, 2);
var result = await sut.CanUpdateAsync(content); var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Developer"));
Assert.True(result); Assert.True(result);
} }
@ -189,7 +190,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var content = CreateContent(Status.Published, 2); var content = CreateContent(Status.Published, 2);
var result = await sut.CanUpdateAsync(content); var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Developer"));
Assert.True(result); Assert.True(result);
} }
@ -199,11 +200,51 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var content = CreateContent(Status.Archived, 2); var content = CreateContent(Status.Archived, 2);
var result = await sut.CanUpdateAsync(content); var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Developer"));
Assert.False(result); Assert.False(result);
} }
[Fact]
public async Task Should_not_be_able_to_update_published_with_true_expression()
{
var content = CreateContent(Status.Published, 2);
var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Owner"));
Assert.False(result);
}
[Fact]
public async Task Should_be_able_to_update_published_with_false_expression()
{
var content = CreateContent(Status.Published, 1);
var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Owner"));
Assert.True(result);
}
[Fact]
public async Task Should_not_be_able_to_update_published_with_correct_roles()
{
var content = CreateContent(Status.Published, 2);
var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Editor"));
Assert.False(result);
}
[Fact]
public async Task Should_be_able_to_update_published_with_incorrect_roles()
{
var content = CreateContent(Status.Published, 1);
var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Owner"));
Assert.True(result);
}
[Fact] [Fact]
public async Task Should_get_next_statuses_for_draft() public async Task Should_get_next_statuses_for_draft()
{ {
@ -276,7 +317,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
new StatusInfo(Status.Draft, StatusColors.Draft) new StatusInfo(Status.Draft, StatusColors.Draft)
}; };
var result = await sut.GetNextsAsync(content, null!); var result = await sut.GetNextsAsync(content, null);
result.Should().BeEquivalentTo(expected); result.Should().BeEquivalentTo(expected);
} }

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

@ -127,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
SetupCanUpdate(true); SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
var command = new UpdateContent { Data = new NamedContentData() }; var command = new UpdateContent { Data = new NamedContentData(), User = user };
await GuardContent.CanUpdate(content, contentWorkflow, command, false); await GuardContent.CanUpdate(content, contentWorkflow, command, false);
} }
@ -165,7 +165,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
SetupCanUpdate(true); SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
var command = new PatchContent { Data = new NamedContentData() }; var command = new PatchContent { Data = new NamedContentData(), User = user };
await GuardContent.CanPatch(content, contentWorkflow, command, false); await GuardContent.CanPatch(content, contentWorkflow, command, false);
} }
@ -286,7 +286,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
private void SetupCanUpdate(bool canUpdate) private void SetupCanUpdate(bool canUpdate)
{ {
A.CallTo(() => contentWorkflow.CanUpdateAsync(A<IContentEntity>.Ignored)) A.CallTo(() => contentWorkflow.CanUpdateAsync(A<IContentEntity>.Ignored, user))
.Returns(canUpdate); .Returns(canUpdate);
} }

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs

@ -149,7 +149,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var source = new ContentEntity { SchemaId = schemaId }; var source = new ContentEntity { SchemaId = schemaId };
A.CallTo(() => contentWorkflow.CanUpdateAsync(source)) A.CallTo(() => contentWorkflow.CanUpdateAsync(source, requestContext.User))
.Returns(true); .Returns(true);
var result = await sut.EnrichAsync(source, requestContext); var result = await sut.EnrichAsync(source, requestContext);
@ -168,7 +168,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
Assert.False(result.CanUpdate); Assert.False(result.CanUpdate);
A.CallTo(() => contentWorkflow.CanUpdateAsync(source)) A.CallTo(() => contentWorkflow.CanUpdateAsync(source, requestContext.User))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

52
frontend/app/features/settings/pages/workflows/workflow-step.component.html

@ -62,15 +62,51 @@
</div> </div>
</div> </div>
<div class="form-check"> <div class="row transition-prevent-updates no-guttersw">
<input class="form-check-input" type="checkbox" id="preventUpdates_{{step.name}}" <div class="col col-step" style="max-width: 12rem">
[disabled]="disabled" <div class="form-check">
[ngModel]="step.noUpdate" <input class="form-check-input transition-prevent-updates-checkbox" type="checkbox" id="preventUpdates_{{step.name}}"
(ngModelChange)="changeNoUpdate($event)" /> [disabled]="disabled"
[ngModel]="step.noUpdate"
(ngModelChange)="changeNoUpdate($event)" />
<label class="form-check-label" for="preventUpdates_{{step.name}}"> <label class="form-check-label" for="preventUpdates_{{step.name}}">
Prevent updates Prevent updates
</label> </label>
</div>
</div>
<ng-container *ngIf="step.noUpdate">
<div class="col-auto col-label">
<span class="text-decent">when</span>
</div>
<div class="col col-step-expression">
<input class="form-control" [class.dashed]="!step.noUpdateExpression" spellcheck="false"
[disabled]="disabled"
[ngModelOptions]="onBlur"
[ngModel]="step.noUpdateExpression"
(ngModelChange)="changeNoUpdateExpression($event)"
placeholder="Expression" />
</div>
<div class="col-auto col-label">
<span class="text-decent">for</span>
</div>
<div class="col col-step-role">
<sqx-tag-editor [allowDuplicates]="false"
[dashed]="true"
[disabled]="disabled"
(ngModelChange)="changeNoUpdateRoles($event)"
[ngModel]="step.noUpdateRoles"
[ngModelOptions]="onBlur"
[singleLine]="true"
[suggestions]="roles"
placeholder="Role">
</sqx-tag-editor>
</div>
</ng-container>
</div> </div>
</div> </div>
<div class="col-auto col-div-line">
<hr class="col-line"/>
</div>
</div> </div>

47
frontend/app/features/settings/pages/workflows/workflow-step.component.scss

@ -2,6 +2,12 @@
@import '_mixins'; @import '_mixins';
:host ::ng-deep { :host ::ng-deep {
.dashed {
@include placeholder-color($color-theme-secondary);
border-style: dashed;
border-width: 1px;
}
.color { .color {
line-height: 2.8rem; line-height: 2.8rem;
} }
@ -67,3 +73,44 @@
visibility: visible; visibility: visible;
} }
} }
.col-step-role {
width: 56px;
padding-left: 0rem;
padding-right: 4rem;
}
.col-step-expression {
padding-left: 0;
padding-right: 0
}
.transition-prevent-updates {
& {
margin-top: .25rem;
margin-bottom: .5rem;
line-height: 2.5rem;
}
&-to {
padding: .5rem .75rem;
padding-right: 0;
line-height: 1.2rem;
}
}
.col-line {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #e1e4e8;
margin: 1em 0;
}
.col-div-line {
padding-top: 0.5rem
}
.transition-prevent-updates-checkbox {
margin-top: 0.8rem;
}

11
frontend/app/features/settings/pages/workflows/workflow-step.component.ts

@ -8,7 +8,6 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { import {
RoleDto,
WorkflowDto, WorkflowDto,
WorkflowStep, WorkflowStep,
WorkflowStepValues, WorkflowStepValues,
@ -53,7 +52,7 @@ export class WorkflowStepComponent implements OnChanges {
public step: WorkflowStep; public step: WorkflowStep;
@Input() @Input()
public roles: ReadonlyArray<RoleDto>; public roles: ReadonlyArray<string>;
@Input() @Input()
public disabled: boolean; public disabled: boolean;
@ -88,6 +87,14 @@ export class WorkflowStepComponent implements OnChanges {
this.update.emit({ noUpdate }); this.update.emit({ noUpdate });
} }
public changeNoUpdateExpression(noUpdateExpression?: string) {
this.update.emit({ noUpdateExpression });
}
public changeNoUpdateRoles(noUpdateRoles?: ReadonlyArray<string>) {
this.update.emit({ noUpdateRoles });
}
public emitMakeInitial() { public emitMakeInitial() {
this.makeInitial.emit(); this.makeInitial.emit();
} }

2
frontend/app/features/settings/pages/workflows/workflow-transition.component.html

@ -31,7 +31,7 @@
[ngModel]="transition.roles" [ngModel]="transition.roles"
[ngModelOptions]="onBlur" [ngModelOptions]="onBlur"
[singleLine]="true" [singleLine]="true"
[suggestions]="roleSuggestions" [suggestions]="roles"
placeholder="Role"> placeholder="Role">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>

5
frontend/app/features/settings/pages/workflows/workflow-transition.component.scss

@ -1,11 +1,6 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
.dashed {
border-style: dashed;
border-width: 1px;
}
.select-placeholder { .select-placeholder {
@include absolute(0, 0, 0, 0); @include absolute(0, 0, 0, 0);
color: $color-theme-secondary; color: $color-theme-secondary;

6
frontend/app/features/settings/pages/workflows/workflow-transition.component.ts

@ -31,15 +31,11 @@ export class WorkflowTransitionComponent {
public transition: WorkflowTransitionView; public transition: WorkflowTransitionView;
@Input() @Input()
public roles: ReadonlyArray<RoleDto>; public roles: ReadonlyArray<string>;
@Input() @Input()
public disabled: boolean; public disabled: boolean;
public get roleSuggestions() {
return this.roles.map(x => x.name);
}
public changeExpression(expression: string) { public changeExpression(expression: string) {
this.update.emit({ expression }); this.update.emit({ expression });
} }

3
frontend/app/features/settings/pages/workflows/workflow.component.ts

@ -12,7 +12,6 @@ import { Component, Input, OnChanges } from '@angular/core';
import { import {
ErrorDto, ErrorDto,
MathHelper, MathHelper,
RoleDto,
SchemaTagConverter, SchemaTagConverter,
WorkflowDto, WorkflowDto,
WorkflowsState, WorkflowsState,
@ -34,7 +33,7 @@ export class WorkflowComponent implements OnChanges {
public workflow: WorkflowDto; public workflow: WorkflowDto;
@Input() @Input()
public roles: ReadonlyArray<RoleDto>; public roles: ReadonlyArray<string>;
@Input() @Input()
public schemasSource: SchemaTagConverter; public schemasSource: SchemaTagConverter;

2
frontend/app/features/settings/pages/workflows/workflows-page.component.html

@ -26,7 +26,6 @@
</ng-container> </ng-container>
<ng-container *ngIf="schemasSource && workflowsState.workflows | async; let workflows"> <ng-container *ngIf="schemasSource && workflowsState.workflows | async; let workflows">
<ng-container *ngIf="rolesState.roles | async; let roles">
<div class="table-items-row table-items-row-empty" *ngIf="workflows.length === 0"> <div class="table-items-row table-items-row-empty" *ngIf="workflows.length === 0">
No workflows created yet. No workflows created yet.
</div> </div>
@ -36,7 +35,6 @@
</sqx-workflow> </sqx-workflow>
<sqx-workflow-add-form *ngIf="workflowsState.canCreate | async"></sqx-workflow-add-form> <sqx-workflow-add-form *ngIf="workflowsState.canCreate | async"></sqx-workflow-add-form>
</ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>

12
frontend/app/features/settings/pages/workflows/workflows-page.component.ts

@ -8,6 +8,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { import {
ResourceOwner,
RolesState, RolesState,
SchemaTagConverter, SchemaTagConverter,
WorkflowDto, WorkflowDto,
@ -19,15 +20,24 @@ import {
styleUrls: ['./workflows-page.component.scss'], styleUrls: ['./workflows-page.component.scss'],
templateUrl: './workflows-page.component.html' templateUrl: './workflows-page.component.html'
}) })
export class WorkflowsPageComponent implements OnInit { export class WorkflowsPageComponent extends ResourceOwner implements OnInit {
public roles: ReadonlyArray<string> = [];
constructor( constructor(
public readonly rolesState: RolesState, public readonly rolesState: RolesState,
public readonly schemasSource: SchemaTagConverter, public readonly schemasSource: SchemaTagConverter,
public readonly workflowsState: WorkflowsState public readonly workflowsState: WorkflowsState
) { ) {
super();
} }
public ngOnInit() { public ngOnInit() {
this.own(
this.rolesState.roles
.subscribe(roles => {
this.roles = roles.map(x => x.name);
}));
this.rolesState.load(); this.rolesState.load();
this.workflowsState.load(); this.workflowsState.load();

12
frontend/app/shared/services/workflows.service.ts

@ -211,7 +211,7 @@ export class WorkflowDto extends Model<WorkflowDto> {
} }
} }
export type WorkflowStepValues = { color?: string; isLocked?: boolean; noUpdate?: boolean; }; export type WorkflowStepValues = { color?: string; isLocked?: boolean; noUpdate?: boolean; noUpdateExpression?: string; noUpdateRoles?: ReadonlyArray<string> };
export type WorkflowStep = { name: string } & WorkflowStepValues; export type WorkflowStep = { name: string } & WorkflowStepValues;
export type WorkflowTransitionValues = { expression?: string; roles?: string[]; }; export type WorkflowTransitionValues = { expression?: string; roles?: string[]; };
@ -305,13 +305,13 @@ function parseWorkflow(workflow: any) {
for (let stepName in workflow.steps) { for (let stepName in workflow.steps) {
if (workflow.steps.hasOwnProperty(stepName)) { if (workflow.steps.hasOwnProperty(stepName)) {
const step = workflow.steps[stepName]; const { transitions: srcTransitions, ...step } = workflow.steps[stepName];
steps.push({ name: stepName, color: step.color, noUpdate: step.noUpdate, isLocked: stepName === 'Published' }); steps.push({ name: stepName, isLocked: stepName === 'Published', ...step });
for (let to in step.transitions) { for (let to in srcTransitions) {
if (step.transitions.hasOwnProperty(to)) { if (srcTransitions.hasOwnProperty(to)) {
const transition = step.transitions[to]; const transition = srcTransitions[to];
transitions.push({ from: stepName, to, ...transition }); transitions.push({ from: stepName, to, ...transition });
} }

Loading…
Cancel
Save