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 public sealed class Workflow : Named
{ {
private const string DefaultName = "Unnamed"; 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 Default = CreateDefault();
public static readonly Workflow Empty = new Workflow(default, EmptySteps); 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); 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) 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; 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 sealed class WorkflowTransition
{ {
public static readonly WorkflowTransition Default = new WorkflowTransition();
public string Expression { get; } public string Expression { get; }
public string Role { 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.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Collections;
@ -49,6 +50,14 @@ namespace Squidex.Domain.Apps.Core.Contents
return new Workflows(With(Guid.Empty, workflow)); 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] [Pure]
public Workflows Update(Guid id, Workflow workflow) 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 abstract class ContentDataCommand : ContentCommand
{ {
public NamedContentData Data { get; set; } 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 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 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."); 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.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data);
await ctx.EnrichAsync(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 = var currentData =
isProposal ? isProposal ?
@ -207,22 +207,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (partial) if (partial)
{ {
await ctx.ValidatePartialAsync(c.Data); await ctx.ValidatePartialAsync(command.Data);
} }
else 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) if (isProposal)
{ {
ProposeUpdate(c, newData); ProposeUpdate(command, newData);
} }
else 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 System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result); 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) 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); 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) 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(); return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray();
} }
public async Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) 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) 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)) 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) 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)) 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) 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(); var (status, step) = workflow.GetInitialStep();
@ -83,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var result = new List<StatusInfo>(); 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)) 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))); result.Add(new StatusInfo(to, GetColor(step)));
} }
@ -96,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result.ToArray(); 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)) if (!string.IsNullOrWhiteSpace(transition.Role))
{ {
@ -108,17 +115,34 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (!string.IsNullOrWhiteSpace(transition.Expression)) if (!string.IsNullOrWhiteSpace(transition.Expression))
{ {
return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression); return scriptEngine.Evaluate("data", data, transition.Expression);
} }
return true; 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); 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) 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 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)); Guard.NotNull(command, nameof(command));
@ -29,6 +29,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
throw new DomainException("Singleton content cannot be created."); 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) 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<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content); 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. // 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.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) Task<IReadOnlyList<string>> ValidateAsync(Guid appId, Workflows workflows);
{
var id = Permissions.AppContentsStatus.Replace("{status}", status.Name);
return Permissions.ForApp(id, app, schema);
}
} }
} }

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 AppContentsRead = "squidex.apps.{app}.contents.{name}.read";
public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create";
public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update";
public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}"; public const string AppContentsDraftDiscard = "squidex.apps.{app}.contents.{name}.draft.discard";
public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.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 AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete";
public const string AppApi = "squidex.apps.{app}.api"; 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.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -24,9 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))] [ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppWorkflowsController : ApiController public sealed class AppWorkflowsController : ApiController
{ {
public AppWorkflowsController(ICommandBus commandBus) private readonly IWorkflowsValidator workflowsValidator;
public AppWorkflowsController(ICommandBus commandBus, IWorkflowsValidator workflowsValidator)
: base(commandBus) : base(commandBus)
{ {
this.workflowsValidator = workflowsValidator;
} }
/// <summary> /// <summary>
@ -44,9 +48,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetWorkflows(string app) 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(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -131,7 +135,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = WorkflowsDto.FromApp(result, this); var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this);
return response; return response;
} }

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

@ -7,7 +7,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -21,13 +23,23 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required] [Required]
public WorkflowDto[] Items { get; set; } 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 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); 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); 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 command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish };
var response = await InvokeCommandAsync(app, name, command); var response = await InvokeCommandAsync(app, name, command);
@ -374,11 +369,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
await contentQuery.GetSchemaOrThrowAsync(Context, name); await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
return new ForbidResult();
}
var command = request.ToCommand(id); var command = request.ToCommand(id);
var response = await InvokeCommandAsync(app, name, command); var response = await InvokeCommandAsync(app, name, command);
@ -403,7 +393,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut] [HttpPut]
[Route("content/{app}/{name}/{id}/discard/")] [Route("content/{app}/{name}/{id}/discard/")]
[ProducesResponseType(typeof(ContentsDto), 200)] [ProducesResponseType(typeof(ContentsDto), 200)]
[ApiPermission(Permissions.AppContentsDiscard)] [ApiPermission(Permissions.AppContentsDraftDiscard)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> DiscardDraft(string app, string name, Guid id) 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("204", $"{schemaName} content status changed.", contentSchema);
operation.AddResponse("400", "Content data valid."); 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("400", "No pending draft.");
operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema); 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 (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)); 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)); 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)); 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) if (content.Nexts != null)
{
foreach (var next in content.Nexts)
{ {
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); 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; 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)); 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>() services.AddSingletonAs<DynamicContentWorkflow>()
.AsOptional<IContentWorkflow>(); .AsOptional<IContentWorkflow>();
services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>();
services.AddSingletonAs<RolePermissionsProvider>() services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf(); .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> <small class="text-decent">(Cannot be removed)</small>
</div> </div>
<div class="col-auto"> <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> <i class="icon-bin2"></i>
</button> </button>
</div> </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 table-items-row-expandable workflow">
<div class="table-items-row-summary"> <div class="table-items-row-summary">
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <div class="col col-name">
{{workflow.displayName}} <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>
<div class="col-options"> <div class="col-options">
<div class="float-right"> <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 { .col-form-label {
min-width: 4rem; min-width: 4rem;
max-width: 4rem; max-width: 4rem;
} }
.col-tags {
padding: .6rem 1rem;
padding-bottom: 0;
}
.form-group { .form-group {
margin-bottom: 2rem; margin-bottom: 2rem;
margin-left: 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() @Input()
public schemasSource: SchemaTagConverter; public schemasSource: SchemaTagConverter;
public error: ErrorDto | null; public error: string | null;
public onBlur = { updateOn: 'blur' }; public onBlur = { updateOn: 'blur' };
@ -68,8 +68,8 @@ export class WorkflowComponent implements OnChanges {
this.workflowsState.update(this.workflow) this.workflowsState.update(this.workflow)
.subscribe(() => { .subscribe(() => {
this.error = null; this.error = null;
}, error => { }, (error: ErrorDto) => {
this.error = error; 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"> <sqx-panel desiredWidth="60rem" [showSidebar]="true" [isLazyLoaded]="false">
<ng-container title> <ng-container title>
Workflow Workflows
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
@ -14,6 +14,17 @@
</ng-container> </ng-container>
<ng-container content> <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="schemasSource && workflowsState.workflows | async; let workflows">
<ng-container *ngIf="rolesState.roles | async; let roles"> <ng-container *ngIf="rolesState.roles | async; let roles">
<div class="table-items-row table-items-row-empty" *ngIf="workflows.length === 0"> <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 '_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> <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.single-line]="singleLine"
[class.focus]="snapshot.hasFocus" [class.focus]="snapshot.hasFocus"
[class.disabled]="addInput.disabled"> [class.disabled]="addInput.disabled">
<span class="item" *ngFor="let item of snapshot.items; let i = index" [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> </span>
<input type="text" class="blank" #input <input type="text" class="blank" #input

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

@ -58,11 +58,24 @@ div {
outline: none; outline: none;
} }
&.disabled,
&:disabled {
background: transparent;
}
&:hover { &:hover {
background: transparent; background: transparent;
} }
} }
.gray {
.item {
background: $color-border;
color: $color-text;
cursor: default;
}
}
.icon-close { .icon-close {
font-size: .6rem; 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; public singleLine = false;
@Input() @Input()
public class: string; public styleBlank = false;
@Input()
public styleGray = false;
@Input() @Input()
public placeholder = ', to add tag'; 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 { export class ModalViewDirective implements OnChanges, OnDestroy {
private modalSubscription: Subscription | null = null; private modalSubscription: Subscription | null = null;
private documentClickListener: Function | null = null;
private renderedView: EmbeddedViewRef<any> | null = null; private renderedView: EmbeddedViewRef<any> | null = null;
private static clickCounter = 0;
@Input('sqxModalView') @Input('sqxModalView')
public modalView: DialogModel | ModalModel | any; public modalView: DialogModel | ModalModel | any;
@ -44,13 +42,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
private readonly templateRef: TemplateRef<any>, private readonly templateRef: TemplateRef<any>,
private readonly viewContainer: ViewContainerRef private readonly viewContainer: ViewContainerRef
) { ) {
if (ModalViewDirective.clickCounter === 0) {
this.renderer.listen('document', 'click', () => {
ModalViewDirective.clickCounter++;
});
ModalViewDirective.clickCounter = 1;
}
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -95,7 +86,7 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
this.renderer.setStyle(this.renderedView.rootNodes[0], 'display', 'block'); this.renderer.setStyle(this.renderedView.rootNodes[0], 'display', 'block');
} }
this.startListening(ModalViewDirective.clickCounter + 1); this.startListening();
this.changeDetector.detectChanges(); this.changeDetector.detectChanges();
} else if (!isOpen && this.renderedView) { } else if (!isOpen && this.renderedView) {
@ -114,40 +105,39 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
return this.placeOnRoot ? this.rootView.viewContainer : this.viewContainer; return this.placeOnRoot ? this.rootView.viewContainer : this.viewContainer;
} }
private startListening(clickCounter: number) { private startListening() {
if (!this.closeAuto) { if (this.closeAuto) {
document.addEventListener('click', this.documentClickListener, true);
}
}
private documentClickListener = (event: MouseEvent) => {
if (!event.target || this.renderedView === null) {
return; return;
} }
this.documentClickListener = if (this.renderedView.rootNodes.length === 0) {
this.renderer.listen('document', 'click', (event: MouseEvent) => { return;
if (!event.target || this.renderedView === null || ModalViewDirective.clickCounter === clickCounter) { }
return;
}
if (this.renderedView.rootNodes.length === 0) { if (this.closeAlways) {
return; 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) { if (!clickedInside && this.modalView) {
this.modalView.hide(); 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;
} }
} }
}); } catch (ex) {
return;
}
}
} }
private unsubscribeToModal() { private unsubscribeToModal() {
@ -158,9 +148,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
} }
private unsubscribeToClick() { private unsubscribeToClick() {
if (this.documentClickListener) { document.removeEventListener('click', this.documentClickListener);
this.documentClickListener();
this.documentClickListener = null;
}
} }
} }

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

