Browse Source

Merge branch 'feature/multiple_workflows' of github.com:Squidex/squidex into feature/multiple_workflows

# Conflicts:
#	src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
pull/382/head
Sebastian 7 years ago
parent
commit
61e5fd37e9
  1. 23
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  2. 2
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  3. 9
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs
  4. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs
  5. 14
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs
  6. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs
  7. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs
  8. 16
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  9. 6
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  10. 57
      src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs
  11. 48
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  12. 7
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  13. 2
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  14. 16
      src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs
  15. 4
      src/Squidex.Shared/Permissions.cs
  16. 12
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  17. 16
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs
  18. 12
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  19. 4
      src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs
  20. 21
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  21. 5
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  22. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  23. 2
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  24. 13
      src/Squidex/app/features/settings/pages/workflows/workflow.component.html
  25. 11
      src/Squidex/app/features/settings/pages/workflows/workflow.component.scss
  26. 6
      src/Squidex/app/features/settings/pages/workflows/workflow.component.ts
  27. 13
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  28. 8
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss
  29. 4
      src/Squidex/app/framework/angular/forms/tag-editor.component.html
  30. 13
      src/Squidex/app/framework/angular/forms/tag-editor.component.scss
  31. 5
      src/Squidex/app/framework/angular/forms/tag-editor.component.ts
  32. 69
      src/Squidex/app/framework/angular/modals/modal-view.directive.ts
  33. 2
      src/Squidex/app/shared/components/asset.component.html
  34. 8
      src/Squidex/app/shared/services/workflows.service.spec.ts
  35. 6
      src/Squidex/app/shared/services/workflows.service.ts
  36. 2
      src/Squidex/app/shared/state/contents.state.ts
  37. 12
      src/Squidex/app/shared/state/workflows.state.ts
  38. 31
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs
  39. 8
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs
  40. 115
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs
  41. 112
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs
  42. 65
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

23
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);
@ -85,16 +85,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;
}

2
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; }

9
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;
@ -49,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)
{

2
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; }
}
}

14
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; }
}
}

2
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
{
}
}

2
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
{
}
}

16
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<object> UpdateAsync(ContentDataCommand c, Func<NamedContentData, NamedContentData> newDataFunc, bool partial)
private async Task<object> UpdateAsync(ContentUpdateCommand command, Func<NamedContentData, NamedContentData> 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);
}
}

6
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<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user)
{
return TaskHelper.True;
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next);

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;
}
}
}

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

@ -34,21 +34,28 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<StatusInfo[]> 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<bool> 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);
return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content.DataDraft, user);
}
public async Task<bool> 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<bool> 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 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<StatusInfo> 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 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<StatusInfo> 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,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var result = new List<StatusInfo>();
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))
{
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,17 +115,34 @@ 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;
}
private async Task<Workflow> GetWorkflowAsync(Guid appId)
private async Task<Workflow> 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)

7
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)

2
src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -16,6 +16,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content);

16
src/Squidex/Areas/Api/Controllers/Contents/Helper.cs → src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs

@ -5,19 +5,15 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
namespace Squidex.Areas.Api.Controllers.Contents
namespace Squidex.Domain.Apps.Entities.Contents
{
public static class Helper
public interface IWorkflowsValidator
{
public static Permission StatusPermission(string app, string schema, Status status)
{
var id = Permissions.AppContentsStatus.Replace("{status}", status.Name);
return Permissions.ForApp(id, app, schema);
}
Task<IReadOnlyList<string>> ValidateAsync(Guid appId, Workflows workflows);
}
}

4
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";

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>
@ -44,9 +48,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)]
public IActionResult GetWorkflows(string app)
{
var response = Deferred.Response(() =>
var response = Deferred.AsyncResponse(() =>
{
return WorkflowsDto.FromApp(App, this);
return WorkflowsDto.FromAppAsync(workflowsValidator, App, this);
});
Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -131,7 +135,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;
}

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

@ -7,7 +7,9 @@
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;
@ -21,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);
}

12
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -274,11 +274,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);
@ -374,11 +369,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);
@ -403,7 +393,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<IActionResult> DiscardDraft(string app, string name, Guid id)
{

4
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.AppContentsUpdate);
});
}
@ -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);
});
}

