From 0606c9d02afde0dd90cdbafc697edebaff2408b6 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 11:30:36 +0200 Subject: [PATCH 1/7] Added global validation logic for workflows. --- .../Contents/Workflow.cs | 4 +- .../Contents/Workflows.cs | 1 + .../Contents/DefaultWorkflowsValidator.cs | 57 +++++++++ .../Contents/IWorkflowsValidator.cs | 19 +++ .../Apps/AppWorkflowsController.cs | 12 +- .../Controllers/Apps/Models/WorkflowsDto.cs | 17 ++- src/Squidex/Config/Domain/EntitiesServices.cs | 3 + .../workflows/workflow-step.component.html | 2 +- .../pages/workflows/workflow.component.html | 7 +- .../pages/workflows/workflow.component.ts | 6 +- .../workflows/workflows-page.component.html | 13 +- .../workflows/workflows-page.component.scss | 8 +- .../shared/services/workflows.service.spec.ts | 8 ++ .../app/shared/services/workflows.service.ts | 6 +- .../app/shared/state/workflows.state.ts | 12 +- .../DefaultWorkflowsValidatorTests.cs | 115 ++++++++++++++++++ 16 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs 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); + } + } +} From 22df4bda42c0a5dbf1434d5cbdbcab9b3922c27a Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 13:18:33 +0200 Subject: [PATCH 2/7] Improvements to tag editor. --- .../pages/workflows/workflow.component.html | 20 ++++-- .../pages/workflows/workflow.component.scss | 11 +++ .../angular/forms/tag-editor.component.html | 4 +- .../angular/forms/tag-editor.component.scss | 13 ++++ .../angular/forms/tag-editor.component.ts | 5 +- .../angular/modals/modal-view.directive.ts | 69 ++++++++----------- .../shared/components/asset.component.html | 2 +- 7 files changed, 73 insertions(+), 51 deletions(-) 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 ca2dea003..dcfac9bda 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,21 @@
-
+
-
- {{workflow.displayName}} +
+ {{workflow.displayName}} +
+
+ +
- @@ -14,8 +23,7 @@ [disabled]="!workflow.canDelete" (sqxConfirmClick)="remove()" confirmTitle="Remove workflow" - confirmText="Do you really want to remove the workflow?" - sqxStopClick> + confirmText="Do you really want to remove the workflow?">
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss index bfb99a1aa..b39e484f1 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss +++ b/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss @@ -7,11 +7,22 @@ } } +.workflow { + &-name { + @include truncate; + } +} + .col-form-label { min-width: 4rem; max-width: 4rem; } +.col-tags { + padding: .6rem 1rem; + padding-bottom: 0; +} + .form-group { margin-bottom: 2rem; margin-left: 2rem; diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.html b/src/Squidex/app/framework/angular/forms/tag-editor.component.html index 927fe127f..14147ae43 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.html +++ b/src/Squidex/app/framework/angular/forms/tag-editor.component.html @@ -1,10 +1,10 @@ -
- {{item}} + {{item}} i public singleLine = false; @Input() - public class: string; + public styleBlank = false; + + @Input() + public styleGray = false; @Input() public placeholder = ', to add tag'; diff --git a/src/Squidex/app/framework/angular/modals/modal-view.directive.ts b/src/Squidex/app/framework/angular/modals/modal-view.directive.ts index 01cd544fb..22dc8c01e 100644 --- a/src/Squidex/app/framework/angular/modals/modal-view.directive.ts +++ b/src/Squidex/app/framework/angular/modals/modal-view.directive.ts @@ -21,9 +21,7 @@ import { RootViewComponent } from './root-view.component'; }) export class ModalViewDirective implements OnChanges, OnDestroy { private modalSubscription: Subscription | null = null; - private documentClickListener: Function | null = null; private renderedView: EmbeddedViewRef | null = null; - private static clickCounter = 0; @Input('sqxModalView') public modalView: DialogModel | ModalModel | any; @@ -44,13 +42,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy { private readonly templateRef: TemplateRef, private readonly viewContainer: ViewContainerRef ) { - if (ModalViewDirective.clickCounter === 0) { - this.renderer.listen('document', 'click', () => { - ModalViewDirective.clickCounter++; - }); - - ModalViewDirective.clickCounter = 1; - } } public ngOnDestroy() { @@ -95,7 +86,7 @@ export class ModalViewDirective implements OnChanges, OnDestroy { this.renderer.setStyle(this.renderedView.rootNodes[0], 'display', 'block'); } - this.startListening(ModalViewDirective.clickCounter + 1); + this.startListening(); this.changeDetector.detectChanges(); } else if (!isOpen && this.renderedView) { @@ -114,40 +105,39 @@ export class ModalViewDirective implements OnChanges, OnDestroy { return this.placeOnRoot ? this.rootView.viewContainer : this.viewContainer; } - private startListening(clickCounter: number) { - if (!this.closeAuto) { + private startListening() { + if (this.closeAuto) { + document.addEventListener('click', this.documentClickListener, true); + } + } + + private documentClickListener = (event: MouseEvent) => { + if (!event.target || this.renderedView === null) { return; } - this.documentClickListener = - this.renderer.listen('document', 'click', (event: MouseEvent) => { - if (!event.target || this.renderedView === null || ModalViewDirective.clickCounter === clickCounter) { - return; - } + if (this.renderedView.rootNodes.length === 0) { + return; + } - if (this.renderedView.rootNodes.length === 0) { - return; - } + if (this.closeAlways) { + this.modalView.hide(); + } else { + try { + const rootNode = this.renderedView.rootNodes[0]; + const rootBounds = rootNode.getBoundingClientRect(); + + if (rootBounds.width > 0 && rootBounds.height > 0) { + const clickedInside = rootNode.contains(event.target); - if (this.closeAlways) { - this.modalView.hide(); - } else { - try { - const rootNode = this.renderedView.rootNodes[0]; - const rootBounds = rootNode.getBoundingClientRect(); - - if (rootBounds.width > 0 && rootBounds.height > 0) { - const clickedInside = rootNode.contains(event.target); - - if (!clickedInside && this.modalView) { - this.modalView.hide(); - } - } - } catch (ex) { - return; + if (!clickedInside && this.modalView) { + this.modalView.hide(); } } - }); + } catch (ex) { + return; + } + } } private unsubscribeToModal() { @@ -158,9 +148,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy { } private unsubscribeToClick() { - if (this.documentClickListener) { - this.documentClickListener(); - this.documentClickListener = null; - } + document.removeEventListener('click', this.documentClickListener); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/asset.component.html b/src/Squidex/app/shared/components/asset.component.html index 7fe2798ce..e3d78800e 100644 --- a/src/Squidex/app/shared/components/asset.component.html +++ b/src/Squidex/app/shared/components/asset.component.html @@ -69,7 +69,7 @@
- +
{{asset.pixelWidth}}x{{asset.pixelHeight}}px, {{asset.fileSize | sqxFileSize}} From f42a47d9aed401bd04801fae166438bca563bc61 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 16:31:17 +0200 Subject: [PATCH 3/7] Temp --- .../Contents/Workflows.cs | 8 ++ .../Contents/DynamicContentWorkflow.cs | 33 ++++++-- src/Squidex.Shared/Permissions.cs | 4 +- .../Contents/ContentsController.cs | 2 +- .../Generator/SchemaSwaggerGenerator.cs | 4 +- .../Areas/Api/Controllers/Contents/Helper.cs | 23 ------ .../Controllers/Contents/Models/ContentDto.cs | 21 +++-- .../Contents/Models/ContentsDto.cs | 2 +- .../Contents/DynamicContentWorkflowTests.cs | 80 +++++++++++++++++-- 9 files changed, 123 insertions(+), 54 deletions(-) delete mode 100644 src/Squidex/Areas/Api/Controllers/Contents/Helper.cs diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs index 504ab5d79..b5d86740c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs @@ -50,6 +50,14 @@ namespace Squidex.Domain.Apps.Core.Contents return new Workflows(With(Guid.Empty, workflow)); } + [Pure] + public Workflows Set(Guid id, Workflow workflow) + { + Guard.NotNull(workflow, nameof(workflow)); + + return new Workflows(With(id, workflow)); + } + [Pure] public Workflows Update(Guid id, Workflow workflow) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 6a302fcce..20d15e902 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -34,21 +34,21 @@ namespace Squidex.Domain.Apps.Entities.Contents public async Task GetAllAsync(ISchemaEntity schema) { - var workflow = await GetWorkflowAsync(schema.AppId.Id); + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); } public async Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) { - var workflow = await GetWorkflowAsync(content.AppId.Id); + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content, user); } public async Task CanUpdateAsync(IContentEntity content) { - var workflow = await GetWorkflowAsync(content.AppId.Id); + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); if (workflow.TryGetStep(content.Status, out var step)) { @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public async Task GetInfoAsync(IContentEntity content) { - var workflow = await GetWorkflowAsync(content.AppId.Id); + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); if (workflow.TryGetStep(content.Status, out var step)) { @@ -72,7 +72,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public async Task GetInitialStatusAsync(ISchemaEntity schema) { - var workflow = await GetWorkflowAsync(schema.AppId.Id); + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); var (status, step) = workflow.GetInitialStep(); @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var result = new List(); - var workflow = await GetWorkflowAsync(content.AppId.Id); + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) { @@ -114,11 +114,28 @@ namespace Squidex.Domain.Apps.Entities.Contents return true; } - private async Task GetWorkflowAsync(Guid appId) + private async Task GetWorkflowAsync(Guid appId, Guid schemaId) { + Workflow result = null; + var app = await appProvider.GetAppAsync(appId); - return app?.Workflows.GetFirst(); + if (app != null) + { + result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schemaId)); + + if (result == null) + { + result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0); + } + } + + if (result == null) + { + result = Workflow.Default; + } + + return result; } private static string GetColor(WorkflowStep step) diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 62329248e..10ceb8fef 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -121,8 +121,8 @@ namespace Squidex.Shared public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; - public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}"; - public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; + public const string AppContentsDraftDiscard = "squidex.apps.{app}.contents.{name}.draft.discard"; + public const string AppContentsDraftPublish = "squidex.apps.{app}.contents.{name}.draft.publish"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppApi = "squidex.apps.{app}.api"; diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 095be8d07..6ddd9970d 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -396,7 +396,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/discard/")] [ProducesResponseType(typeof(ContentsDto), 200)] - [ApiPermission(Permissions.AppContentsDiscard)] + [ApiPermission(Permissions.AppContentsDraftDiscard)] [ApiCosts(1)] public async Task DiscardDraft(string app, string name, Guid id) { diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs index 4aad54547..bda1b710d 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs @@ -194,7 +194,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddResponse("204", $"{schemaName} content status changed.", contentSchema); operation.AddResponse("400", "Content data valid."); - AddSecurity(operation, Permissions.AppContentsStatus); + AddSecurity(operation, Permissions.AppContentsMove); }); } @@ -209,7 +209,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddResponse("400", "No pending draft."); operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema); - AddSecurity(operation, Permissions.AppContentsDiscard); + AddSecurity(operation, Permissions.AppContentsDraftDiscard); }); } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs b/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs deleted file mode 100644 index 8644c925a..000000000 --- a/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure.Security; -using Squidex.Shared; - -namespace Squidex.Areas.Api.Controllers.Contents -{ - public static class Helper - { - public static Permission StatusPermission(string app, string schema, Status status) - { - var id = Permissions.AppContentsStatus.Replace("{status}", status.Name); - - return Permissions.ForApp(id, app, schema); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 0725239e4..01775bd9c 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -122,12 +122,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models if (IsPending) { - if (controller.HasPermission(Permissions.AppContentsDiscard, app, schema)) + if (controller.HasPermission(Permissions.AppContentsDraftDiscard, app, schema)) { AddPutLink("draft/discard", controller.Url(x => nameof(x.DiscardDraft), values)); } - if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) + if (controller.HasPermission(Permissions.AppContentsDraftPublish, app, schema)) { AddPutLink("draft/publish", controller.Url(x => nameof(x.PutContentStatus), values)); } @@ -146,24 +146,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models } AddPatchLink("patch", controller.Url(x => nameof(x.PatchContent), values)); - } - - if (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) - { - AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); - } - if (content.Nexts != null) - { - foreach (var next in content.Nexts) + if (content.Nexts != null) { - if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status))) + foreach (var next in content.Nexts) { AddPutLink($"status/{next.Status}", controller.Url(x => nameof(x.PutContentStatus), values), next.Color); } } } + if (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); + } + return this; } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index 749e662d1..ebf991903 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -80,7 +80,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { AddPostLink("create", controller.Url(x => nameof(x.PostContent), values)); - if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) + if (controller.HasPermission(Permissions.AppContentsCreatePublished, app, schema)) { AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs index 027bc03c1..ccffa1c8c 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs @@ -23,6 +23,8 @@ namespace Squidex.Domain.Apps.Entities.Contents public class DynamicContentWorkflowTests { private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); + private readonly NamedId simpleSchemaId = NamedId.Of(Guid.NewGuid(), "my-simple-schema"); private readonly IAppProvider appProvider = A.Fake(); private readonly IAppEntity appEntity = A.Fake(); private readonly DynamicContentWorkflow sut; @@ -56,13 +58,38 @@ namespace Squidex.Domain.Apps.Entities.Contents StatusColors.Published) }); + private readonly Workflow simpleWorkflow; + public DynamicContentWorkflowTests() { + simpleWorkflow = new Workflow( + Status.Draft, + new Dictionary + { + [Status.Draft] = + new WorkflowStep( + new Dictionary + { + [Status.Published] = new WorkflowTransition() + }, + StatusColors.Draft), + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Published) + }, + new List { simpleSchemaId.Id }); + + var workflows = Workflows.Empty.Set(workflow).Set(Guid.NewGuid(), simpleWorkflow); + A.CallTo(() => appProvider.GetAppAsync(appId.Id)) .Returns(appEntity); A.CallTo(() => appEntity.Workflows) - .Returns(Workflows.Empty.Set(workflow)); + .Returns(workflows); sut = new DynamicContentWorkflow(new JintScriptEngine(), appProvider); } @@ -229,24 +256,67 @@ namespace Squidex.Domain.Apps.Entities.Contents result.Should().BeEquivalentTo(expected); } - private ISchemaEntity CreateSchema() + [Fact] + public async Task Should_return_all_statuses_for_simple_schema_workflow() + { + var expected = new[] + { + new StatusInfo(Status.Draft, StatusColors.Draft), + new StatusInfo(Status.Published, StatusColors.Published) + }; + + var result = await sut.GetAllAsync(CreateSchema(true)); + + result.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task Should_return_all_statuses_for_default_workflow_when_no_workflow_configured() + { + A.CallTo(() => appEntity.Workflows).Returns(Workflows.Empty); + + var expected = new[] + { + new StatusInfo(Status.Archived, StatusColors.Archived), + new StatusInfo(Status.Draft, StatusColors.Draft), + new StatusInfo(Status.Published, StatusColors.Published) + }; + + var result = await sut.GetAllAsync(CreateSchema(true)); + + result.Should().BeEquivalentTo(expected); + } + + private ISchemaEntity CreateSchema(bool simple = false) { var schema = A.Fake(); A.CallTo(() => schema.AppId).Returns(appId); + A.CallTo(() => schema.Id).Returns(simple ? simpleSchemaId.Id : schemaId.Id); return schema; } - private IContentEntity CreateContent(Status status, int value) + private IContentEntity CreateContent(Status status, int value, bool simple = false) { - var data = + var content = new ContentEntity { AppId = appId, Status = status }; + + if (simple) + { + content.SchemaId = simpleSchemaId; + } + else + { + content.SchemaId = schemaId; + } + + content.DataDraft = new NamedContentData() .AddField("field", new ContentFieldData() .AddValue("iv", value)); - return new ContentEntity { AppId = appId, Status = status, DataDraft = data }; + return content; } private ClaimsPrincipal User(string role) From b7c4c8f2e3a65a85402f29fa2ed62a7cc3257e60 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 16:57:01 +0200 Subject: [PATCH 4/7] Temp. --- .../Api/Controllers/Contents/ContentsController.cs | 10 ---------- .../Contents/Generator/SchemaSwaggerGenerator.cs | 2 +- .../Api/Controllers/Contents/Models/ContentsDto.cs | 5 +---- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 6ddd9970d..4f75a0417 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -267,11 +267,6 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.GetSchemaOrThrowAsync(Context, name); - if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published))) - { - return new ForbidResult(); - } - var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; var response = await InvokeCommandAsync(app, name, command); @@ -367,11 +362,6 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.GetSchemaOrThrowAsync(Context, name); - if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published))) - { - return new ForbidResult(); - } - var command = request.ToCommand(id); var response = await InvokeCommandAsync(app, name, command); diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs index bda1b710d..56209c00c 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs @@ -194,7 +194,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddResponse("204", $"{schemaName} content status changed.", contentSchema); operation.AddResponse("400", "Content data valid."); - AddSecurity(operation, Permissions.AppContentsMove); + AddSecurity(operation, Permissions.AppContentsUpdate); }); } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index ebf991903..4d997492d 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -80,10 +80,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { AddPostLink("create", controller.Url(x => nameof(x.PostContent), values)); - if (controller.HasPermission(Permissions.AppContentsCreatePublished, app, schema)) - { - AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); - } + AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); } } From 18407a1a716d3e24f3b8012fc05fc3fe4943b704 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 17:01:45 +0200 Subject: [PATCH 5/7] Fix for status query. --- src/Squidex/app/shared/state/contents.state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Squidex/app/shared/state/contents.state.ts b/src/Squidex/app/shared/state/contents.state.ts index c75eb0125..017d5dc94 100644 --- a/src/Squidex/app/shared/state/contents.state.ts +++ b/src/Squidex/app/shared/state/contents.state.ts @@ -355,5 +355,5 @@ function buildQueries(statuses: StatusInfo[] | undefined): ContentQuery[] { } function buildQuery(s: StatusInfo) { - return ({ name: s.status, color: s.color, filter: `$filter=status eq '${s}'` }); + return ({ name: s.status, color: s.color, filter: `$filter=status eq '${s.status}'` }); } From b791e8675ad545b82dd9efa2a633dd90db7dc64f Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 17:10:28 +0200 Subject: [PATCH 6/7] Allow to go to initial state if state not found. --- .../Contents/Workflow.cs | 19 ++++++++++-- .../Contents/WorkflowTransition.cs | 2 ++ .../Model/Contents/WorkflowTests.cs | 31 +++++++++++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 859e546ef..4e67af712 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -61,16 +61,29 @@ namespace Squidex.Domain.Apps.Core.Contents yield return (transition.Key, Steps[transition.Key], transition.Value); } } + else if (TryGetStep(Initial, out var initial)) + { + yield return (Initial, initial, WorkflowTransition.Default); + } } public bool TryGetTransition(Status from, Status to, out WorkflowTransition transition) { - if (TryGetStep(from, out var step) && step.Transitions.TryGetValue(to, out transition)) + transition = null; + + if (TryGetStep(from, out var step)) { - return true; + if (step.Transitions.TryGetValue(to, out transition)) + { + return true; + } } + else if (to == Initial) + { + transition = WorkflowTransition.Default; - transition = null; + return true; + } return false; } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs index 5beed9a62..a41c5ba73 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Core.Contents { public sealed class WorkflowTransition { + public static readonly WorkflowTransition Default = new WorkflowTransition(); + public string Expression { get; } public string Role { get; } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs index 21515232d..26c0fb5c5 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs @@ -77,10 +77,20 @@ namespace Squidex.Domain.Apps.Core.Model.Contents Assert.Equal("ToArchivedRole", transition.Role); } + [Fact] + public void Should_provide_transition_to_initial_if_step_not_found() + { + var found = workflow.TryGetTransition(new Status("Other"), Status.Draft, out var transition); + + Assert.True(found); + Assert.Null(transition.Expression); + Assert.Null(transition.Role); + } + [Fact] public void Should_not_provide_transition_from_unknown_step() { - var found = workflow.TryGetTransition(default, Status.Archived, out var transition); + var found = workflow.TryGetTransition(new Status("Other"), Status.Archived, out var transition); Assert.False(found); Assert.Null(transition); @@ -107,14 +117,29 @@ namespace Squidex.Domain.Apps.Core.Model.Contents Assert.Equal(Status.Archived, status1); Assert.Equal("ToArchivedExpr", transition1.Expression); Assert.Equal("ToArchivedRole", transition1.Role); - Assert.Same(workflow.Steps[Status.Archived], step1); + 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.Same(workflow.Steps[Status.Published], step2); + Assert.Same(workflow.Steps[status2], step2); + } + + [Fact] + public void Should_provide_transitions_to_initial_step_if_status_not_found() + { + var transitions = workflow.GetTransitions(new Status("Other")).ToArray(); + + Assert.Single(transitions); + + var (status1, step1, transition1) = transitions[0]; + + Assert.Equal(Status.Draft, status1); + Assert.Null(transition1.Expression); + Assert.Null(transition1.Role); + Assert.Same(workflow.Steps[status1], step1); } } } From af38e08e5c407b3336364affa932c96f7189d7d3 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 2 Jul 2019 17:53:02 +0200 Subject: [PATCH 7/7] Check publishing. --- .../Contents/Commands/ContentDataCommand.cs | 2 - .../Contents/Commands/ContentUpdateCommand.cs | 14 ++++ .../Contents/Commands/PatchContent.cs | 2 +- .../Contents/Commands/UpdateContent.cs | 2 +- .../Contents/ContentGrain.cs | 16 ++--- .../Contents/DefaultContentWorkflow.cs | 6 ++ .../Contents/DynamicContentWorkflow.cs | 15 +++-- .../Contents/Guards/GuardContent.cs | 7 +- .../Contents/IContentWorkflow.cs | 2 + .../Contents/DefaultContentWorkflowTests.cs | 8 +++ .../Contents/DynamicContentWorkflowTests.cs | 32 ++++++++- .../Contents/Guard/GuardContentTests.cs | 65 +++++++++++-------- 12 files changed, 125 insertions(+), 46 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs index 7f0842c16..f2eea4643 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs @@ -12,7 +12,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public abstract class ContentDataCommand : ContentCommand { public NamedContentData Data { get; set; } - - public bool AsDraft { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs new file mode 100644 index 000000000..63bd8a400 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Contents.Commands +{ + public abstract class ContentUpdateCommand : ContentDataCommand + { + public bool AsDraft { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs index 80206cebd..6654339d9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class PatchContent : ContentDataCommand + public sealed class PatchContent : ContentUpdateCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs index 01f642d5c..aeb2ce59e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class UpdateContent : ContentDataCommand + public sealed class UpdateContent : ContentUpdateCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 9d4ba5eb3..ce07554a7 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, Guid.Empty, () => "Failed to create content."); - GuardContent.CanCreate(ctx.Schema, c); + await GuardContent.CanCreate(ctx.Schema, contentWorkflow, c); await ctx.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data); await ctx.EnrichAsync(c.Data); @@ -190,9 +190,9 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private async Task UpdateAsync(ContentDataCommand c, Func newDataFunc, bool partial) + private async Task UpdateAsync(ContentUpdateCommand command, Func newDataFunc, bool partial) { - var isProposal = c.AsDraft && Snapshot.Status == Status.Published; + var isProposal = command.AsDraft && Snapshot.Status == Status.Published; var currentData = isProposal ? @@ -207,22 +207,22 @@ namespace Squidex.Domain.Apps.Entities.Contents if (partial) { - await ctx.ValidatePartialAsync(c.Data); + await ctx.ValidatePartialAsync(command.Data); } else { - await ctx.ValidateAsync(c.Data); + await ctx.ValidateAsync(command.Data); } - newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", c, newData, Snapshot.Data); + newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", command, newData, Snapshot.Data); if (isProposal) { - ProposeUpdate(c, newData); + ProposeUpdate(command, newData); } else { - Update(c, newData); + Update(command, newData); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs index 0f0075906..47c76f4e0 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs @@ -12,6 +12,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Contents { @@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Contents return Task.FromResult(result); } + public Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) + { + return TaskHelper.True; + } + public Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) { var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 20d15e902..6788f21e5 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -43,7 +43,14 @@ 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, user); + return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(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); } public async Task CanUpdateAsync(IContentEntity content) @@ -87,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.Contents foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) { - if (CanUse(transition, content, user)) + if (CanUse(transition, content.DataDraft, user)) { result.Add(new StatusInfo(to, GetColor(step))); } @@ -96,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return result.ToArray(); } - private bool CanUse(WorkflowTransition transition, IContentEntity content, ClaimsPrincipal user) + private bool CanUse(WorkflowTransition transition, NamedContentData data, ClaimsPrincipal user) { if (!string.IsNullOrWhiteSpace(transition.Role)) { @@ -108,7 +115,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!string.IsNullOrWhiteSpace(transition.Expression)) { - return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression); + return scriptEngine.Evaluate("data", data, transition.Expression); } return true; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 70f78d9b5..494237996 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { public static class GuardContent { - public static void CanCreate(ISchemaEntity schema, CreateContent command) + public static async Task CanCreate(ISchemaEntity schema, IContentWorkflow contentWorkflow, CreateContent command) { Guard.NotNull(command, nameof(command)); @@ -29,6 +29,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { throw new DomainException("Singleton content cannot be created."); } + + if (command.Publish && !await contentWorkflow.CanPublishOnCreateAsync(schema, command.Data, command.User)) + { + throw new DomainException("Content workflow prevents publishing."); + } } public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs index fd2f9dd37..b9acaffc9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs @@ -16,6 +16,8 @@ namespace Squidex.Domain.Apps.Entities.Contents { Task GetInitialStatusAsync(ISchemaEntity schema); + Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user); + Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); Task CanUpdateAsync(IContentEntity content); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs index 6145d14ba..a738581b2 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs @@ -16,6 +16,14 @@ namespace Squidex.Domain.Apps.Entities.Contents { private readonly DefaultContentWorkflow sut = new DefaultContentWorkflow(); + [Fact] + public async Task Should_always_allow_publish_on_create() + { + var result = await sut.CanPublishOnCreateAsync(null, null, null); + + Assert.True(result); + } + [Fact] public async Task Should_draft_as_initial_status() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs index ccffa1c8c..911c756ec 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs @@ -104,6 +104,36 @@ namespace Squidex.Domain.Apps.Entities.Contents result.Should().BeEquivalentTo(expected); } + [Fact] + public async Task Should_allow_publish_on_create() + { + var content = CreateContent(Status.Draft, 2); + + var result = await sut.CanPublishOnCreateAsync(CreateSchema(), content.DataDraft, User("Editor")); + + Assert.True(result); + } + + [Fact] + public async Task Should_not_allow_publish_on_create_if_data_is_invalid() + { + var content = CreateContent(Status.Draft, 4); + + var result = await sut.CanPublishOnCreateAsync(CreateSchema(), content.DataDraft, User("Editor")); + + Assert.False(result); + } + + [Fact] + public async Task Should_not_allow_publish_on_create_if_role_not_allowed() + { + var content = CreateContent(Status.Draft, 2); + + var result = await sut.CanPublishOnCreateAsync(CreateSchema(), content.DataDraft, User("Developer")); + + Assert.False(result); + } + [Fact] public async Task Should_check_is_valid_next() { @@ -125,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } [Fact] - public async Task Should_not_allow_transition_if_expression_does_not_evauate_to_true() + public async Task Should_not_allow_transition_if_data_not_valid() { var content = CreateContent(Status.Draft, 4); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs index b7e827dc3..ac1149312 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs @@ -27,51 +27,71 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard private readonly ClaimsPrincipal user = new ClaimsPrincipal(); private readonly Instant dueTimeInPast = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromHours(1)); - [Fact] - public void CanCreate_should_throw_exception_if_data_is_null() + public GuardContentTests() { SetupSingleton(false); + } + [Fact] + public async Task CanCreate_should_throw_exception_if_data_is_null() + { var command = new CreateContent(); - ValidationAssert.Throws(() => GuardContent.CanCreate(schema, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command), new ValidationError("Data is required.", "Data")); } [Fact] - public void CanCreate_should_throw_exception_if_singleton() + public async Task CanCreate_should_throw_exception_if_singleton() { SetupSingleton(true); var command = new CreateContent { Data = new NamedContentData() }; - Assert.Throws(() => GuardContent.CanCreate(schema, command)); + await Assert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command)); } [Fact] - public void CanCreate_should_not_throw_exception_if_singleton_and_id_is_schema_id() + public async Task CanCreate_should_not_throw_exception_if_singleton_and_id_is_schema_id() { SetupSingleton(true); var command = new CreateContent { Data = new NamedContentData(), ContentId = schema.Id }; - GuardContent.CanCreate(schema, command); + await GuardContent.CanCreate(schema, contentWorkflow, command); } [Fact] - public void CanCreate_should_not_throw_exception_if_data_is_not_null() + public async Task CanCreate_should_throw_exception_publish_not_allowed() { - SetupSingleton(false); + SetupCanCreatePublish(false); + var command = new CreateContent { Data = new NamedContentData(), Publish = true }; + + await Assert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command)); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_publishing_allowed() + { + SetupCanCreatePublish(true); + + var command = new CreateContent { Data = new NamedContentData(), Publish = true }; + + await Assert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command)); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_if_data_is_not_null() + { var command = new CreateContent { Data = new NamedContentData() }; - GuardContent.CanCreate(schema, command); + await GuardContent.CanCreate(schema, contentWorkflow, command); } [Fact] public async Task CanUpdate_should_throw_exception_if_data_is_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); @@ -84,7 +104,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it() { - SetupSingleton(false); SetupCanUpdate(false); var content = CreateContent(Status.Draft, false); @@ -96,7 +115,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanUpdate_should_not_throw_exception_if_data_is_not_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); @@ -108,7 +126,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanPatch_should_throw_exception_if_data_is_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); @@ -121,7 +138,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanPatch_should_throw_exception_if_workflow_blocks_it() { - SetupSingleton(false); SetupCanUpdate(false); var content = CreateContent(Status.Draft, false); @@ -133,7 +149,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanPatch_should_not_throw_exception_if_data_is_not_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); @@ -145,8 +160,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanChangeStatus_should_throw_exception_if_publishing_without_pending_changes() { - SetupSingleton(false); - var content = CreateContent(Status.Published, false); var command = new ChangeContentStatus { Status = Status.Published }; @@ -179,8 +192,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanChangeStatus_should_throw_exception_if_due_date_in_past() { - SetupSingleton(false); - var content = CreateContent(Status.Draft, false); var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast, User = user }; @@ -194,8 +205,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanChangeStatus_should_throw_exception_if_status_flow_not_valid() { - SetupSingleton(false); - var content = CreateContent(Status.Draft, false); var command = new ChangeContentStatus { Status = Status.Published, User = user }; @@ -209,8 +218,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid() { - SetupSingleton(false); - var content = CreateContent(Status.Draft, false); var command = new ChangeContentStatus { Status = Status.Published, User = user }; @@ -231,8 +238,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public void CanDiscardChanges_should_not_throw_exception_if_pending() { - SetupSingleton(false); - var command = new DiscardChanges(); GuardContent.CanDiscardChanges(true, command); @@ -251,8 +256,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public void CanDelete_should_not_throw_exception() { - SetupSingleton(false); - var command = new DeleteContent(); GuardContent.CanDelete(schema, command); @@ -264,6 +267,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard .Returns(canUpdate); } + private void SetupCanCreatePublish(bool canCreate) + { + A.CallTo(() => contentWorkflow.CanPublishOnCreateAsync(schema, A.Ignored, user)) + .Returns(canCreate); + } + private void SetupSingleton(bool isSingleton) { A.CallTo(() => schema.SchemaDef)