Browse Source

Added global validation logic for workflows.

pull/382/head
Sebastian Stehle 7 years ago
parent
commit
0606c9d02a
  1. 4
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  2. 1
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs
  3. 57
      src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs
  4. 19
      src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs
  5. 12
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  6. 17
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs
  7. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  8. 2
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  9. 7
      src/Squidex/app/features/settings/pages/workflows/workflow.component.html
  10. 6
      src/Squidex/app/features/settings/pages/workflows/workflow.component.ts
  11. 13
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  12. 8
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss
  13. 8
      src/Squidex/app/shared/services/workflows.service.spec.ts
  14. 6
      src/Squidex/app/shared/services/workflows.service.ts
  15. 12
      src/Squidex/app/shared/state/workflows.state.ts
  16. 115
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs

4
src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -13,9 +13,9 @@ namespace Squidex.Domain.Apps.Core.Contents
public sealed class Workflow : Named
{
private const string DefaultName = "Unnamed";
private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
private static readonly IReadOnlyList<Guid> EmptySchemaIds = new List<Guid>();
public static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
public static readonly IReadOnlyList<Guid> EmptySchemaIds = new List<Guid>();
public static readonly Workflow Default = CreateDefault();
public static readonly Workflow Empty = new Workflow(default, EmptySteps);

1
src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;

57
src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs

@ -0,0 +1,57 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DefaultWorkflowsValidator : IWorkflowsValidator
{
private readonly IAppProvider appProvider;
public DefaultWorkflowsValidator(IAppProvider appProvider)
{
Guard.NotNull(appProvider, nameof(appProvider));
this.appProvider = appProvider;
}
public async Task<IReadOnlyList<string>> ValidateAsync(Guid appId, Workflows workflows)
{
Guard.NotNull(workflows, nameof(workflows));
var errors = new List<string>();
if (workflows.Values.Count(x => x.SchemaIds.Count == 0) > 1)
{
errors.Add("Multiple workflows cover all schemas.");
}
var uniqueSchemaIds = workflows.Values.SelectMany(x => x.SchemaIds).Distinct().ToList();
foreach (var schemaId in uniqueSchemaIds)
{
if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1)
{
var schema = await appProvider.GetSchemaAsync(appId, schemaId);
if (schema != null)
{
errors.Add($"The schema `{schema.SchemaDef.Name}` is covered by multiple workflows.");
}
}
}
return errors;
}
}
}

19
src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IWorkflowsValidator
{
Task<IReadOnlyList<string>> ValidateAsync(Guid appId, Workflows workflows);
}
}

12
src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -12,6 +12,7 @@ using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;
using Squidex.Web;
@ -24,9 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppWorkflowsController : ApiController
{
public AppWorkflowsController(ICommandBus commandBus)
private readonly IWorkflowsValidator workflowsValidator;
public AppWorkflowsController(ICommandBus commandBus, IWorkflowsValidator workflowsValidator)
: base(commandBus)
{
this.workflowsValidator = workflowsValidator;
}
/// <summary>
@ -42,9 +46,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsRead)]
[ApiCosts(0)]
public IActionResult GetWorkflows(string app)
public async Task<IActionResult> GetWorkflows(string app)
{
var response = WorkflowsDto.FromApp(App, this);
var response = await WorkflowsDto.FromAppAsync(workflowsValidator, App, this);
Response.Headers[HeaderNames.ETag] = App.Version.ToString();
@ -128,7 +132,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>();
var response = WorkflowsDto.FromApp(result, this);
var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this);
return response;
}

17
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs

@ -5,10 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Shared;
using Squidex.Web;
@ -22,13 +23,23 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required]
public WorkflowDto[] Items { get; set; }
public static WorkflowsDto FromApp(IAppEntity app, ApiController controller)
/// <summary>
/// The errros that should be fixed.
/// </summary>
[Required]
public string[] Errors { get; set; }
public static async Task<WorkflowsDto> FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller)
{
var result = new WorkflowsDto
{
Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray()
Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray(),
};
var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows);
result.Errors = errors.ToArray();
return result.CreateLinks(controller, app.Name);
}

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

