Browse Source

Ability to handle multiple roles in workflow transitions (#429)

* Enable multi-select of roles for workflow transitions.

* Applied dash via parameter. Handled UI for multi-select. Applied no-duplicates.

* Fixed conflicts due to ReadOnlyArray changes.

* Build fixes.

* Fixed review comments.
pull/432/head
Mittul Madaan 6 years ago
committed by Sebastian Stehle
parent
commit
8dd98f7612
  1. 55
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/JsonWorkflowTransition.cs
  2. 30
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowTransitionConverter.cs
  3. 10
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  4. 4
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  5. 9
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs
  6. 3
      src/Squidex/Config/Domain/SerializationServices.cs
  7. 22
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html
  8. 8
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.ts
  9. 3
      src/Squidex/app/framework/angular/forms/tag-editor.component.html
  10. 5
      src/Squidex/app/framework/angular/forms/tag-editor.component.scss
  11. 3
      src/Squidex/app/framework/angular/forms/tag-editor.component.ts
  12. 16
      src/Squidex/app/shared/services/workflows.service.spec.ts
  13. 2
      src/Squidex/app/shared/services/workflows.service.ts
  14. 4
      src/Squidex/app/theme/_mixins.scss
  15. 14
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs
  16. 16
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs
  17. 11
      tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs
  18. 13
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs

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

@ -0,0 +1,55 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// 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.Collections;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Core.Contents.Json
{
public class JsonWorkflowTransition
{
[JsonProperty]
public string Expression { get; set; }
[JsonProperty]
public string Role { get; set; }
[JsonProperty]
public List<string> Roles { get; }
public JsonWorkflowTransition()
{
}
public JsonWorkflowTransition(WorkflowTransition client)
{
SimpleMapper.Map(client, this);
}
public WorkflowTransition ToTransition()
{
var rolesList = Roles;
if (!string.IsNullOrEmpty(Role))
{
rolesList = new List<string> { Role };
}
ReadOnlyCollection<string> roles = null;
if (rolesList != null && rolesList.Count > 0)
{
roles = ReadOnlyCollection.Create(rolesList.ToArray());
}
return new WorkflowTransition(Expression, roles);
}
}
}

30
src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowTransitionConverter.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
namespace Squidex.Domain.Apps.Core.Contents.Json
{
public sealed class WorkflowTransitionConverter : JsonClassConverter<WorkflowTransition>
{
protected override void WriteValue(JsonWriter writer, WorkflowTransition value, JsonSerializer serializer)
{
var json = new JsonWorkflowTransition(value);
serializer.Serialize(writer, json);
}
protected override WorkflowTransition ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer)
{
var json = serializer.Deserialize<JsonWorkflowTransition>(reader);
return json.ToTransition();
}
}
}

10
src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs

@ -5,6 +5,8 @@
// 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
@ -13,13 +15,13 @@ namespace Squidex.Domain.Apps.Core.Contents
public string Expression { get; } public string Expression { get; }
public string Role { get; } public ReadOnlyCollection<string> Roles { get; }
public WorkflowTransition(string expression = null, string role = null) public WorkflowTransition(string expression = null, ReadOnlyCollection<string> roles = null)
{ {
Expression = expression; Expression = expression;
Role = role; Roles = roles;
} }
} }
} }

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

@ -105,9 +105,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
private bool CanUse(WorkflowTransition transition, NamedContentData data, ClaimsPrincipal user) private bool CanUse(WorkflowTransition transition, NamedContentData data, ClaimsPrincipal user)
{ {
if (!string.IsNullOrWhiteSpace(transition.Role)) if (transition.Roles != null)
{ {
if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && x.Value == transition.Role)) if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && transition.Roles.Contains(x.Value)))
{ {
return false; return false;
} }

9
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.ObjectModel;
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
@ -19,7 +20,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary> /// <summary>
/// The optional restricted role. /// The optional restricted role.
/// </summary> /// </summary>
public string Role { get; set; } public ReadOnlyCollection<string> Roles { get; set; }
public static WorkflowTransitionDto FromWorkflowTransition(WorkflowTransition transition) public static WorkflowTransitionDto FromWorkflowTransition(WorkflowTransition transition)
{ {
@ -28,12 +29,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
return null; return null;
} }
return new WorkflowTransitionDto { Expression = transition.Expression, Role = transition.Role }; return new WorkflowTransitionDto { Expression = transition.Expression, Roles = transition.Roles };
} }
public WorkflowTransition ToTransition() public WorkflowTransition ToTransition()
{ {
return new WorkflowTransition(Expression, Role); return new WorkflowTransition(Expression, Roles);
} }
} }
} }

