diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 391863c0e..f0187e361 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/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 EmptySteps = new Dictionary(); - private static readonly IReadOnlyList EmptySchemaIds = new List(); + public static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); + public static readonly IReadOnlyList EmptySchemaIds = new List(); public static readonly Workflow Default = CreateDefault(); public static readonly Workflow Empty = new Workflow(default, EmptySteps); diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs index 3675f1b81..504ab5d79 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs +++ b/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; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs new file mode 100644 index 000000000..75ddd704b --- /dev/null +++ b/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> ValidateAsync(Guid appId, Workflows workflows) + { + Guard.NotNull(workflows, nameof(workflows)); + + var errors = new List(); + + 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; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs b/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs new file mode 100644 index 000000000..01c8574b4 --- /dev/null +++ b/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> ValidateAsync(Guid appId, Workflows workflows); + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs index 22e003e75..0c3007269 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs +++ b/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; } /// @@ -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 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(); - var response = WorkflowsDto.FromApp(result, this); + var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this); return response; } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs index b58be115c..5e3515eba 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs +++ b/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) + /// + /// The errros that should be fixed. + /// + [Required] + public string[] Errors { get; set; } + + public static async Task 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); } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 47c2e44b9..b9a9813c3 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -123,6 +123,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsOptional(); + services.AddSingletonAs() + .AsOptional(); + services.AddSingletonAs() .AsSelf(); diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html index 57c22536d..162be35c1 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html @@ -29,7 +29,7 @@ (Cannot be removed)
-
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow.component.html index b4d834a63..ca2dea003 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflow.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflow.component.html @@ -1,12 +1,12 @@
-
+
{{workflow.displayName}}
- @@ -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>
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts b/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts index 976fd86b8..afd1c34a9 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts +++ b/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; }); } diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html index 535f3a7f0..135386ceb 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html @@ -2,7 +2,7 @@ - Workflow + Workflows @@ -14,6 +14,17 @@ + +
+
    +
  • {{error}}
  • +
+
+
+ {{errors[0]}} +
+
+
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss index fbb752506..ad50cdf61 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss +++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss @@ -1,2 +1,8 @@ @import '_vars'; -@import '_mixins'; \ No newline at end of file +@import '_mixins'; + +.panel-alert { + ul { + margin: 0; + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/workflows.service.spec.ts b/src/Squidex/app/shared/services/workflows.service.spec.ts index a3bb52009..901a7b206 100644 --- a/src/Squidex/app/shared/services/workflows.service.spec.ts +++ b/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' } diff --git a/src/Squidex/app/shared/services/workflows.service.ts b/src/Squidex/app/shared/services/workflows.service.ts index cc34d9e82..373160909 100644 --- a/src/Squidex/app/shared/services/workflows.service.ts +++ b/src/Squidex/app/shared/services/workflows.service.ts @@ -30,6 +30,8 @@ export type WorkflowsDto = Versioned; 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) { diff --git a/src/Squidex/app/shared/state/workflows.state.ts b/src/Squidex/app/shared/state/workflows.state.ts index 841365fba..6557dea6f 100644 --- a/src/Squidex/app/shared/state/workflows.state.ts +++ b/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 { 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 { 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 { @@ -103,12 +109,12 @@ export class WorkflowsState extends State { } 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 }; }); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs new file mode 100644 index 000000000..9a887a73b --- /dev/null +++ b/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(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); + private readonly DefaultWorkflowsValidator sut; + + public DefaultWorkflowsValidatorTests() + { + var schema = A.Fake(); + + A.CallTo(() => schema.Id).Returns(schemaId.Id); + A.CallTo(() => schema.SchemaDef).Returns(new Schema(schemaId.Name)); + + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A.Ignored, false)) + .Returns(Task.FromResult(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 { schemaId.Id })) + .Update(id2, new Workflow(default, Workflow.EmptySteps, new List { 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 { oldSchemaId })) + .Update(id2, new Workflow(default, Workflow.EmptySteps, new List { 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 { 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); + } + } +}