@ -69,7 +69,7 @@
</div> </div>
</div> </div>
<div class="file-tags tags"> <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>
<div class="file-info"> <div class="file-info">
<ng-container *ngIf="asset.pixelWidth">{{asset.pixelWidth}}x{{asset.pixelHeight}}px, </ng-container> {{asset.fileSize | sqxFileSize}} <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[]) { function workflowsResponse(...names: string[]) {
return { return {
errors: [
'Error1',
'Error2'
],
items: names.map(name => workflowResponse(name)), items: names.map(name => workflowResponse(name)),
_links: { _links: {
create: { method: 'POST', href: '/workflows' } create: { method: 'POST', href: '/workflows' }
@ -187,6 +191,10 @@ describe('WorkflowsService', () => {
export function createWorkflows(...names: string[]): WorkflowsPayload { export function createWorkflows(...names: string[]): WorkflowsPayload {
return { return {
errors: [
'Error1',
'Error2'
],
items: names.map(name => createWorkflow(name)), items: names.map(name => createWorkflow(name)),
_links: { _links: {
create: { method: 'POST', href: '/workflows' } 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 = { export type WorkflowsPayload = {
readonly items: WorkflowDto[]; readonly items: WorkflowDto[];
readonly errors: string[];
readonly canCreate: boolean; readonly canCreate: boolean;
} & Resource; } & Resource;
@ -330,9 +332,9 @@ function parseWorkflows(response: any) {
const items = raw.map(item => const items = raw.map(item =>
parseWorkflow(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) { 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) { 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. // The app version.
version: Version; version: Version;
// The errors.
errors: string[];
// Indicates if the workflows are loaded. // Indicates if the workflows are loaded.
isLoaded?: boolean; isLoaded?: boolean;
@ -46,6 +49,9 @@ export class WorkflowsState extends State<Snapshot> {
public workflows = public workflows =
this.project(x => x.workflows); this.project(x => x.workflows);
public errors =
this.project(x => x.errors);
public isLoaded = public isLoaded =
this.project(x => !!x.isLoaded); this.project(x => !!x.isLoaded);
@ -57,7 +63,7 @@ export class WorkflowsState extends State<Snapshot> {
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly dialogs: DialogService 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> { public load(isReload = false): Observable<any> {
@ -103,12 +109,12 @@ export class WorkflowsState extends State<Snapshot> {
} }
private replaceWorkflows(payload: WorkflowsPayload, version: Version) { private replaceWorkflows(payload: WorkflowsPayload, version: Version) {
const { canCreate, items } = payload; const { canCreate, errors, items } = payload;
const workflows = ImmutableArray.of(items); const workflows = ImmutableArray.of(items);
this.next(s => { 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); 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] [Fact]
public void Should_not_provide_transition_from_unknown_step() 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.False(found);
Assert.Null(transition); Assert.Null(transition);
@ -107,14 +117,29 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
Assert.Equal(Status.Archived, status1); Assert.Equal(Status.Archived, status1);
Assert.Equal("ToArchivedExpr", transition1.Expression); Assert.Equal("ToArchivedExpr", transition1.Expression);
Assert.Equal("ToArchivedRole", transition1.Role); Assert.Equal("ToArchivedRole", transition1.Role);
Assert.Same(workflow.Steps[Status.Archived], step1); Assert.Same(workflow.Steps[status1], step1);
var (status2, step2, transition2) = transitions[1]; var (status2, step2, transition2) = transitions[1];
Assert.Equal(Status.Published, status2); Assert.Equal(Status.Published, status2);
Assert.Equal("ToPublishedExpr", transition2.Expression); Assert.Equal("ToPublishedExpr", transition2.Expression);
Assert.Equal("ToPublishedRole", transition2.Role); 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(); 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] [Fact]
public async Task Should_draft_as_initial_status() 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 public class DynamicContentWorkflowTests
{ {
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); 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 IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppEntity appEntity = A.Fake<IAppEntity>(); private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
private readonly DynamicContentWorkflow sut; private readonly DynamicContentWorkflow sut;
@ -56,13 +58,38 @@ namespace Squidex.Domain.Apps.Entities.Contents
StatusColors.Published) StatusColors.Published)
}); });
private readonly Workflow simpleWorkflow;
public DynamicContentWorkflowTests() 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)) A.CallTo(() => appProvider.GetAppAsync(appId.Id))
.Returns(appEntity); .Returns(appEntity);
A.CallTo(() => appEntity.Workflows) A.CallTo(() => appEntity.Workflows)
.Returns(Workflows.Empty.Set(workflow)); .Returns(workflows);
sut = new DynamicContentWorkflow(new JintScriptEngine(), appProvider); sut = new DynamicContentWorkflow(new JintScriptEngine(), appProvider);
} }
@ -77,6 +104,36 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.Should().BeEquivalentTo(expected); 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] [Fact]
public async Task Should_check_is_valid_next() public async Task Should_check_is_valid_next()
{ {
@ -98,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
[Fact] [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); var content = CreateContent(Status.Draft, 4);
@ -229,24 +286,67 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.Should().BeEquivalentTo(expected); 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>(); var schema = A.Fake<ISchemaEntity>();
A.CallTo(() => schema.AppId).Returns(appId); A.CallTo(() => schema.AppId).Returns(appId);
A.CallTo(() => schema.Id).Returns(simple ? simpleSchemaId.Id : schemaId.Id);
return schema; 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() new NamedContentData()
.AddField("field", .AddField("field",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", value)); .AddValue("iv", value));
return new ContentEntity { AppId = appId, Status = status, DataDraft = data }; return content;
} }
private ClaimsPrincipal User(string role) 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 ClaimsPrincipal user = new ClaimsPrincipal();
private readonly Instant dueTimeInPast = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromHours(1)); private readonly Instant dueTimeInPast = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromHours(1));
[Fact] public GuardContentTests()
public void CanCreate_should_throw_exception_if_data_is_null()
{ {
SetupSingleton(false); SetupSingleton(false);
}
[Fact]
public async Task CanCreate_should_throw_exception_if_data_is_null()
{
var command = new CreateContent(); var command = new CreateContent();
ValidationAssert.Throws(() => GuardContent.CanCreate(schema, command), await ValidationAssert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command),
new ValidationError("Data is required.", "Data")); new ValidationError("Data is required.", "Data"));
} }
[Fact] [Fact]
public void CanCreate_should_throw_exception_if_singleton() public async Task CanCreate_should_throw_exception_if_singleton()
{ {
SetupSingleton(true); SetupSingleton(true);
var command = new CreateContent { Data = new NamedContentData() }; var command = new CreateContent { Data = new NamedContentData() };
Assert.Throws<DomainException>(() => GuardContent.CanCreate(schema, command)); await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanCreate(schema, contentWorkflow, command));
} }
[Fact] [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); SetupSingleton(true);
var command = new CreateContent { Data = new NamedContentData(), ContentId = schema.Id }; var command = new CreateContent { Data = new NamedContentData(), ContentId = schema.Id };
GuardContent.CanCreate(schema, command); await GuardContent.CanCreate(schema, contentWorkflow, command);
} }
[Fact] [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() }; var command = new CreateContent { Data = new NamedContentData() };
GuardContent.CanCreate(schema, command); await GuardContent.CanCreate(schema, contentWorkflow, command);
} }
[Fact] [Fact]
public async Task CanUpdate_should_throw_exception_if_data_is_null() public async Task CanUpdate_should_throw_exception_if_data_is_null()
{ {
SetupSingleton(false);
SetupCanUpdate(true); SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
@ -84,7 +104,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it() public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it()
{ {
SetupSingleton(false);
SetupCanUpdate(false); SetupCanUpdate(false);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
@ -96,7 +115,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanUpdate_should_not_throw_exception_if_data_is_not_null() public async Task CanUpdate_should_not_throw_exception_if_data_is_not_null()
{ {
SetupSingleton(false);
SetupCanUpdate(true); SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
@ -108,7 +126,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanPatch_should_throw_exception_if_data_is_null() public async Task CanPatch_should_throw_exception_if_data_is_null()
{ {
SetupSingleton(false);
SetupCanUpdate(true); SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
@ -121,7 +138,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanPatch_should_throw_exception_if_workflow_blocks_it() public async Task CanPatch_should_throw_exception_if_workflow_blocks_it()
{ {
SetupSingleton(false);
SetupCanUpdate(false); SetupCanUpdate(false);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
@ -133,7 +149,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanPatch_should_not_throw_exception_if_data_is_not_null() public async Task CanPatch_should_not_throw_exception_if_data_is_not_null()
{ {
SetupSingleton(false);
SetupCanUpdate(true); SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
@ -145,8 +160,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanChangeStatus_should_throw_exception_if_publishing_without_pending_changes() public async Task CanChangeStatus_should_throw_exception_if_publishing_without_pending_changes()
{ {
SetupSingleton(false);
var content = CreateContent(Status.Published, false); var content = CreateContent(Status.Published, false);
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
@ -179,8 +192,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanChangeStatus_should_throw_exception_if_due_date_in_past() public async Task CanChangeStatus_should_throw_exception_if_due_date_in_past()
{ {
SetupSingleton(false);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast, User = user }; var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast, User = user };
@ -194,8 +205,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanChangeStatus_should_throw_exception_if_status_flow_not_valid() public async Task CanChangeStatus_should_throw_exception_if_status_flow_not_valid()
{ {
SetupSingleton(false);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
var command = new ChangeContentStatus { Status = Status.Published, User = user }; var command = new ChangeContentStatus { Status = Status.Published, User = user };
@ -209,8 +218,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid() public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid()
{ {
SetupSingleton(false);
var content = CreateContent(Status.Draft, false); var content = CreateContent(Status.Draft, false);
var command = new ChangeContentStatus { Status = Status.Published, User = user }; var command = new ChangeContentStatus { Status = Status.Published, User = user };
@ -231,8 +238,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public void CanDiscardChanges_should_not_throw_exception_if_pending() public void CanDiscardChanges_should_not_throw_exception_if_pending()
{ {
SetupSingleton(false);
var command = new DiscardChanges(); var command = new DiscardChanges();
GuardContent.CanDiscardChanges(true, command); GuardContent.CanDiscardChanges(true, command);
@ -251,8 +256,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
[Fact] [Fact]
public void CanDelete_should_not_throw_exception() public void CanDelete_should_not_throw_exception()
{ {
SetupSingleton(false);
var command = new DeleteContent(); var command = new DeleteContent();
GuardContent.CanDelete(schema, command); GuardContent.CanDelete(schema, command);
@ -264,6 +267,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
.Returns(canUpdate); .Returns(canUpdate);
} }
private void SetupCanCreatePublish(bool canCreate)
{
A.CallTo(() => contentWorkflow.CanPublishOnCreateAsync(schema, A<NamedContentData>.Ignored, user))
.Returns(canCreate);
}
private void SetupSingleton(bool isSingleton) private void SetupSingleton(bool isSingleton)
{ {
A.CallTo(() => schema.SchemaDef) A.CallTo(() => schema.SchemaDef)

Loading…
Cancel
Save