3
src/Squidex/Config/Domain/SerializationServices.cs

@ -52,7 +52,8 @@ namespace Squidex.Config.Domain
new SchemaConverter(), new SchemaConverter(),
new StatusConverter(), new StatusConverter(),
new StringEnumConverter(), new StringEnumConverter(),
new WorkflowConverter()); new WorkflowConverter(),
new WorkflowTransitionConverter());
settings.NullValueHandling = NullValueHandling.Ignore; settings.NullValueHandling = NullValueHandling.Ignore;

22
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html

@ -21,17 +21,21 @@
<div class="col-auto col-label"> <div class="col-auto col-label">
<span class="text-decent">for</span> <span class="text-decent">for</span>
</div> </div>
<div class="col">
<select class="form-control" [class.dashed]="!transition.role || transition.role === ''" <div class="col" style="width: 70px">
<sqx-tag-editor
[disabled]="disabled" [disabled]="disabled"
[ngModel]="transition.role" [dashed]="true"
(ngModelChange)="changeRole($event)"> [allowDuplicates]="false"
<option></option> [suggestions]="roleSuggestions"
<option *ngFor="let role of roles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option> [singleLine]="true"
</select> [ngModelOptions]="onBlur"
[ngModel]="transition.roles"
<span class="select-placeholder" *ngIf="!transition.role || transition.role === ''">Add Role</span> (ngModelChange)="changeRole($event)"
placeholder="Add Role">
</sqx-tag-editor>
</div> </div>
<div class="col-auto pl-2"> <div class="col-auto pl-2">
<button type="button" class="btn btn-text-danger" (click)="emitRemove()" [disabled]="disabled"> <button type="button" class="btn btn-text-danger" (click)="emitRemove()" [disabled]="disabled">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>

8
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.ts