21
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<ContentsController>(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<ContentsController>(x => nameof(x.PutContentStatus), values));
}
@ -146,24 +146,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
}
AddPatchLink("patch", controller.Url<ContentsController>(x => nameof(x.PatchContent), values));
}
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{
AddDeleteLink("delete", controller.Url<ContentsController>(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<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
}
}
}
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
return this;
}
}

5
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -70,10 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
{
AddPostLink("create", controller.Url<ContentsController>(x => nameof(x.PostContent), values));
if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published)))
{
AddPostLink("create/publish", controller.Url<ContentsController>(x => nameof(x.PostContent), values) + "?publish=true");
}
AddPostLink("create/publish", controller.Url<ContentsController>(x => nameof(x.PostContent), values) + "?publish=true");
}
}

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>

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

@ -1,8 +1,17 @@
<div class="table-items-row table-items-row-expandable workflow">
<div class="table-items-row-summary">
<div class="row no-gutters">
<div class="col">
{{workflow.displayName}}
<div class="col col-name">
<span class="workflow-name">{{workflow.displayName}}</span>
</div>
<div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource" [ngModel]="workflow.schemaIds"
placeholder=""
styleGray="true"
styleBlank="true"
singleLine="true"
disabled="true">
</sqx-tag-editor>
</div>
<div class="col-options">
<div class="float-right">

11
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;

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;
}
}

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

@ -1,10 +1,10 @@
<ng-container>
<div class="form-control {{class}}" #form (click)="input.focus()"
<div class="form-control" [class.blank]="styleBlank" [class.gray]="styleGray" #form (click)="input.focus()"
[class.single-line]="singleLine"
[class.focus]="snapshot.hasFocus"
[class.disabled]="addInput.disabled">
<span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled">
{{item}} <i class="icon-close" (click)="remove(i)"></i>
{{item}} <i class="icon-close" *ngIf="!addInput.disabled" (click)="remove(i)"></i>
</span>
<input type="text" class="blank" #input

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

@ -58,11 +58,24 @@ div {
outline: none;
}
&.disabled,
&:disabled {
background: transparent;
}
&:hover {
background: transparent;
}
}
.gray {
.item {
background: $color-border;
color: $color-text;
cursor: default;
}
}
.icon-close {
font-size: .6rem;
}

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

@ -163,7 +163,10 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
public singleLine = false;
@Input()
public class: string;
public styleBlank = false;
@Input()
public styleGray = false;
@Input()
public placeholder = ', to add tag';

69
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<any> | 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<any>,
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);
}
}

2
src/Squidex/app/shared/components/asset.component.html

@ -69,7 +69,7 @@
</div>
</div>
<div class="file-tags tags">
<sqx-tag-editor [disabled]="true" [ngModel]="asset.tags" class="blank" placeholder="+Tag"></sqx-tag-editor>
<sqx-tag-editor [ngModel]="asset.tags" disabled="true" styleBlank="true" placeholder="+Tag"></sqx-tag-editor>
</div>
<div class="file-info">
<ng-container *ngIf="asset.pixelWidth">{{asset.pixelWidth}}x{{asset.pixelHeight}}px, </ng-container> {{asset.fileSize | sqxFileSize}}

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) {

2
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}'` });
}

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 };
});
}

31
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);
}
}
}

8
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()
{

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);
}
}
}

112
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<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly NamedId<Guid> simpleSchemaId = NamedId.Of(Guid.NewGuid(), "my-simple-schema");
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
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, WorkflowStep>
{
[Status.Draft] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Published] = new WorkflowTransition()
},
StatusColors.Draft),
[Status.Published] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Published)
},
new List<Guid> { 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);
}
@ -77,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()
{
@ -98,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);
@ -229,24 +286,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<ISchemaEntity>();
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)

65
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<DomainException>(() => GuardContent.CanCreate(schema, command));
await Assert.ThrowsAsync<DomainException>(() => 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<DomainException>(() => 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<DomainException>(() => 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<NamedContentData>.Ignored, user))
.Returns(canCreate);
}
private void SetupSingleton(bool isSingleton)
{
A.CallTo(() => schema.SchemaDef)

Loading…
Cancel
Save