diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 4e67af712..dae5dfd26 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -5,51 +5,75 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; namespace Squidex.Domain.Apps.Core.Contents { - public sealed class Workflow + public sealed class Workflow : Named { - private static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); + private const string DefaultName = "Unnamed"; - public static readonly Workflow Default = new Workflow( - new Dictionary - { - [Status.Archived] = - new WorkflowStep( - new Dictionary - { - [Status.Draft] = new WorkflowTransition() - }, - StatusColors.Archived, true), - [Status.Draft] = - new WorkflowStep( - new Dictionary - { - [Status.Archived] = new WorkflowTransition(), - [Status.Published] = new WorkflowTransition() - }, - StatusColors.Draft), - [Status.Published] = - new WorkflowStep( - new Dictionary - { - [Status.Archived] = new WorkflowTransition(), - [Status.Draft] = new WorkflowTransition() - }, - StatusColors.Published) - }, Status.Draft); - - public IReadOnlyDictionary Steps { get; } + public static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); + public static readonly IReadOnlyList EmptySchemaIds = new List(); + public static readonly Workflow Default = CreateDefault(); + public static readonly Workflow Empty = new Workflow(default, EmptySteps); + + public IReadOnlyDictionary Steps { get; } = EmptySteps; + + public IReadOnlyList SchemaIds { get; } = EmptySchemaIds; public Status Initial { get; } - public Workflow(IReadOnlyDictionary steps, Status initial) + public Workflow( + Status initial, + IReadOnlyDictionary steps, + IReadOnlyList schemaIds = null, + string name = null) + : base(name ?? DefaultName) { - Steps = steps ?? EmptySteps; - Initial = initial; + + if (steps != null) + { + Steps = steps; + } + + if (schemaIds != null) + { + SchemaIds = schemaIds; + } + } + + public static Workflow CreateDefault(string name = null) + { + return new Workflow( + Status.Draft, new Dictionary + { + [Status.Archived] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Archived, true), + [Status.Draft] = + new WorkflowStep( + new Dictionary + { + [Status.Archived] = new WorkflowTransition(), + [Status.Published] = new WorkflowTransition() + }, + StatusColors.Draft), + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Archived] = new WorkflowTransition(), + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Published) + }, null, name); } public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs index d027b8d32..b5d86740c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Linq; +using System.Threading.Tasks; using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; @@ -27,6 +28,20 @@ namespace Squidex.Domain.Apps.Core.Contents { } + [Pure] + public Workflows Remove(Guid id) + { + return new Workflows(Without(id)); + } + + [Pure] + public Workflows Add(Guid workflowId, string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + return new Workflows(With(workflowId, Workflow.CreateDefault(name))); + } + [Pure] public Workflows Set(Workflow workflow) { @@ -35,6 +50,32 @@ 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) + { + Guard.NotNull(workflow, nameof(workflow)); + + if (id == Guid.Empty) + { + return Set(workflow); + } + + if (!ContainsKey(id)) + { + return this; + } + + return new Workflows(With(id, workflow)); + } + public Workflow GetFirst() { return Values.FirstOrDefault() ?? Workflow.Default; diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs b/src/Squidex.Domain.Apps.Core.Model/Named.cs similarity index 93% rename from src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs rename to src/Squidex.Domain.Apps.Core.Model/Named.cs index 69ba9a3c1..fd76c4e8f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Named.cs @@ -7,7 +7,7 @@ using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Core.Apps +namespace Squidex.Domain.Apps.Core { public abstract class Named { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs index 729e6ab0c..3a7a90900 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs @@ -44,7 +44,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json if (Properties is ArrayFieldProperties arrayProperties) { - var nested = Children?.ToArray(n => n.ToNestedField()) ?? Array.Empty(); + var nested = Children?.Map(n => n.ToNestedField()) ?? Array.Empty(); return new ArrayField(Id, Name, partitioning, nested, arrayProperties, this); } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs index 83196b881..54c31c88f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs @@ -49,7 +49,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json SimpleMapper.Map(schema, this); Fields = - schema.Fields.ToArray(x => + schema.Fields.Select(x => new JsonFieldModel { Id = x.Id, @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json IsDisabled = x.IsDisabled, Partitioning = x.Partitioning.Key, Properties = x.RawProperties - }); + }).ToArray(); PreviewUrls = schema.PreviewUrls.ToDictionary(x => x.Key, x => x.Value); } @@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json { if (field is ArrayField arrayField) { - return arrayField.Fields.ToArray(x => + return arrayField.Fields.Select(x => new JsonNestedFieldModel { Id = x.Id, @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json IsLocked = x.IsLocked, IsDisabled = x.IsDisabled, Properties = x.RawProperties - }); + }).ToArray(); } return null; @@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json public Schema ToSchema() { - var fields = Fields.ToArray(f => f.ToField()) ?? Array.Empty(); + var fields = Fields.Map(f => f.ToField()) ?? Array.Empty(); var schema = new Schema(Name, fields, Properties, IsPublished, IsSingleton); diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs index f0fc88a3a..ad819ba57 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs @@ -19,11 +19,11 @@ namespace Squidex.Domain.Apps.Core.Tags Task> DenormalizeTagsAsync(Guid appId, string group, HashSet ids); - Task> GetTagsAsync(Guid appId, string group); + Task GetTagsAsync(Guid appId, string group); - Task GetExportableTagsAsync(Guid appId, string group); + Task GetExportableTagsAsync(Guid appId, string group); - Task RebuildTagsAsync(Guid appId, string group, TagSet tags); + Task RebuildTagsAsync(Guid appId, string group, TagsExport tags); Task ClearAsync(Guid appId, string group); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs similarity index 88% rename from src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs rename to src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs index 530c28b00..d1f54ecf7 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs @@ -9,7 +9,7 @@ using System.Collections.Generic; namespace Squidex.Domain.Apps.Core.Tags { - public sealed class TagSet : Dictionary + public sealed class TagsExport : Dictionary { } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs new file mode 100644 index 000000000..8e87ee8ab --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Tags +{ + public sealed class TagsSet : Dictionary + { + public long Version { get; set; } + + public TagsSet() + { + } + + public TagsSet(IDictionary tags, long version) + : base(tags) + { + Version = version; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index e17c17290..95ba1cefc 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -119,12 +119,32 @@ namespace Squidex.Domain.Apps.Entities.Apps return Snapshot; }); - case ConfigureWorkflow configureWorkflow: - return UpdateReturn(configureWorkflow, c => + case AddWorkflow addWorkflow: + return UpdateReturn(addWorkflow, c => { - GuardAppWorkflows.CanConfigure(c); + GuardAppWorkflows.CanAdd(c); - ConfigureWorkflow(c); + AddWorkflow(c); + + return Snapshot; + }); + + case UpdateWorkflow updateWorkflow: + return UpdateReturn(updateWorkflow, c => + { + GuardAppWorkflows.CanUpdate(Snapshot.Workflows, c); + + UpdateWorkflow(c); + + return Snapshot; + }); + + case DeleteWorkflow deleteWorkflow: + return UpdateReturn(deleteWorkflow, c => + { + GuardAppWorkflows.CanDelete(Snapshot.Workflows, c); + + DeleteWorkflow(c); return Snapshot; }); @@ -329,9 +349,19 @@ namespace Squidex.Domain.Apps.Entities.Apps RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); } - public void ConfigureWorkflow(ConfigureWorkflow command) + public void AddWorkflow(AddWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowAdded())); + } + + public void UpdateWorkflow(UpdateWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowUpdated())); + } + + public void DeleteWorkflow(DeleteWorkflow command) { - RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured())); + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowDeleted())); } public void AddLanguage(AddLanguage command) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs new file mode 100644 index 000000000..54ca7b4bb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class AddWorkflow : AppCommand + { + public Guid WorkflowId { get; set; } + + public string Name { get; set; } + + public AddWorkflow() + { + WorkflowId = Guid.NewGuid(); + } + } +} diff --git a/src/Squidex.Web/IGenerateEtag.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs similarity index 72% rename from src/Squidex.Web/IGenerateEtag.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs index 6986f1acc..c21492e79 100644 --- a/src/Squidex.Web/IGenerateEtag.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs @@ -7,12 +7,10 @@ using System; -namespace Squidex.Web +namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public interface IGenerateETag + public sealed class DeleteWorkflow : AppCommand { - Guid Id { get; } - - long Version { get; } + public Guid WorkflowId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs similarity index 82% rename from src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs index efa2b503b..635936040 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs @@ -5,12 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class ConfigureWorkflow : AppCommand + public sealed class UpdateWorkflow : AppCommand { + public Guid WorkflowId { get; set; } + public Workflow Workflow { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs index 1e675ac8e..738b2f70a 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; @@ -13,11 +14,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { public static class GuardAppWorkflows { - public static void CanConfigure(ConfigureWorkflow command) + public static void CanAdd(AddWorkflow command) { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot configure workflow.", e => + Validate.It(() => "Cannot add workflow.", e => + { + if (string.IsNullOrWhiteSpace(command.Name)) + { + e(Not.Defined("Name"), nameof(command.Name)); + } + }); + } + + public static void CanUpdate(Workflows workflows, UpdateWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + GetWorkflowOrThrow(workflows, command.WorkflowId); + + Validate.It(() => "Cannot update workflow.", e => { if (command.Workflow == null) { @@ -72,5 +88,22 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards } }); } + + public static void CanDelete(Workflows workflows, DeleteWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + GetWorkflowOrThrow(workflows, command.WorkflowId); + } + + private static Workflow GetWorkflowOrThrow(Workflows workflows, Guid id) + { + if (!workflows.TryGetValue(id, out var workflow)) + { + throw new DomainObjectNotFoundException(id.ToString(), "Workflows", typeof(IAppEntity)); + } + + return workflow; + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index ac71df0bc..7e870d56c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -96,9 +96,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.State Clients = Clients.Revoke(@event.Id); } - protected void On(AppWorkflowConfigured @event) + protected void On(AppWorkflowAdded @event) { - Workflows = Workflows.Set(@event.Workflow); + Workflows = Workflows.Add(@event.WorkflowId, @event.Name); + } + + protected void On(AppWorkflowUpdated @event) + { + Workflows = Workflows.Update(@event.WorkflowId, @event.Workflow); + } + + protected void On(AppWorkflowDeleted @event) + { + Workflows = Workflows.Remove(@event.WorkflowId); } protected void On(AppPatternAdded @event) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs index 068a807de..44701ee16 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs @@ -82,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Assets private async Task RestoreTagsAsync(Guid appId, BackupReader reader) { - var tags = await reader.ReadJsonAttachmentAsync(TagsFile); + var tags = await reader.ReadJsonAttachmentAsync(TagsFile); await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs index 7f0842c16..f2eea4643 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs @@ -12,7 +12,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public abstract class ContentDataCommand : ContentCommand { public NamedContentData Data { get; set; } - - public bool AsDraft { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs new file mode 100644 index 000000000..63bd8a400 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Contents.Commands +{ + public abstract class ContentUpdateCommand : ContentDataCommand + { + public bool AsDraft { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs index 80206cebd..6654339d9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class PatchContent : ContentDataCommand + public sealed class PatchContent : ContentUpdateCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs index 01f642d5c..aeb2ce59e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class UpdateContent : ContentDataCommand + public sealed class UpdateContent : ContentUpdateCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 9d4ba5eb3..60f425b5e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, Guid.Empty, () => "Failed to create content."); - GuardContent.CanCreate(ctx.Schema, c); + await GuardContent.CanCreate(ctx.Schema, contentWorkflow, c); await ctx.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data); await ctx.EnrichAsync(c.Data); @@ -93,17 +93,21 @@ namespace Squidex.Domain.Apps.Entities.Contents case UpdateContent updateContent: return UpdateReturnAsync(updateContent, async c => { - await GuardContent.CanUpdate(Snapshot, contentWorkflow, c); + var isProposal = c.AsDraft && Snapshot.Status == Status.Published; - return await UpdateAsync(c, x => c.Data, false); + await GuardContent.CanUpdate(Snapshot, contentWorkflow, c, isProposal); + + return await UpdateAsync(c, x => c.Data, false, isProposal); }); case PatchContent patchContent: return UpdateReturnAsync(patchContent, async c => { - await GuardContent.CanPatch(Snapshot, contentWorkflow, c); + var isProposal = c.AsDraft && Snapshot.Status == Status.Published; + + await GuardContent.CanPatch(Snapshot, contentWorkflow, c, isProposal); - return await UpdateAsync(c, c.Data.MergeInto, true); + return await UpdateAsync(c, c.Data.MergeInto, true, isProposal); }); case ChangeContentStatus changeContentStatus: @@ -111,9 +115,11 @@ namespace Squidex.Domain.Apps.Entities.Contents { try { + var isChangeConfirm = Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published; + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content."); - await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c); + await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c, isChangeConfirm); if (c.DueTime.HasValue) { @@ -121,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } else { - if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published) + if (isChangeConfirm) { ConfirmChanges(c); } @@ -190,10 +196,8 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private async Task UpdateAsync(ContentDataCommand c, Func newDataFunc, bool partial) + private async Task UpdateAsync(ContentUpdateCommand command, Func newDataFunc, bool partial, bool isProposal) { - var isProposal = c.AsDraft && Snapshot.Status == Status.Published; - var currentData = isProposal ? Snapshot.DataDraft : @@ -207,22 +211,22 @@ namespace Squidex.Domain.Apps.Entities.Contents if (partial) { - await ctx.ValidatePartialAsync(c.Data); + await ctx.ValidatePartialAsync(command.Data); } else { - await ctx.ValidateAsync(c.Data); + await ctx.ValidateAsync(command.Data); } - newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", c, newData, Snapshot.Data); + newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", command, newData, Snapshot.Data); if (isProposal) { - ProposeUpdate(c, newData); + ProposeUpdate(command, newData); } else { - Update(c, newData); + Update(command, newData); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index 306b49309..e88a08f67 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -8,7 +8,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Microsoft.OData; @@ -23,9 +22,7 @@ using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries.OData; using Squidex.Infrastructure.Reflection; -using Squidex.Infrastructure.Security; using Squidex.Shared; -using Squidex.Shared.Identity; #pragma warning disable RECS0147 diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs index 0f0075906..47c76f4e0 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs @@ -12,6 +12,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.Contents { @@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Contents return Task.FromResult(result); } + public Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) + { + return TaskHelper.True; + } + public Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) { var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs new file mode 100644 index 000000000..75ddd704b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class DefaultWorkflowsValidator : IWorkflowsValidator + { + private readonly IAppProvider appProvider; + + public DefaultWorkflowsValidator(IAppProvider appProvider) + { + Guard.NotNull(appProvider, nameof(appProvider)); + + this.appProvider = appProvider; + } + + public async Task> ValidateAsync(Guid appId, Workflows workflows) + { + Guard.NotNull(workflows, nameof(workflows)); + + var errors = new List(); + + if (workflows.Values.Count(x => x.SchemaIds.Count == 0) > 1) + { + errors.Add("Multiple workflows cover all schemas."); + } + + var uniqueSchemaIds = workflows.Values.SelectMany(x => x.SchemaIds).Distinct().ToList(); + + foreach (var schemaId in uniqueSchemaIds) + { + if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1) + { + var schema = await appProvider.GetSchemaAsync(appId, schemaId); + + if (schema != null) + { + errors.Add($"The schema `{schema.SchemaDef.Name}` is covered by multiple workflows."); + } + } + } + + return errors; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 6a302fcce..6788f21e5 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -34,21 +34,28 @@ namespace Squidex.Domain.Apps.Entities.Contents public async Task GetAllAsync(ISchemaEntity schema) { - var workflow = await GetWorkflowAsync(schema.AppId.Id); + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); } public async Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) { - var workflow = await GetWorkflowAsync(content.AppId.Id); + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); - return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content, user); + return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content.DataDraft, user); + } + + public async Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) + { + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); + + return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && CanUse(transition, data, user); } public async Task CanUpdateAsync(IContentEntity content) { - 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 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 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(); - 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 GetWorkflowAsync(Guid appId) + private async Task GetWorkflowAsync(Guid appId, Guid schemaId) { + Workflow result = null; + var app = await appProvider.GetAppAsync(appId); - return app?.Workflows.GetFirst(); + if (app != null) + { + result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schemaId)); + + if (result == null) + { + result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0); + } + } + + if (result == null) + { + result = Workflow.Default; + } + + return result; } private static string GetColor(WorkflowStep step) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 2d3d5e353..1fa48486c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); - return (result.Any(x => x.HasError), result.ToArray(x => x.Response)); + return (result.Any(x => x.HasError), result.Map(x => x.Response)); } public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 70f78d9b5..9feaadbd4 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { public static class GuardContent { - public static void CanCreate(ISchemaEntity schema, CreateContent command) + public static async Task CanCreate(ISchemaEntity schema, IContentWorkflow contentWorkflow, CreateContent command) { Guard.NotNull(command, nameof(command)); @@ -29,9 +29,14 @@ 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) + public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command, bool isProposal) { Guard.NotNull(command, nameof(command)); @@ -40,10 +45,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards ValidateData(command, e); }); - await ValidateCanUpdate(content, contentWorkflow); + if (!isProposal) + { + await ValidateCanUpdate(content, contentWorkflow); + } } - public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command) + public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command, bool isProposal) { Guard.NotNull(command, nameof(command)); @@ -52,7 +60,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards ValidateData(command, e); }); - await ValidateCanUpdate(content, contentWorkflow); + if (!isProposal) + { + await ValidateCanUpdate(content, contentWorkflow); + } } public static void CanDiscardChanges(bool isPending, DiscardChanges command) @@ -65,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards } } - public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command) + public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command, bool isChangeConfirm) { Guard.NotNull(command, nameof(command)); @@ -76,20 +87,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards return Validate.It(() => "Cannot change status.", async e => { - if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User)) + if (isChangeConfirm) { - if (content.Status == command.Status && content.Status == Status.Published) - { - if (!content.IsPending) - { - e("Content has no changes to publish.", nameof(command.Status)); - } - } - else + if (!content.IsPending) { - e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status)); + e("Content has no changes to publish.", nameof(command.Status)); } } + else if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User)) + { + e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status)); + } if (command.DueTime.HasValue && command.DueTime.Value < SystemClock.Instance.GetCurrentInstant()) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs index fd2f9dd37..b9acaffc9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs @@ -16,6 +16,8 @@ namespace Squidex.Domain.Apps.Entities.Contents { Task GetInitialStatusAsync(ISchemaEntity schema); + Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user); + Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); Task CanUpdateAsync(IContentEntity content); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs b/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs new file mode 100644 index 000000000..01c8574b4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IWorkflowsValidator + { + Task> ValidateAsync(Guid appId, Workflows workflows); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs index ad8c37457..08f1ff835 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs @@ -45,17 +45,17 @@ namespace Squidex.Domain.Apps.Entities.Tags return GetGrain(appId, group).DenormalizeTagsAsync(ids); } - public Task> GetTagsAsync(Guid appId, string group) + public Task GetTagsAsync(Guid appId, string group) { return GetGrain(appId, group).GetTagsAsync(); } - public Task GetExportableTagsAsync(Guid appId, string group) + public Task GetExportableTagsAsync(Guid appId, string group) { return GetGrain(appId, group).GetExportableTagsAsync(); } - public Task RebuildTagsAsync(Guid appId, string group, TagSet tags) + public Task RebuildTagsAsync(Guid appId, string group, TagsExport tags) { return GetGrain(appId, group).RebuildAsync(tags); } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs index d43b6f022..be9a5bdfb 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs @@ -20,12 +20,12 @@ namespace Squidex.Domain.Apps.Entities.Tags Task> DenormalizeTagsAsync(HashSet ids); - Task> GetTagsAsync(); + Task GetTagsAsync(); - Task GetExportableTagsAsync(); + Task GetExportableTagsAsync(); Task ClearAsync(); - Task RebuildAsync(TagSet tags); + Task RebuildAsync(TagsExport tags); } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index 9062e3366..3053bf1a6 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Tags [CollectionName("Index_Tags")] public sealed class GrainState { - public TagSet Tags { get; set; } = new TagSet(); + public TagsExport Tags { get; set; } = new TagsExport(); } public TagGrain(IStore store) @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Tags return ClearStateAsync(); } - public Task RebuildAsync(TagSet tags) + public Task RebuildAsync(TagsExport tags) { State.Tags = tags; @@ -132,12 +132,14 @@ namespace Squidex.Domain.Apps.Entities.Tags return Task.FromResult(result); } - public Task> GetTagsAsync() + public Task GetTagsAsync() { - return Task.FromResult(State.Tags.Values.ToDictionary(x => x.Name, x => x.Count)); + var tags = State.Tags.Values.ToDictionary(x => x.Name, x => x.Count); + + return Task.FromResult(new TagsSet(tags, Persistence.Version)); } - public Task GetExportableTagsAsync() + public Task GetExportableTagsAsync() { return Task.FromResult(State.Tags); } diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs new file mode 100644 index 000000000..3e5627bee --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowAdded))] + public sealed class AppWorkflowAdded : AppEvent + { + public Guid WorkflowId { get; set; } + + public string Name { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs new file mode 100644 index 000000000..15d418994 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowDeleted))] + public sealed class AppWorkflowDeleted : AppEvent + { + public Guid WorkflowId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs similarity index 78% rename from src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs rename to src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs index 65166ae97..672242ed8 100644 --- a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs @@ -5,14 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Apps { - [EventType(nameof(AppWorkflowConfigured))] - public sealed class AppWorkflowConfigured : AppEvent + [EventType(nameof(AppWorkflowUpdated))] + public sealed class AppWorkflowUpdated : AppEvent { + public Guid WorkflowId { get; set; } + public Workflow Workflow { get; set; } } } diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index a8f44e515..248197dc1 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -53,7 +53,7 @@ namespace Squidex.Infrastructure return source.Concat(Enumerable.Repeat(value, 1)); } - public static TResult[] ToArray(this T[] value, Func convert) + public static TResult[] Map(this T[] value, Func convert) { var result = new TResult[value.Length]; @@ -65,20 +65,6 @@ namespace Squidex.Infrastructure return result; } - public static TResult[] ToArray(this IReadOnlyCollection value, Func convert) - { - var result = new TResult[value.Count]; - var i = 0; - - foreach (var v in value) - { - result[i] = convert(v); - i++; - } - - return result; - } - public static int SequentialHashCode(this IEnumerable collection) { return collection.SequentialHashCode(EqualityComparer.Default); diff --git a/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs b/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs index fd7b6d533..a560e4abc 100644 --- a/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs +++ b/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs @@ -36,6 +36,18 @@ namespace Squidex.Infrastructure.Json.Newtonsoft } } + protected override JsonArrayContract CreateArrayContract(Type objectType) + { + if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)) + { + var implementationType = typeof(List<>).MakeGenericType(objectType.GetGenericArguments()); + + return base.CreateArrayContract(implementationType); + } + + return base.CreateArrayContract(objectType); + } + protected override JsonDictionaryContract CreateDictionaryContract(Type objectType) { if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) diff --git a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs index 48a30f22c..9d684fc59 100644 --- a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs +++ b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs @@ -190,7 +190,7 @@ namespace Squidex.Infrastructure.States private EventData[] GetEventData(Envelope[] events, Guid commitId) { - return events.ToArray(x => eventDataFormatter.ToEventData(x, commitId, true)); + return events.Map(x => eventDataFormatter.ToEventData(x, commitId, true)); } private string GetStreamName() diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 62329248e..10ceb8fef 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -121,8 +121,8 @@ namespace Squidex.Shared public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; - public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}"; - public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; + public const string AppContentsDraftDiscard = "squidex.apps.{app}.contents.{name}.draft.discard"; + public const string AppContentsDraftPublish = "squidex.apps.{app}.contents.{name}.draft.publish"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppApi = "squidex.apps.{app}.api"; diff --git a/src/Squidex.Web/ApiExceptionFilterAttribute.cs b/src/Squidex.Web/ApiExceptionFilterAttribute.cs index 3e195c0be..ce1b22b55 100644 --- a/src/Squidex.Web/ApiExceptionFilterAttribute.cs +++ b/src/Squidex.Web/ApiExceptionFilterAttribute.cs @@ -93,7 +93,7 @@ namespace Squidex.Web private static string[] ToDetails(ValidationException ex) { - return ex.Errors?.ToArray(e => + return ex.Errors?.Select(e => { if (e.PropertyNames?.Any() == true) { @@ -103,7 +103,7 @@ namespace Squidex.Web { return e.Message; } - }); + }).ToArray(); } } } diff --git a/src/Squidex.Web/ApiPermissionAttribute.cs b/src/Squidex.Web/ApiPermissionAttribute.cs index e93e1fed2..f655b2c6f 100644 --- a/src/Squidex.Web/ApiPermissionAttribute.cs +++ b/src/Squidex.Web/ApiPermissionAttribute.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Tasks; -using Squidex.Shared.Identity; namespace Squidex.Web { diff --git a/src/Squidex.Web/Deferred.cs b/src/Squidex.Web/Deferred.cs new file mode 100644 index 000000000..717182f49 --- /dev/null +++ b/src/Squidex.Web/Deferred.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure; + +namespace Squidex.Web +{ + public struct Deferred + { + private readonly Lazy> value; + + public Task Value + { + get { return value.Value; } + } + + private Deferred(Func> value) + { + this.value = new Lazy>(value); + } + + public static Deferred Response(Func factory) + { + Guard.NotNull(factory, nameof(factory)); + + return new Deferred(() => Task.FromResult(factory())); + } + + public static Deferred AsyncResponse(Func> factory) + { + Guard.NotNull(factory, nameof(factory)); + + return new Deferred(async () => await factory()); + } + } +} diff --git a/src/Squidex.Web/ETagExtensions.cs b/src/Squidex.Web/ETagExtensions.cs index 5ee961a9d..034a9b958 100644 --- a/src/Squidex.Web/ETagExtensions.cs +++ b/src/Squidex.Web/ETagExtensions.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Text; +using Squidex.Domain.Apps.Entities; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; @@ -17,40 +18,54 @@ namespace Squidex.Web { private static readonly int GuidLength = Guid.Empty.ToString().Length; - public static string ToManyEtag(this IReadOnlyList items, long total = 0) where T : IGenerateETag + public static string ToEtag(this IReadOnlyList items, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion { using (Profiler.Trace("CalculateEtag")) { - var unhashed = Unhashed(items, total); + var unhashed = Unhashed(items, 0, app); return unhashed.Sha256Base64(); } } - private static string Unhashed(IReadOnlyList items, long total) where T : IGenerateETag + public static string ToEtag(this IResultList items, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion { - var sb = new StringBuilder((items.Count * (GuidLength + 4)) + 10); + using (Profiler.Trace("CalculateEtag")) + { + var unhashed = Unhashed(items, items.Total, app); + + return unhashed.Sha256Base64(); + } + } + + private static string Unhashed(IReadOnlyList items, long total, IEntityWithVersion app) where T : IEntity, IEntityWithVersion + { + var sb = new StringBuilder((items.Count * (GuidLength + 8)) + 10); + + for (var i = 0; i < items.Count; i++) + { + sb.Append(";"); + sb.Append(items[i].ToEtag()); + } - sb.Append(total); sb.Append("_"); + sb.Append(total); - if (items.Count > 0) + if (app != null) { - sb.Append(items[0].Id.ToString()); - sb.Append(items[0].Version); - - for (var i = 1; i < items.Count; i++) - { - sb.Append(";"); - sb.Append(items[i].Id.ToString()); - sb.Append(items[i].Version); - } + sb.Append("_"); + sb.Append(app.Version); } - return sb.ToString().Sha256Base64(); + return sb.ToString(); + } + + public static string ToSurrogateKey(this T item) where T : IEntity + { + return item.Id.ToString(); } - public static string ToSurrogateKeys(this IReadOnlyList items) where T : IGenerateETag + public static string ToSurrogateKeys(this IReadOnlyList items) where T : IEntity { if (items.Count == 0) { @@ -70,9 +85,17 @@ namespace Squidex.Web return sb.ToString(); } - public static string ToEtag(this T item) where T : IGenerateETag + public static string ToEtag(this T item, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion { - return item.Version.ToString(); + var result = $"{item.Id};{item.Version}"; + + if (app != null) + { + result += ";"; + result += app.Version; + } + + return result; } } } diff --git a/src/Squidex.Web/MyJsonInheritanceConverter.cs b/src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs similarity index 80% rename from src/Squidex.Web/MyJsonInheritanceConverter.cs rename to src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs index ff3a1854e..f87d632fd 100644 --- a/src/Squidex.Web/MyJsonInheritanceConverter.cs +++ b/src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs @@ -16,17 +16,16 @@ using Squidex.Infrastructure; #pragma warning disable RECS0108 // Warns about static fields in generic types -namespace Squidex.Web +namespace Squidex.Web.Json { - public class MyJsonInheritanceConverter : JsonInheritanceConverter + public class TypedJsonInheritanceConverter : JsonInheritanceConverter { - private static readonly Dictionary DefaultMapping = new Dictionary(); - private readonly IReadOnlyDictionary maping; - - static MyJsonInheritanceConverter() + private static readonly Lazy> DefaultMapping = new Lazy>(() => { var baseName = typeof(T).Name; + var result = new Dictionary(); + void AddType(Type type) { var discriminator = type.Name; @@ -36,7 +35,7 @@ namespace Squidex.Web discriminator = discriminator.Substring(0, discriminator.Length - baseName.Length); } - DefaultMapping[discriminator] = type; + result[discriminator] = type; } foreach (var attribute in typeof(T).GetCustomAttributes()) @@ -66,17 +65,21 @@ namespace Squidex.Web } } } - } - public MyJsonInheritanceConverter(string discriminator) - : this(discriminator, DefaultMapping) + return result; + }); + + private readonly IReadOnlyDictionary maping; + + public TypedJsonInheritanceConverter(string discriminator) + : this(discriminator, DefaultMapping.Value) { } - public MyJsonInheritanceConverter(string discriminator, IReadOnlyDictionary mapping) + public TypedJsonInheritanceConverter(string discriminator, IReadOnlyDictionary mapping) : base(typeof(T), discriminator) { - maping = mapping ?? DefaultMapping; + maping = mapping ?? DefaultMapping.Value; } protected override Type GetDiscriminatorType(JObject jObject, Type objectType, string discriminatorValue) diff --git a/src/Squidex.Web/Pipeline/DeferredActionFilter.cs b/src/Squidex.Web/Pipeline/DeferredActionFilter.cs new file mode 100644 index 000000000..e57a62981 --- /dev/null +++ b/src/Squidex.Web/Pipeline/DeferredActionFilter.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Squidex.Web.Pipeline +{ + public sealed class DeferredActionFilter : IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var resultContext = await next(); + + if (resultContext.Result is ObjectResult objectResult && objectResult.Value is Deferred deferred) + { + objectResult.Value = await deferred.Value; + } + } + } +} diff --git a/src/Squidex.Web/ETagFilter.cs b/src/Squidex.Web/Pipeline/ETagFilter.cs similarity index 98% rename from src/Squidex.Web/ETagFilter.cs rename to src/Squidex.Web/Pipeline/ETagFilter.cs index b76772ad3..4dd680374 100644 --- a/src/Squidex.Web/ETagFilter.cs +++ b/src/Squidex.Web/Pipeline/ETagFilter.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -namespace Squidex.Web +namespace Squidex.Web.Pipeline { public sealed class ETagFilter : IAsyncActionFilter { diff --git a/src/Squidex.Web/ETagOptions.cs b/src/Squidex.Web/Pipeline/ETagOptions.cs similarity index 93% rename from src/Squidex.Web/ETagOptions.cs rename to src/Squidex.Web/Pipeline/ETagOptions.cs index 8e832dbca..d6715b233 100644 --- a/src/Squidex.Web/ETagOptions.cs +++ b/src/Squidex.Web/Pipeline/ETagOptions.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Web +namespace Squidex.Web.Pipeline { public sealed class ETagOptions { diff --git a/src/Squidex.Web/ResourceLink.cs b/src/Squidex.Web/ResourceLink.cs index ef54bfa98..d1caffc8d 100644 --- a/src/Squidex.Web/ResourceLink.cs +++ b/src/Squidex.Web/ResourceLink.cs @@ -19,7 +19,6 @@ namespace Squidex.Web [Display(Description = "The link method.")] public string Method { get; set; } - [Required] [Display(Description = "Additional data about the link.")] public string Metadata { get; set; } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index b89697d26..92e749028 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -46,9 +46,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetClients(string app) { - var response = ClientsDto.FromApp(App, this); + var response = Deferred.Response(() => + { + return ClientsDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index 021afa123..8d1534b74 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -48,9 +48,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetContributors(string app) { - var response = ContributorsDto.FromApp(App, appPlansProvider, this, false); + var response = Deferred.Response(() => + { + return ContributorsDto.FromApp(App, appPlansProvider, this, false); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -73,18 +76,8 @@ namespace Squidex.Areas.Api.Controllers.Apps public async Task PostContributor(string app, [FromBody] AssignContributorDto request) { var command = request.ToCommand(); - var context = await CommandBus.PublishAsync(command); - var response = (ContributorsDto)null; - - if (context.PlainResult is IAppEntity newApp) - { - response = ContributorsDto.FromApp(newApp, appPlansProvider, this, false); - } - else if (context.PlainResult is InvitedResult invited) - { - response = ContributorsDto.FromApp(invited.App, appPlansProvider, this, true); - } + var response = await InvokeCommandAsync(command); return CreatedAtAction(nameof(GetContributors), new { app }, response); } @@ -117,10 +110,14 @@ namespace Squidex.Areas.Api.Controllers.Apps { var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = ContributorsDto.FromApp(result, appPlansProvider, this, false); - - return response; + if (context.PlainResult is InvitedResult invited) + { + return ContributorsDto.FromApp(invited.App, appPlansProvider, this, true); + } + else + { + return ContributorsDto.FromApp(context.Result(), appPlansProvider, this, false); + } } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 03064da7b..43498aa82 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -45,9 +45,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetLanguages(string app) { - var response = AppLanguagesDto.FromApp(App, this); + var response = Deferred.Response(() => + { + return AppLanguagesDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index 022a20cab..74f9fc136 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetPatterns(string app) { - var response = PatternsDto.FromApp(App, this); + var response = Deferred.Response(() => + { + return PatternsDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs index d51daaf2d..ac427a567 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs @@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetRoles(string app) { - var response = RolesDto.FromApp(App, this); + var response = Deferred.Response(() => + { + return RolesDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -67,11 +70,14 @@ namespace Squidex.Areas.Api.Controllers.Apps [ProducesResponseType(typeof(string[]), 200)] [ApiPermission(Permissions.AppRolesRead)] [ApiCosts(0)] - public async Task GetPermissions(string app) + public IActionResult GetPermissions(string app) { - var response = await permissionsProvider.GetPermissionsAsync(App); + var response = Deferred.AsyncResponse(() => + { + return permissionsProvider.GetPermissionsAsync(App); + }); - Response.Headers[HeaderNames.ETag] = string.Join(";", response).Sha256Base64(); + Response.Headers[HeaderNames.ETag] = string.Concat(response).Sha256Base64(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs index be489f20e..9f798add0 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs @@ -5,11 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; 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; @@ -22,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; } /// @@ -36,35 +42,38 @@ namespace Squidex.Areas.Api.Controllers.Apps /// 404 => App not found. /// [HttpGet] - [Route("apps/{app}/workflow/")] - [ProducesResponseType(typeof(WorkflowResponseDto), 200)] + [Route("apps/{app}/workflows/")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] [ApiPermission(Permissions.AppWorkflowsRead)] [ApiCosts(0)] - public IActionResult GetWorkflow(string app) + public IActionResult GetWorkflows(string app) { - var response = WorkflowResponseDto.FromApp(App, this); + var response = Deferred.AsyncResponse(() => + { + return WorkflowsDto.FromAppAsync(workflowsValidator, App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } /// - /// Configure workflow of the app. + /// Create a workflow. /// /// The name of the app. /// The new workflow. /// - /// 200 => Workflow configured. - /// 400 => Workflow is not valid. - /// 404 => App not found. + /// 200 => Workflow updated. + /// 400 => Workflow request is not valid. + /// 404 => Workflow or app not found. /// - [HttpPut] - [Route("apps/{app}/workflow/")] - [ProducesResponseType(typeof(WorkflowResponseDto), 200)] + [HttpPost] + [Route("apps/{app}/workflows/")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] [ApiPermission(Permissions.AppWorkflowsUpdate)] [ApiCosts(1)] - public async Task PutWorkflow(string app, [FromBody] UpsertWorkflowDto request) + public async Task PostWorkflow(string app, [FromBody] AddWorkflowDto request) { var command = request.ToCommand(); @@ -73,12 +82,60 @@ namespace Squidex.Areas.Api.Controllers.Apps return Ok(response); } - private async Task InvokeCommandAsync(ICommand command) + /// + /// Update a workflow. + /// + /// The name of the app. + /// The new workflow. + /// The id of the workflow to update. + /// + /// 200 => Workflow updated. + /// 400 => Workflow request is not valid. + /// 404 => Workflow or app not found. + /// + [HttpPut] + [Route("apps/{app}/workflows/{id}")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task PutWorkflow(string app, Guid id, [FromBody] UpdateWorkflowDto request) + { + var command = request.ToCommand(id); + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + /// + /// Delete a workflow. + /// + /// The name of the app. + /// The id of the workflow to update. + /// + /// 200 => Workflow deleted. + /// 404 => Workflow or app not found. + /// + [HttpDelete] + [Route("apps/{app}/workflows/{id}")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task DeleteWorkflow(string app, Guid id) + { + var command = new DeleteWorkflow { WorkflowId = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) { var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = WorkflowResponseDto.FromApp(result, this); + var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this); return response; } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 1d2b9c26a..8a1b950df 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; @@ -62,9 +63,12 @@ namespace Squidex.Areas.Api.Controllers.Apps var apps = await appProvider.GetUserApps(userOrClientId, userPermissions); - var response = apps.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)); + var response = Deferred.Response(() => + { + return apps.Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); + }); - Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); + Response.Headers[HeaderNames.ETag] = apps.ToEtag(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs similarity index 53% rename from src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs rename to src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs index 3186a7893..823794c6b 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs @@ -6,27 +6,22 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Web; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Commands; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class WorkflowResponseDto : Resource + public sealed class AddWorkflowDto { /// - /// The workflow. + /// The name of the workflow. /// [Required] - public WorkflowDto Workflow { get; set; } + public string Name { get; set; } - public static WorkflowResponseDto FromApp(IAppEntity app, ApiController controller) + public ICommand ToCommand() { - var result = new WorkflowResponseDto - { - Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name) - }; - - return result; + return new AddWorkflow { Name = Name }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 46e37a1ea..1eee4cdd5 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -25,7 +25,7 @@ using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppDto : Resource, IGenerateETag + public sealed class AppDto : Resource { /// /// The name of the app. @@ -179,7 +179,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions)) { - AddGetLink("workflows", controller.Url(x => nameof(x.GetWorkflow), values)); + AddGetLink("workflows", controller.Url(x => nameof(x.GetWorkflows), values)); } if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions)) diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs index 9d8baf5e3..b9e264241 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using System.Linq; using Newtonsoft.Json; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -36,11 +37,9 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans, ApiController controller, bool isInvited) { - var contributors = app.Contributors.ToArray(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)); - var result = new ContributorsDto { - Items = contributors, + Items = app.Contributors.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)).ToArray(), }; if (isInvited) diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs similarity index 72% rename from src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs rename to src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs index d808c60b6..08e1b4fa1 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -13,22 +14,33 @@ using Squidex.Domain.Apps.Entities.Apps.Commands; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class UpsertWorkflowDto + public sealed class UpdateWorkflowDto { + /// + /// The name of the workflow. + /// + public string Name { get; set; } + /// /// The workflow steps. /// [Required] public Dictionary Steps { get; set; } + /// + /// The schema ids. + /// + public List SchemaIds { get; set; } + /// /// The initial step. /// public Status Initial { get; set; } - public ConfigureWorkflow ToCommand() + public UpdateWorkflow ToCommand(Guid id) { var workflow = new Workflow( + Initial, Steps?.ToDictionary( x => x.Key, x => new WorkflowStep( @@ -37,9 +49,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models y => new WorkflowTransition(y.Value.Expression, y.Value.Role)), x.Value.Color, x.Value.NoUpdate)), - Initial); + SchemaIds, + Name); - return new ConfigureWorkflow { Workflow = workflow }; + return new UpdateWorkflow { WorkflowId = id, Workflow = workflow }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs index 3a6a3fecc..5e249085b 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -17,44 +18,62 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class WorkflowDto : Resource { + /// + /// The workflow id. + /// + public Guid Id { get; set; } + + /// + /// The name of the workflow. + /// + public string Name { get; set; } + /// /// The workflow steps. /// [Required] public Dictionary Steps { get; set; } + /// + /// The schema ids. + /// + public IReadOnlyList SchemaIds { get; set; } + /// /// The initial step. /// public Status Initial { get; set; } - public static WorkflowDto FromWorkflow(Workflow workflow, ApiController controller, string app) + public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app) { - var result = new WorkflowDto - { - Steps = workflow.Steps.ToDictionary( - x => x.Key, - x => SimpleMapper.Map(x.Value, new WorkflowStepDto - { - Transitions = x.Value.Transitions.ToDictionary( - y => y.Key, - y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) - })), - Initial = workflow.Initial - }; - - return result.CreateLinks(controller, app); + var result = SimpleMapper.Map(workflow, new WorkflowDto { Id = id }); + + result.Steps = workflow.Steps.ToDictionary( + x => x.Key, + x => SimpleMapper.Map(x.Value, new WorkflowStepDto + { + Transitions = x.Value.Transitions.ToDictionary( + y => y.Key, + y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) + })); + + return result.CreateLinks(controller, app, id); } - private WorkflowDto CreateLinks(ApiController controller, string app) + private WorkflowDto CreateLinks(ApiController controller, string app, Guid id) { - var values = new { app }; + var values = new { app, id }; if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) { AddPutLink("update", controller.Url(x => nameof(x.PutWorkflow), values)); } + if (controller.HasPermission(Permissions.AppWorkflowsDelete, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteWorkflow), values)); + } + return this; } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs new file mode 100644 index 000000000..5e3515eba --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +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; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowsDto : Resource + { + /// + /// The workflow. + /// + [Required] + public WorkflowDto[] Items { get; set; } + + /// + /// The errros that should be fixed. + /// + [Required] + public string[] Errors { get; set; } + + public static async Task FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller) + { + var result = new WorkflowsDto + { + Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray(), + }; + + var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows); + + result.Errors = errors.ToArray(); + + return result.CreateLinks(controller, app.Name); + } + + private WorkflowsDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetWorkflows), values)); + + if (controller.HasPermission(Permissions.AppWorkflowsCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostWorkflow), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 38faed8f3..0456767f0 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -77,9 +77,11 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetTags(string app) { - var response = await tagService.GetTagsAsync(AppId, TagGroups.Assets); + var tags = await tagService.GetTagsAsync(AppId, TagGroups.Assets); - return Ok(response); + Response.Headers[HeaderNames.ETag] = tags.Version.ToString(); + + return Ok(tags); } /// @@ -103,14 +105,17 @@ namespace Squidex.Areas.Api.Controllers.Assets { var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); - var response = AssetsDto.FromAssets(assets, this, app); + var response = Deferred.Response(() => + { + return AssetsDto.FromAssets(assets, this, app); + }); - if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + if (controllerOptions.Value.EnableSurrogateKeys && assets.Count <= controllerOptions.Value.MaxItemsForSurrogateKeys) { - Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = assets.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.ToEtag(); + Response.Headers[HeaderNames.ETag] = assets.ToEtag(); return Ok(response); } @@ -138,14 +143,17 @@ namespace Squidex.Areas.Api.Controllers.Assets return NotFound(); } - var response = AssetDto.FromAsset(asset, this, app); + var response = Deferred.Response(() => + { + return AssetDto.FromAsset(asset, this, app); + }); if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = asset.Id.ToString(); + Response.Headers["Surrogate-Key"] = asset.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = asset.Version.ToString(); + Response.Headers[HeaderNames.ETag] = asset.ToEtag(); return Ok(response); } @@ -175,10 +183,7 @@ namespace Squidex.Areas.Api.Controllers.Assets var command = new CreateAsset { File = assetFile }; - var context = await CommandBus.PublishAsync(command); - - var result = context.Result(); - var response = AssetDto.FromAsset(result.Asset, this, app, result.IsDuplicate); + var response = await InvokeCommandAsync(app, command); return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); } @@ -263,10 +268,14 @@ namespace Squidex.Areas.Api.Controllers.Assets { var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = AssetDto.FromAsset(result, this, app); - - return response; + if (context.PlainResult is AssetCreatedResult created) + { + return AssetDto.FromAsset(created.Asset, this, app, created.IsDuplicate); + } + else + { + return AssetDto.FromAsset(context.Result(), this, app); + } } private async Task CheckAssetFileAsync(IReadOnlyList file) diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index 5c996cf0d..4ba2cb68f 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -18,7 +18,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Assets.Models { - public sealed class AssetDto : Resource, IGenerateETag + public sealed class AssetDto : Resource { /// /// The id of the asset. diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs index efd81147b..fbaa6dd46 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs @@ -27,16 +27,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [Required] public AssetDto[] Items { get; set; } - public string ToEtag() - { - return Items.ToManyEtag(Total); - } - - public string ToSurrogateKeys() - { - return Items.ToSurrogateKeys(); - } - public static AssetsDto FromAssets(IResultList assets, ApiController controller, string app) { var response = new AssetsDto diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 8f39a7140..5e0163380 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -62,6 +62,8 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteBackup), values)); } + AddGetLink("download", controller.Url(x => nameof(x.GetBackupContent), values)); + return this; } } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs similarity index 95% rename from src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs rename to src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs index a6b103a05..f51bc342b 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs @@ -10,7 +10,7 @@ using System.ComponentModel.DataAnnotations; namespace Squidex.Areas.Api.Controllers.Backups.Models { - public sealed class RestoreRequest + public sealed class RestoreRequestDto { /// /// The name of the app. diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index 4426330de..8a5a5c4d1 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -68,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [HttpPost] [Route("apps/restore/")] [ApiPermission(Permissions.AdminRestore)] - public async Task PostRestore([FromBody] RestoreRequest request) + public async Task PostRestore([FromBody] RestoreRequestDto request) { var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); diff --git a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs index 6125003f5..735bd640a 100644 --- a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -55,9 +55,13 @@ namespace Squidex.Areas.Api.Controllers.Comments public async Task GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any) { var result = await grainFactory.GetGrain(commentsId).GetCommentsAsync(version); - var response = CommentsDto.FromResult(result); - Response.Headers[HeaderNames.ETag] = response.Version.ToString(); + var response = Deferred.Response(() => + { + return CommentsDto.FromResult(result); + }); + + Response.Headers[HeaderNames.ETag] = result.Version.ToString(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 095be8d07..42bf4b1ce 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -126,14 +127,17 @@ namespace Squidex.Areas.Api.Controllers.Contents { var contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(ids).Ids); - var response = await ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow); + var response = Deferred.AsyncResponse(() => + { + return ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow); + }); - if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + if (ShouldProvideSurrogateKeys(contents)) { - Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; + Response.Headers[HeaderNames.ETag] = contents.ToEtag(App); return Ok(response); } @@ -160,16 +164,19 @@ namespace Squidex.Areas.Api.Controllers.Contents { var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); - var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name); + var response = Deferred.AsyncResponse(async () => + { + var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name); - var response = await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow); + return await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow); + }); - if (ShouldProvideSurrogateKeys(response)) + if (ShouldProvideSurrogateKeys(contents)) { - Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; + Response.Headers[HeaderNames.ETag] = contents.ToEtag(App); return Ok(response); } @@ -200,10 +207,10 @@ namespace Squidex.Areas.Api.Controllers.Contents if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = content.Id.ToString(); + Response.Headers["Surrogate-Key"] = content.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; + Response.Headers[HeaderNames.ETag] = content.ToEtag(App); return Ok(response); } @@ -235,10 +242,10 @@ namespace Squidex.Areas.Api.Controllers.Contents if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = content.Id.ToString(); + Response.Headers["Surrogate-Key"] = content.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; + Response.Headers[HeaderNames.ETag] = content.ToEtag(App); return Ok(response.Data); } @@ -267,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); @@ -367,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); @@ -396,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 DiscardDraft(string app, string name, Guid id) { @@ -447,9 +444,9 @@ namespace Squidex.Areas.Api.Controllers.Contents return response; } - private bool ShouldProvideSurrogateKeys(ContentsDto response) + private bool ShouldProvideSurrogateKeys(IReadOnlyList response) { - return controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys; + return controllerOptions.Value.EnableSurrogateKeys && response.Count <= controllerOptions.Value.MaxItemsForSurrogateKeys; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs index 4aad54547..56209c00c 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs @@ -194,7 +194,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddResponse("204", $"{schemaName} content status changed.", contentSchema); operation.AddResponse("400", "Content data valid."); - AddSecurity(operation, Permissions.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); }); } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs b/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs deleted file mode 100644 index 8644c925a..000000000 --- a/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure.Security; -using Squidex.Shared; - -namespace Squidex.Areas.Api.Controllers.Contents -{ - public static class Helper - { - public static Permission StatusPermission(string app, string schema, Status status) - { - var id = Permissions.AppContentsStatus.Replace("{status}", status.Name); - - return Permissions.ForApp(id, app, schema); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 0725239e4..ce728612f 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -19,7 +19,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class ContentDto : Resource, IGenerateETag + public sealed class ContentDto : Resource { /// /// The if of the content item. @@ -122,12 +122,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models if (IsPending) { - if (controller.HasPermission(Permissions.AppContentsDiscard, app, schema)) + if (controller.HasPermission(Permissions.AppContentsDraftDiscard, app, schema)) { AddPutLink("draft/discard", controller.Url(x => nameof(x.DiscardDraft), values)); } - if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) + if (controller.HasPermission(Permissions.AppContentsDraftPublish, app, schema)) { AddPutLink("draft/publish", controller.Url(x => nameof(x.PutContentStatus), values)); } @@ -146,24 +146,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models } AddPatchLink("patch", controller.Url(x => nameof(x.PatchContent), values)); - } - - if (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) - { - AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); - } - if (content.Nexts != null) - { - foreach (var next in content.Nexts) + if (content.Nexts != null) { - if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status))) + foreach (var next in content.Nexts) { AddPutLink($"status/{next.Status}", controller.Url(x => nameof(x.PutContentStatus), values), next.Color); } } } + if (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); + } + return this; } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index 749e662d1..f665c4d40 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -37,16 +37,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models [Required] public StatusInfoDto[] Statuses { get; set; } - public string ToEtag() - { - return Items.ToManyEtag(Total); - } - - public string ToSurrogateKeys() - { - return Items.ToSurrogateKeys(); - } - public static async Task FromContentsAsync(IResultList contents, Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow) { @@ -80,10 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { AddPostLink("create", controller.Url(x => nameof(x.PostContent), values)); - if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) - { - AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); - } + AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); } } diff --git a/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs index e87b8c68c..62726a9bb 100644 --- a/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs @@ -40,7 +40,10 @@ namespace Squidex.Areas.Api.Controllers.Languages [ApiPermission] public IActionResult GetLanguages() { - var response = Language.AllLanguages.Select(LanguageDto.FromLanguage).ToArray(); + var response = Deferred.Response(() => + { + return Language.AllLanguages.Select(LanguageDto.FromLanguage).ToArray(); + }); Response.Headers[HeaderNames.ETag] = "1"; diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index a14f220a3..99de9745b 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -51,9 +51,12 @@ namespace Squidex.Areas.Api.Controllers.Plans { var hasPortal = appPlansBillingManager.HasPortal; - var response = AppPlansDto.FromApp(App, appPlansProvider, hasPortal); + var response = Deferred.Response(() => + { + return AppPlansDto.FromApp(App, appPlansProvider, hasPortal); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs index 8f1da7b9e..b108b7be4 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs @@ -8,11 +8,11 @@ using System; using System.Collections.Generic; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Web; +using Squidex.Web.Json; namespace Squidex.Areas.Api.Controllers.Rules.Models { - public sealed class RuleActionConverter : MyJsonInheritanceConverter + public sealed class RuleActionConverter : TypedJsonInheritanceConverter { public static IReadOnlyDictionary Mapping { get; set; } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs index b0337ed42..7bd538707 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs @@ -55,7 +55,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models if (oldName != null) { context.Document.Definitions.Remove(oldName); - context.Document.Definitions.Add(action.Key, derivedSchema); + context.Document.Definitions.Add($"{action.Key}RuleActionDto", derivedSchema); } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index 39ec2ff60..e625f2f35 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -19,7 +19,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Rules.Models { - public sealed class RuleDto : Resource, IGenerateETag + public sealed class RuleDto : Resource { /// /// The id of the rule. diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs index 9ac6cd699..4392bdfba 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs @@ -10,11 +10,11 @@ using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Web; +using Squidex.Web.Json; namespace Squidex.Areas.Api.Controllers.Rules.Models { - [JsonConverter(typeof(MyJsonInheritanceConverter), "triggerType")] + [JsonConverter(typeof(TypedJsonInheritanceConverter), "triggerType")] [KnownType(nameof(Subtypes))] public abstract class RuleTriggerDto { diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs index c13c163fb..7379e019a 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs @@ -22,11 +22,6 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [Required] public RuleDto[] Items { get; set; } - public string GenerateEtag() - { - return Items.ToManyEtag(0); - } - public static RulesDto FromRules(IEnumerable items, ApiController controller, string app) { var result = new RulesDto diff --git a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index c6213c93b..bd5ed4fbd 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -58,9 +58,12 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(0)] public IActionResult GetActions() { - var etag = string.Join(";", ruleRegistry.Actions.Select(x => x.Key)).Sha256Base64(); + var etag = string.Concat(ruleRegistry.Actions.Select(x => x.Key)).Sha256Base64(); - var response = ruleRegistry.Actions.ToDictionary(x => x.Key, x => RuleElementDto.FromDefinition(x.Value)); + var response = Deferred.Response(() => + { + return ruleRegistry.Actions.ToDictionary(x => x.Key, x => RuleElementDto.FromDefinition(x.Value)); + }); Response.Headers[HeaderNames.ETag] = etag; @@ -84,9 +87,12 @@ namespace Squidex.Areas.Api.Controllers.Rules { var rules = await appProvider.GetRulesAsync(AppId); - var response = RulesDto.FromRules(rules, this, app); + var response = Deferred.Response(() => + { + return RulesDto.FromRules(rules, this, app); + }); - Response.Headers[HeaderNames.ETag] = response.GenerateEtag(); + Response.Headers[HeaderNames.ETag] = rules.ToEtag(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs index 02376143b..b09c7d002 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs @@ -11,11 +11,11 @@ using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Web; +using Squidex.Web.Json; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - [JsonConverter(typeof(MyJsonInheritanceConverter), "fieldType")] + [JsonConverter(typeof(TypedJsonInheritanceConverter), "fieldType")] [KnownType(nameof(Subtypes))] public abstract class FieldPropertiesDto { diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index 35fafd420..4b349216c 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -17,7 +17,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public class SchemaDto : Resource, IGenerateETag + public class SchemaDto : Resource { /// /// The id of the schema. diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs index 596c80d07..ebdaa95ab 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs @@ -21,11 +21,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public SchemaDto[] Items { get; set; } - public string ToEtag() - { - return Items.ToManyEtag(); - } - public static SchemasDto FromSchemas(IList schemas, ApiController controller, string app) { var result = new SchemasDto diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 67e807a45..036999da6 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -50,9 +50,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas { var schemas = await appProvider.GetSchemasAsync(AppId); - var response = SchemasDto.FromSchemas(schemas, this, app); + var response = Deferred.Response(() => + { + return SchemasDto.FromSchemas(schemas, this, app); + }); - Response.Headers[HeaderNames.ETag] = response.ToEtag(); + Response.Headers[HeaderNames.ETag] = schemas.ToEtag(); return Ok(response); } @@ -89,9 +92,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NotFound(); } - var response = SchemaDetailsDto.FromSchemaWithDetails(schema, this, app); + var response = Deferred.Response(() => + { + return SchemaDetailsDto.FromSchemaWithDetails(schema, this, app); + }); - Response.Headers[HeaderNames.ETag] = schema.Version.ToString(); + Response.Headers[HeaderNames.ETag] = schema.ToEtag(); return Ok(response); } @@ -108,7 +114,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPost] [Route("apps/{app}/schemas/")] - [ProducesResponseType(typeof(SchemaDetailsDto), 200)] + [ProducesResponseType(typeof(SchemaDetailsDto), 201)] [ApiPermission(Permissions.AppSchemasCreate)] [ApiCosts(1)] public async Task PostSchema(string app, [FromBody] CreateSchemaDto request) diff --git a/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs b/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs index 2df7efe1c..e07e6d059 100644 --- a/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs +++ b/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs @@ -176,7 +176,7 @@ namespace Squidex.Areas.IdentityServer.Config }, Claims = new List { - new Claim(SquidexClaimTypes.Permissions, Permissions.Admin) + new Claim(SquidexClaimTypes.Permissions, Permissions.All) } }; } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 47c2e44b9..b9a9813c3 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -123,6 +123,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsOptional(); + services.AddSingletonAs() + .AsOptional(); + services.AddSingletonAs() .AsSelf(); diff --git a/src/Squidex/Config/Domain/SerializationInitializer.cs b/src/Squidex/Config/Domain/SerializationInitializer.cs index 0254b318b..9d1bb868e 100644 --- a/src/Squidex/Config/Domain/SerializationInitializer.cs +++ b/src/Squidex/Config/Domain/SerializationInitializer.cs @@ -29,6 +29,7 @@ namespace Squidex.Config.Domain { this.jsonNetSerializer = jsonNetSerializer; this.jsonSerializer = jsonSerializer; + this.ruleRegistry = ruleRegistry; } diff --git a/src/Squidex/Config/Web/WebServices.cs b/src/Squidex/Config/Web/WebServices.cs index 580b214ad..e4e867d85 100644 --- a/src/Squidex/Config/Web/WebServices.cs +++ b/src/Squidex/Config/Web/WebServices.cs @@ -51,6 +51,7 @@ namespace Squidex.Config.Web services.AddMvc(options => { options.Filters.Add(); + options.Filters.Add(); options.Filters.Add(); options.Filters.Add(); }) diff --git a/src/Squidex/WebStartup.cs b/src/Squidex/WebStartup.cs index 6bc48e67d..741aa75b0 100644 --- a/src/Squidex/WebStartup.cs +++ b/src/Squidex/WebStartup.cs @@ -35,6 +35,7 @@ using Squidex.Infrastructure.Translations; using Squidex.Pipeline.Plugins; using Squidex.Pipeline.Robots; using Squidex.Web; +using Squidex.Web.Pipeline; namespace Squidex { diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index cf274c475..8eee695c3 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -149,7 +149,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD this.contentForm.submitFailed(error); }); } else { - if (this.content && !this.content.canUpdate) { + if (this.content && !this.content.canUpdateAny) { return; } @@ -183,7 +183,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD private loadContent(data: any) { this.contentForm.loadContent(data); - this.contentForm.setEnabled(!this.content || this.content.canUpdate); + this.contentForm.setEnabled(!this.content || this.content.canUpdateAny); } public discardChanges() { diff --git a/src/Squidex/app/features/settings/declarations.ts b/src/Squidex/app/features/settings/declarations.ts index a6f8916c6..1a0553935 100644 --- a/src/Squidex/app/features/settings/declarations.ts +++ b/src/Squidex/app/features/settings/declarations.ts @@ -20,6 +20,7 @@ export * from './pages/roles/role.component'; export * from './pages/roles/roles-page.component'; export * from './pages/workflows/workflow-step.component'; export * from './pages/workflows/workflow-transition.component'; +export * from './pages/workflows/workflow.component'; export * from './pages/workflows/workflows-page.component'; export * from './settings-area.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/settings/module.ts b/src/Squidex/app/features/settings/module.ts index bf7eddbf8..031f1a72e 100644 --- a/src/Squidex/app/features/settings/module.ts +++ b/src/Squidex/app/features/settings/module.ts @@ -16,7 +16,6 @@ import { } from '@app/shared'; import { - BackupDownloadUrlPipe, BackupDurationPipe, BackupsPageComponent, ClientComponent, @@ -31,6 +30,7 @@ import { RoleComponent, RolesPageComponent, SettingsAreaComponent, + WorkflowComponent, WorkflowsPageComponent, WorkflowStepComponent, WorkflowTransitionComponent @@ -198,7 +198,6 @@ const routes: Routes = [ RouterModule.forChild(routes) ], declarations: [ - BackupDownloadUrlPipe, BackupDurationPipe, BackupsPageComponent, ClientComponent, @@ -213,6 +212,7 @@ const routes: Routes = [ RoleComponent, RolesPageComponent, SettingsAreaComponent, + WorkflowComponent, WorkflowsPageComponent, WorkflowTransitionComponent, WorkflowStepComponent diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index 94ade6d25..f55d7a783 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -72,7 +72,7 @@
Download: - + Ready
diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts b/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts index 9a0f1b700..13c0ce496 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts @@ -10,6 +10,7 @@ import { timer } from 'rxjs'; import { onErrorResumeNext, switchMap } from 'rxjs/operators'; import { + ApiUrlConfig, AppsState, BackupDto, BackupsState, @@ -23,6 +24,7 @@ import { }) export class BackupsPageComponent extends ResourceOwner implements OnInit { constructor( + public readonly apiUrl: ApiUrlConfig, public readonly appsState: AppsState, public readonly backupsState: BackupsState ) { diff --git a/src/Squidex/app/features/settings/pages/backups/pipes.ts b/src/Squidex/app/features/settings/pages/backups/pipes.ts index 7351ce9a6..d73192f42 100644 --- a/src/Squidex/app/features/settings/pages/backups/pipes.ts +++ b/src/Squidex/app/features/settings/pages/backups/pipes.ts @@ -7,12 +7,7 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { - ApiUrlConfig, - AppsState, - BackupDto, - Duration -} from '@app/shared'; +import { BackupDto, Duration } from '@app/shared'; @Pipe({ name: 'sqxBackupDuration', @@ -22,20 +17,4 @@ export class BackupDurationPipe implements PipeTransform { public transform(backup: BackupDto) { return Duration.create(backup.started, backup.stopped!).toString(); } -} - -@Pipe({ - name: 'sqxBackupDownloadUrl', - pure: true -}) -export class BackupDownloadUrlPipe implements PipeTransform { - constructor( - private readonly apiUrl: ApiUrlConfig, - private readonly appsState: AppsState - ) { - } - - public transform(backup: BackupDto) { - return this.apiUrl.buildUrl(`api/apps/${this.appsState.appName}/backups/${backup.id}`); - } } \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts b/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts index 885085116..48bc18f61 100644 --- a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts +++ b/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts @@ -9,8 +9,8 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { + AddClientForm, AppsState, - AttachClientForm, ClientDto, ClientsState, RolesState @@ -22,7 +22,7 @@ import { templateUrl: './clients-page.component.html' }) export class ClientsPageComponent implements OnInit { - public addClientForm = new AttachClientForm(this.formBuilder); + public addClientForm = new AddClientForm(this.formBuilder); constructor( public readonly appsState: AppsState, diff --git a/src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts b/src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts new file mode 100644 index 000000000..1f3bd4ab0 --- /dev/null +++ b/src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts @@ -0,0 +1,38 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Converter, SchemaDto, TagValue } from '@app/shared'; + +export class SchemaTagConverter implements Converter { + public readonly suggestions: TagValue[]; + + constructor( + private readonly schemas: SchemaDto[] + ) { + this.suggestions = schemas.map(x => new TagValue(x.id, x.name, x.id)); + } + + public convertInput(input: string): TagValue | null { + const schema = this.schemas.find(x => x.name === input); + + if (schema) { + return new TagValue(schema.id, schema.name, schema.id); + } + + return null; + } + + public convertValue(value: any): TagValue | null { + const schema = this.schemas.find(x => x.id === value); + + if (schema) { + return new TagValue(schema.id, schema.name, schema.id); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html index 57c22536d..7e6d01324 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html @@ -13,7 +13,7 @@ [ngModelOptions]="onBlur" [ngModel]="step.color" (ngModelChange)="changeColor($event)" - [disabled]="step.isLocked || disabled"> + [disabled]="disabled">
@@ -29,7 +29,7 @@ (Cannot be removed)
-
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow.component.html new file mode 100644 index 000000000..dcfac9bda --- /dev/null +++ b/src/Squidex/app/features/settings/pages/workflows/workflow.component.html @@ -0,0 +1,96 @@ +
+
+
+
+ {{workflow.displayName}} +
+
+ + +
+
+
+ + + +
+
+
+
+ +
+
+
+ + +
+
+ +
+ + +
+ + +
+ + + + Optional name for the workflow. + +
+
+ +
+ + +
+ + + + + Restrict this workflow to specific schemas or keep it empty for all schemas. + +
+
+ + + + + +
+
+
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss new file mode 100644 index 000000000..b39e484f1 --- /dev/null +++ b/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss @@ -0,0 +1,34 @@ +@import '_vars'; +@import '_mixins'; + +.table-items-row-details { + &::before { + right: 4.55rem; + } +} + +.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; +} + +.btn-success { + margin-bottom: 1rem; + margin-left: 2rem; +} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts b/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts new file mode 100644 index 000000000..afd1c34a9 --- /dev/null +++ b/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts @@ -0,0 +1,129 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, Input, OnChanges } from '@angular/core'; + +import { + ErrorDto, + MathHelper, + RoleDto, + WorkflowDto, + WorkflowsState, + WorkflowStep, + WorkflowStepValues, + WorkflowTransition, + WorkflowTransitionValues +} from '@app/shared'; + +import { SchemaTagConverter } from './schema-tag-converter'; + +@Component({ + selector: 'sqx-workflow', + styleUrls: ['./workflow.component.scss'], + templateUrl: './workflow.component.html' +}) +export class WorkflowComponent implements OnChanges { + @Input() + public workflow: WorkflowDto; + + @Input() + public roles: RoleDto[]; + + @Input() + public schemasSource: SchemaTagConverter; + + public error: string | null; + + public onBlur = { updateOn: 'blur' }; + + public isEditing = false; + public isEditable = false; + + constructor( + private readonly workflowsState: WorkflowsState + ) { + } + + public ngOnChanges() { + this.isEditable = this.workflow.canUpdate; + } + + public toggleEditing() { + this.isEditing = !this.isEditing; + } + + public remove() { + this.workflowsState.delete(this.workflow); + } + + public save() { + if (!this.isEditable) { + return; + } + + this.workflowsState.update(this.workflow) + .subscribe(() => { + this.error = null; + }, (error: ErrorDto) => { + this.error = error.displayMessage; + }); + } + + public addStep() { + let index = this.workflow.steps.length; + + for (let i = index; i < index + 100; i++) { + const name = `Step${i}`; + + if (!this.workflow.getStep(name)) { + this.workflow = this.workflow.setStep(name, { color: MathHelper.randomColor() }); + return; + } + } + } + + public rename(name: string) { + this.workflow = this.workflow.rename(name); + } + + public changeSchemaIds(schemaIds: string[]) { + this.workflow = this.workflow.changeSchemaIds(schemaIds); + } + + public setInitial(step: WorkflowStep) { + this.workflow = this.workflow.setInitial(step.name); + } + + public addTransiton(from: WorkflowStep, to: WorkflowStep) { + this.workflow = this.workflow.setTransition(from.name, to.name, {}); + } + + public removeTransition(from: WorkflowStep, transition: WorkflowTransition) { + this.workflow = this.workflow.removeTransition(from.name, transition.to); + } + + public updateTransition(update: { transition: WorkflowTransition, values: WorkflowTransitionValues }) { + this.workflow = this.workflow.setTransition(update.transition.from, update.transition.to, update.values); + } + + public updateStep(step: WorkflowStep, values: WorkflowStepValues) { + this.workflow = this.workflow.setStep(step.name, values); + } + + public renameStep(step: WorkflowStep, newName: string) { + this.workflow = this.workflow.renameStep(step.name, newName); + } + + public removeStep(step: WorkflowStep) { + this.workflow = this.workflow.removeStep(step.name); + } + + public trackByStep(step: WorkflowStep) { + return step.name; + } +} + diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html index 6fd2a206a..135386ceb 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html @@ -1,47 +1,58 @@ - + - Workflow + Workflows - - - - - - - - + +
+
    +
  • {{error}}
  • +
+
+
+ {{errors[0]}} +
+
+ + - - +
+ No workflows created yet. +
+ + + + +
- -
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss index fbb752506..ad50cdf61 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss +++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss @@ -1,2 +1,8 @@ @import '_vars'; -@import '_mixins'; \ No newline at end of file +@import '_mixins'; + +.panel-alert { + ul { + margin: 0; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts index a458bee00..0ecaf7f8c 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts +++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts @@ -6,97 +6,75 @@ */ import { Component, OnInit } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; import { + AddWorkflowForm, AppsState, - MathHelper, + ResourceOwner, RolesState, + SchemasState, WorkflowDto, - WorkflowsState, - WorkflowStep, - WorkflowStepValues, - WorkflowTransition, - WorkflowTransitionValues + WorkflowsState } from '@app/shared'; +import { SchemaTagConverter } from './schema-tag-converter'; + @Component({ selector: 'sqx-workflows-page', styleUrls: ['./workflows-page.component.scss'], templateUrl: './workflows-page.component.html' }) -export class WorkflowsPageComponent implements OnInit { - public workflow: WorkflowDto; +export class WorkflowsPageComponent extends ResourceOwner implements OnInit { + public addWorkflowForm = new AddWorkflowForm(this.formBuilder); + + public schemasSource: SchemaTagConverter; constructor( public readonly appsState: AppsState, public readonly rolesState: RolesState, - public readonly workflowsState: WorkflowsState + public readonly schemasState: SchemasState, + public readonly workflowsState: WorkflowsState, + private readonly formBuilder: FormBuilder ) { + super(); } public ngOnInit() { - this.workflowsState.load() - .subscribe(workflow => { - this.workflow = workflow; - }); + this.own(this.schemasState.changes.subscribe(s => { + if (s.isLoaded) { + this.schemasSource = new SchemaTagConverter(s.schemas.values); + } + })); this.rolesState.load(); + this.schemasState.load(); + this.workflowsState.load(); } public reload() { - this.workflowsState.load(true) - .subscribe(workflow => { - this.workflow = workflow; - }); - } - - public save() { - this.workflowsState.save(this.workflow); + this.workflowsState.load(true); } - public addStep() { - let index = this.workflow.steps.length; + public addWorkflow() { + const value = this.addWorkflowForm.submit(); - for (let i = index; i < index + 100; i++) { - const name = `Step${i}`; - - if (!this.workflow.getStep(name)) { - this.workflow = this.workflow.setStep(name, { color: MathHelper.randomColor() }); - return; - } + if (value) { + this.workflowsState.add(value.name) + .subscribe(() => { + this.addWorkflowForm.submitCompleted(); + }, error => { + this.addWorkflowForm.submitFailed(error); + }); } } - public setInitial(step: WorkflowStep) { - this.workflow = this.workflow.setInitial(step.name); - } - - public addTransiton(from: WorkflowStep, to: WorkflowStep) { - this.workflow = this.workflow.setTransition(from.name, to.name, {}); - } - - public removeTransition(from: WorkflowStep, transition: WorkflowTransition) { - this.workflow = this.workflow.removeTransition(from.name, transition.to); - } - - public updateTransition(update: { transition: WorkflowTransition, values: WorkflowTransitionValues }) { - this.workflow = this.workflow.setTransition(update.transition.from, update.transition.to, update.values); - } - - public updateStep(step: WorkflowStep, values: WorkflowStepValues) { - this.workflow = this.workflow.setStep(step.name, values); - } - - public renameStep(step: WorkflowStep, newName: string) { - this.workflow = this.workflow.renameStep(step.name, newName); - } - - public removeStep(step: WorkflowStep) { - this.workflow = this.workflow.removeStep(step.name); + public cancelAddWorkflow() { + this.addWorkflowForm.submitCompleted(); } - public trackByStep(index: number, step: WorkflowStep) { - return step.name; + public trackByWorkflow(index: number, workflow: WorkflowDto) { + return workflow.id; } } diff --git a/src/Squidex/app/features/settings/settings-area.component.html b/src/Squidex/app/features/settings/settings-area.component.html index 1a1a9bcb7..cee2481d3 100644 --- a/src/Squidex/app/features/settings/settings-area.component.html +++ b/src/Squidex/app/features/settings/settings-area.component.html @@ -45,7 +45,7 @@ diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.html b/src/Squidex/app/framework/angular/forms/tag-editor.component.html index 927fe127f..14147ae43 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.html +++ b/src/Squidex/app/framework/angular/forms/tag-editor.component.html @@ -1,10 +1,10 @@ -
- {{item}} + {{item}} { + public readonly lowerCaseName: string; + + constructor( + public readonly id: any, + public readonly name: string, + public readonly value: T + ) { + this.lowerCaseName = name.toLowerCase(); + } + + public toString() { + return this.name; + } +} + export interface Converter { - convert(input: string): any; + convertInput(input: string): TagValue | null; - isValidInput(input: string): boolean; - isValidValue(value: any): boolean; + convertValue(value: any): TagValue | null; } export class IntConverter implements Converter { - public isValidInput(input: string): boolean { - return !!parseInt(input, 10) || input === '0'; - } + private static ZERO = new TagValue(0, '0', 0); + + public convertInput(input: string): TagValue | null { + if (input === '0') { + return IntConverter.ZERO; + } + + const parsed = parseInt(input, 10); - public isValidValue(value: any): boolean { - return Types.isNumber(value); + if (parsed) { + return new TagValue(parsed, input, parsed); + } + + return null; } - public convert(input: string): any { - return parseInt(input, 10) || 0; + public convertValue(value: any): TagValue | null { + if (Types.isNumber(value)) { + return new TagValue(value, `${value}`, value); + } + + return null; } } export class FloatConverter implements Converter { - public isValidInput(input: string): boolean { - return !!parseFloat(input) || input === '0'; - } + private static ZERO = new TagValue(0, '0', 0); + + public convertInput(input: string): TagValue | null { + if (input === '0') { + return FloatConverter.ZERO; + } + + const parsed = parseFloat(input); + + if (parsed) { + return new TagValue(parsed, input, parsed); + } - public isValidValue(value: any): boolean { - return Types.isNumber(value); + return null; } - public convert(input: string): any { - return parseFloat(input) || 0; + public convertValue(value: any): TagValue | null { + if (Types.isNumber(value)) { + return new TagValue(value, `${value}`, value); + } + + return null; } } export class StringConverter implements Converter { - public isValidInput(input: string): boolean { - return input.trim().length > 0; - } + public convertInput(input: string): TagValue | null { + if (input) { + const trimmed = input.trim(); - public isValidValue(value: any): boolean { - return Types.isString(value); + if (trimmed.length > 0) { + return new TagValue(trimmed, trimmed, trimmed); + } + } + + return null; } - public convert(input: string): any { - return input.trim(); + public convertValue(value: any): TagValue | null { + if (Types.isString(value)) { + const trimmed = value.trim(); + + return new TagValue(trimmed, trimmed, trimmed); + } + + return null; } } @@ -73,10 +124,10 @@ let CACHED_FONT: string; interface State { hasFocus: boolean; - suggestedItems: string[]; + suggestedItems: TagValue[]; suggestedIndex: number; - items: any[]; + items: TagValue[]; } @Component({ @@ -93,6 +144,9 @@ export class TagEditorComponent extends StatefulControlComponent i @ViewChild('input', { static: false }) public inputElement: ElementRef; + @Input() + public suggestedValues: TagValue[] = []; + @Input() public converter: Converter = new StringConverter(); @@ -106,13 +160,13 @@ export class TagEditorComponent extends StatefulControlComponent i public allowDuplicates = true; @Input() - public suggestions: string[] = []; + public singleLine = false; @Input() - public singleLine = false; + public styleBlank = false; @Input() - public class: string; + public styleGray = false; @Input() public placeholder = ', to add tag'; @@ -120,6 +174,15 @@ export class TagEditorComponent extends StatefulControlComponent i @Input() public inputName = 'tag-editor'; + @Input() + public set suggestions(value: string[]) { + if (value) { + this.suggestedValues = value.map(x => new TagValue(x, x, x)); + } else { + this.suggestedValues = []; + } + } + @Input() public set disabled(value: boolean) { this.setDisabledState(value); @@ -161,8 +224,8 @@ export class TagEditorComponent extends StatefulControlComponent i }), distinctUntilChanged(), map(query => { - if (Types.isArray(this.suggestions) && query && query.length > 0) { - return this.suggestions.filter(s => s.toLowerCase().indexOf(query) >= 0 && this.snapshot.items.indexOf(s) < 0); + if (Types.isArray(this.suggestedValues) && query && query.length > 0) { + return this.suggestedValues.filter(s => s.lowerCaseName.indexOf(query) >= 0 && !this.snapshot.items.find(x => x.id === s.id)); } else { return []; } @@ -180,11 +243,23 @@ export class TagEditorComponent extends StatefulControlComponent i this.resetForm(); this.resetSize(); - if (this.converter && Types.isArrayOf(obj, v => this.converter.isValidValue(v))) { - this.next(s => ({ ...s, items: obj })); - } else { - this.next(s => ({ ...s, items: [] })); + const items: any[] = []; + + if (this.converter && Types.isArray(obj)) { + for (let value of obj) { + if (Types.is(value, TagValue)) { + items.push(value); + } else { + const converted = this.converter.convertValue(value); + + if (converted) { + items.push(converted); + } + } + } } + + this.next(s => ({ ...s, items })); } public setDisabledState(isDisabled: boolean): void { @@ -217,11 +292,9 @@ export class TagEditorComponent extends StatefulControlComponent i } public resetSize() { - if (!CACHED_FONT) { - return; - } - - if (!this.inputElement.nativeElement) { + if (!CACHED_FONT || + !this.inputElement || + !this.inputElement.nativeElement) { return; } @@ -296,16 +369,22 @@ export class TagEditorComponent extends StatefulControlComponent i return true; } - public selectValue(value: string, noFocus?: boolean) { + public selectValue(value: TagValue | string, noFocus?: boolean) { if (!noFocus) { this.inputElement.nativeElement.focus(); } - if (value && this.converter.isValidInput(value)) { - const converted = this.converter.convert(value); + let tagValue: TagValue | null; + + if (Types.isString(value)) { + tagValue = this.converter.convertInput(value); + } else { + tagValue = value; + } - if (this.allowDuplicates || this.snapshot.items.indexOf(converted) < 0) { - this.updateItems([...this.snapshot.items, converted]); + if (tagValue) { + if (this.allowDuplicates || !this.snapshot.items.find(x => x.id === tagValue!.id)) { + this.updateItems([...this.snapshot.items, tagValue]); } this.resetForm(); @@ -363,7 +442,7 @@ export class TagEditorComponent extends StatefulControlComponent i public onCopy(event: ClipboardEvent) { if (!this.hasSelection()) { if (event.clipboardData) { - event.clipboardData.setData('text/plain', this.snapshot.items.filter(x => !!x).join(',')); + event.clipboardData.setData('text/plain', this.snapshot.items.map(x => x.name).join(',')); } event.preventDefault(); @@ -380,7 +459,7 @@ export class TagEditorComponent extends StatefulControlComponent i const values = [...this.snapshot.items]; for (let part of value.split(',')) { - const converted = this.converter.convert(part); + const converted = this.converter.convertInput(part); if (converted) { values.push(converted); @@ -401,13 +480,13 @@ export class TagEditorComponent extends StatefulControlComponent i return s && e && (e - s) > 0; } - private updateItems(items: any[]) { + private updateItems(items: TagValue[]) { this.next(s => ({ ...s, items })); if (items.length === 0 && this.undefinedWhenEmpty) { this.callChange(undefined); } else { - this.callChange(items); + this.callChange(items.map(x => x.value)); } this.resetSize(); diff --git a/src/Squidex/app/framework/angular/modals/modal-view.directive.ts b/src/Squidex/app/framework/angular/modals/modal-view.directive.ts index 01cd544fb..fa2637aaa 100644 --- a/src/Squidex/app/framework/angular/modals/modal-view.directive.ts +++ b/src/Squidex/app/framework/angular/modals/modal-view.directive.ts @@ -21,9 +21,7 @@ import { RootViewComponent } from './root-view.component'; }) export class ModalViewDirective implements OnChanges, OnDestroy { private modalSubscription: Subscription | null = null; - private documentClickListener: Function | null = null; private renderedView: EmbeddedViewRef | null = null; - private static clickCounter = 0; @Input('sqxModalView') public modalView: DialogModel | ModalModel | any; @@ -44,13 +42,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy { private readonly templateRef: TemplateRef, private readonly viewContainer: ViewContainerRef ) { - if (ModalViewDirective.clickCounter === 0) { - this.renderer.listen('document', 'click', () => { - ModalViewDirective.clickCounter++; - }); - - ModalViewDirective.clickCounter = 1; - } } public ngOnDestroy() { @@ -95,7 +86,7 @@ export class ModalViewDirective implements OnChanges, OnDestroy { this.renderer.setStyle(this.renderedView.rootNodes[0], 'display', 'block'); } - this.startListening(ModalViewDirective.clickCounter + 1); + this.startListening(); this.changeDetector.detectChanges(); } else if (!isOpen && this.renderedView) { @@ -114,40 +105,43 @@ 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) { + const modal = this.modalView; + + setTimeout(() => { + modal.hide(); + }, 100); + } 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 +152,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy { } private unsubscribeToClick() { - if (this.documentClickListener) { - this.documentClickListener(); - this.documentClickListener = null; - } + document.removeEventListener('click', this.documentClickListener); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/asset.component.html b/src/Squidex/app/shared/components/asset.component.html index 7fe2798ce..e3d78800e 100644 --- a/src/Squidex/app/shared/components/asset.component.html +++ b/src/Squidex/app/shared/components/asset.component.html @@ -69,7 +69,7 @@
- +
{{asset.pixelWidth}}x{{asset.pixelHeight}}px, {{asset.fileSize | sqxFileSize}} diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 71a0a1561..c53694089 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -63,6 +63,7 @@ export * from './state/rules.state'; export * from './state/schemas.forms'; export * from './state/schemas.state'; export * from './state/ui.state'; +export * from './state/workflows.forms'; export * from './state/workflows.state'; export * from './utils/messages'; diff --git a/src/Squidex/app/shared/services/backups.service.spec.ts b/src/Squidex/app/shared/services/backups.service.spec.ts index 11f7d81fe..9713a023a 100644 --- a/src/Squidex/app/shared/services/backups.service.spec.ts +++ b/src/Squidex/app/shared/services/backups.service.spec.ts @@ -16,6 +16,7 @@ import { BackupsService, DateTime, Resource, + ResourceLinks, RestoreDto } from '@app/shared/internal'; @@ -52,30 +53,16 @@ describe('BackupsService', () => { expect(req.request.headers.get('If-Match')).toBeNull(); req.flush({ - items: [{ - id: '1', - started: '2017-02-03', - stopped: '2017-02-04', - handledEvents: 13, - handledAssets: 17, - status: 'Failed', - _links: {} - }, - { - id: '2', - started: '2018-02-03', - stopped: null, - handledEvents: 23, - handledAssets: 27, - status: 'Completed', - _links: {} - }] + items: [ + backupResponse(12), + backupResponse(13) + ] }); expect(backups!).toEqual( new BackupsDto([ - new BackupDto({}, '1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, 'Failed'), - new BackupDto({}, '2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, 'Completed') + createBackup(12), + createBackup(13) ])); })); @@ -203,4 +190,32 @@ describe('BackupsService', () => { req.flush({}); })); -}); \ No newline at end of file + + function backupResponse(id: number) { + return { + id: `id${id}`, + started: `${id % 1000 + 2000}-12-12T10:10:00`, + stopped: id % 2 === 0 ? `${id % 1000 + 2000}-11-11T10:10:00` : null, + handledEvents: id * 17, + handledAssets: id * 23, + status: id % 2 === 0 ? 'Status' : 'Failed', + _links: { + download: { method: 'GET', href: '/api/backups/1' } + } + }; + } +}); + +export function createBackup(id: number) { + const links: ResourceLinks = { + download: { method: 'GET', href: '/api/backups/1' } + }; + + return new BackupDto(links, + `id${id}`, + DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), + id % 2 === 0 ? DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`) : null, + id * 17, + id * 23, + id % 2 === 0 ? 'Status' : 'Failed'); +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/backups.service.ts b/src/Squidex/app/shared/services/backups.service.ts index 3af49a2c7..9ecf9a270 100644 --- a/src/Squidex/app/shared/services/backups.service.ts +++ b/src/Squidex/app/shared/services/backups.service.ts @@ -41,6 +41,8 @@ export class BackupDto { public readonly canDelete: boolean; + public readonly downloadUrl: string; + constructor( links: ResourceLinks, public readonly id: string, @@ -53,6 +55,8 @@ export class BackupDto { this._links = links; this.canDelete = hasAnyLink(links, 'delete'); + + this.downloadUrl = links['download'].href; } } diff --git a/src/Squidex/app/shared/services/contents.service.ts b/src/Squidex/app/shared/services/contents.service.ts index 1096e3679..1120f6b99 100644 --- a/src/Squidex/app/shared/services/contents.service.ts +++ b/src/Squidex/app/shared/services/contents.service.ts @@ -65,6 +65,7 @@ export class ContentDto { public readonly canDraftPropose: boolean; public readonly canDraftPublish: boolean; public readonly canUpdate: boolean; + public readonly canUpdateAny: boolean; constructor(links: ResourceLinks, public readonly id: string, @@ -87,6 +88,7 @@ export class ContentDto { this.canDraftPropose = hasAnyLink(links, 'draft/propose'); this.canDraftPublish = hasAnyLink(links, 'draft/publish'); this.canUpdate = hasAnyLink(links, 'update'); + this.canUpdateAny = this.canUpdate || this.canDraftPropose; this.statusUpdates = Object.keys(links).filter(x => x.startsWith('status/')).map(x => ({ status: x.substr(7), color: links[x].metadata! })); } diff --git a/src/Squidex/app/shared/services/contributors.service.spec.ts b/src/Squidex/app/shared/services/contributors.service.spec.ts index 250d4bba0..20f6c27d5 100644 --- a/src/Squidex/app/shared/services/contributors.service.spec.ts +++ b/src/Squidex/app/shared/services/contributors.service.spec.ts @@ -119,7 +119,7 @@ describe('ContributorsService', () => { function contributorsResponse(...ids: number[]) { return { - items: ids.map(id => ({ + items: ids.map(id => ({ contributorId: `id${id}`, role: id % 2 === 0 ? 'Owner' : 'Developer', _links: { update: { method: 'PUT', href: `/contributors/id${id}` } diff --git a/src/Squidex/app/shared/services/workflows.service.spec.ts b/src/Squidex/app/shared/services/workflows.service.spec.ts index c42b620f0..667bedc99 100644 --- a/src/Squidex/app/shared/services/workflows.service.spec.ts +++ b/src/Squidex/app/shared/services/workflows.service.spec.ts @@ -13,9 +13,9 @@ import { ApiUrlConfig, Resource, Version, - Versioned, WorkflowDto, - WorkflowPayload, + WorkflowsDto, + WorkflowsPayload, WorkflowsService } from '@app/shared/internal'; @@ -43,94 +43,175 @@ describe('WorkflowsService', () => { it('should make a get request to get app workflows', inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { - let workflow: Versioned; + let workflows: WorkflowsDto; - workflowsService.getWorkflow('my-app').subscribe(result => { - workflow = result; + workflowsService.getWorkflows('my-app').subscribe(result => { + workflows = result; }); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush(workflowsResponse('Draft'), + req.flush(workflowsResponse('1', '2'), { headers: { etag: '2' } }); - expect(workflow!).toEqual({ payload: createWorkflow('Draft'), version: new Version('2') }); + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); })); - it('should make a put request to assign a workflow', + it('should make a post request to create a workflow', + inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { + + let workflows: WorkflowsDto; + + workflowsService.postWorkflow('my-app', { name: 'New' }, version).subscribe(result => { + workflows = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toEqual(version.value); + + req.flush(workflowsResponse('1', '2'), { + headers: { + etag: '2' + } + }); + + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); + })); + + it('should make a put request to update a workflow', inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { const resource: Resource = { _links: { - update: { method: 'PUT', href: '/api/apps/my-app/workflow' } + update: { method: 'PUT', href: '/api/apps/my-app/workflows/123' } } }; - let workflow: Versioned; + let workflows: WorkflowsDto; workflowsService.putWorkflow('my-app', resource, {}, version).subscribe(result => { - workflow = result; + workflows = result; }); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows/123'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush(workflowsResponse('Draft'), { + req.flush(workflowsResponse('1', '2'), { + headers: { + etag: '2' + } + }); + + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); + })); + + it('should make a delete request to delete a workflow', + inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { + + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/workflows/123' } + } + }; + + let workflows: WorkflowsDto; + + workflowsService.deleteWorkflow('my-app', resource, version).subscribe(result => { + workflows = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows/123'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toEqual(version.value); + + req.flush(workflowsResponse('1', '2'), { headers: { etag: '2' } }); - expect(workflow!).toEqual({ payload: createWorkflow('Draft'), version: new Version('2') }); + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); })); - function workflowsResponse(name: string) { + function workflowsResponse(...names: string[]) { + return { + errors: [ + 'Error1', + 'Error2' + ], + items: names.map(name => workflowResponse(name)), + _links: { + create: { method: 'POST', href: '/workflows' } + } + }; + } + + function workflowResponse(name: string) { return { - workflow: { - steps: { - [`${name}1`]: { - transitions: { - [`${name}2`]: { - expression: 'Expression1', role: 'Role1' - } - }, - color: `${name}1`, noUpdate: true + id: `id_${name}`, + name: `name_${name}`, + initial: `${name}1`, + schemaIds: [`schema_${name}`], + steps: { + [`${name}1`]: { + transitions: { + [`${name}2`]: { + expression: 'Expression1', role: 'Role1' + } }, - [`${name}2`]: { - transitions: { - [`${name}1`]: { - expression: 'Expression2', role: 'Role2' - } - }, - color: `${name}2`, noUpdate: true - } + color: `${name}1`, noUpdate: true }, - initial: `${name}1`, - _links: { - update: { method: 'PUT', href: '/api/workflows' } + [`${name}2`]: { + transitions: { + [`${name}1`]: { + expression: 'Expression2', role: 'Role2' + } + }, + color: `${name}2`, noUpdate: true } }, - _links: {}, - canCreate: true + _links: { + update: { method: 'PUT', href: `/workflows/${name}` } + } }; } }); -export function createWorkflow(name: string): WorkflowPayload { +export function createWorkflows(...names: string[]): WorkflowsPayload { return { - workflow: new WorkflowDto({ - update: { method: 'PUT', href: '/api/workflows' } + errors: [ + 'Error1', + 'Error2' + ], + items: names.map(name => createWorkflow(name)), + _links: { + create: { method: 'POST', href: '/workflows' } + }, + canCreate: true + }; +} + +export function createWorkflow(name: string): WorkflowDto { + return new WorkflowDto( + { + update: { method: 'PUT', href: `/workflows/${name}` } }, - `${name}1`, + `id_${name}`, `name_${name}`, `${name}1`, + [ + `schema_${name}` + ], [ { name: `${name}1`, color: `${name}1`, noUpdate: true, isLocked: false }, { name: `${name}2`, color: `${name}2`, noUpdate: true, isLocked: false } @@ -138,24 +219,24 @@ export function createWorkflow(name: string): WorkflowPayload { [ { from: `${name}1`, to: `${name}2`, expression: 'Expression1', role: 'Role1' }, { from: `${name}2`, to: `${name}1`, expression: 'Expression2', role: 'Role2' } - ]), - _links: {} - }; + ]); } describe('Workflow', () => { it('should create empty workflow', () => { - const workflow = new WorkflowDto(); + const workflow = new WorkflowDto({}, 'id'); expect(workflow.initial).not.toBeDefined(); }); it('should add step to workflow', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1', { color: '#00ff00' }); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '1': { transitions: {}, color: '#00ff00' } }, @@ -165,11 +246,13 @@ describe('Workflow', () => { it('should override settings if step already exists', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1', { color: '#00ff00', noUpdate: true }) .setStep('1', { color: 'red' }); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '1': { transitions: {}, color: 'red', noUpdate: true } }, @@ -177,19 +260,9 @@ describe('Workflow', () => { }); }); - it('should return same workflow if step to update is locked', () => { - const workflow = - new WorkflowDto() - .setStep('1', { color: '#00ff00', isLocked: true }); - - const updated = workflow.setStep('1', { color: 'red' }); - - expect(updated).toBe(workflow); - }); - it('should sort steps case invariant', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('Z') .setStep('a'); @@ -201,7 +274,7 @@ describe('Workflow', () => { it('should return same workflow if step to remove is locked', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1', { color: '#00ff00', isLocked: true }); const updated = workflow.removeStep('1'); @@ -211,7 +284,7 @@ describe('Workflow', () => { it('should return same workflow if step to remove not found', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1'); const updated = workflow.removeStep('3'); @@ -221,7 +294,7 @@ describe('Workflow', () => { it('should remove step', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1', { color: '#00ff00' }) .setStep('2', { color: '#ff0000' }) .setStep('3', { color: '#0000ff' }) @@ -231,6 +304,8 @@ describe('Workflow', () => { .removeStep('1'); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '2': { transitions: { @@ -246,13 +321,15 @@ describe('Workflow', () => { it('should make first non-locked step the initial step if initial removed', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2', { isLocked: true }) .setStep('3') .removeStep('1'); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '2': { transitions: {}, isLocked: true }, '3': { transitions: {} } @@ -263,16 +340,16 @@ describe('Workflow', () => { it('should unset initial step if initial removed', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .removeStep('1'); - expect(workflow.serialize()).toEqual({ steps: {}, initial: undefined }); + expect(workflow.serialize()).toEqual({ name: null, schemaIds: [], steps: {}, initial: null }); }); it('should rename step', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1', { color: '#00ff00' }) .setStep('2', { color: '#ff0000' }) .setStep('3', { color: '#0000ff' }) @@ -282,6 +359,8 @@ describe('Workflow', () => { .renameStep('1', 'a'); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { 'a': { transitions: { @@ -304,13 +383,15 @@ describe('Workflow', () => { it('should add transitions to workflow', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setTransition('1', '2', { expression: '1 === 2' }) .setTransition('2', '1', { expression: '2 === 1' }); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '1': { transitions: { @@ -329,7 +410,7 @@ describe('Workflow', () => { it('should remove transition from workflow', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setTransition('1', '2', { expression: '1 === 2' }) @@ -337,6 +418,8 @@ describe('Workflow', () => { .removeTransition('1', '2'); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '1': { transitions: {}}, '2': { @@ -351,13 +434,15 @@ describe('Workflow', () => { it('should override settings if transition already exists', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setTransition('2', '1', { expression: '2 === 1', role: 'Role' }) .setTransition('2', '1', { expression: '2 !== 1' }); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '1': { transitions: {} }, '2': { @@ -372,7 +457,7 @@ describe('Workflow', () => { it('should return same workflow if transition to update not found by from step', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setTransition('1', '2'); @@ -384,7 +469,7 @@ describe('Workflow', () => { it('should return same workflow if transition to update not found by to step', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setTransition('1', '2'); @@ -396,7 +481,7 @@ describe('Workflow', () => { it('should return same workflow if transition to remove not', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setTransition('1', '2'); @@ -408,7 +493,7 @@ describe('Workflow', () => { it('should return same workflow if step to make initial is locked', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2', { color: '#00ff00', isLocked: true }); @@ -419,12 +504,14 @@ describe('Workflow', () => { it('should set initial step', () => { const workflow = - new WorkflowDto() + new WorkflowDto({}, 'id') .setStep('1') .setStep('2') .setInitial('2'); expect(workflow.serialize()).toEqual({ + name: null, + schemaIds: [], steps: { '1': { transitions: {} }, '2': { transitions: {} } @@ -433,4 +520,19 @@ describe('Workflow', () => { }); }); + it('should rename workflow', () => { + const workflow = + new WorkflowDto({}, 'id') + .rename('name'); + + expect(workflow.serialize()).toEqual({ name: 'name', schemaIds: [], steps: {}, initial: null }); + }); + + it('should update schemaIds', () => { + const workflow = + new WorkflowDto({}, 'id') + .changeSchemaIds(['1', '2']); + + expect(workflow.serialize()).toEqual({ name: null, schemaIds: ['1', '2'], steps: {}, initial: null }); + }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/workflows.service.ts b/src/Squidex/app/shared/services/workflows.service.ts index ce9861f10..3988acfa1 100644 --- a/src/Squidex/app/shared/services/workflows.service.ts +++ b/src/Squidex/app/shared/services/workflows.service.ts @@ -17,44 +17,57 @@ import { hasAnyLink, HTTP, mapVersioned, + Model, pretifyError, Resource, ResourceLinks, + StringHelper, Version, Versioned } from '@app/framework'; -export type WorkflowsDto = Versioned; -export type WorkflowPayload = { workflow: WorkflowDto; } & Resource; +export type WorkflowsDto = Versioned; +export type WorkflowsPayload = { + readonly items: WorkflowDto[]; -export class WorkflowDto { + readonly errors: string[]; + + readonly canCreate: boolean; +} & Resource; + +export class WorkflowDto extends Model { public readonly _links: ResourceLinks; public readonly canUpdate: boolean; + public readonly canDelete: boolean; - public static DEFAULT = - new WorkflowDto() - .setStep('Draft', { color: '#8091a5' }) - .setStep('Archived', { color: '#eb3142', noUpdate: true }) - .setStep('Published', { color: '#4bb958', isLocked: true }) - .setTransition('Archived', 'Draft') - .setTransition('Draft', 'Archived') - .setTransition('Draft', 'Published') - .setTransition('Published', 'Draft') - .setTransition('Published', 'Archived'); - - constructor(links: ResourceLinks = {}, - public readonly initial?: string, + public readonly displayName: string; + + constructor( + links: ResourceLinks = {}, + public readonly id: string, + public readonly name: string | null = null, + public readonly initial: string | null = null, + public readonly schemaIds: string[] = [], public readonly steps: WorkflowStep[] = [], - private readonly transitions: WorkflowTransition[] = [] + public readonly transitions: WorkflowTransition[] = [] ) { - this.steps.sort((a, b) => compareStringsAsc(a.name, b.name)); + super(); - this.transitions.sort((a, b) => compareStringsAsc(a.to, b.to)); + this.onCloned(); this._links = links; this.canUpdate = hasAnyLink(links, 'update'); + this.canDelete = hasAnyLink(links, 'delete'); + + this.displayName = StringHelper.firstNonEmpty(name, 'Unnamed Workflow'); + } + + protected onCloned() { + this.steps.sort((a, b) => compareStringsAsc(a.name, b.name)); + + this.transitions.sort((a, b) => compareStringsAsc(a.to, b.to)); } public getOpenSteps(step: WorkflowStep) { @@ -70,27 +83,29 @@ export class WorkflowDto { } public setStep(name: string, values: Partial = {}) { - const found = this.getStep(name); + const old = this.getStep(name); - if (found) { - const { name: _, ...existing } = found; + const step = { ...old, name, ...values }; + const steps = [...this.steps.filter(s => s !== old), step]; - if (found.isLocked) { - return this; - } - - values = { ...existing, ...values }; + if (steps.length === 1) { + return this.with({ initial: name, steps }); + } else { + return this.with({ steps }); } + } - const steps = [...this.steps.filter(s => s !== found), { name, ...values }]; + public setTransition(from: string, to: string, values: Partial = {}) { + if (!this.getStep(from) || !this.getStep(to)) { + return this; + } - let initial = this.initial; + const old = this.transitions.find(x => x.from === from && x.to === to); - if (steps.length === 1) { - initial = steps[0].name; - } + const transition = { ...old, from, to, ...values }; + const transitions = [...this.transitions.filter(t => t !== old), transition]; - return new WorkflowDto(this._links, initial, steps, this.transitions); + return this.with({ transitions }); } public setInitial(initial: string) { @@ -100,7 +115,7 @@ export class WorkflowDto { return this; } - return new WorkflowDto(this._links, initial, this.steps, this.transitions); + return this.with({ initial }); } public removeStep(name: string) { @@ -110,20 +125,23 @@ export class WorkflowDto { return this; } - const transitions = - steps.length !== this.steps.length ? - this.transitions.filter(t => t.from !== name && t.to !== name) : - this.transitions; - - let initial = this.initial; + const transitions = this.transitions.filter(t => t.from !== name && t.to !== name); - if (initial === name) { + if (this.initial === name) { const first = steps.find(x => !x.isLocked); - initial = first ? first.name : undefined; + return this.with({ initial: first ? first.name : null, steps, transitions }); + } else { + return this.with({ steps, transitions }); } + } + + public changeSchemaIds(schemaIds: string[]) { + return this.with({ schemaIds }); + } - return new WorkflowDto(this._links, initial, steps, transitions); + public rename(name: string) { + return this.with({ name }); } public renameStep(name: string, newName: string) { @@ -153,13 +171,11 @@ export class WorkflowDto { return transition; }); - let initial = this.initial; - - if (initial === name) { - initial = newName; + if (this.initial === name) { + return this.with({ initial: newName, steps, transitions }); + } else { + return this.with({ steps, transitions }); } - - return new WorkflowDto(this._links, initial, steps, transitions); } public removeTransition(from: string, to: string) { @@ -169,37 +185,11 @@ export class WorkflowDto { return this; } - return new WorkflowDto(this._links, this.initial, this.steps, transitions); - } - - public setTransition(from: string, to: string, values: Partial = {}) { - const stepFrom = this.getStep(from); - - if (!stepFrom) { - return this; - } - - const stepTo = this.getStep(to); - - if (!stepTo) { - return this; - } - - const found = this.transitions.find(x => x.from === from && x.to === to); - - if (found) { - const { from: _, to: __, ...existing } = found; - - values = { ...existing, ...values }; - } - - const transitions = [...this.transitions.filter(t => t !== found), { from, to, ...values }]; - - return new WorkflowDto(this._links, this.initial, this.steps, transitions); + return this.with({ transitions }); } public serialize(): any { - const result = { steps: {}, initial: this.initial }; + const result = { steps: {}, schemaIds: this.schemaIds, initial: this.initial, name: this.name }; for (let step of this.steps) { const { name, ...values } = step; @@ -227,6 +217,10 @@ export type WorkflowTransition = { from: string; to: string } & WorkflowTransiti export type WorkflowTransitionView = { step: WorkflowStep } & WorkflowTransition; +export interface CreateWorkflowDto { + readonly name: string; +} + @Injectable() export class WorkflowsService { constructor( @@ -236,41 +230,74 @@ export class WorkflowsService { ) { } - public getWorkflow(appName: string): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflow`); + public getWorkflows(appName: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflows`); return HTTP.getVersioned(this.http, url).pipe( mapVersioned(({ body }) => { - return parseWorkflowPayload(body); + return parseWorkflows(body); }), pretifyError('Failed to load workflows. Please reload.')); } - public putWorkflow(appName: string, resource: Resource, dto: any, version: Version): Observable> { + public postWorkflow(appName: string, dto: CreateWorkflowDto, version: Version): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflows`); + + return HTTP.postVersioned(this.http, url, dto, version).pipe( + mapVersioned(({ body }) => { + return parseWorkflows(body); + }), + tap(() => { + this.analytics.trackEvent('Workflow', 'Created', appName); + }), + pretifyError('Failed to create workflow. Please reload.')); + } + + public putWorkflow(appName: string, resource: Resource, dto: any, version: Version): Observable { const link = resource._links['update']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( mapVersioned(({ body }) => { - return parseWorkflowPayload(body); + return parseWorkflows(body); }), tap(() => { - this.analytics.trackEvent('Workflow', 'Configured', appName); + this.analytics.trackEvent('Workflow', 'Updated', appName); }), - pretifyError('Failed to configure Workflow. Please reload.')); + pretifyError('Failed to update Workflow. Please reload.')); + } + + public deleteWorkflow(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['delete']; + + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version).pipe( + mapVersioned(({ body }) => { + return parseWorkflows(body); + }), + tap(() => { + this.analytics.trackEvent('Workflow', 'Deleted', appName); + }), + pretifyError('Failed to delete Workflow. Please reload.')); } } -function parseWorkflowPayload(response: any) { - const { workflow, _links } = response; +function parseWorkflows(response: any) { + const raw: any[] = response.items; + + const items = raw.map(item => + parseWorkflow(item)); - const result = parseWorkflow(workflow); + const { errors, _links } = response; - return { workflow: result, _links }; + return { errors, items, _links, canCreate: hasAnyLink(_links, 'create') }; } function parseWorkflow(workflow: any) { + const { id, name, initial, schemaIds, _links } = workflow; + const steps: WorkflowStep[] = []; const transitions: WorkflowTransition[] = []; @@ -290,5 +317,5 @@ function parseWorkflow(workflow: any) { } } - return new WorkflowDto(workflow._links, workflow.initial, steps, transitions); + return new WorkflowDto(_links, id, name, initial, schemaIds, steps, transitions); } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/backups.state.spec.ts b/src/Squidex/app/shared/state/backups.state.spec.ts index 948589f35..22d5bb952 100644 --- a/src/Squidex/app/shared/state/backups.state.spec.ts +++ b/src/Squidex/app/shared/state/backups.state.spec.ts @@ -10,24 +10,24 @@ import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { - BackupDto, BackupsDto, BackupsService, BackupsState, - DateTime, DialogService } from '@app/shared/internal'; import { TestValues } from './_test-helpers'; +import { createBackup } from './../services/backups.service.spec'; + describe('BackupsState', () => { const { app, appsState } = TestValues; - const backup1 = new BackupDto({}, 'id1', DateTime.now(), null, 1, 1, 'Started'); - const backup2 = new BackupDto({}, 'id2', DateTime.now(), null, 2, 2, 'Started'); + const backup1 = createBackup(12); + const backup2 = createBackup(13); let dialogs: IMock; let backupsService: IMock; diff --git a/src/Squidex/app/shared/state/clients.forms.ts b/src/Squidex/app/shared/state/clients.forms.ts index 4b1ad2372..fbd4c8ba7 100644 --- a/src/Squidex/app/shared/state/clients.forms.ts +++ b/src/Squidex/app/shared/state/clients.forms.ts @@ -25,7 +25,7 @@ export class RenameClientForm extends Form { } } -export class AttachClientForm extends Form { +export class AddClientForm extends Form { public hasNoName = hasNoValue$(this.form.controls['name']); constructor(formBuilder: FormBuilder) { diff --git a/src/Squidex/app/shared/state/languages.state.ts b/src/Squidex/app/shared/state/languages.state.ts index 7f1a77f27..fa792951a 100644 --- a/src/Squidex/app/shared/state/languages.state.ts +++ b/src/Squidex/app/shared/state/languages.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { forkJoin, Observable } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +import { map, shareReplay, tap } from 'rxjs/operators'; import { DialogService, @@ -65,6 +65,8 @@ type LanguageResultList = ImmutableArray; @Injectable() export class LanguagesState extends State { + private cachedLanguage$: Observable; + public languages = this.project(x => x.languages); @@ -96,9 +98,7 @@ export class LanguagesState extends State { this.resetState(); } - return forkJoin( - this.languagesService.getLanguages(), - this.appLanguagesService.getLanguages(this.appName)).pipe( + return forkJoin(this.getAllLanguages(), this.getAppLanguages()).pipe( map(args => { return { allLanguages: args[0], languages: args[1] }; }), @@ -166,6 +166,20 @@ export class LanguagesState extends State { return this.snapshot.version; } + private getAppLanguages() { + return this.appLanguagesService.getLanguages(this.appName); + } + + private getAllLanguages() { + if (!this.cachedLanguage$) { + this.cachedLanguage$ = + this.languagesService.getLanguages().pipe( + shareReplay(1)); + } + + return this.cachedLanguage$; + } + private createLanguage(language: AppLanguageDto, languages: AppLanguagesList): SnapshotLanguage { return { language, diff --git a/src/Squidex/app/shared/state/workflows.forms.ts b/src/Squidex/app/shared/state/workflows.forms.ts new file mode 100644 index 000000000..7c0fc21ac --- /dev/null +++ b/src/Squidex/app/shared/state/workflows.forms.ts @@ -0,0 +1,24 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { Form, hasNoValue$ } from '@app/framework'; + +export class AddWorkflowForm extends Form { + public hasNoName = hasNoValue$(this.form.controls['name']); + + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + name: ['', + [ + Validators.required + ] + ] + })); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/state/workflows.state.spec.ts b/src/Squidex/app/shared/state/workflows.state.spec.ts index 4ad671a27..272d685f1 100644 --- a/src/Squidex/app/shared/state/workflows.state.spec.ts +++ b/src/Squidex/app/shared/state/workflows.state.spec.ts @@ -11,12 +11,12 @@ import { IMock, It, Mock, Times } from 'typemoq'; import { DialogService, versioned, - WorkflowPayload, + WorkflowsPayload, WorkflowsService, WorkflowsState } from '@app/shared/internal'; -import { createWorkflow } from '../services/workflows.service.spec'; +import { createWorkflows } from '../services/workflows.service.spec'; import { TestValues } from './_test-helpers'; @@ -28,7 +28,7 @@ describe('WorkflowsState', () => { version } = TestValues; - const oldWorkflow = createWorkflow('test'); + const oldWorkflows = createWorkflows('1', '2'); let dialogs: IMock; let workflowsService: IMock; @@ -47,12 +47,12 @@ describe('WorkflowsState', () => { describe('Loading', () => { it('should load workflow', () => { - workflowsService.setup(x => x.getWorkflow(app)) - .returns(() => of(versioned(version, oldWorkflow))).verifiable(); + workflowsService.setup(x => x.getWorkflows(app)) + .returns(() => of(versioned(version, oldWorkflows))).verifiable(); workflowsState.load().subscribe(); - expect(workflowsState.snapshot.workflow).toEqual(oldWorkflow.workflow); + expect(workflowsState.snapshot.workflows.values).toEqual(oldWorkflows.items); expect(workflowsState.snapshot.isLoaded).toBeTruthy(); expect(workflowsState.snapshot.version).toEqual(version); @@ -60,8 +60,8 @@ describe('WorkflowsState', () => { }); it('should show notification on load when reload is true', () => { - workflowsService.setup(x => x.getWorkflow(app)) - .returns(() => of(versioned(version, oldWorkflow))).verifiable(); + workflowsService.setup(x => x.getWorkflows(app)) + .returns(() => of(versioned(version, oldWorkflows))).verifiable(); workflowsState.load(true).subscribe(); @@ -73,29 +73,51 @@ describe('WorkflowsState', () => { describe('Updates', () => { beforeEach(() => { - workflowsService.setup(x => x.getWorkflow(app)) - .returns(() => of(versioned(version, oldWorkflow))).verifiable(); + workflowsService.setup(x => x.getWorkflows(app)) + .returns(() => of(versioned(version, oldWorkflows))).verifiable(); workflowsState.load().subscribe(); }); - it('should update workflows when saved', () => { - const updated = createWorkflow('updated'); + it('should update workflows when workflow added', () => { + const updated = createWorkflows('1', '2', '3'); - const request = oldWorkflow.workflow.serialize(); + workflowsService.setup(x => x.postWorkflow(app, { name: 'my-workflow' }, version)) + .returns(() => of(versioned(newVersion, updated))).verifiable(); + + workflowsState.add('my-workflow' ).subscribe(); + + expectNewWorkflows(updated); + }); + + it('should update workflows when workflow updated', () => { + const updated = createWorkflows('1', '2', '3'); + + const request = oldWorkflows.items[0].serialize(); - workflowsService.setup(x => x.putWorkflow(app, oldWorkflow.workflow, request, version)) + workflowsService.setup(x => x.putWorkflow(app, oldWorkflows.items[0], request, version)) .returns(() => of(versioned(newVersion, updated))).verifiable(); - workflowsState.save(oldWorkflow.workflow).subscribe(); + workflowsState.update(oldWorkflows.items[0]).subscribe(); expectNewWorkflows(updated); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); - function expectNewWorkflows(updated: WorkflowPayload) { - expect(workflowsState.snapshot.workflow).toEqual(updated.workflow); + it('should update workflows when workflow deleted', () => { + const updated = createWorkflows('1', '2', '3'); + + workflowsService.setup(x => x.deleteWorkflow(app, oldWorkflows.items[0], version)) + .returns(() => of(versioned(newVersion, updated))).verifiable(); + + workflowsState.delete(oldWorkflows.items[0]).subscribe(); + + expectNewWorkflows(updated); + }); + + function expectNewWorkflows(updated: WorkflowsPayload) { + expect(workflowsState.snapshot.workflows.values).toEqual(updated.items); expect(workflowsState.snapshot.version).toEqual(newVersion); } }); diff --git a/src/Squidex/app/shared/state/workflows.state.ts b/src/Squidex/app/shared/state/workflows.state.ts index b43a558e0..6557dea6f 100644 --- a/src/Squidex/app/shared/state/workflows.state.ts +++ b/src/Squidex/app/shared/state/workflows.state.ts @@ -13,7 +13,7 @@ import { tap } from 'rxjs/operators'; import { DialogService, - shareMapSubscribed, + ImmutableArray, shareSubscribed, State, Version @@ -23,68 +23,98 @@ import { AppsState } from './apps.state'; import { WorkflowDto, - WorkflowPayload, + WorkflowsPayload, WorkflowsService } from './../services/workflows.service'; interface Snapshot { // The current workflow. - workflow?: WorkflowDto; + workflows: ImmutableArray; // The app version. version: Version; + // The errors. + errors: string[]; + // Indicates if the workflows are loaded. isLoaded?: boolean; + + // Indicates if the user can create new workflow. + canCreate?: boolean; } @Injectable() export class WorkflowsState extends State { - public workflow = - this.project(x => x.workflow); + public workflows = + this.project(x => x.workflows); + + public errors = + this.project(x => x.errors); public isLoaded = this.project(x => !!x.isLoaded); + public canCreate = + this.project(x => !!x.canCreate); + constructor( private readonly workflowsService: WorkflowsService, private readonly appsState: AppsState, private readonly dialogs: DialogService ) { - super({ version: Version.EMPTY }); + super({ errors: [], workflows: ImmutableArray.empty(), version: Version.EMPTY }); } - public load(isReload = false): Observable { + public load(isReload = false): Observable { if (!isReload) { this.resetState(); } - return this.workflowsService.getWorkflow(this.appName).pipe( + return this.workflowsService.getWorkflows(this.appName).pipe( tap(({ version, payload }) => { if (isReload) { - this.dialogs.notifyInfo('Workflow reloaded.'); + this.dialogs.notifyInfo('Workflows reloaded.'); } - this.replaceWorkflow(payload, version); + this.replaceWorkflows(payload, version); }), - shareMapSubscribed(this.dialogs, x => x.payload.workflow)); + shareSubscribed(this.dialogs)); } - public save(workflow: WorkflowDto): Observable { - return this.workflowsService.putWorkflow(this.appName, workflow, workflow.serialize(), this.version).pipe( + public add(name: string): Observable { + return this.workflowsService.postWorkflow(this.appName, { name }, this.version).pipe( tap(({ version, payload }) => { - this.replaceWorkflow(payload, version); + this.replaceWorkflows(payload, version); + }), + shareSubscribed(this.dialogs)); + } + public update(workflow: WorkflowDto): Observable { + return this.workflowsService.putWorkflow(this.appName, workflow, workflow.serialize(), this.version).pipe( + tap(({ version, payload }) => { this.dialogs.notifyInfo('Workflow has been saved.'); + + this.replaceWorkflows(payload, version); }), shareSubscribed(this.dialogs)); } - private replaceWorkflow(payload: WorkflowPayload, version: Version) { - const { workflow } = payload; + public delete(workflow: WorkflowDto): Observable { + return this.workflowsService.deleteWorkflow(this.appName, workflow, this.version).pipe( + tap(({ version, payload }) => { + this.replaceWorkflows(payload, version); + }), + shareSubscribed(this.dialogs)); + } + + private replaceWorkflows(payload: WorkflowsPayload, version: Version) { + const { canCreate, errors, items } = payload; + + const workflows = ImmutableArray.of(items); this.next(s => { - return { ...s, workflow, isLoaded: true, version }; + return { ...s, workflows, errors, isLoaded: true, version, canCreate }; }); } diff --git a/src/Squidex/app/shell/pages/internal/apps-menu.component.html b/src/Squidex/app/shell/pages/internal/apps-menu.component.html index d78bbfb5e..7a3897564 100644 --- a/src/Squidex/app/shell/pages/internal/apps-menu.component.html +++ b/src/Squidex/app/shell/pages/internal/apps-menu.component.html @@ -29,7 +29,7 @@ diff --git a/src/Squidex/app/shell/pages/internal/apps-menu.component.ts b/src/Squidex/app/shell/pages/internal/apps-menu.component.ts index 22fbc20bb..18dc1d355 100644 --- a/src/Squidex/app/shell/pages/internal/apps-menu.component.ts +++ b/src/Squidex/app/shell/pages/internal/apps-menu.component.ts @@ -36,11 +36,6 @@ export class AppsMenuComponent { ) { } - public createApp() { - this.appsMenu.hide(); - this.addAppDialog.show(); - } - public trackByApp(index: number, app: AppDto) { return app.id; } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs index 9f95ac4b2..843c2e424 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs @@ -95,7 +95,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var clients_1 = clients_0.Revoke("2"); - Assert.NotSame(clients_0, clients_1); + Assert.NotEmpty(clients_1); } } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs index 56d615159..de159e090 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs @@ -70,7 +70,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var patterns_1 = patterns_0.Remove(id); - Assert.NotSame(patterns_0, patterns_1); + Assert.NotEmpty(patterns_1); } } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs index e8337ac4e..591708388 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var roles_1 = roles_0.Remove(role); - Assert.NotSame(roles_0, roles_1); + Assert.NotEmpty(roles_1); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs index 26c0fb5c5..7de67b1c2 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs @@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents public class WorkflowTests { private readonly Workflow workflow = new Workflow( - new Dictionary + Status.Draft, new Dictionary { [Status.Draft] = new WorkflowStep( @@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents new WorkflowStep(), [Status.Published] = new WorkflowStep() - }, Status.Draft); + }); [Fact] public void Should_provide_default_workflow_if_none_found() diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs index 37ce537c4..8a3c485e8 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs @@ -33,5 +33,61 @@ namespace Squidex.Domain.Apps.Core.Model.Contents Assert.Single(workflows_1); Assert.Same(Workflow.Default, workflows_1[Guid.Empty]); } + + [Fact] + public void Should_add_new_workflow_with_default_states() + { + var id = Guid.NewGuid(); + + var workflows_1 = workflows_0.Add(id, "1"); + + Assert.Equal(workflows_1[id].Steps.Keys, new[] { Status.Archived, Status.Draft, Status.Published }); + } + + [Fact] + public void Should_update_workflow() + { + var id = Guid.NewGuid(); + + var workflows_1 = workflows_0.Add(id, "1"); + var workflows_2 = workflows_1.Update(id, Workflow.Empty); + + Assert.Empty(workflows_2.GetFirst().Steps.Keys); + } + + [Fact] + public void Should_update_workflow_with_default_guid() + { + var workflows_1 = workflows_0.Update(Guid.Empty, Workflow.Empty); + + Assert.NotEmpty(workflows_1); + } + + [Fact] + public void Should_do_nothing_if_workflow_to_update_not_found() + { + var workflows_1 = workflows_0.Update(Guid.NewGuid(), Workflow.Empty); + + Assert.Same(workflows_0, workflows_1); + } + + [Fact] + public void Should_remove_workflow() + { + var id = Guid.NewGuid(); + + var workflows_1 = workflows_0.Add(id, "1"); + var workflows_2 = workflows_1.Remove(id); + + Assert.Empty(workflows_2); + } + + [Fact] + public void Should_do_nothing_if_workflow_to_remove_not_found() + { + var workflows_1 = workflows_0.Remove(Guid.NewGuid()); + + Assert.Empty(workflows_1); + } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs index ac892f78a..6d3ac1bb3 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs @@ -38,6 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Apps private readonly string planIdPaid = "premium"; private readonly string planIdFree = "free"; private readonly AppGrain sut; + private readonly Guid workflowId = Guid.NewGuid(); private readonly Guid patternId1 = Guid.NewGuid(); private readonly Guid patternId2 = Guid.NewGuid(); private readonly Guid patternId3 = Guid.NewGuid(); @@ -299,9 +300,9 @@ namespace Squidex.Domain.Apps.Entities.Apps } [Fact] - public async Task ConfigureWorkflow_should_create_events_and_update_state() + public async Task AddWorkflow_should_create_events_and_update_state() { - var command = new ConfigureWorkflow { Workflow = Workflow.Default }; + var command = new AddWorkflow { WorkflowId = workflowId, Name = "my-workflow" }; await ExecuteCreateAsync(); @@ -313,7 +314,47 @@ namespace Squidex.Domain.Apps.Entities.Apps LastEvents .ShouldHaveSameEvents( - CreateEvent(new AppWorkflowConfigured { Workflow = Workflow.Default }) + CreateEvent(new AppWorkflowAdded { WorkflowId = workflowId, Name = "my-workflow" }) + ); + } + + [Fact] + public async Task UpdateWorkflow_should_create_events_and_update_state() + { + var command = new UpdateWorkflow { WorkflowId = workflowId, Workflow = Workflow.Default }; + + await ExecuteCreateAsync(); + await ExecuteAddWorkflowAsync(); + + var result = await sut.ExecuteAsync(CreateCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.NotEmpty(sut.Snapshot.Workflows); + + LastEvents + .ShouldHaveSameEvents( + CreateEvent(new AppWorkflowUpdated { WorkflowId = workflowId, Workflow = Workflow.Default }) + ); + } + + [Fact] + public async Task DeleteWorkflow_should_create_events_and_update_state() + { + var command = new DeleteWorkflow { WorkflowId = workflowId }; + + await ExecuteCreateAsync(); + await ExecuteAddWorkflowAsync(); + + var result = await sut.ExecuteAsync(CreateCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Empty(sut.Snapshot.Workflows); + + LastEvents + .ShouldHaveSameEvents( + CreateEvent(new AppWorkflowDeleted { WorkflowId = workflowId }) ); } @@ -540,6 +581,11 @@ namespace Squidex.Domain.Apps.Entities.Apps return sut.ExecuteAsync(CreateCommand(new AddLanguage { Language = language })); } + private Task ExecuteAddWorkflowAsync() + { + return sut.ExecuteAsync(CreateCommand(new AddWorkflow { WorkflowId = workflowId, Name = "my-workflow" })); + } + private Task ExecuteChangePlanAsync() { return sut.ExecuteAsync(CreateCommand(new ChangePlan { PlanId = planIdPaid })); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs index 3bb1902c1..3431e88ce 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards } [Fact] - public void CanAdd_should_not_throw_exception_if_success() + public void CanAdd_should_not_throw_exception_if_command_is_valid() { var command = new AddPattern { PatternId = patternId, Name = "any", Pattern = ".*" }; @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards } [Fact] - public void CanDelete_should_not_throw_exception_if_success() + public void CanDelete_should_not_throw_exception_if_command_is_valid() { var patterns_1 = patterns_0.Add(patternId, "any", ".*", "Message"); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs index bd16881f7..c469ac3f4 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs @@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards } [Fact] - public void CanAdd_should_not_throw_exception_if_success() + public void CanAdd_should_not_throw_exception_if_command_is_valid() { var command = new AddRole { Name = roleName }; @@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards } [Fact] - public void CanDelete_should_not_throw_exception_if_success() + public void CanDelete_should_not_throw_exception_if_command_is_valid() { var roles_1 = roles_0.Add(roleName); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs index 99f2f32ca..5d2ebbb52 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps.Commands; @@ -16,90 +17,132 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { public class GuardAppWorkflowTests { + private readonly Guid workflowId = Guid.NewGuid(); + private readonly Workflows workflows; + + public GuardAppWorkflowTests() + { + workflows = Workflows.Empty.Add(workflowId, "name"); + } + + [Fact] + public void CanAdd_should_throw_exception_if_name_is_not_defined() + { + var command = new AddWorkflow(); + + ValidationAssert.Throws(() => GuardAppWorkflows.CanAdd(command), + new ValidationError("Name is required.", "Name")); + } + + [Fact] + public void CanAdd_should_not_throw_exception_if_command_is_valid() + { + var command = new AddWorkflow { Name = "my-workflow" }; + + GuardAppWorkflows.CanAdd(command); + } + [Fact] - public void CanConfigure_should_throw_exception_if_workflow_is_not_defined() + public void CanUpdate_should_throw_exception_if_workflow_not_found() { - var command = new ConfigureWorkflow(); + var command = new UpdateWorkflow + { + Workflow = Workflow.Empty, + WorkflowId = Guid.NewGuid() + }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + Assert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command)); + } + + [Fact] + public void CanUpdate_should_throw_exception_if_workflow_is_not_defined() + { + var command = new UpdateWorkflow { WorkflowId = workflowId }; + + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Workflow is required.", "Workflow")); } [Fact] - public void CanConfigure_should_throw_exception_if_workflow_has_no_initial_step() + public void CanUpdate_should_throw_exception_if_workflow_has_no_initial_step() { - var command = new ConfigureWorkflow + var command = new UpdateWorkflow { Workflow = new Workflow( + default, new Dictionary { [Status.Published] = new WorkflowStep() - }, - default) + }), + WorkflowId = workflowId }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Initial step is required.", "Workflow.Initial")); } [Fact] - public void CanConfigure_should_throw_exception_if_initial_step_is_published() + public void CanUpdate_should_throw_exception_if_initial_step_is_published() { - var command = new ConfigureWorkflow + var command = new UpdateWorkflow { Workflow = new Workflow( + Status.Published, new Dictionary { [Status.Published] = new WorkflowStep() - }, - Status.Published) + }), + WorkflowId = workflowId }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Initial step cannot be published step.", "Workflow.Initial")); } [Fact] - public void CanConfigure_should_throw_exception_if_workflow_does_not_have_published_state() + public void CanUpdate_should_throw_exception_if_workflow_does_not_have_published_state() { - var command = new ConfigureWorkflow + var command = new UpdateWorkflow { Workflow = new Workflow( + Status.Draft, new Dictionary { [Status.Draft] = new WorkflowStep() - }, - Status.Draft) + }), + WorkflowId = workflowId }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Workflow must have a published step.", "Workflow.Steps")); } [Fact] - public void CanConfigure_should_throw_exception_if_workflow_step_is_not_defined() + public void CanUpdate_should_throw_exception_if_workflow_step_is_not_defined() { - var command = new ConfigureWorkflow + var command = new UpdateWorkflow { Workflow = new Workflow( + Status.Draft, new Dictionary { [Status.Published] = null, [Status.Draft] = new WorkflowStep() - }, - Status.Draft) + }), + WorkflowId = workflowId }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Step is required.", "Workflow.Steps.Published")); } [Fact] - public void CanConfigure_should_throw_exception_if_workflow_transition_is_invalid() + public void CanUpdate_should_throw_exception_if_workflow_transition_is_invalid() { - var command = new ConfigureWorkflow + var command = new UpdateWorkflow { Workflow = new Workflow( + Status.Draft, new Dictionary { [Status.Published] = @@ -109,20 +152,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards [Status.Archived] = new WorkflowTransition() }), [Status.Draft] = new WorkflowStep() - }, - Status.Draft) + }), + WorkflowId = workflowId }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Transition has an invalid target.", "Workflow.Steps.Published.Transitions.Archived")); } [Fact] - public void CanConfigure_should_throw_exception_if_workflow_transition_is_not_defined() + public void CanUpdate_should_throw_exception_if_workflow_transition_is_not_defined() { - var command = new ConfigureWorkflow + var command = new UpdateWorkflow { Workflow = new Workflow( + Status.Draft, new Dictionary { [Status.Draft] = @@ -133,20 +177,36 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { [Status.Draft] = null }) - }, - Status.Draft) + }), + WorkflowId = workflowId }; - ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + ValidationAssert.Throws(() => GuardAppWorkflows.CanUpdate(workflows, command), new ValidationError("Transition is required.", "Workflow.Steps.Published.Transitions.Draft")); } [Fact] - public void CanConfigure_should_not_throw_exception_if_workflow_is_valid() + public void CanUpdate_should_not_throw_exception_if_workflow_is_valid() + { + var command = new UpdateWorkflow { Workflow = Workflow.Default, WorkflowId = workflowId }; + + GuardAppWorkflows.CanUpdate(workflows, command); + } + + [Fact] + public void CanDelete_should_throw_exception_if_workflow_not_found() + { + var command = new DeleteWorkflow { WorkflowId = Guid.NewGuid() }; + + Assert.Throws(() => GuardAppWorkflows.CanDelete(workflows, command)); + } + + [Fact] + public void CanDelete_should_not_throw_exception_if_workflow_is_found() { - var command = new ConfigureWorkflow { Workflow = Workflow.Default }; + var command = new DeleteWorkflow { WorkflowId = workflowId }; - GuardAppWorkflows.CanConfigure(command); + GuardAppWorkflows.CanDelete(workflows, command); } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs index 6145d14ba..a738581b2 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs @@ -16,6 +16,14 @@ namespace Squidex.Domain.Apps.Entities.Contents { private readonly DefaultContentWorkflow sut = new DefaultContentWorkflow(); + [Fact] + public async Task Should_always_allow_publish_on_create() + { + var result = await sut.CanPublishOnCreateAsync(null, null, null); + + Assert.True(result); + } + [Fact] public async Task Should_draft_as_initial_status() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs new file mode 100644 index 000000000..9a887a73b --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs @@ -0,0 +1,115 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class DefaultWorkflowsValidatorTests + { + private readonly IAppProvider appProvider = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); + private readonly DefaultWorkflowsValidator sut; + + public DefaultWorkflowsValidatorTests() + { + var schema = A.Fake(); + + A.CallTo(() => schema.Id).Returns(schemaId.Id); + A.CallTo(() => schema.SchemaDef).Returns(new Schema(schemaId.Name)); + + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A.Ignored, false)) + .Returns(Task.FromResult(null)); + + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + .Returns(schema); + + sut = new DefaultWorkflowsValidator(appProvider); + } + + [Fact] + public async Task Should_generate_error_if_multiple_workflows_cover_all_schemas() + { + var workflows = Workflows.Empty + .Add(Guid.NewGuid(), "workflow1") + .Add(Guid.NewGuid(), "workflow2"); + + var errors = await sut.ValidateAsync(appId.Id, workflows); + + Assert.Equal(errors, new string[] { "Multiple workflows cover all schemas." }); + } + + [Fact] + public async Task Should_generate_error_if_multiple_workflows_cover_specific_schema() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var workflows = Workflows.Empty + .Add(id1, "workflow1") + .Add(id2, "workflow2") + .Update(id1, new Workflow(default, Workflow.EmptySteps, new List { schemaId.Id })) + .Update(id2, new Workflow(default, Workflow.EmptySteps, new List { schemaId.Id })); + + var errors = await sut.ValidateAsync(appId.Id, workflows); + + Assert.Equal(errors, new string[] { "The schema `my-schema` is covered by multiple workflows." }); + } + + [Fact] + public async Task Should_not_generate_error_if_schema_deleted() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var oldSchemaId = Guid.NewGuid(); + + var workflows = Workflows.Empty + .Add(id1, "workflow1") + .Add(id2, "workflow2") + .Update(id1, new Workflow(default, Workflow.EmptySteps, new List { oldSchemaId })) + .Update(id2, new Workflow(default, Workflow.EmptySteps, new List { oldSchemaId })); + + var errors = await sut.ValidateAsync(appId.Id, workflows); + + Assert.Empty(errors); + } + + [Fact] + public async Task Should_not_generate_errors_for_no_overlaps() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var workflows = Workflows.Empty + .Add(id1, "workflow1") + .Add(id2, "workflow2") + .Update(id1, new Workflow(default, Workflow.EmptySteps, new List { schemaId.Id })); + + var errors = await sut.ValidateAsync(appId.Id, workflows); + + Assert.Empty(errors); + } + + [Fact] + public async Task Should_not_generate_errors_for_empty_workflows() + { + var errors = await sut.ValidateAsync(appId.Id, Workflows.Empty); + + Assert.Empty(errors); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs index 037fcb694..911c756ec 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs @@ -23,11 +23,14 @@ namespace Squidex.Domain.Apps.Entities.Contents public class DynamicContentWorkflowTests { private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); + private readonly NamedId simpleSchemaId = NamedId.Of(Guid.NewGuid(), "my-simple-schema"); private readonly IAppProvider appProvider = A.Fake(); private readonly IAppEntity appEntity = A.Fake(); private readonly DynamicContentWorkflow sut; private readonly Workflow workflow = new Workflow( + Status.Draft, new Dictionary { [Status.Archived] = @@ -53,16 +56,40 @@ namespace Squidex.Domain.Apps.Entities.Contents [Status.Draft] = new WorkflowTransition() }, StatusColors.Published) - }, - Status.Draft); + }); + + private readonly Workflow simpleWorkflow; public DynamicContentWorkflowTests() { + simpleWorkflow = new Workflow( + Status.Draft, + new Dictionary + { + [Status.Draft] = + new WorkflowStep( + new Dictionary + { + [Status.Published] = new WorkflowTransition() + }, + StatusColors.Draft), + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Published) + }, + new List { simpleSchemaId.Id }); + + var workflows = Workflows.Empty.Set(workflow).Set(Guid.NewGuid(), simpleWorkflow); + A.CallTo(() => appProvider.GetAppAsync(appId.Id)) .Returns(appEntity); A.CallTo(() => appEntity.Workflows) - .Returns(Workflows.Empty.Set(workflow)); + .Returns(workflows); sut = new DynamicContentWorkflow(new JintScriptEngine(), appProvider); } @@ -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(); 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) diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs index b7e827dc3..8f69ae486 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs @@ -27,130 +27,143 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard private readonly ClaimsPrincipal user = new ClaimsPrincipal(); private readonly Instant dueTimeInPast = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromHours(1)); - [Fact] - public void CanCreate_should_throw_exception_if_data_is_null() + public GuardContentTests() { SetupSingleton(false); + } + [Fact] + public async Task CanCreate_should_throw_exception_if_data_is_null() + { var command = new CreateContent(); - ValidationAssert.Throws(() => GuardContent.CanCreate(schema, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command), new ValidationError("Data is required.", "Data")); } [Fact] - public void CanCreate_should_throw_exception_if_singleton() + public async Task CanCreate_should_throw_exception_if_singleton() { SetupSingleton(true); var command = new CreateContent { Data = new NamedContentData() }; - Assert.Throws(() => GuardContent.CanCreate(schema, command)); + await Assert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command)); } [Fact] - public void CanCreate_should_not_throw_exception_if_singleton_and_id_is_schema_id() + public async Task CanCreate_should_not_throw_exception_if_singleton_and_id_is_schema_id() { SetupSingleton(true); var command = new CreateContent { Data = new NamedContentData(), ContentId = schema.Id }; - GuardContent.CanCreate(schema, command); + await GuardContent.CanCreate(schema, contentWorkflow, command); } [Fact] - public void CanCreate_should_not_throw_exception_if_data_is_not_null() + public async Task CanCreate_should_throw_exception_publish_not_allowed() { - SetupSingleton(false); + SetupCanCreatePublish(false); + var command = new CreateContent { Data = new NamedContentData(), Publish = true }; + + await Assert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command)); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_publishing_allowed() + { + SetupCanCreatePublish(true); + + var command = new CreateContent { Data = new NamedContentData(), Publish = true }; + + await Assert.ThrowsAsync(() => GuardContent.CanCreate(schema, contentWorkflow, command)); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_if_data_is_not_null() + { var command = new CreateContent { Data = new NamedContentData() }; - GuardContent.CanCreate(schema, command); + await GuardContent.CanCreate(schema, contentWorkflow, command); } [Fact] public async Task CanUpdate_should_throw_exception_if_data_is_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); var command = new UpdateContent(); - await ValidationAssert.ThrowsAsync(() => GuardContent.CanUpdate(content, contentWorkflow, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanUpdate(content, contentWorkflow, command, false), new ValidationError("Data is required.", "Data")); } [Fact] public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it() { - SetupSingleton(false); SetupCanUpdate(false); var content = CreateContent(Status.Draft, false); var command = new UpdateContent { Data = new NamedContentData() }; - await Assert.ThrowsAsync(() => GuardContent.CanUpdate(content, contentWorkflow, command)); + await Assert.ThrowsAsync(() => GuardContent.CanUpdate(content, contentWorkflow, command, false)); } [Fact] public async Task CanUpdate_should_not_throw_exception_if_data_is_not_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); var command = new UpdateContent { Data = new NamedContentData() }; - await GuardContent.CanUpdate(content, contentWorkflow, command); + await GuardContent.CanUpdate(content, contentWorkflow, command, false); } [Fact] public async Task CanPatch_should_throw_exception_if_data_is_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); var command = new PatchContent(); - await ValidationAssert.ThrowsAsync(() => GuardContent.CanPatch(content, contentWorkflow, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanPatch(content, contentWorkflow, command, false), new ValidationError("Data is required.", "Data")); } [Fact] public async Task CanPatch_should_throw_exception_if_workflow_blocks_it() { - SetupSingleton(false); SetupCanUpdate(false); var content = CreateContent(Status.Draft, false); var command = new PatchContent { Data = new NamedContentData() }; - await Assert.ThrowsAsync(() => GuardContent.CanPatch(content, contentWorkflow, command)); + await Assert.ThrowsAsync(() => GuardContent.CanPatch(content, contentWorkflow, command, false)); } [Fact] public async Task CanPatch_should_not_throw_exception_if_data_is_not_null() { - SetupSingleton(false); SetupCanUpdate(true); var content = CreateContent(Status.Draft, false); var command = new PatchContent { Data = new NamedContentData() }; - await GuardContent.CanPatch(content, contentWorkflow, command); + await GuardContent.CanPatch(content, contentWorkflow, command, false); } [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 }; - await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command, true), new ValidationError("Content has no changes to publish.", "Status")); } @@ -162,7 +175,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard var content = CreateContent(Status.Published, false); var command = new ChangeContentStatus { Status = Status.Draft }; - await Assert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command)); + await Assert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command, false)); } [Fact] @@ -173,51 +186,45 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard var content = CreateContent(Status.Published, true); var command = new ChangeContentStatus { Status = Status.Published }; - await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command); + await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command, true); } [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 }; A.CallTo(() => contentWorkflow.CanMoveToAsync(content, command.Status, user)) .Returns(true); - await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command, false), new ValidationError("Due time must be in the future.", "DueTime")); } [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 }; A.CallTo(() => contentWorkflow.CanMoveToAsync(content, command.Status, user)) .Returns(false); - await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command), + await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command, false), new ValidationError("Cannot change status from Draft to Published.", "Status")); } [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 }; A.CallTo(() => contentWorkflow.CanMoveToAsync(content, command.Status, user)) .Returns(true); - await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command); + await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command, false); } [Fact] @@ -231,8 +238,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public void CanDiscardChanges_should_not_throw_exception_if_pending() { - SetupSingleton(false); - var command = new DiscardChanges(); GuardContent.CanDiscardChanges(true, command); @@ -251,8 +256,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard [Fact] public void CanDelete_should_not_throw_exception() { - SetupSingleton(false); - var command = new DeleteContent(); GuardContent.CanDelete(schema, command); @@ -264,6 +267,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard .Returns(canUpdate); } + private void SetupCanCreatePublish(bool canCreate) + { + A.CallTo(() => contentWorkflow.CanPublishOnCreateAsync(schema, A.Ignored, user)) + .Returns(canCreate); + } + private void SetupSingleton(bool isSingleton) { A.CallTo(() => schema.SchemaDef) diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs index 5f449249f..aac32efb0 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs @@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Tags [Fact] public async Task Should_call_grain_when_rebuilding() { - var tags = new TagSet(); + var tags = new TagsExport(); await sut.RebuildTagsAsync(appId, TagGroups.Assets, tags); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs index 9642dd244..562ceb805 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs @@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Tags [Fact] public async Task Should_rebuild_tags() { - var tags = new TagSet + var tags = new TagsExport { ["id1"] = new Tag { Name = "name1", Count = 1 }, ["id2"] = new Tag { Name = "name2", Count = 2 }, diff --git a/tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyDictionaryTests.cs b/tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyCollectionTests.cs similarity index 51% rename from tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyDictionaryTests.cs rename to tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyCollectionTests.cs index 2d3ed2869..5e2b2a449 100644 --- a/tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyDictionaryTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyCollectionTests.cs @@ -11,17 +11,17 @@ using Xunit; namespace Squidex.Infrastructure.Json.Newtonsoft { - public class ReadOnlyDictionaryTests + public class ReadOnlyCollectionTests { - public sealed class MyClass + public sealed class MyClass { - public IReadOnlyDictionary Values { get; set; } + public T Values { get; set; } } [Fact] - public void Should_serialize_and_deserialize_without_type_name() + public void Should_serialize_and_deserialize_dictionary_without_type_name() { - var source = new MyClass + var source = new MyClass> { Values = new Dictionary { @@ -37,7 +37,32 @@ namespace Squidex.Infrastructure.Json.Newtonsoft var json = JsonConvert.SerializeObject(source, serializerSettings); - var serialized = JsonConvert.DeserializeObject(json); + var serialized = JsonConvert.DeserializeObject>>(json); + + Assert.DoesNotContain("$type", json); + Assert.Equal(2, serialized.Values.Count); + } + + [Fact] + public void Should_serialize_and_deserialize_list_without_type_name() + { + var source = new MyClass> + { + Values = new List + { + 2, + 3 + } + }; + + var serializerSettings = new JsonSerializerSettings + { + ContractResolver = new ConverterContractResolver() + }; + + var json = JsonConvert.SerializeObject(source, serializerSettings); + + var serialized = JsonConvert.DeserializeObject>>(json); Assert.DoesNotContain("$type", json); Assert.Equal(2, serialized.Values.Count); diff --git a/tools/Migrate_01/OldEvents/AppWorkflowConfigured.cs b/tools/Migrate_01/OldEvents/AppWorkflowConfigured.cs new file mode 100644 index 000000000..df6da26bf --- /dev/null +++ b/tools/Migrate_01/OldEvents/AppWorkflowConfigured.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; + +namespace Migrate_01.OldEvents +{ + [EventType(nameof(AppWorkflowConfigured))] + [Obsolete] + public sealed class AppWorkflowConfigured : AppEvent, IMigrated + { + public Workflow Workflow { get; set; } + + public IEvent Migrate() + { + return SimpleMapper.Map(this, new AppWorkflowUpdated()); + } + } +}