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. 8
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  4. 4
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  5. 7
      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();
}
}
}

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

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.ObjectModel;
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class WorkflowTransition
@ -13,13 +15,13 @@ namespace Squidex.Domain.Apps.Core.Contents
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;
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)
{
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;
}

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.ObjectModel;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Areas.Api.Controllers.Apps.Models
@ -19,7 +20,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary>
/// The optional restricted role.
/// </summary>
public string Role { get; set; }
public ReadOnlyCollection<string> Roles { get; set; }
public static WorkflowTransitionDto FromWorkflowTransition(WorkflowTransition transition)
{
@ -28,12 +29,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
return null;
}
return new WorkflowTransitionDto { Expression = transition.Expression, Role = transition.Role };
return new WorkflowTransitionDto { Expression = transition.Expression, Roles = transition.Roles };
}
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 StatusConverter(),
new StringEnumConverter(),
new WorkflowConverter());
new WorkflowConverter(),
new WorkflowTransitionConverter());
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">
<span class="text-decent">for</span>
</div>
<div class="col">
<select class="form-control" [class.dashed]="!transition.role || transition.role === ''"
[disabled]="disabled"
[ngModel]="transition.role"
(ngModelChange)="changeRole($event)">
<option></option>
<option *ngFor="let role of roles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option>
</select>
<span class="select-placeholder" *ngIf="!transition.role || transition.role === ''">Add Role</span>
<div class="col" style="width: 70px">
<sqx-tag-editor
[disabled]="disabled"
[dashed]="true"
[allowDuplicates]="false"
[suggestions]="roleSuggestions"
[singleLine]="true"
[ngModelOptions]="onBlur"
[ngModel]="transition.roles"
(ngModelChange)="changeRole($event)"
placeholder="Add Role">
</sqx-tag-editor>
</div>
<div class="col-auto pl-2">
<button type="button" class="btn btn-text-danger" (click)="emitRemove()" [disabled]="disabled">
<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()
public disabled: boolean;
public get roleSuggestions() {
return this.roles.map(x => x.name);
}
public changeExpression(expression: string) {
this.update.emit({ expression });
}
public changeRole(role: string) {
this.update.emit({ role: role || '' });
public changeRole(roles: ReadonlyArray<string>) {
this.update.emit(({ roles: roles || [] }) as any);
}
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()"
[class.single-line]="singleLine"
[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">
{{item}} <i class="icon-close" (click)="remove(i)"></i>
</span>

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

@ -31,6 +31,11 @@ $focus-color: #b3d3ff;
overflow-y: hidden;
white-space: nowrap;
}
&.dashed {
@include placeholder-color($color-theme-secondary);
border-style: dashed;
}
}
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()
public allowDuplicates = true;
@Input()
public dashed = false;
@Input()
public singleLine = false;

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