@ -123,6 +123,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<DynamicContentWorkflow>()
.AsOptional<IContentWorkflow>();
services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>();
services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf();

2
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html

@ -29,7 +29,7 @@
<small class="text-decent">(Cannot be removed)</small>
</div>
<div class="col-auto">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked" [disabled]="disabled">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked && workflow.steps.length > 2" [disabled]="disabled">
<i class="icon-bin2"></i>
</button>
</div>

7
src/Squidex/app/features/settings/pages/workflows/workflow.component.html

@ -1,12 +1,12 @@
<div class="table-items-row table-items-row-expandable workflow">
<div class="table-items-row-summary">
<div class="table-items-row-summary" (click)="toggleEditing()">
<div class="row no-gutters">
<div class="col">
{{workflow.displayName}}
</div>
<div class="col-options">
<div class="float-right">
<button type="button" class="btn btn-secondary table-items-edit-button mr-1" [class.active]="isEditing" (click)="toggleEditing()">
<button type="button" class="btn btn-secondary table-items-edit-button mr-1" [class.active]="isEditing" (click)="toggleEditing()" sqxStopClick>
<i class="icon-settings"></i>
</button>
@ -14,7 +14,8 @@
[disabled]="!workflow.canDelete"
(sqxConfirmClick)="remove()"
confirmTitle="Remove workflow"
confirmText="Do you really want to remove the workflow?">
confirmText="Do you really want to remove the workflow?"
sqxStopClick>
<i class="icon-bin2"></i>
</button>
</div>

6
src/Squidex/app/features/settings/pages/workflows/workflow.component.ts