@ -36,12 +36,16 @@ export class WorkflowTransitionComponent {
@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 });
} }
public changeRole(role: string) { public changeRole(roles: ReadonlyArray<string>) {
this.update.emit({ role: role || '' }); this.update.emit(({ roles: roles || [] }) as any);
} }
public emitRemove() { public emitRemove() {

3
src/Squidex/app/framework/angular/forms/tag-editor.component.html

@ -1,7 +1,8 @@
<div class="form-control tags" [class.blank]="styleBlank" [class.gray]="styleGray" #form (click)="input.focus()" <div class="form-control tags" [class.blank]="styleBlank" [class.gray]="styleGray" #form (click)="input.focus()"
[class.single-line]="singleLine" [class.single-line]="singleLine"
[class.focus]="snapshot.hasFocus" [class.focus]="snapshot.hasFocus"
[class.disabled]="addInput.disabled"> [class.disabled]="addInput.disabled"
[class.dashed]="dashed && !(snapshot.items.length > 0)">
<span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled"> <span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled">
{{item}} <i class="icon-close" (click)="remove(i)"></i> {{item}} <i class="icon-close" (click)="remove(i)"></i>
</span> </span>

5
src/Squidex/app/framework/angular/forms/tag-editor.component.scss

@ -31,6 +31,11 @@ $focus-color: #b3d3ff;
overflow-y: hidden; overflow-y: hidden;
white-space: nowrap; white-space: nowrap;
} }
&.dashed {
@include placeholder-color($color-theme-secondary);
border-style: dashed;
}
} }
div { div {

3
src/Squidex/app/framework/angular/forms/tag-editor.component.ts

@ -168,6 +168,9 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
@Input() @Input()
public allowDuplicates = true; public allowDuplicates = true;
@Input()
public dashed = false;
@Input() @Input()
public singleLine = false; public singleLine = false;

16
src/Squidex/app/shared/services/workflows.service.spec.ts

@ -168,7 +168,7 @@ describe('WorkflowsService', () => {
[`${name}1`]: { [`${name}1`]: {
transitions: { transitions: {
[`${name}2`]: { [`${name}2`]: {
expression: 'Expression1', role: 'Role1' expression: 'Expression1', roles: ['Role1']
} }
}, },
color: `${name}1`, noUpdate: true color: `${name}1`, noUpdate: true
@ -176,7 +176,7 @@ describe('WorkflowsService', () => {
[`${name}2`]: { [`${name}2`]: {
transitions: { transitions: {
[`${name}1`]: { [`${name}1`]: {
expression: 'Expression2', role: 'Role2' expression: 'Expression2', roles: ['Role2']
} }
}, },
color: `${name}2`, noUpdate: true color: `${name}2`, noUpdate: true
@ -217,8 +217,8 @@ export function createWorkflow(name: string): WorkflowDto {
{ name: `${name}2`, color: `${name}2`, noUpdate: true, isLocked: false } { name: `${name}2`, color: `${name}2`, noUpdate: true, isLocked: false }
], ],
[ [
{ from: `${name}1`, to: `${name}2`, expression: 'Expression1', role: 'Role1' }, { from: `${name}1`, to: `${name}2`, expression: 'Expression1', roles: ['Role1'] },
{ from: `${name}2`, to: `${name}1`, expression: 'Expression2', role: 'Role2' } { from: `${name}2`, to: `${name}1`, expression: 'Expression2', roles: ['Role2'] }
]); ]);
} }
@ -437,7 +437,7 @@ describe('Workflow', () => {
new WorkflowDto({}, 'id') new WorkflowDto({}, 'id')
.setStep('1') .setStep('1')
.setStep('2') .setStep('2')
.setTransition('2', '1', { expression: '2 === 1', role: 'Role' }) .setTransition('2', '1', { expression: '2 === 1', roles: ['Role'] })
.setTransition('2', '1', { expression: '2 !== 1' }); .setTransition('2', '1', { expression: '2 !== 1' });
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
@ -447,7 +447,7 @@ describe('Workflow', () => {
'1': { transitions: {} }, '1': { transitions: {} },
'2': { '2': {
transitions: { transitions: {
'1': { expression: '2 !== 1', role: 'Role' } '1': { expression: '2 !== 1', roles: ['Role'] }
} }
} }
}, },
@ -462,7 +462,7 @@ describe('Workflow', () => {
.setStep('2') .setStep('2')
.setTransition('1', '2'); .setTransition('1', '2');
const updated = workflow.setTransition('3', '2', { role: 'Role' }); const updated = workflow.setTransition('3', '2', { roles: ['Role'] });
expect(updated).toBe(workflow); expect(updated).toBe(workflow);
}); });
@ -474,7 +474,7 @@ describe('Workflow', () => {
.setStep('2') .setStep('2')
.setTransition('1', '2'); .setTransition('1', '2');
const updated = workflow.setTransition('1', '3', { role: 'Role' }); const updated = workflow.setTransition('1', '3', { roles: ['Role'] });
expect(updated).toBe(workflow); expect(updated).toBe(workflow);
}); });

2
src/Squidex/app/shared/services/workflows.service.ts

@ -214,7 +214,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; };
export type WorkflowStep = { name: string } & WorkflowStepValues; export type WorkflowStep = { name: string } & WorkflowStepValues;
export type WorkflowTransitionValues = { expression?: string; role?: string; }; export type WorkflowTransitionValues = { expression?: string; roles?: string[]; };
export type WorkflowTransition = { from: string; to: string } & WorkflowTransitionValues; export type WorkflowTransition = { from: string; to: string } & WorkflowTransitionValues;
export type WorkflowTransitionView = { step: WorkflowStep } & WorkflowTransition; export type WorkflowTransitionView = { step: WorkflowStep } & WorkflowTransition;

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

@ -213,6 +213,10 @@
} }
@mixin placeholder-color($color) { @mixin placeholder-color($color) {
::placeholder {
color: $color;
}
&::-webkit-input-placeholder { &::-webkit-input-placeholder {
color: $color; color: $color;
} }

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

@ -7,6 +7,7 @@
using FluentAssertions; using FluentAssertions;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Contents.Json;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Contents namespace Squidex.Domain.Apps.Core.Model.Contents
@ -22,5 +23,18 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
serialized.Should().BeEquivalentTo(workflow); serialized.Should().BeEquivalentTo(workflow);
} }
[Fact]
public void Should_verify_roles_mapping_in_workflow_transition()
{
var source = new JsonWorkflowTransition { Expression = "expression_1", Role = "role_1" };
var serialized = source.SerializeAndDeserialize();
var result = serialized.ToTransition();
Assert.Single(result.Roles);
Assert.Equal(source.Role, result.Roles[0]);
}
} }
} }

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

