From 98a62a248eaef0277d2dc1ec9dd2d3654b651a7c Mon Sep 17 00:00:00 2001 From: Mittul Madaan Date: Tue, 12 Nov 2019 11:25:19 +0000 Subject: [PATCH] Enable multi-select of roles for prevent updates. (#444) * Enable multi-select of roles for prevent updates. --- CHANGELOG.md | 2 +- .../Contents/Json/JsonWorkflowTransition.cs | 21 ++---- .../Contents/NoUpdate.cs | 24 +++++++ .../Contents/Workflow.cs | 16 ++--- .../Contents/WorkflowCondition.cs | 29 ++++++++ .../Contents/WorkflowStep.cs | 6 +- .../Contents/WorkflowTransition.cs | 19 +++-- .../Contents/DefaultContentWorkflow.cs | 2 +- .../Contents/DynamicContentWorkflow.cs | 22 +++--- .../Contents/Guards/GuardContent.cs | 11 +-- .../Contents/IContentWorkflow.cs | 2 +- .../Contents/Queries/ContentEnricher.cs | 6 +- .../Apps/Models/WorkflowStepDto.cs | 29 +++++++- .../Apps/Models/WorkflowTransitionDto.cs | 10 +-- .../Model/Contents/WorkflowJsonTests.cs | 15 +++- .../Model/Contents/WorkflowTests.cs | 27 ++++---- .../Apps/Guards/GuardAppWorkflowTests.cs | 2 +- .../Contents/DefaultContentWorkflowTests.cs | 6 +- .../Contents/DynamicContentWorkflowTests.cs | 69 +++++++++++++++---- .../Contents/Guard/GuardContentTests.cs | 6 +- .../Contents/Queries/ContentEnricherTests.cs | 4 +- .../workflows/workflow-step.component.html | 60 ++++++++++++---- .../workflows/workflow-step.component.scss | 47 +++++++++++++ .../workflows/workflow-step.component.ts | 11 ++- .../workflow-transition.component.html | 2 +- .../workflow-transition.component.scss | 5 -- .../workflow-transition.component.ts | 6 +- .../pages/workflows/workflow.component.ts | 3 +- .../workflows/workflows-page.component.html | 4 +- .../workflows/workflows-page.component.ts | 12 +++- .../app/shared/services/workflows.service.ts | 12 ++-- 31 files changed, 348 insertions(+), 142 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Core.Model/Contents/NoUpdate.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a3716bbd2..687d82565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -565,4 +565,4 @@ Various small bugfixes for UI and API. * Asset endpoint: * `take` query parameter renamed to `$top` for OData compatibility. * `skip` query parameter renamed to `$skip` for OData compatibility. - * `query` query parameter replaced with OData. Use `$query=contains(fileName, 'MyQuery')` instead. \ No newline at end of file + * `query` query parameter replaced with OData. Use `$query=contains(fileName, 'MyQuery')` instead. diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs index 7f2ced411..e6b1afb02 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs @@ -1,12 +1,10 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschränkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; -using System.Collections.ObjectModel; using Newtonsoft.Json; using Squidex.Infrastructure.Reflection; @@ -21,7 +19,7 @@ namespace Squidex.Domain.Apps.Core.Contents.Json public string Role { get; set; } [JsonProperty] - public List Roles { get; } + public string[] Roles { get; } public JsonWorkflowTransition() { @@ -34,21 +32,14 @@ namespace Squidex.Domain.Apps.Core.Contents.Json public WorkflowTransition ToTransition() { - var rolesList = Roles; + var roles = Roles; if (!string.IsNullOrEmpty(Role)) { - rolesList = new List { Role }; - } - - ReadOnlyCollection? roles = null; - - if (rolesList != null && rolesList.Count > 0) - { - roles = new ReadOnlyCollection(rolesList); + roles = new[] { Role }; } return new WorkflowTransition(Expression, roles); } } -} +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/NoUpdate.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/NoUpdate.cs new file mode 100644 index 000000000..d4eb744b5 --- /dev/null +++ b/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); + } + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 9bc70ab86..e41132ece 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -55,23 +55,23 @@ namespace Squidex.Domain.Apps.Core.Contents new WorkflowStep( new Dictionary { - [Status.Draft] = new WorkflowTransition() + [Status.Draft] = WorkflowTransition.Always }, - StatusColors.Archived, true), + StatusColors.Archived, NoUpdate.Always), [Status.Draft] = new WorkflowStep( new Dictionary { - [Status.Archived] = new WorkflowTransition(), - [Status.Published] = new WorkflowTransition() + [Status.Archived] = WorkflowTransition.Always, + [Status.Published] = WorkflowTransition.Always }, StatusColors.Draft), [Status.Published] = new WorkflowStep( new Dictionary { - [Status.Archived] = new WorkflowTransition(), - [Status.Draft] = new WorkflowTransition() + [Status.Archived] = WorkflowTransition.Always, + [Status.Draft] = WorkflowTransition.Always }, StatusColors.Published) }, null, name); @@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Core.Contents } 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) { - transition = WorkflowTransition.Default; + transition = WorkflowTransition.Always; return true; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs new file mode 100644 index 000000000..4cbb74403 --- /dev/null +++ b/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 Roles { get; } + + protected WorkflowCondition(string expression, params string[] roles) + { + Expression = expression; + + if (roles != null) + { + Roles = ReadOnlyCollection.Create(roles); + } + } + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs index 5e5d97217..718b9286a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs @@ -15,11 +15,11 @@ namespace Squidex.Domain.Apps.Core.Contents public IReadOnlyDictionary Transitions { get; } - public string? Color { get; } + public string Color { get; } - public bool NoUpdate { get; } + public NoUpdate NoUpdate { get; } - public WorkflowStep(IReadOnlyDictionary? transitions = null, string? color = null, bool noUpdate = false) + public WorkflowStep(IReadOnlyDictionary? transitions = null, string? color = null, NoUpdate? noUpdate = null) { Transitions = transitions ?? EmptyTransitions; diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs index c5cbc4581..4ef55737c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs @@ -5,23 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.ObjectModel; - namespace Squidex.Domain.Apps.Core.Contents { - public sealed class WorkflowTransition + public sealed class WorkflowTransition : WorkflowCondition { - public static readonly WorkflowTransition Default = new WorkflowTransition(); - - public string? Expression { get; } - - public ReadOnlyCollection? Roles { get; } + public static readonly WorkflowTransition Always = new WorkflowTransition(null, null); - public WorkflowTransition(string? expression = null, ReadOnlyCollection? 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); } } } \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs index 47c76f4e0..b0ca0b3bf 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs @@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return Task.FromResult(result); } - public Task CanUpdateAsync(IContentEntity content) + public Task CanUpdateAsync(IContentEntity content, ClaimsPrincipal user) { var result = content.Status != Status.Archived; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 72b45c8ca..0e56b5340 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/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); - 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 CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) { 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 CanUpdateAsync(IContentEntity content) + public async Task CanUpdateAsync(IContentEntity content, ClaimsPrincipal user) { var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); if (workflow.TryGetStep(content.Status, out var step)) { - return !step.NoUpdate; + return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.DataDraft, user); } return true; @@ -94,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.Contents 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))); } @@ -103,19 +103,21 @@ namespace Squidex.Domain.Apps.Entities.Contents 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; } } - 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; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 9e346fe42..6ed3e20d5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Security.Claims; using System.Threading.Tasks; using NodaTime; using Squidex.Domain.Apps.Core.Contents; @@ -48,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards 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) { - 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) { - 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}"); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs index b9acaffc9..b51b6eeb6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); - Task CanUpdateAsync(IContentEntity content); + Task CanUpdateAsync(IContentEntity content, ClaimsPrincipal user); Task GetInfoAsync(IContentEntity content); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs index 54e50c013..49072fda2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries if (ShouldEnrichWithStatuses(context)) { await EnrichNextsAsync(content, result, context); - await EnrichCanUpdateAsync(content, result); + await EnrichCanUpdateAsync(content, result, context); } results.Add(result); @@ -327,9 +327,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries 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) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs index aece664c1..84ca13287 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs @@ -10,6 +10,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure.Reflection; +using NoUpdateType = Squidex.Domain.Apps.Core.Contents.NoUpdate; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -31,6 +32,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public bool NoUpdate { get; set; } + /// + /// Optional expression that must evaluate to true when you want to prevent updates. + /// + public string NoUpdateExpression { get; set; } + + /// + /// Optional list of roles to restrict the updates for users with these roles. + /// + public string[] NoUpdateRoles { get; set; } + public static WorkflowStepDto? FromWorkflowStep(WorkflowStep step) { if (step == null) @@ -38,12 +49,21 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models return null; } - return SimpleMapper.Map(step, new WorkflowStepDto + var response = SimpleMapper.Map(step, new WorkflowStepDto { Transitions = step.Transitions.ToDictionary( 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() @@ -52,7 +72,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models Transitions?.ToDictionary( y => y.Key, y => y.Value?.ToTransition()!), - Color, NoUpdate); + Color, + NoUpdate ? + NoUpdateType.When(NoUpdateExpression, NoUpdateRoles) : + null); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs index a76650d40..2e9eff3c7 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.ObjectModel; +using System.Linq; using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Areas.Api.Controllers.Apps.Models @@ -15,21 +15,21 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// /// The optional expression. /// - public string? Expression { get; set; } + public string Expression { get; set; } /// /// The optional restricted role. /// - public ReadOnlyCollection? Roles { get; set; } + public string[] Roles { get; set; } - public static WorkflowTransitionDto? FromWorkflowTransition(WorkflowTransition transition) + public static WorkflowTransitionDto FromWorkflowTransition(WorkflowTransition transition) { if (transition == 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() diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs index 3faa18603..a92ca70a6 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Linq; using FluentAssertions; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents.Json; @@ -24,6 +25,16 @@ namespace Squidex.Domain.Apps.Core.Model.Contents 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] public void Should_verify_roles_mapping_in_workflow_transition() { @@ -33,7 +44,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents var result = serialized.ToTransition(); - Assert.Equal(new string[] { "role_1" }, result.Roles); + Assert.Equal(source.Role, result?.Roles.Single()); } } -} +} \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs index c71529a9a..0e1a5071c 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs +++ b/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 Dictionary { - [Status.Archived] = new WorkflowTransition("ToArchivedExpr", ReadOnlyCollection.Create("ToArchivedRole" )), - [Status.Published] = new WorkflowTransition("ToPublishedExpr", ReadOnlyCollection.Create("ToPublishedRole" )) + [Status.Archived] = WorkflowTransition.When("ToArchivedExpr", "ToArchivedRole"), + [Status.Published] = WorkflowTransition.When("ToPublishedExpr", "ToPublishedRole") }, StatusColors.Draft), [Status.Archived] = @@ -74,8 +74,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents var found = workflow.TryGetTransition(Status.Draft, Status.Archived, out var transition); Assert.True(found); - Assert.Equal("ToArchivedExpr", transition!.Expression); - Assert.Equal(new[] { "ToArchivedRole" }, transition!.Roles); + Assert.Equal("ToArchivedExpr", transition?.Expression); + Assert.Equal("ToArchivedRole", transition?.Roles.Single()); } [Fact] @@ -84,8 +84,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents var found = workflow.TryGetTransition(new Status("Other"), Status.Draft, out var transition); Assert.True(found); - Assert.Null(transition!.Expression); - Assert.Null(transition!.Roles); + Assert.Null(transition?.Expression); + Assert.Null(transition?.Roles); } [Fact] @@ -116,16 +116,15 @@ namespace Squidex.Domain.Apps.Core.Model.Contents var (status1, step1, transition1) = transitions[0]; Assert.Equal(Status.Archived, status1); - Assert.Equal("ToArchivedExpr", transition1.Expression); - - Assert.Equal(new[] { "ToArchivedRole" }, transition1.Roles); + Assert.Equal("ToArchivedExpr", transition1?.Expression); + Assert.Equal("ToArchivedRole", transition1?.Roles.Single()); Assert.Same(workflow.Steps[status1], step1); var (status2, step2, transition2) = transitions[1]; Assert.Equal(Status.Published, status2); - Assert.Equal("ToPublishedExpr", transition2.Expression); - Assert.Equal(new[] { "ToPublishedRole" }, transition2.Roles); + Assert.Equal("ToPublishedExpr", transition2?.Expression); + Assert.Equal("ToPublishedRole", transition2?.Roles.Single()); Assert.Same(workflow.Steps[status2], step2); } @@ -139,9 +138,9 @@ namespace Squidex.Domain.Apps.Core.Model.Contents var (status1, step1, transition1) = transitions[0]; Assert.Equal(Status.Draft, status1); - Assert.Null(transition1.Expression); - Assert.Null(transition1.Roles); + Assert.Null(transition1?.Expression); + Assert.Null(transition1?.Roles); Assert.Same(workflow.Steps[status1], step1); } } -} +} \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs index 02cac699d..aae856168 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs +++ b/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 Dictionary { - [Status.Archived] = new WorkflowTransition() + [Status.Archived] = WorkflowTransition.Always }), [Status.Draft] = new WorkflowStep() }), diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs index 093d7da92..6ddfef7b4 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs +++ b/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 result = await sut.CanUpdateAsync(content); + var result = await sut.CanUpdateAsync(content, null!); Assert.True(result); } @@ -59,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var content = new ContentEntity { Status = Status.Published }; - var result = await sut.CanUpdateAsync(content); + var result = await sut.CanUpdateAsync(content, null!); Assert.True(result); } @@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var content = new ContentEntity { Status = Status.Archived }; - var result = await sut.CanUpdateAsync(content); + var result = await sut.CanUpdateAsync(content, null!); Assert.False(result); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs index 25b4389fc..89d4573dd 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FakeItEasy; using FluentAssertions; @@ -37,25 +38,25 @@ namespace Squidex.Domain.Apps.Entities.Contents new WorkflowStep( new Dictionary { - [Status.Draft] = new WorkflowTransition() + [Status.Draft] = WorkflowTransition.Always }, - StatusColors.Archived, true), + StatusColors.Archived, NoUpdate.Always), [Status.Draft] = new WorkflowStep( new Dictionary { - [Status.Archived] = new WorkflowTransition(), - [Status.Published] = new WorkflowTransition("data.field.iv === 2", ReadOnlyCollection.Create("Owner", "Editor")) + [Status.Archived] = WorkflowTransition.Always, + [Status.Published] = WorkflowTransition.When("data.field.iv === 2", "Editor") }, StatusColors.Draft), [Status.Published] = new WorkflowStep( new Dictionary { - [Status.Archived] = new WorkflowTransition(), - [Status.Draft] = new WorkflowTransition() + [Status.Archived] = WorkflowTransition.Always, + [Status.Draft] = WorkflowTransition.Always }, - StatusColors.Published) + StatusColors.Published, NoUpdate.When("data.field.iv === 2", "Owner", "Editor")) }); public DynamicContentWorkflowTests() @@ -70,14 +71,14 @@ namespace Squidex.Domain.Apps.Entities.Contents new WorkflowStep( new Dictionary { - [Status.Published] = new WorkflowTransition() + [Status.Published] = WorkflowTransition.Always }, StatusColors.Draft), [Status.Published] = new WorkflowStep( new Dictionary { - [Status.Draft] = new WorkflowTransition() + [Status.Draft] = WorkflowTransition.Always }, StatusColors.Published) }, @@ -179,7 +180,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var content = CreateContent(Status.Published, 2); - var result = await sut.CanUpdateAsync(content); + var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Developer")); Assert.True(result); } @@ -189,7 +190,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var content = CreateContent(Status.Published, 2); - var result = await sut.CanUpdateAsync(content); + var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Developer")); Assert.True(result); } @@ -199,11 +200,51 @@ namespace Squidex.Domain.Apps.Entities.Contents { var content = CreateContent(Status.Archived, 2); - var result = await sut.CanUpdateAsync(content); + var result = await sut.CanUpdateAsync(content, Mocks.FrontendUser("Developer")); 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] 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) }; - var result = await sut.GetNextsAsync(content, null!); + var result = await sut.GetNextsAsync(content, null); result.Should().BeEquivalentTo(expected); } @@ -349,4 +390,4 @@ namespace Squidex.Domain.Apps.Entities.Contents return content; } } -} +} \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs index 342d00960..f5b6820c8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs @@ -127,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard SetupCanUpdate(true); 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); } @@ -165,7 +165,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard SetupCanUpdate(true); 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); } @@ -286,7 +286,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard private void SetupCanUpdate(bool canUpdate) { - A.CallTo(() => contentWorkflow.CanUpdateAsync(A.Ignored)) + A.CallTo(() => contentWorkflow.CanUpdateAsync(A.Ignored, user)) .Returns(canUpdate); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs index 36962b4cd..313524a26 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs +++ b/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 }; - A.CallTo(() => contentWorkflow.CanUpdateAsync(source)) + A.CallTo(() => contentWorkflow.CanUpdateAsync(source, requestContext.User)) .Returns(true); var result = await sut.EnrichAsync(source, requestContext); @@ -168,7 +168,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.False(result.CanUpdate); - A.CallTo(() => contentWorkflow.CanUpdateAsync(source)) + A.CallTo(() => contentWorkflow.CanUpdateAsync(source, requestContext.User)) .MustNotHaveHappened(); } diff --git a/frontend/app/features/settings/pages/workflows/workflow-step.component.html b/frontend/app/features/settings/pages/workflows/workflow-step.component.html index 339049415..63627ed0b 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-step.component.html +++ b/frontend/app/features/settings/pages/workflows/workflow-step.component.html @@ -34,16 +34,16 @@ - +
- - +
@@ -62,15 +62,51 @@
-
- +
+
+
+ + + +
+
+ +
+ when +
+
+ +
+
+ for +
- -
+
+ + +
+ +
+
+
+
\ No newline at end of file diff --git a/frontend/app/features/settings/pages/workflows/workflow-step.component.scss b/frontend/app/features/settings/pages/workflows/workflow-step.component.scss index 5fa3adc10..e3b0d12bd 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-step.component.scss +++ b/frontend/app/features/settings/pages/workflows/workflow-step.component.scss @@ -2,6 +2,12 @@ @import '_mixins'; :host ::ng-deep { + .dashed { + @include placeholder-color($color-theme-secondary); + border-style: dashed; + border-width: 1px; + } + .color { line-height: 2.8rem; } @@ -66,4 +72,45 @@ &.active { 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; } \ No newline at end of file diff --git a/frontend/app/features/settings/pages/workflows/workflow-step.component.ts b/frontend/app/features/settings/pages/workflows/workflow-step.component.ts index 299e3f773..e9c38357b 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-step.component.ts +++ b/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 { - RoleDto, WorkflowDto, WorkflowStep, WorkflowStepValues, @@ -53,7 +52,7 @@ export class WorkflowStepComponent implements OnChanges { public step: WorkflowStep; @Input() - public roles: ReadonlyArray; + public roles: ReadonlyArray; @Input() public disabled: boolean; @@ -88,6 +87,14 @@ export class WorkflowStepComponent implements OnChanges { this.update.emit({ noUpdate }); } + public changeNoUpdateExpression(noUpdateExpression?: string) { + this.update.emit({ noUpdateExpression }); + } + + public changeNoUpdateRoles(noUpdateRoles?: ReadonlyArray) { + this.update.emit({ noUpdateRoles }); + } + public emitMakeInitial() { this.makeInitial.emit(); } diff --git a/frontend/app/features/settings/pages/workflows/workflow-transition.component.html b/frontend/app/features/settings/pages/workflows/workflow-transition.component.html index 39a6d5dc4..18af9f12c 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-transition.component.html +++ b/frontend/app/features/settings/pages/workflows/workflow-transition.component.html @@ -31,7 +31,7 @@ [ngModel]="transition.roles" [ngModelOptions]="onBlur" [singleLine]="true" - [suggestions]="roleSuggestions" + [suggestions]="roles" placeholder="Role"> diff --git a/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss b/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss index b1f919540..df6f1397f 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss +++ b/frontend/app/features/settings/pages/workflows/workflow-transition.component.scss @@ -1,11 +1,6 @@ @import '_vars'; @import '_mixins'; -.dashed { - border-style: dashed; - border-width: 1px; -} - .select-placeholder { @include absolute(0, 0, 0, 0); color: $color-theme-secondary; diff --git a/frontend/app/features/settings/pages/workflows/workflow-transition.component.ts b/frontend/app/features/settings/pages/workflows/workflow-transition.component.ts index f83ff18da..6167acb87 100644 --- a/frontend/app/features/settings/pages/workflows/workflow-transition.component.ts +++ b/frontend/app/features/settings/pages/workflows/workflow-transition.component.ts @@ -31,15 +31,11 @@ export class WorkflowTransitionComponent { public transition: WorkflowTransitionView; @Input() - public roles: ReadonlyArray; + public roles: ReadonlyArray; @Input() public disabled: boolean; - public get roleSuggestions() { - return this.roles.map(x => x.name); - } - public changeExpression(expression: string) { this.update.emit({ expression }); } diff --git a/frontend/app/features/settings/pages/workflows/workflow.component.ts b/frontend/app/features/settings/pages/workflows/workflow.component.ts index e6b83c07d..866376e87 100644 --- a/frontend/app/features/settings/pages/workflows/workflow.component.ts +++ b/frontend/app/features/settings/pages/workflows/workflow.component.ts @@ -12,7 +12,6 @@ import { Component, Input, OnChanges } from '@angular/core'; import { ErrorDto, MathHelper, - RoleDto, SchemaTagConverter, WorkflowDto, WorkflowsState, @@ -34,7 +33,7 @@ export class WorkflowComponent implements OnChanges { public workflow: WorkflowDto; @Input() - public roles: ReadonlyArray; + public roles: ReadonlyArray; @Input() public schemasSource: SchemaTagConverter; diff --git a/frontend/app/features/settings/pages/workflows/workflows-page.component.html b/frontend/app/features/settings/pages/workflows/workflows-page.component.html index 51a04106a..7302384a9 100644 --- a/frontend/app/features/settings/pages/workflows/workflows-page.component.html +++ b/frontend/app/features/settings/pages/workflows/workflows-page.component.html @@ -26,7 +26,6 @@ -
No workflows created yet.
@@ -36,7 +35,6 @@ -
@@ -49,4 +47,4 @@ - + \ No newline at end of file diff --git a/frontend/app/features/settings/pages/workflows/workflows-page.component.ts b/frontend/app/features/settings/pages/workflows/workflows-page.component.ts index 1679050d9..b367be908 100644 --- a/frontend/app/features/settings/pages/workflows/workflows-page.component.ts +++ b/frontend/app/features/settings/pages/workflows/workflows-page.component.ts @@ -8,6 +8,7 @@ import { Component, OnInit } from '@angular/core'; import { + ResourceOwner, RolesState, SchemaTagConverter, WorkflowDto, @@ -19,15 +20,24 @@ import { styleUrls: ['./workflows-page.component.scss'], templateUrl: './workflows-page.component.html' }) -export class WorkflowsPageComponent implements OnInit { +export class WorkflowsPageComponent extends ResourceOwner implements OnInit { + public roles: ReadonlyArray = []; + constructor( public readonly rolesState: RolesState, public readonly schemasSource: SchemaTagConverter, public readonly workflowsState: WorkflowsState ) { + super(); } public ngOnInit() { + this.own( + this.rolesState.roles + .subscribe(roles => { + this.roles = roles.map(x => x.name); + })); + this.rolesState.load(); this.workflowsState.load(); diff --git a/frontend/app/shared/services/workflows.service.ts b/frontend/app/shared/services/workflows.service.ts index b1385a41c..4b7391245 100644 --- a/frontend/app/shared/services/workflows.service.ts +++ b/frontend/app/shared/services/workflows.service.ts @@ -211,7 +211,7 @@ export class WorkflowDto extends Model { } } -export type WorkflowStepValues = { color?: string; isLocked?: boolean; noUpdate?: boolean; }; +export type WorkflowStepValues = { color?: string; isLocked?: boolean; noUpdate?: boolean; noUpdateExpression?: string; noUpdateRoles?: ReadonlyArray }; export type WorkflowStep = { name: string } & WorkflowStepValues; export type WorkflowTransitionValues = { expression?: string; roles?: string[]; }; @@ -305,13 +305,13 @@ function parseWorkflow(workflow: any) { for (let stepName in workflow.steps) { 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) { - if (step.transitions.hasOwnProperty(to)) { - const transition = step.transitions[to]; + for (let to in srcTransitions) { + if (srcTransitions.hasOwnProperty(to)) { + const transition = srcTransitions[to]; transitions.push({ from: stepName, to, ...transition }); }