@ -36,7 +36,7 @@ export class WorkflowComponent implements OnChanges {
@Input()
public schemasSource: SchemaTagConverter;
public error: ErrorDto | null;
public error: string | null;
public onBlur = { updateOn: 'blur' };
@ -68,8 +68,8 @@ export class WorkflowComponent implements OnChanges {
this.workflowsState.update(this.workflow)
.subscribe(() => {
this.error = null;
}, error => {
this.error = error;
}, (error: ErrorDto) => {
this.error = error.displayMessage;
});
}

13
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html

@ -2,7 +2,7 @@
<sqx-panel desiredWidth="60rem" [showSidebar]="true" [isLazyLoaded]="false">
<ng-container title>
Workflow
Workflows
</ng-container>
<ng-container menu>
@ -14,6 +14,17 @@
</ng-container>
<ng-container content>
<ng-container *ngIf="workflowsState.errors | async; let errors">
<div class="panel-alert panel-alert-danger" *ngIf="errors.length > 1">
<ul>
<li *ngFor="let error of errors">{{error}}</li>
</ul>
</div>
<div class="panel-alert panel-alert-danger" *ngIf="errors.length === 1">
{{errors[0]}}
</div>
</ng-container>
<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">

8
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss

@ -1,2 +1,8 @@
@import '_vars';
@import '_mixins';
@import '_mixins';
.panel-alert {
ul {
margin: 0;
}
}

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

@ -147,6 +147,10 @@ describe('WorkflowsService', () => {
function workflowsResponse(...names: string[]) {
return {
errors: [
'Error1',
'Error2'
],
items: names.map(name => workflowResponse(name)),
_links: {
create: { method: 'POST', href: '/workflows' }
@ -187,6 +191,10 @@ describe('WorkflowsService', () => {
export function createWorkflows(...names: string[]): WorkflowsPayload {
return {
errors: [
'Error1',
'Error2'
],
items: names.map(name => createWorkflow(name)),
_links: {
create: { method: 'POST', href: '/workflows' }

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

@ -30,6 +30,8 @@ export type WorkflowsDto = Versioned<WorkflowsPayload>;
export type WorkflowsPayload = {
readonly items: WorkflowDto[];
readonly errors: string[];
readonly canCreate: boolean;
} & Resource;
@ -330,9 +332,9 @@ function parseWorkflows(response: any) {
const items = raw.map(item =>
parseWorkflow(item));
const { _links } = response;
const { errors, _links } = response;
return { items, _links, canCreate: hasAnyLink(_links, 'create') };
return { errors, items, _links, canCreate: hasAnyLink(_links, 'create') };
}
function parseWorkflow(workflow: any) {

12
src/Squidex/app/shared/state/workflows.state.ts

@ -34,6 +34,9 @@ interface Snapshot {
// The app version.
version: Version;
// The errors.
errors: string[];
// Indicates if the workflows are loaded.
isLoaded?: boolean;
@ -46,6 +49,9 @@ export class WorkflowsState extends State<Snapshot> {
public workflows =
this.project(x => x.workflows);
public errors =
this.project(x => x.errors);
public isLoaded =
this.project(x => !!x.isLoaded);
@ -57,7 +63,7 @@ export class WorkflowsState extends State<Snapshot> {
private readonly appsState: AppsState,
private readonly dialogs: DialogService
) {
super({ workflows: ImmutableArray.empty(), version: Version.EMPTY });
super({ errors: [], workflows: ImmutableArray.empty(), version: Version.EMPTY });
}
public load(isReload = false): Observable<any> {
@ -103,12 +109,12 @@ export class WorkflowsState extends State<Snapshot> {
}
private replaceWorkflows(payload: WorkflowsPayload, version: Version) {
const { canCreate, items } = payload;
const { canCreate, errors, items } = payload;
const workflows = ImmutableArray.of(items);
this.next(s => {
return { ...s, workflows, isLoaded: true, version, canCreate };
return { ...s, workflows, errors, isLoaded: true, version, canCreate };
});
}

115
tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs

@ -0,0 +1,115 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class DefaultWorkflowsValidatorTests
{
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly DefaultWorkflowsValidator sut;
public DefaultWorkflowsValidatorTests()
{
var schema = A.Fake<ISchemaEntity>();
A.CallTo(() => schema.Id).Returns(schemaId.Id);
A.CallTo(() => schema.SchemaDef).Returns(new Schema(schemaId.Name));
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>.Ignored, false))
.Returns(Task.FromResult<ISchemaEntity>(null));
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false))
.Returns(schema);
sut = new DefaultWorkflowsValidator(appProvider);
}
[Fact]
public async Task Should_generate_error_if_multiple_workflows_cover_all_schemas()
{
var workflows = Workflows.Empty
.Add(Guid.NewGuid(), "workflow1")
.Add(Guid.NewGuid(), "workflow2");
var errors = await sut.ValidateAsync(appId.Id, workflows);
Assert.Equal(errors, new string[] { "Multiple workflows cover all schemas." });
}
[Fact]
public async Task Should_generate_error_if_multiple_workflows_cover_specific_schema()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var workflows = Workflows.Empty
.Add(id1, "workflow1")
.Add(id2, "workflow2")
.Update(id1, new Workflow(default, Workflow.EmptySteps, new List<Guid> { schemaId.Id }))
.Update(id2, new Workflow(default, Workflow.EmptySteps, new List<Guid> { schemaId.Id }));
var errors = await sut.ValidateAsync(appId.Id, workflows);
Assert.Equal(errors, new string[] { "The schema `my-schema` is covered by multiple workflows." });
}
[Fact]
public async Task Should_not_generate_error_if_schema_deleted()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var oldSchemaId = Guid.NewGuid();
var workflows = Workflows.Empty
.Add(id1, "workflow1")
.Add(id2, "workflow2")
.Update(id1, new Workflow(default, Workflow.EmptySteps, new List<Guid> { oldSchemaId }))
.Update(id2, new Workflow(default, Workflow.EmptySteps, new List<Guid> { oldSchemaId }));
var errors = await sut.ValidateAsync(appId.Id, workflows);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_generate_errors_for_no_overlaps()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var workflows = Workflows.Empty
.Add(id1, "workflow1")
.Add(id2, "workflow2")
.Update(id1, new Workflow(default, Workflow.EmptySteps, new List<Guid> { schemaId.Id }));
var errors = await sut.ValidateAsync(appId.Id, workflows);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_generate_errors_for_empty_workflows()
{
var errors = await sut.ValidateAsync(appId.Id, Workflows.Empty);
Assert.Empty(errors);
}
}
}
Loading…
Cancel
Save