@ -168,7 +168,7 @@ describe('WorkflowsService', () => {
[`${name}1`]: {
transitions: {
[`${name}2`]: {
expression: 'Expression1', role: 'Role1'
expression: 'Expression1', roles: ['Role1']
}
},
color: `${name}1`, noUpdate: true
@ -176,7 +176,7 @@ describe('WorkflowsService', () => {
[`${name}2`]: {
transitions: {
[`${name}1`]: {
expression: 'Expression2', role: 'Role2'
expression: 'Expression2', roles: ['Role2']
}
},
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 }
],
[
{ from: `${name}1`, to: `${name}2`, expression: 'Expression1', role: 'Role1' },
{ from: `${name}2`, to: `${name}1`, expression: 'Expression2', role: 'Role2' }
{ from: `${name}1`, to: `${name}2`, expression: 'Expression1', roles: ['Role1'] },
{ from: `${name}2`, to: `${name}1`, expression: 'Expression2', roles: ['Role2'] }
]);
}
@ -437,7 +437,7 @@ describe('Workflow', () => {
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('2', '1', { expression: '2 === 1', role: 'Role' })
.setTransition('2', '1', { expression: '2 === 1', roles: ['Role'] })
.setTransition('2', '1', { expression: '2 !== 1' });
expect(workflow.serialize()).toEqual({
@ -447,7 +447,7 @@ describe('Workflow', () => {
'1': { transitions: {} },
'2': {
transitions: {
'1': { expression: '2 !== 1', role: 'Role' }
'1': { expression: '2 !== 1', roles: ['Role'] }
}
}
},
@ -462,7 +462,7 @@ describe('Workflow', () => {
.setStep('2')
.setTransition('1', '2');
const updated = workflow.setTransition('3', '2', { role: 'Role' });
const updated = workflow.setTransition('3', '2', { roles: ['Role'] });
expect(updated).toBe(workflow);
});
@ -474,7 +474,7 @@ describe('Workflow', () => {
.setStep('2')
.setTransition('1', '2');
const updated = workflow.setTransition('1', '3', { role: 'Role' });
const updated = workflow.setTransition('1', '3', { roles: ['Role'] });
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 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 WorkflowTransitionView = { step: WorkflowStep } & WorkflowTransition;

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

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

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

@ -7,6 +7,7 @@
using FluentAssertions;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Contents.Json;
using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Contents
@ -22,5 +23,18 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
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.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Collections;
using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Contents
@ -21,8 +22,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition("ToArchivedExpr", "ToArchivedRole"),
[Status.Published] = new WorkflowTransition("ToPublishedExpr", "ToPublishedRole")
[Status.Archived] = new WorkflowTransition("ToArchivedExpr", ReadOnlyCollection.Create("ToArchivedRole" )),
[Status.Published] = new WorkflowTransition("ToPublishedExpr", ReadOnlyCollection.Create("ToPublishedRole" ))
},
StatusColors.Draft),
[Status.Archived] =
@ -74,7 +75,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.True(found);
Assert.Equal("ToArchivedExpr", transition.Expression);
Assert.Equal("ToArchivedRole", transition.Role);
Assert.Equal(new[] { "ToArchivedRole" }, transition.Roles);
}
[Fact]
@ -84,7 +85,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.True(found);
Assert.Null(transition.Expression);
Assert.Null(transition.Role);
Assert.Null(transition.Roles);
}
[Fact]
@ -116,14 +117,15 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.Equal(Status.Archived, status1);
Assert.Equal("ToArchivedExpr", transition1.Expression);
Assert.Equal("ToArchivedRole", transition1.Role);
Assert.Equal(new[] { "ToArchivedRole" }, transition1.Roles);
Assert.Same(workflow.Steps[status1], step1);
var (status2, step2, transition2) = transitions[1];
Assert.Equal(Status.Published, status2);
Assert.Equal("ToPublishedExpr", transition2.Expression);
Assert.Equal("ToPublishedRole", transition2.Role);
Assert.Equal(new[] { "ToPublishedRole" }, transition2.Roles);
Assert.Same(workflow.Steps[status2], step2);
}
@ -138,7 +140,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.Equal(Status.Draft, status1);
Assert.Null(transition1.Expression);
Assert.Null(transition1.Role);
Assert.Null(transition1.Roles);
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 StatusConverter(),
new StringEnumConverter(),
new WorkflowConverter()),
new WorkflowConverter(),
new WorkflowTransitionConverter()),
TypeNameHandling = typeNameHandling
};
@ -139,9 +140,7 @@ namespace Squidex.Domain.Apps.Core
foreach (var property in properties)
{
var value =
property.PropertyType.IsValueType ?
Activator.CreateInstance(property.PropertyType) :
null;
property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null;
property.SetValue(sut, value);
@ -155,9 +154,7 @@ namespace Squidex.Domain.Apps.Core
foreach (var property in properties)
{
var value =
property.PropertyType.IsValueType ?
Activator.CreateInstance(property.PropertyType) :
null;
property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null;
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.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
@ -44,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
new Dictionary<Status, 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),
[Status.Published] =
@ -153,6 +154,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
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]
public async Task Should_not_allow_transition_if_data_not_valid()
{

Loading…
Cancel
Save