@ -8,6 +8,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Collections;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Contents namespace Squidex.Domain.Apps.Core.Model.Contents
@ -21,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", "ToArchivedRole"), [Status.Archived] = new WorkflowTransition("ToArchivedExpr", ReadOnlyCollection.Create("ToArchivedRole" )),
[Status.Published] = new WorkflowTransition("ToPublishedExpr", "ToPublishedRole") [Status.Published] = new WorkflowTransition("ToPublishedExpr", ReadOnlyCollection.Create("ToPublishedRole" ))
}, },
StatusColors.Draft), StatusColors.Draft),
[Status.Archived] = [Status.Archived] =
@ -74,7 +75,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.True(found); Assert.True(found);
Assert.Equal("ToArchivedExpr", transition.Expression); Assert.Equal("ToArchivedExpr", transition.Expression);
Assert.Equal("ToArchivedRole", transition.Role); Assert.Equal(new[] { "ToArchivedRole" }, transition.Roles);
} }
[Fact] [Fact]
@ -84,7 +85,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.True(found); Assert.True(found);
Assert.Null(transition.Expression); Assert.Null(transition.Expression);
Assert.Null(transition.Role); Assert.Null(transition.Roles);
} }
[Fact] [Fact]
@ -116,14 +117,15 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.Equal(Status.Archived, status1); Assert.Equal(Status.Archived, status1);
Assert.Equal("ToArchivedExpr", transition1.Expression); Assert.Equal("ToArchivedExpr", transition1.Expression);
Assert.Equal("ToArchivedRole", transition1.Role);
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("ToPublishedRole", transition2.Role); Assert.Equal(new[] { "ToPublishedRole" }, transition2.Roles);
Assert.Same(workflow.Steps[status2], step2); Assert.Same(workflow.Steps[status2], step2);
} }
@ -138,7 +140,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.Equal(Status.Draft, status1); Assert.Equal(Status.Draft, status1);
Assert.Null(transition1.Expression); Assert.Null(transition1.Expression);
Assert.Null(transition1.Role); Assert.Null(transition1.Roles);
Assert.Same(workflow.Steps[status1], step1); Assert.Same(workflow.Steps[status1], step1);
} }
} }

11
tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs

@ -64,7 +64,8 @@ namespace Squidex.Domain.Apps.Core
new SchemaConverter(), new SchemaConverter(),
new StatusConverter(), new StatusConverter(),
new StringEnumConverter(), new StringEnumConverter(),
new WorkflowConverter()), new WorkflowConverter(),
new WorkflowTransitionConverter()),
TypeNameHandling = typeNameHandling TypeNameHandling = typeNameHandling
}; };
@ -139,9 +140,7 @@ namespace Squidex.Domain.Apps.Core
foreach (var property in properties) foreach (var property in properties)
{ {
var value = var value =
property.PropertyType.IsValueType ? property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null;
Activator.CreateInstance(property.PropertyType) :
null;
property.SetValue(sut, value); property.SetValue(sut, value);
@ -155,9 +154,7 @@ namespace Squidex.Domain.Apps.Core
foreach (var property in properties) foreach (var property in properties)
{ {
var value = var value =
property.PropertyType.IsValueType ? property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null;
Activator.CreateInstance(property.PropertyType) :
null;
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
{ {

13
tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs

@ -15,6 +15,7 @@ using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
@ -44,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
new Dictionary<Status, WorkflowTransition> new Dictionary<Status, WorkflowTransition>
{ {
[Status.Archived] = new WorkflowTransition(), [Status.Archived] = new WorkflowTransition(),
[Status.Published] = new WorkflowTransition("data.field.iv === 2", "Editor") [Status.Published] = new WorkflowTransition("data.field.iv === 2", ReadOnlyCollection.Create("Owner", "Editor"))
}, },
StatusColors.Draft), StatusColors.Draft),
[Status.Published] = [Status.Published] =
@ -153,6 +154,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
Assert.False(result); Assert.False(result);
} }
[Fact]
public async Task Should_allow_transition_if_role_is_allowed()
{
var content = CreateContent(Status.Draft, 2);
var result = await sut.CanMoveToAsync(content, Status.Published, Mocks.FrontendUser("Editor"));
Assert.True(result);
}
[Fact] [Fact]
public async Task Should_not_allow_transition_if_data_not_valid() public async Task Should_not_allow_transition_if_data_not_valid()
{ {

Loading…
Cancel
Save