diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs index fd670011f..518ecd646 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs @@ -21,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Apps { Guard.NotNullOrEmpty(secret, nameof(secret)); Guard.NotNullOrEmpty(role, nameof(role)); - + Role = role; Secret = secret; diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs index 15ed0a51c..864961903 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.Apps : base(name) { Guard.NotNullOrEmpty(pattern, nameof(pattern)); - + Pattern = pattern; Message = message; diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs index 51b23d192..2a7be22e2 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs @@ -5,12 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Newtonsoft.Json; -using Squidex.Infrastructure.Json.Newtonsoft; -using Squidex.Infrastructure.Security; using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; +using Squidex.Infrastructure.Security; namespace Squidex.Domain.Apps.Core.Apps.Json { diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs index 0e2770682..22f6f1b73 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs @@ -5,11 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; -using Squidex.Infrastructure.Security; using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; using P = Squidex.Shared.Permissions; namespace Squidex.Domain.Apps.Core.Apps @@ -71,7 +71,8 @@ namespace Squidex.Domain.Apps.Core.Apps return new Role(Editor, P.ForApp(P.AppAssets, app), P.ForApp(P.AppCommon, app), - P.ForApp(P.AppContents, app)); + P.ForApp(P.AppContents, app), + P.ForApp(P.AppWorkflowsRead, app)); } public static Role CreateReader(string app) @@ -90,6 +91,7 @@ namespace Squidex.Domain.Apps.Core.Apps P.ForApp(P.AppCommon, app), P.ForApp(P.AppContents, app), P.ForApp(P.AppPatterns, app), + P.ForApp(P.AppWorkflows, app), P.ForApp(P.AppRules, app), P.ForApp(P.AppSchemas, app)); } diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs index 58e7c5bfa..4e3e1d066 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs @@ -5,12 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; -using Squidex.Infrastructure.Collections; using System; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Linq; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; namespace Squidex.Domain.Apps.Core.Apps { diff --git a/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs b/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs index a9b2bb7cb..61a90f23c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using NodaTime; using Squidex.Infrastructure; -using System; namespace Squidex.Domain.Apps.Core.Comments { diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs index a56722c55..286b83d12 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs @@ -5,10 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Newtonsoft.Json; -using Squidex.Infrastructure.Json.Newtonsoft; using System; using System.Collections.Generic; +using Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; namespace Squidex.Domain.Apps.Core.Contents.Json { diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs new file mode 100644 index 000000000..84e8092ee --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// 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 Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; + +namespace Squidex.Domain.Apps.Core.Contents.Json +{ + public sealed class WorkflowConverter : JsonClassConverter + { + protected override void WriteValue(JsonWriter writer, Workflows value, JsonSerializer serializer) + { + var json = new Dictionary(value.Count); + + foreach (var workflow in value) + { + json.Add(workflow.Key, workflow.Value); + } + + serializer.Serialize(writer, json); + } + + protected override Workflows ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer) + { + var json = serializer.Deserialize>(reader); + + return new Workflows(json.ToArray()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs index d4c0374c8..32026fc44 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs @@ -5,11 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System; +using System.ComponentModel; namespace Squidex.Domain.Apps.Core.Contents { + [TypeConverter(typeof(StatusConverter))] public struct Status : IEquatable { public static readonly Status Archived = new Status("Archived"); @@ -45,7 +46,7 @@ namespace Squidex.Domain.Apps.Core.Contents public override string ToString() { - return name; + return Name; } public static bool operator ==(Status lhs, Status rhs) diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs new file mode 100644 index 000000000..0e64ea00b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Contents +{ + public static class StatusColors + { + public const string Archived = "#eb3142"; + public const string Draft = "#8091a5"; + public const string Published = "#4bb958"; + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs new file mode 100644 index 000000000..a7ba559c7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class StatusConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) + { + return destinationType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return new Status(value?.ToString()); + } + + public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) + { + return value.ToString(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs new file mode 100644 index 000000000..a444badaa --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class StatusInfo + { + public Status Status { get; } + + public string Color { get; } + + public StatusInfo(Status status, string color) + { + Status = status; + + Color = color; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs new file mode 100644 index 000000000..859e546ef --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -0,0 +1,88 @@ +// ========================================================================== +// 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.Contents +{ + public sealed class Workflow + { + private static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); + + 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 Status Initial { get; } + + public Workflow(IReadOnlyDictionary steps, Status initial) + { + Steps = steps ?? EmptySteps; + + Initial = initial; + } + + public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) + { + if (TryGetStep(status, out var step)) + { + foreach (var transition in step.Transitions) + { + yield return (transition.Key, Steps[transition.Key], transition.Value); + } + } + } + + public bool TryGetTransition(Status from, Status to, out WorkflowTransition transition) + { + if (TryGetStep(from, out var step) && step.Transitions.TryGetValue(to, out transition)) + { + return true; + } + + transition = null; + + return false; + } + + public bool TryGetStep(Status status, out WorkflowStep step) + { + return Steps.TryGetValue(status, out step); + } + + public (Status Key, WorkflowStep) GetInitialStep() + { + return (Initial, Steps[Initial]); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs new file mode 100644 index 000000000..04eb595c5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class WorkflowStep + { + private static readonly IReadOnlyDictionary EmptyTransitions = new Dictionary(); + + public IReadOnlyDictionary Transitions { get; } + + public string Color { get; } + + public bool NoUpdate { get; } + + public WorkflowStep(IReadOnlyDictionary transitions = null, string color = null, bool noUpdate = false) + { + Transitions = transitions ?? EmptyTransitions; + + Color = color; + + NoUpdate = noUpdate; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs new file mode 100644 index 000000000..5beed9a62 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class WorkflowTransition + { + public string Expression { get; } + + public string Role { get; } + + public WorkflowTransition(string expression = null, string role = null) + { + Expression = expression; + + Role = role; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs new file mode 100644 index 000000000..d027b8d32 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs @@ -0,0 +1,43 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class Workflows : ArrayDictionary + { + public static readonly Workflows Empty = new Workflows(); + + private Workflows() + { + } + + public Workflows(KeyValuePair[] items) + : base(items) + { + } + + [Pure] + public Workflows Set(Workflow workflow) + { + Guard.NotNull(workflow, nameof(workflow)); + + return new Workflows(With(Guid.Empty, workflow)); + } + + public Workflow GetFirst() + { + return Values.FirstOrDefault() ?? Workflow.Default; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs index ed1d9b033..d9c958390 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs @@ -5,8 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System.Collections.ObjectModel; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Rules.Triggers { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs index 99d62bd4e..77cf55f72 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs @@ -5,10 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs index f07114303..4450ef2d1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public sealed class FieldCollection : Cloneable> where T : IField { public static readonly FieldCollection Empty = new FieldCollection(); - + private static readonly Dictionary EmptyById = new Dictionary(); private static readonly Dictionary EmptyByString = new Dictionary(); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs index 88a11c699..76ba5da7d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System.Collections.Generic; using System.Linq; +using Squidex.Infrastructure; using NamedIdStatic = Squidex.Infrastructure.NamedId; namespace Squidex.Domain.Apps.Core.Schemas 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 78e3d3f81..729e6ab0c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Newtonsoft.Json; using Squidex.Infrastructure; -using System; using P = Squidex.Domain.Apps.Core.Partitioning; namespace Squidex.Domain.Apps.Core.Schemas.Json diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs index b7dfb78d4..5dc24c564 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== - namespace Squidex.Domain.Apps.Core.Schemas { public sealed class JsonFieldProperties : FieldProperties diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs index b1d672216..23989053a 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs @@ -5,8 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { diff --git a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index 7a88572ce..f5b9cd862 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -14,6 +14,8 @@ runtime; build; native; contentfiles; analyzers + + diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 6444ba638..b7ccaf0ed 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } - public async Task> QueryByHashAsync(Guid appId, string hash) + public async Task> QueryByHashAsync(Guid appId, string hash) { using (Profiler.TraceMethod()) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index aa24583e6..51e7ca5a9 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents if (fullTextIds?.Count == 0) { - return ResultList.Create(0); + return ResultList.CreateFrom(0); } return await contents.QueryAsync(schema, query, fullTextIds, status, inDraft, includeDraft); diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index a227d68bf..82507f4d9 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -65,6 +65,17 @@ namespace Squidex.Domain.Apps.Entities }); } + public Task GetAppAsync(Guid appId) + { + return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () => + { + using (Profiler.TraceMethod()) + { + return await GetAppByIdAsync(appId); + } + }); + } + public Task GetAppAsync(string appName) { return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => @@ -78,14 +89,7 @@ namespace Squidex.Domain.Apps.Entities return null; } - var app = await grainFactory.GetGrain(appId).GetStateAsync(); - - if (!IsExisting(app)) - { - return null; - } - - return app.Value; + return await GetAppByIdAsync(appId); } }); } @@ -184,6 +188,18 @@ namespace Squidex.Domain.Apps.Entities }); } + private async Task GetAppByIdAsync(Guid appId) + { + var app = await grainFactory.GetGrain(appId).GetStateAsync(); + + if (!IsExisting(app)) + { + return null; + } + + return app.Value; + } + private async Task> GetAppIdsByUserAsync(string userId) { using (Profiler.TraceMethod()) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index eceb7bfa5..e17c17290 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -119,6 +119,16 @@ namespace Squidex.Domain.Apps.Entities.Apps return Snapshot; }); + case ConfigureWorkflow configureWorkflow: + return UpdateReturn(configureWorkflow, c => + { + GuardAppWorkflows.CanConfigure(c); + + ConfigureWorkflow(c); + + return Snapshot; + }); + case AddLanguage addLanguage: return UpdateReturn(addLanguage, c => { @@ -319,6 +329,11 @@ namespace Squidex.Domain.Apps.Entities.Apps RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); } + public void ConfigureWorkflow(ConfigureWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured())); + } + public void AddLanguage(AddLanguage command) { RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded())); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index 9b79da894..901eb1e68 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -19,15 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Apps public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry) : base(typeNameRegistry) { - AddEventMessage("AppContributorAssignedEvent", - "assigned {user:[Contributor]} as {[Role]}"); - - AddEventMessage("AppClientUpdatedEvent", - "updated client {[Id]}"); - - AddEventMessage("AppPlanChanged", - "changed plan to {[Plan]}"); - AddEventMessage( "assigned {user:[Contributor]} as {[Role]}"); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs new file mode 100644 index 000000000..efa2b503b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class ConfigureWorkflow : AppCommand + { + 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 new file mode 100644 index 000000000..1e675ac8e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs @@ -0,0 +1,76 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardAppWorkflows + { + public static void CanConfigure(ConfigureWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot configure workflow.", e => + { + if (command.Workflow == null) + { + e(Not.Defined("Workflow"), nameof(command.Workflow)); + return; + } + + var workflow = command.Workflow; + + if (!workflow.Steps.ContainsKey(workflow.Initial)) + { + e(Not.Defined("Initial step"), $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); + } + + if (workflow.Initial == Status.Published) + { + e("Initial step cannot be published step.", $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); + } + + var stepsPrefix = $"{nameof(command.Workflow)}.{nameof(workflow.Steps)}"; + + if (!workflow.Steps.ContainsKey(Status.Published)) + { + e("Workflow must have a published step.", stepsPrefix); + } + + foreach (var step in workflow.Steps) + { + var stepPrefix = $"{stepsPrefix}.{step.Key}"; + + if (step.Value == null) + { + e(Not.Defined("Step"), stepPrefix); + } + else + { + foreach (var transition in step.Value.Transitions) + { + var transitionPrefix = $"{stepPrefix}.{nameof(step.Value.Transitions)}.{transition.Key}"; + + if (!workflow.Steps.ContainsKey(transition.Key)) + { + e("Transition has an invalid target.", transitionPrefix); + } + + if (transition.Value == null) + { + e(Not.Defined("Transition"), transitionPrefix); + } + } + } + } + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs index bf81d2612..a41e3368f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Entities.Apps { @@ -29,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Apps LanguagesConfig LanguagesConfig { get; } + Workflows Workflows { get; } + bool IsArchived { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 4f31dbdc0..ac71df0bc 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -7,6 +7,7 @@ using System.Runtime.Serialization; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Apps; using Squidex.Infrastructure.Dispatching; @@ -42,6 +43,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.State [DataMember] public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English; + [DataMember] + public Workflows Workflows { get; set; } = Workflows.Empty; + [DataMember] public bool IsArchived { get; set; } @@ -92,6 +96,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.State Clients = Clients.Revoke(@event.Id); } + protected void On(AppWorkflowConfigured @event) + { + Workflows = Workflows.Set(@event.Workflow); + } + protected void On(AppPatternAdded @event) { Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index e45e02645..fe8da3714 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Security.Cryptography; using System.Threading.Tasks; using Orleans; -using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Tags; using Squidex.Infrastructure; @@ -22,31 +21,31 @@ namespace Squidex.Domain.Apps.Entities.Assets public sealed class AssetCommandMiddleware : GrainCommandMiddleware { private readonly IAssetStore assetStore; + private readonly IAssetEnricher assetEnricher; private readonly IAssetQueryService assetQuery; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IEnumerable> tagGenerators; - private readonly ITagService tagService; public AssetCommandMiddleware( IGrainFactory grainFactory, + IAssetEnricher assetEnricher, IAssetQueryService assetQuery, IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator, - IEnumerable> tagGenerators, - ITagService tagService) + IEnumerable> tagGenerators) : base(grainFactory) { + Guard.NotNull(assetEnricher, nameof(assetEnricher)); Guard.NotNull(assetStore, nameof(assetStore)); Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); Guard.NotNull(tagGenerators, nameof(tagGenerators)); - Guard.NotNull(tagService, nameof(tagService)); this.assetStore = assetStore; + this.assetEnricher = assetEnricher; this.assetQuery = assetQuery; this.assetThumbnailGenerator = assetThumbnailGenerator; this.tagGenerators = tagGenerators; - this.tagService = tagService; } public override async Task HandleAsync(CommandContext context, Func next) @@ -67,35 +66,30 @@ namespace Squidex.Domain.Apps.Entities.Assets { var existings = await assetQuery.QueryByHashAsync(createAsset.AppId.Id, createAsset.FileHash); - AssetCreatedResult result = null; - foreach (var existing in existings) { if (IsDuplicate(createAsset, existing)) { - var denormalizedTags = await tagService.DenormalizeTagsAsync(createAsset.AppId.Id, TagGroups.Assets, existing.Tags); + var result = new AssetCreatedResult(existing, true); - result = new AssetCreatedResult(existing, true, new HashSet(denormalizedTags.Values)); + context.Complete(result); + await next(); + return; } - - break; } - if (result == null) + foreach (var tagGenerator in tagGenerators) { - foreach (var tagGenerator in tagGenerators) - { - tagGenerator.GenerateTags(createAsset, createAsset.Tags); - } + tagGenerator.GenerateTags(createAsset, createAsset.Tags); + } - var asset = (IAssetEntity)await ExecuteCommandAsync(createAsset); + await HandleCoreAsync(context, next); - result = new AssetCreatedResult(asset, false, createAsset.Tags); + var asset = context.PlainResult as IEnrichedAssetEntity; - await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null); - } + context.Complete(new AssetCreatedResult(asset, false)); - context.Complete(result); + await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null); } finally { @@ -112,11 +106,11 @@ namespace Squidex.Domain.Apps.Entities.Assets try { - var result = (AssetResult)await ExecuteAndAdjustTagsAsync(updateAsset); + await HandleCoreAsync(context, next); - context.Complete(result); + var asset = context.PlainResult as IAssetEntity; - await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.Asset.FileVersion, null); + await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), asset.FileVersion, null); } finally { @@ -126,34 +120,23 @@ namespace Squidex.Domain.Apps.Entities.Assets break; } - case AssetCommand command: - { - var result = await ExecuteAndAdjustTagsAsync(command); - - context.Complete(result); - - break; - } - default: - await base.HandleAsync(context, next); + await HandleCoreAsync(context, next); break; } } - private async Task ExecuteAndAdjustTagsAsync(AssetCommand command) + private async Task HandleCoreAsync(CommandContext context, Func next) { - var result = await ExecuteCommandAsync(command); + await base.HandleAsync(context, next); - if (result is IAssetEntity asset) + if (context.PlainResult is IAssetEntity asset && !(context.PlainResult is IEnrichedAssetEntity)) { - var denormalizedTags = await tagService.DenormalizeTagsAsync(asset.AppId.Id, TagGroups.Assets, asset.Tags); + var enriched = await assetEnricher.EnrichAsync(asset); - return new AssetResult(asset, new HashSet(denormalizedTags.Values)); + context.Complete(enriched); } - - return result; } private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs index 9ccc00763..aa932bf36 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs @@ -5,17 +5,18 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; - namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class AssetCreatedResult : AssetResult + public sealed class AssetCreatedResult { + public IEnrichedAssetEntity Asset { get; } + public bool IsDuplicate { get; } - public AssetCreatedResult(IAssetEntity asset, bool isDuplicate, HashSet tags) - : base(asset, tags) + public AssetCreatedResult(IEnrichedAssetEntity asset, bool isDuplicate) { + Asset = asset; + IsDuplicate = isDuplicate; } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs new file mode 100644 index 000000000..3a1b802e8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetEnricher : IAssetEnricher + { + private readonly ITagService tagService; + + public AssetEnricher(ITagService tagService) + { + Guard.NotNull(tagService, nameof(tagService)); + + this.tagService = tagService; + } + + public async Task EnrichAsync(IAssetEntity asset) + { + Guard.NotNull(asset, nameof(asset)); + + var enriched = await EnrichAsync(Enumerable.Repeat(asset, 1)); + + return enriched[0]; + } + + public async Task> EnrichAsync(IEnumerable assets) + { + Guard.NotNull(assets, nameof(assets)); + + using (Profiler.TraceMethod()) + { + var results = new List(); + + foreach (var group in assets.GroupBy(x => x.AppId.Id)) + { + var tagsById = await CalculateTags(group); + + foreach (var asset in group) + { + var result = SimpleMapper.Map(asset, new AssetEntity()); + + result.TagNames = new HashSet(); + + if (asset.Tags != null) + { + foreach (var id in asset.Tags) + { + if (tagsById.TryGetValue(id, out var name)) + { + result.TagNames.Add(name); + } + } + } + + results.Add(result); + } + } + + return results; + } + } + + private async Task> CalculateTags(IGrouping group) + { + var uniqueIds = group.Where(x => x.Tags != null).SelectMany(x => x.Tags).ToHashSet(); + + return await tagService.DenormalizeTagsAsync(group.Key, TagGroups.Assets, uniqueIds); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs similarity index 86% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs rename to src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs index 2daf028de..150e53b78 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs @@ -1,19 +1,18 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System; using System.Collections.Generic; using NodaTime; -using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.Contents.TestData +namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class FakeAssetEntity : IAssetEntity + public sealed class AssetEntity : IEnrichedAssetEntity { public NamedId AppId { get; set; } @@ -31,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData public HashSet Tags { get; set; } + public HashSet TagNames { get; set; } + public long Version { get; set; } public string MimeType { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs index 833920248..29d6b28e9 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Microsoft.OData; @@ -21,9 +20,10 @@ using Squidex.Infrastructure.Queries.OData; namespace Squidex.Domain.Apps.Entities.Assets { - public class AssetQueryService : IAssetQueryService + public sealed class AssetQueryService : IAssetQueryService { private readonly ITagService tagService; + private readonly IAssetEnricher assetEnricher; private readonly IAssetRepository assetRepository; private readonly AssetOptions options; @@ -32,76 +32,82 @@ namespace Squidex.Domain.Apps.Entities.Assets get { return options.DefaultPageSizeGraphQl; } } - public AssetQueryService(ITagService tagService, IAssetRepository assetRepository, IOptions options) + public AssetQueryService( + ITagService tagService, + IAssetEnricher assetEnricher, + IAssetRepository assetRepository, + IOptions options) { Guard.NotNull(tagService, nameof(tagService)); - Guard.NotNull(options, nameof(options)); + Guard.NotNull(assetEnricher, nameof(assetEnricher)); Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(options, nameof(options)); + this.tagService = tagService; + this.assetEnricher = assetEnricher; this.assetRepository = assetRepository; this.options = options.Value; - this.tagService = tagService; - } - - public Task FindAssetAsync(QueryContext context, Guid id) - { - Guard.NotNull(context, nameof(context)); - - return FindAssetAsync(context.App.Id, id); } - public async Task FindAssetAsync(Guid appId, Guid id) + public async Task FindAssetAsync( Guid id) { var asset = await assetRepository.FindAssetAsync(id); if (asset != null) { - await DenormalizeTagsAsync(appId, Enumerable.Repeat(asset, 1)); + return await assetEnricher.EnrichAsync(asset); } - return asset; + return null; } - public async Task> QueryByHashAsync(Guid appId, string hash) + public async Task> QueryByHashAsync(Guid appId, string hash) { Guard.NotNull(hash, nameof(hash)); var assets = await assetRepository.QueryByHashAsync(appId, hash); - await DenormalizeTagsAsync(appId, assets); - - return assets; + return await assetEnricher.EnrichAsync(assets); } - public async Task> QueryAsync(QueryContext context, Q query) + public async Task> QueryAsync(QueryContext context, Q query) { Guard.NotNull(context, nameof(context)); Guard.NotNull(query, nameof(query)); IResultList assets; - if (query.Ids != null) + if (query.Ids != null && query.Ids.Count > 0) { - assets = await assetRepository.QueryAsync(context.App.Id, new HashSet(query.Ids)); - assets = Sort(assets, query.Ids); + assets = await QueryByIdsAsync(context, query); } else { - var parsedQuery = ParseQuery(context, query.ODataQuery); - - assets = await assetRepository.QueryAsync(context.App.Id, parsedQuery); + assets = await QueryByQueryAsync(context, query); } - await DenormalizeTagsAsync(context.App.Id, assets); + var enriched = await assetEnricher.EnrichAsync(assets); - return assets; + return ResultList.Create(assets.Total, enriched); } - private static IResultList Sort(IResultList assets, IReadOnlyList ids) + private async Task> QueryByQueryAsync(QueryContext context, Q query) + { + var parsedQuery = ParseQuery(context, query.ODataQuery); + + return await assetRepository.QueryAsync(context.App.Id, parsedQuery); + } + + private async Task> QueryByIdsAsync(QueryContext context, Q query) { - var sorted = ids.Select(id => assets.FirstOrDefault(x => x.Id == id)).Where(x => x != null); + var assets = await assetRepository.QueryAsync(context.App.Id, new HashSet(query.Ids)); - return ResultList.Create(assets.Total, sorted); + return Sort(assets, query.Ids); + } + + private static IResultList Sort(IResultList assets, IReadOnlyList ids) + { + return assets.SortSet(x => x.Id, ids); } private Query ParseQuery(QueryContext context, string query) @@ -140,34 +146,5 @@ namespace Squidex.Domain.Apps.Entities.Assets throw new ValidationException($"Failed to parse query: {ex.Message}", ex); } } - - private async Task DenormalizeTagsAsync(Guid appId, IEnumerable assets) - { - var tags = new HashSet(assets.Where(x => x.Tags != null).SelectMany(x => x.Tags).Distinct()); - - var tagsById = await tagService.DenormalizeTagsAsync(appId, TagGroups.Assets, tags); - - foreach (var asset in assets) - { - if (asset.Tags?.Count > 0) - { - var tagNames = asset.Tags.ToList(); - - asset.Tags.Clear(); - - foreach (var id in tagNames) - { - if (tagsById.TryGetValue(id, out var name)) - { - asset.Tags.Add(name); - } - } - } - else - { - asset.Tags?.Clear(); - } - } - } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs new file mode 100644 index 000000000..1807af316 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetEnricher + { + Task EnrichAsync(IAssetEntity asset); + + Task> EnrichAsync(IEnumerable assets); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index 501d690a9..a186e376c 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -16,10 +16,10 @@ namespace Squidex.Domain.Apps.Entities.Assets { int DefaultPageSizeGraphQl { get; } - Task> QueryByHashAsync(Guid appId, string hash); + Task> QueryByHashAsync(Guid appId, string hash); - Task> QueryAsync(QueryContext contex, Q query); + Task> QueryAsync(QueryContext contex, Q query); - Task FindAssetAsync(QueryContext context, Guid id); + Task FindAssetAsync(Guid id); } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs similarity index 64% rename from src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs rename to src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs index b43713da5..eab0cde16 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs @@ -9,17 +9,8 @@ using System.Collections.Generic; namespace Squidex.Domain.Apps.Entities.Assets { - public class AssetResult + public interface IEnrichedAssetEntity : IAssetEntity { - public IAssetEntity Asset { get; } - - public HashSet Tags { get; } - - public AssetResult(IAssetEntity asset, HashSet tags) - { - Asset = asset; - - Tags = tags; - } + HashSet TagNames { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index 12de8c72a..533ce993f 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { - Task> QueryByHashAsync(Guid appId, string hash); + Task> QueryByHashAsync(Guid appId, string hash); Task> QueryAsync(Guid appId, Query query); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs new file mode 100644 index 000000000..d72e3eee1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentCommandMiddleware : GrainCommandMiddleware + { + private readonly IContentEnricher contentEnricher; + + public ContentCommandMiddleware(IGrainFactory grainFactory, IContentEnricher contentEnricher) + : base(grainFactory) + { + Guard.NotNull(contentEnricher, nameof(contentEnricher)); + + this.contentEnricher = contentEnricher; + } + + public override async Task HandleAsync(CommandContext context, Func next) + { + await base.HandleAsync(context, next); + + if (context.PlainResult is IContentEntity content && !(context.PlainResult is IEnrichedContentEntity)) + { + var enriched = await contentEnricher.EnrichAsync(content); + + context.Complete(enriched); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs new file mode 100644 index 000000000..d744488e9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs @@ -0,0 +1,96 @@ +// ========================================================================== +// 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.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentEnricher : IContentEnricher + { + private const string DefaultColor = StatusColors.Draft; + private readonly IContentWorkflow contentWorkflow; + + public ContentEnricher(IContentWorkflow contentWorkflow) + { + this.contentWorkflow = contentWorkflow; + } + + public async Task EnrichAsync(IContentEntity content) + { + Guard.NotNull(content, nameof(content)); + + var enriched = await EnrichAsync(Enumerable.Repeat(content, 1)); + + return enriched[0]; + } + + public async Task> EnrichAsync(IEnumerable contents) + { + Guard.NotNull(contents, nameof(contents)); + + using (Profiler.TraceMethod()) + { + var results = new List(); + + var cache = new Dictionary<(Guid, Status), StatusInfo>(); + + foreach (var content in contents) + { + var result = SimpleMapper.Map(content, new ContentEntity()); + + await ResolveColorAsync(content, result, cache); + await ResolveNextsAsync(content, result); + await ResolveCanUpdateAsync(content, result); + + results.Add(result); + } + + return results; + } + } + + private async Task ResolveCanUpdateAsync(IContentEntity content, ContentEntity result) + { + result.CanUpdate = await contentWorkflow.CanUpdateAsync(content); + } + + private async Task ResolveNextsAsync(IContentEntity content, ContentEntity result) + { + result.Nexts = await contentWorkflow.GetNextsAsync(content, ClaimsPrincipal.Current); + } + + private async Task ResolveColorAsync(IContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache) + { + result.StatusColor = await GetColorAsync(content, cache); + } + + private async Task GetColorAsync(IContentEntity content, Dictionary<(Guid, Status), StatusInfo> cache) + { + if (!cache.TryGetValue((content.SchemaId.Id, content.Status), out var info)) + { + info = await contentWorkflow.GetInfoAsync(content); + + if (info == null) + { + info = new StatusInfo(content.Status, DefaultColor); + } + + cache[(content.SchemaId.Id, content.Status)] = info; + } + + return info.Color; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 8b1b6aac1..2e9f92115 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -12,7 +12,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class ContentEntity : IContentEntity + public sealed class ContentEntity : IEnrichedContentEntity { public Guid Id { get; set; } @@ -38,6 +38,12 @@ namespace Squidex.Domain.Apps.Entities.Contents public Status Status { get; set; } + public StatusInfo[] Nexts { get; set; } + + public string StatusColor { get; set; } + + public bool CanUpdate { get; set; } + public bool IsPending { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 60af67527..9d4ba5eb3 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -83,9 +83,9 @@ namespace Squidex.Domain.Apps.Entities.Contents await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data); } - var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema); + var statusInfo = await contentWorkflow.GetInitialStatusAsync(ctx.Schema); - Create(c, status); + Create(c, statusInfo.Status); return Snapshot; }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index b67536690..88d8907be 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -34,10 +34,12 @@ namespace Squidex.Domain.Apps.Entities.Contents public sealed class ContentQueryService : IContentQueryService { private static readonly Status[] StatusPublishedOnly = { Status.Published }; - private readonly IContentRepository contentRepository; - private readonly IContentVersionLoader contentVersionLoader; + private static readonly IResultList EmptyContents = ResultList.CreateFrom(0); private readonly IAppProvider appProvider; private readonly IAssetUrlGenerator assetUrlGenerator; + private readonly IContentEnricher contentEnricher; + private readonly IContentRepository contentRepository; + private readonly IContentVersionLoader contentVersionLoader; private readonly IScriptEngine scriptEngine; private readonly ContentOptions options; private readonly EdmModelBuilder modelBuilder; @@ -50,6 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public ContentQueryService( IAppProvider appProvider, IAssetUrlGenerator assetUrlGenerator, + IContentEnricher contentEnricher, IContentRepository contentRepository, IContentVersionLoader contentVersionLoader, IScriptEngine scriptEngine, @@ -58,6 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(assetUrlGenerator, nameof(assetUrlGenerator)); + Guard.NotNull(contentEnricher, nameof(contentEnricher)); Guard.NotNull(contentRepository, nameof(contentRepository)); Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader)); Guard.NotNull(modelBuilder, nameof(modelBuilder)); @@ -66,6 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Contents this.appProvider = appProvider; this.assetUrlGenerator = assetUrlGenerator; + this.contentEnricher = contentEnricher; this.contentRepository = contentRepository; this.contentVersionLoader = contentVersionLoader; this.modelBuilder = modelBuilder; @@ -73,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents this.scriptEngine = scriptEngine; } - public async Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1) + public async Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1) { Guard.NotNull(context, nameof(context)); @@ -83,25 +88,27 @@ namespace Squidex.Domain.Apps.Entities.Contents using (Profiler.TraceMethod()) { - var isVersioned = version > EtagVersion.Empty; - - var status = GetStatus(context); + IContentEntity content; - var content = - isVersioned ? - await FindContentByVersionAsync(id, version) : - await FindContentAsync(context, id, status, schema); + if (version > EtagVersion.Empty) + { + content = await FindByVersionAsync(id, version); + } + else + { + content = await FindCoreAsync(context, id, schema); + } if (content == null || (content.Status != Status.Published && !context.IsFrontendClient) || content.SchemaId.Id != schema.Id) { throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity)); } - return Transform(context, schema, content); + return await TransformAsync(context, schema, content); } } - public async Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query) + public async Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query) { Guard.NotNull(context, nameof(context)); @@ -111,95 +118,88 @@ namespace Squidex.Domain.Apps.Entities.Contents using (Profiler.TraceMethod()) { - var status = GetStatus(context); - IResultList contents; - if (query.Ids?.Count > 0) + if (query.Ids != null && query.Ids.Count > 0) { - contents = await QueryAsync(context, schema, query.Ids.ToHashSet(), status); - contents = SortSet(contents, query.Ids); + contents = await QueryByIdsAsync(context, query, schema); } else { - var parsedQuery = ParseQuery(context, query.ODataQuery, schema); - - contents = await QueryAsync(context, schema, parsedQuery, status); + contents = await QueryByQueryAsync(context, query, schema); } - return Transform(context, schema, contents); + return await TransformAsync(context, schema, contents); } } - public async Task> QueryAsync(QueryContext context, IReadOnlyList ids) + public async Task> QueryAsync(QueryContext context, IReadOnlyList ids) { Guard.NotNull(context, nameof(context)); using (Profiler.TraceMethod()) { - var status = GetStatus(context); - - List result; - - if (ids?.Count > 0) + if (ids == null || ids.Count == 0) { - var contents = await QueryAsync(context, ids, status); + return EmptyContents; + } - var permissions = context.User.Permissions(); + var results = new List(); - contents = contents.Where(x => HasPermission(permissions, x.Schema)).ToList(); + var contents = await QueryCoreAsync(context, ids); - result = contents.Select(x => Transform(context, x.Schema, x.Content)).ToList(); - result = SortList(result, ids).ToList(); - } - else + var permissions = context.User.Permissions(); + + foreach (var group in contents.GroupBy(x => x.Schema.Id)) { - result = new List(); + var schema = group.First().Schema; + + if (HasPermission(permissions, schema)) + { + var enriched = await TransformCoreAsync(context, schema, group.Select(x => x.Content)); + + results.AddRange(enriched); + } } - return result; + return ResultList.Create(results.Count, results.SortList(x => x.Id, ids)); } } - private IResultList Transform(QueryContext context, ISchemaEntity schema, IResultList contents) + private async Task> TransformAsync(QueryContext context, ISchemaEntity schema, IResultList contents) { - var transformed = TransformCore(context, schema, contents); + var transformed = await TransformCoreAsync(context, schema, contents); return ResultList.Create(contents.Total, transformed); } - private IContentEntity Transform(QueryContext context, ISchemaEntity schema, IContentEntity content) + private async Task TransformAsync(QueryContext context, ISchemaEntity schema, IContentEntity content) { - return TransformCore(context, schema, Enumerable.Repeat(content, 1)).FirstOrDefault(); - } + var transformed = await TransformCoreAsync(context, schema, Enumerable.Repeat(content, 1)); - private static IResultList SortSet(IResultList contents, IReadOnlyList ids) - { - return ResultList.Create(contents.Total, SortList(contents, ids)); + return transformed[0]; } - private static IEnumerable SortList(IEnumerable contents, IReadOnlyList ids) - { - return ids.Select(id => contents.FirstOrDefault(x => x.Id == id)).Where(x => x != null); - } - - private IEnumerable TransformCore(QueryContext context, ISchemaEntity schema, IEnumerable contents) + private async Task> TransformCoreAsync(QueryContext context, ISchemaEntity schema, IEnumerable contents) { using (Profiler.TraceMethod()) { + var results = new List(); + var converters = GenerateConverters(context).ToArray(); var scriptText = schema.SchemaDef.Scripts.Query; + var scripting = !string.IsNullOrWhiteSpace(scriptText); - var isScripting = !string.IsNullOrWhiteSpace(scriptText); + var enriched = await contentEnricher.EnrichAsync(contents); - foreach (var content in contents) + foreach (var content in enriched) { var result = SimpleMapper.Map(content, new ContentEntity()); if (result.Data != null) { - if (!context.IsFrontendClient && isScripting) + if (!context.IsFrontendClient && scripting) { var ctx = new ScriptContext { User = context.User, Data = content.Data, ContentId = content.Id }; @@ -218,8 +218,10 @@ namespace Squidex.Domain.Apps.Entities.Contents result.DataDraft = null; } - yield return result; + results.Add(result); } + + return results; } } @@ -344,32 +346,46 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private Task> QueryAsync(QueryContext context, IReadOnlyList ids, Status[] status) + private async Task> QueryByQueryAsync(QueryContext context, Q query, ISchemaEntity schema) + { + var parsedQuery = ParseQuery(context, query.ODataQuery, schema); + + return await QueryCoreAsync(context, schema, parsedQuery); + } + + private async Task> QueryByIdsAsync(QueryContext context, Q query, ISchemaEntity schema) + { + var contents = await QueryCoreAsync(context, schema, query.Ids.ToHashSet()); + + return contents.SortSet(x => x.Id, query.Ids); + } + + private Task> QueryCoreAsync(QueryContext context, IReadOnlyList ids) { - return contentRepository.QueryAsync(context.App, status, new HashSet(ids), ShouldIncludeDraft(context)); + return contentRepository.QueryAsync(context.App, GetStatus(context), new HashSet(ids), WithDraft(context)); } - private Task> QueryAsync(QueryContext context, ISchemaEntity schema, Query query, Status[] status) + private Task> QueryCoreAsync(QueryContext context, ISchemaEntity schema, Query query) { - return contentRepository.QueryAsync(context.App, schema, status, context.IsFrontendClient, query, ShouldIncludeDraft(context)); + return contentRepository.QueryAsync(context.App, schema, GetStatus(context), context.IsFrontendClient, query, WithDraft(context)); } - private Task> QueryAsync(QueryContext context, ISchemaEntity schema, HashSet ids, Status[] status) + private Task> QueryCoreAsync(QueryContext context, ISchemaEntity schema, HashSet ids) { - return contentRepository.QueryAsync(context.App, schema, status, ids, ShouldIncludeDraft(context)); + return contentRepository.QueryAsync(context.App, schema, GetStatus(context), ids, WithDraft(context)); } - private Task FindContentAsync(QueryContext context, Guid id, Status[] status, ISchemaEntity schema) + private Task FindCoreAsync(QueryContext context, Guid id, ISchemaEntity schema) { - return contentRepository.FindContentAsync(context.App, schema, status, id, ShouldIncludeDraft(context)); + return contentRepository.FindContentAsync(context.App, schema, GetStatus(context), id, WithDraft(context)); } - private Task FindContentByVersionAsync(Guid id, long version) + private Task FindByVersionAsync(Guid id, long version) { return contentVersionLoader.LoadAsync(id, version); } - private static bool ShouldIncludeDraft(QueryContext context) + private static bool WithDraft(QueryContext context) { return context.ApiStatus == StatusForApi.All || context.IsFrontendClient; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs index a3da96afd..0f0075906 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs @@ -8,45 +8,81 @@ using System; using System.Collections.Generic; using System.Linq; +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 { public sealed class DefaultContentWorkflow : IContentWorkflow { - private static readonly Status[] All = { Status.Archived, Status.Draft, Status.Published }; + private static readonly StatusInfo InfoArchived = new StatusInfo(Status.Archived, StatusColors.Archived); + private static readonly StatusInfo InfoDraft = new StatusInfo(Status.Draft, StatusColors.Draft); + private static readonly StatusInfo InfoPublished = new StatusInfo(Status.Published, StatusColors.Published); - private static readonly Dictionary Flow = new Dictionary + private static readonly StatusInfo[] All = { - [Status.Draft] = new[] { Status.Archived, Status.Published }, - [Status.Archived] = new[] { Status.Draft }, - [Status.Published] = new[] { Status.Draft, Status.Archived } + InfoArchived, + InfoDraft, + InfoPublished }; - public Task GetInitialStatusAsync(ISchemaEntity schema) + private static readonly Dictionary Flow = + new Dictionary + { + [Status.Archived] = (InfoArchived, new[] + { + InfoDraft + }), + [Status.Draft] = (InfoDraft, new[] + { + InfoArchived, + InfoPublished + }), + [Status.Published] = (InfoPublished, new[] + { + InfoDraft, + InfoArchived + }) + }; + + public Task GetInitialStatusAsync(ISchemaEntity schema) { - return Task.FromResult(Status.Draft); + var result = InfoDraft; + + return Task.FromResult(result); } - public Task CanMoveToAsync(IContentEntity content, Status next) + public Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) { - return Task.FromResult(Flow.TryGetValue(content.Status, out var state) && state.Contains(next)); + var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next); + + return Task.FromResult(result); } public Task CanUpdateAsync(IContentEntity content) { - return Task.FromResult(content.Status != Status.Archived); + var result = content.Status != Status.Archived; + + return Task.FromResult(result); } - public Task GetNextsAsync(IContentEntity content) + public Task GetInfoAsync(IContentEntity content) { - return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty()); + var result = Flow[content.Status].Info; + + return Task.FromResult(result); + } + + public Task GetNextsAsync(IContentEntity content, ClaimsPrincipal user) + { + var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty(); + + return Task.FromResult(result); } - public Task GetAllAsync(ISchemaEntity schema) + public Task GetAllAsync(ISchemaEntity schema) { return Task.FromResult(All); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs new file mode 100644 index 000000000..6a302fcce --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -0,0 +1,129 @@ +// ========================================================================== +// 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.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class DynamicContentWorkflow : IContentWorkflow + { + private readonly IScriptEngine scriptEngine; + private readonly IAppProvider appProvider; + + public DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvider appProvider) + { + Guard.NotNull(scriptEngine, nameof(scriptEngine)); + Guard.NotNull(appProvider, nameof(appProvider)); + + this.scriptEngine = scriptEngine; + + this.appProvider = appProvider; + } + + public async Task GetAllAsync(ISchemaEntity schema) + { + var workflow = await GetWorkflowAsync(schema.AppId.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); + + return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content, user); + } + + public async Task CanUpdateAsync(IContentEntity content) + { + var workflow = await GetWorkflowAsync(content.AppId.Id); + + if (workflow.TryGetStep(content.Status, out var step)) + { + return !step.NoUpdate; + } + + return true; + } + + public async Task GetInfoAsync(IContentEntity content) + { + var workflow = await GetWorkflowAsync(content.AppId.Id); + + if (workflow.TryGetStep(content.Status, out var step)) + { + return new StatusInfo(content.Status, GetColor(step)); + } + + return new StatusInfo(content.Status, StatusColors.Draft); + } + + public async Task GetInitialStatusAsync(ISchemaEntity schema) + { + var workflow = await GetWorkflowAsync(schema.AppId.Id); + + var (status, step) = workflow.GetInitialStep(); + + return new StatusInfo(status, GetColor(step)); + } + + public async Task GetNextsAsync(IContentEntity content, ClaimsPrincipal user) + { + var result = new List(); + + var workflow = await GetWorkflowAsync(content.AppId.Id); + + foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) + { + if (CanUse(transition, content, user)) + { + result.Add(new StatusInfo(to, GetColor(step))); + } + } + + return result.ToArray(); + } + + private bool CanUse(WorkflowTransition transition, IContentEntity content, ClaimsPrincipal user) + { + if (!string.IsNullOrWhiteSpace(transition.Role)) + { + if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && x.Value == transition.Role)) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(transition.Expression)) + { + return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression); + } + + return true; + } + + private async Task GetWorkflowAsync(Guid appId) + { + var app = await appProvider.GetAppAsync(appId); + + return app?.Workflows.GetFirst(); + } + + private static string GetColor(WorkflowStep step) + { + return step.Color ?? StatusColors.Draft; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index c5dbf6024..db7e2859c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class GraphQLExecutionContext : QueryExecutionContext { - private static readonly List EmptyAssets = new List(); + private static readonly List EmptyAssets = new List(); private static readonly List EmptyContents = new List(); private readonly IDataLoaderContextAccessor dataLoaderContextAccessor; private readonly IDependencyResolver resolver; @@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL execution.UserContext = this; } - public override Task FindAssetAsync(Guid id) + public override Task FindAssetAsync(Guid id) { var dataLoader = GetAssetsLoader(); @@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return dataLoader.LoadAsync(id); } - public async Task> GetReferencedAssetsAsync(IJsonValue value) + public async Task> GetReferencedAssetsAsync(IJsonValue value) { var ids = ParseIds(value); @@ -95,9 +95,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return await dataLoader.LoadManyAsync(ids); } - private IDataLoader GetAssetsLoader() + private IDataLoader GetAssetsLoader() { - return dataLoaderContextAccessor.Context.GetOrAddBatchLoader("Assets", + return dataLoaderContextAccessor.Context.GetOrAddBatchLoader("Assets", async batch => { var result = await GetReferencedAssetsAsync(new List(batch)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs index 8f3207ebf..c8ee5d2f2 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs @@ -7,7 +7,6 @@ using System; using GraphQL.Types; -using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs index ecdcb3620..801f44c81 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class AssetGraphType : ObjectGraphType + public sealed class AssetGraphType : ObjectGraphType { public AssetGraphType(IGraphModel model) { @@ -167,8 +167,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = "tags", ResolvedType = null, - Resolver = Resolve(x => x.Tags), - Description = "The height of the image in pixels if the asset is an image.", + Resolver = Resolve(x => x.TagNames), + Description = "The asset tags.", Type = AllTypes.NonNullTagsType }); @@ -186,9 +186,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = "An asset"; } - private static IFieldResolver Resolve(Func action) + private static IFieldResolver Resolve(Func action) { - return new FuncFieldResolver(c => action(c.Source)); + return new FuncFieldResolver(c => action(c.Source)); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs index a6d876742..63f571c1a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class ContentGraphType : ObjectGraphType + public sealed class ContentGraphType : ObjectGraphType { public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType) { @@ -78,6 +78,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The the status of the {schemaName} content." }); + AddField(new FieldType + { + Name = "statusColor", + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.StatusColor), + Description = $"The color status of the {schemaName} content." + }); + AddField(new FieldType { Name = "url", @@ -108,9 +116,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The structure of a {schemaName} content type."; } - private static IFieldResolver Resolve(Func action) + private static IFieldResolver Resolve(Func action) { - return new FuncFieldResolver(c => action(c.Source)); + return new FuncFieldResolver(c => action(c.Source)); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 4395ffd11..70f78d9b5 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards return Validate.It(() => "Cannot change status.", async e => { - if (!await contentWorkflow.CanMoveToAsync(content, command.Status)) + if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User)) { if (content.Status == command.Status && content.Status == Status.Published) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs new file mode 100644 index 000000000..1b7334134 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentEnricher + { + Task EnrichAsync(IContentEntity content); + + Task> EnrichAsync(IEnumerable contents); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index 769422de1..11b64a42f 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -17,11 +17,11 @@ namespace Squidex.Domain.Apps.Entities.Contents { int DefaultPageSizeGraphQl { get; } - Task> QueryAsync(QueryContext context, IReadOnlyList ids); + Task> QueryAsync(QueryContext context, IReadOnlyList ids); - Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query); + Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query); - Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); + Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); Task GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs index c812f8a4f..fd2f9dd37 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Security.Claims; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Schemas; @@ -13,14 +14,16 @@ namespace Squidex.Domain.Apps.Entities.Contents { public interface IContentWorkflow { - Task GetInitialStatusAsync(ISchemaEntity schema); + Task GetInitialStatusAsync(ISchemaEntity schema); - Task CanMoveToAsync(IContentEntity content, Status next); + Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); Task CanUpdateAsync(IContentEntity content); - Task GetNextsAsync(IContentEntity content); + Task GetInfoAsync(IContentEntity content); - Task GetAllAsync(ISchemaEntity schema); + Task GetNextsAsync(IContentEntity content, ClaimsPrincipal user); + + Task GetAllAsync(ISchemaEntity schema); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs new file mode 100644 index 000000000..45b3506a4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IEnrichedContentEntity : IContentEntity + { + bool CanUpdate { get; } + + string StatusColor { get; } + + StatusInfo[] Nexts { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs index 8b186cbd4..a40896a94 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public class QueryExecutionContext { private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); - private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); + private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); private readonly IContentQueryService contentQuery; private readonly IAssetQueryService assetQuery; private readonly QueryContext context; @@ -34,13 +34,13 @@ namespace Squidex.Domain.Apps.Entities.Contents this.context = context; } - public virtual async Task FindAssetAsync(Guid id) + public virtual async Task FindAssetAsync(Guid id) { var asset = cachedAssets.GetOrDefault(id); if (asset == null) { - asset = await assetQuery.FindAssetAsync(context, id); + asset = await assetQuery.FindAssetAsync(id); if (asset != null) { @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return result; } - public virtual async Task> GetReferencedAssetsAsync(ICollection ids) + public virtual async Task> GetReferencedAssetsAsync(ICollection ids) { Guard.NotNull(ids, nameof(ids)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs index fe48b89e4..6d0c7799c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs @@ -16,12 +16,12 @@ namespace Squidex.Domain.Apps.Entities.Contents { public Guid Id { get; } + public Instant DueTime { get; } + public Status Status { get; } public RefToken ScheduledBy { get; } - public Instant DueTime { get; } - public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime) { Id = id; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index 0f0234dc9..e413b9117 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -50,11 +50,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.State SimpleMapper.Map(@event, this); UpdateData(null, @event.Data, false); - - if (Status == default) - { - Status = Status.Draft; - } } protected void On(ContentChangesPublished @event) @@ -68,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.State { ScheduleJob = null; - Status = @event.Status; + SimpleMapper.Map(@event, this); if (@event.Status == Status.Published) { diff --git a/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs b/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs index 92f18d5a2..7e5c018b3 100644 --- a/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs +++ b/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs @@ -53,14 +53,17 @@ namespace Squidex.Domain.Apps.Entities.History message = new Lazy(() => { - var result = texts[item.Message]; - - foreach (var kvp in item.Parameters) + if (texts.TryGetValue(item.Message, out var result)) { - result = result.Replace("[" + kvp.Key + "]", kvp.Value); + foreach (var kvp in item.Parameters) + { + result = result.Replace("[" + kvp.Key + "]", kvp.Value); + } + + return result; } - return result; + return null; }); } } diff --git a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index f40ca30ca..114ac3384 100644 --- a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities { Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id); + Task GetAppAsync(Guid appId); + Task GetAppAsync(string appName); Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs index f640cf173..21721c416 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs @@ -19,12 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas public SchemaHistoryEventsCreator(TypeNameRegistry typeNameRegistry) : base(typeNameRegistry) { - AddEventMessage("SchemaCreatedEvent", - "created schema {[Name]}."); - - AddEventMessage("ScriptsConfiguredEvent", - "configured script of schema {[Name]}."); - AddEventMessage( "reordered fields of schema {[Name]}."); diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs new file mode 100644 index 000000000..65166ae97 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// 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.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowConfigured))] + public sealed class AppWorkflowConfigured : AppEvent + { + public Workflow Workflow { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs index 317b3b176..0aeb82997 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Contents { - [EventType(nameof(ContentCreated))] + [EventType(nameof(ContentCreated), 2)] public sealed class ContentCreated : ContentEvent { public Status Status { get; set; } diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs index e7b201ffe..86f782df4 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Contents { - [EventType(nameof(ContentStatusChanged))] + [EventType(nameof(ContentStatusChanged), 2)] public sealed class ContentStatusChanged : ContentEvent { public StatusChange Change { get; set; } diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index cfe546a24..a8f44e515 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -13,6 +13,16 @@ namespace Squidex.Infrastructure { public static class CollectionExtensions { + public static IResultList SortSet(this IResultList input, Func idProvider, IReadOnlyList ids) where T : class + { + return ResultList.Create(input.Total, SortList(input, idProvider, ids)); + } + + public static IEnumerable SortList(this IEnumerable input, Func idProvider, IReadOnlyList ids) where T : class + { + return ids.Select(id => input.FirstOrDefault(x => Equals(idProvider(x), id))).Where(x => x != null); + } + public static void AddRange(this ICollection target, IEnumerable source) { foreach (var value in source) diff --git a/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs b/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs index bbf07c80e..e3f800f4b 100644 --- a/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs +++ b/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Diagnostics; using Squidex.Infrastructure.Json; namespace Squidex.Infrastructure.EventSourcing @@ -33,6 +34,11 @@ namespace Squidex.Infrastructure.EventSourcing if (payloadObj is IMigrated migratedEvent) { payloadObj = migratedEvent.Migrate(); + + if (ReferenceEquals(migratedEvent, payloadObj)) + { + Debug.WriteLine("Migration should return new event."); + } } var envelope = new Envelope(payloadObj, eventData.Headers); @@ -47,6 +53,11 @@ namespace Squidex.Infrastructure.EventSourcing if (migrate && eventPayload is IMigrated migratedEvent) { eventPayload = migratedEvent.Migrate(); + + if (ReferenceEquals(migratedEvent, eventPayload)) + { + Debug.WriteLine("Migration should return new event."); + } } var payloadType = typeNameRegistry.GetName(eventPayload.GetType()); diff --git a/src/Squidex.Infrastructure/ResultList.cs b/src/Squidex.Infrastructure/ResultList.cs index 957ecc48b..7f835704f 100644 --- a/src/Squidex.Infrastructure/ResultList.cs +++ b/src/Squidex.Infrastructure/ResultList.cs @@ -27,7 +27,7 @@ namespace Squidex.Infrastructure return new Impl(items, total); } - public static IResultList Create(long total, params T[] items) + public static IResultList CreateFrom(long total, params T[] items) { return new Impl(items, total); } diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 7118a0604..62329248e 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -81,6 +81,12 @@ namespace Squidex.Shared public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; + public const string AppWorkflows = "squidex.apps.{app}.workflows"; + public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read"; + public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create"; + public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update"; + public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete"; + public const string AppBackups = "squidex.apps.{app}.backups"; public const string AppBackupsRead = "squidex.apps.{app}.backups.read"; public const string AppBackupsCreate = "squidex.apps.{app}.backups.create"; diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs index d3eba847d..31602a124 100644 --- a/src/Squidex.Web/Resource.cs +++ b/src/Squidex.Web/Resource.cs @@ -24,38 +24,38 @@ namespace Squidex.Web AddGetLink("self", href); } - public void AddGetLink(string rel, string href) + public void AddGetLink(string rel, string href, string metadata = null) { - AddLink(rel, "GET", href); + AddLink(rel, "GET", href, metadata); } - public void AddPatchLink(string rel, string href) + public void AddPatchLink(string rel, string href, string metadata = null) { - AddLink(rel, "PATCH", href); + AddLink(rel, "PATCH", href, metadata); } - public void AddPostLink(string rel, string href) + public void AddPostLink(string rel, string href, string metadata = null) { - AddLink(rel, "POST", href); + AddLink(rel, "POST", href, metadata); } - public void AddPutLink(string rel, string href) + public void AddPutLink(string rel, string href, string metadata = null) { - AddLink(rel, "PUT", href); + AddLink(rel, "PUT", href, metadata); } - public void AddDeleteLink(string rel, string href) + public void AddDeleteLink(string rel, string href, string metadata = null) { - AddLink(rel, "DELETE", href); + AddLink(rel, "DELETE", href, metadata); } - public void AddLink(string rel, string method, string href) + public void AddLink(string rel, string method, string href, string metadata = null) { Guard.NotNullOrEmpty(rel, nameof(rel)); Guard.NotNullOrEmpty(href, nameof(href)); Guard.NotNullOrEmpty(method, nameof(method)); - Links[rel] = new ResourceLink { Href = href, Method = method }; + Links[rel] = new ResourceLink { Href = href, Method = method, Metadata = metadata }; } } } diff --git a/src/Squidex.Web/ResourceLink.cs b/src/Squidex.Web/ResourceLink.cs index 964610e7d..2d2b4c0c5 100644 --- a/src/Squidex.Web/ResourceLink.cs +++ b/src/Squidex.Web/ResourceLink.cs @@ -18,5 +18,9 @@ namespace Squidex.Web [Required] [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.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs index 27f00a1d9..a4bc9280d 100644 --- a/src/Squidex.Web/UrlHelperExtensions.cs +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -38,7 +38,7 @@ namespace Squidex.Web public static string Url(this Controller controller, Func action, object values = null) where T : Controller { - return controller.Url.Url(action, values); + return controller.Url.Url(action, values); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs new file mode 100644 index 000000000..be489f20e --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +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.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps +{ + /// + /// Manages and configures apps. + /// + [ApiExplorerSettings(GroupName = nameof(Apps))] + public sealed class AppWorkflowsController : ApiController + { + public AppWorkflowsController(ICommandBus commandBus) + : base(commandBus) + { + } + + /// + /// Get app workflow. + /// + /// The name of the app. + /// + /// 200 => App workflows returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/workflow/")] + [ProducesResponseType(typeof(WorkflowResponseDto), 200)] + [ApiPermission(Permissions.AppWorkflowsRead)] + [ApiCosts(0)] + public IActionResult GetWorkflow(string app) + { + var response = WorkflowResponseDto.FromApp(App, this); + + Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + + return Ok(response); + } + + /// + /// Configure workflow of the app. + /// + /// The name of the app. + /// The new workflow. + /// + /// 200 => Workflow configured. + /// 400 => Workflow is not valid. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/workflow/")] + [ProducesResponseType(typeof(WorkflowResponseDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task PutWorkflow(string app, [FromBody] UpsertWorkflowDto request) + { + var command = request.ToCommand(); + + 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); + + return response; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 6d706dc30..46e37a1ea 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -177,6 +177,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models AddGetLink("schemas", controller.Url(x => nameof(x.GetSchemas), values)); } + if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions)) + { + AddGetLink("workflows", controller.Url(x => nameof(x.GetWorkflow), values)); + } + if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions)) { AddPostLink("schemas/create", controller.Url(x => nameof(x.PostSchema), values)); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs new file mode 100644 index 000000000..f091ced6c --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class UpsertWorkflowDto + { + /// + /// The workflow steps. + /// + [Required] + public Dictionary Steps { get; set; } + + /// + /// The initial step. + /// + public Status Initial { get; set; } + + public ConfigureWorkflow ToCommand() + { + var workflow = new Workflow( + Steps?.ToDictionary( + x => x.Key, + x => new WorkflowStep( + x.Value?.Transitions.ToDictionary( + y => x.Key, + y => new WorkflowTransition(y.Value.Expression, y.Value.Role)), + x.Value.Color, + x.Value.NoUpdate)), + Initial); + + return new ConfigureWorkflow { Workflow = workflow }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs new file mode 100644 index 000000000..3a6a3fecc --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs @@ -0,0 +1,61 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowDto : Resource + { + /// + /// The workflow steps. + /// + [Required] + public Dictionary Steps { get; set; } + + /// + /// The initial step. + /// + public Status Initial { get; set; } + + public static WorkflowDto FromWorkflow(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); + } + + private WorkflowDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) + { + AddPutLink("update", controller.Url(x => nameof(x.PutWorkflow), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs new file mode 100644 index 000000000..3186a7893 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowResponseDto : Resource + { + /// + /// The workflow. + /// + [Required] + public WorkflowDto Workflow { get; set; } + + public static WorkflowResponseDto FromApp(IAppEntity app, ApiController controller) + { + var result = new WorkflowResponseDto + { + Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name) + }; + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs new file mode 100644 index 000000000..9012d406d --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowStepDto + { + /// + /// The transitions. + /// + [Required] + public Dictionary Transitions { get; set; } + + /// + /// The optional color. + /// + public string Color { get; set; } + + /// + /// Indicates if updates should not be allowed. + /// + public bool NoUpdate { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs new file mode 100644 index 000000000..94235a60d --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowTransitionDto + { + /// + /// The optional expression. + /// + public string Expression { get; set; } + + /// + /// The optional restricted role. + /// + public string Role { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 965056fbe..75b9867a7 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -133,9 +133,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetAsset(string app, Guid id) { - var context = Context(); - - var asset = await assetQuery.FindAssetAsync(context, id); + var asset = await assetQuery.FindAssetAsync(id); if (asset == null) { @@ -182,7 +180,7 @@ namespace Squidex.Areas.Api.Controllers.Assets var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = AssetDto.FromAsset(result.Asset, this, app, result.Tags, result.IsDuplicate); + var response = AssetDto.FromAsset(result.Asset, this, app, result.IsDuplicate); return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); } @@ -267,8 +265,8 @@ namespace Squidex.Areas.Api.Controllers.Assets { var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = AssetDto.FromAsset(result.Asset, this, app, result.Tags); + var result = context.Result(); + var response = AssetDto.FromAsset(result, this, app); return response; } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index ec0e68899..5c996cf0d 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -118,14 +118,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [JsonProperty("_meta")] public AssetMetadata Metadata { get; set; } - public static AssetDto FromAsset(IAssetEntity asset, ApiController controller, string app, HashSet tags = null, bool isDuplicate = false) + public static AssetDto FromAsset(IEnrichedAssetEntity asset, ApiController controller, string app, bool isDuplicate = false) { var response = SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() }); - if (tags != null) - { - response.Tags = tags; - } + response.Tags = asset.TagNames; if (isDuplicate) { diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs index f6ceca82d..efd81147b 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs @@ -37,7 +37,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models return Items.ToSurrogateKeys(); } - public static AssetsDto FromAssets(IResultList assets, ApiController controller, string app) + public static AssetsDto FromAssets(IResultList assets, ApiController controller, string app) { var response = new AssetsDto { diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 833c67dbe..6885ce6cf 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -16,7 +16,6 @@ using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.GraphQL; -using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Web; @@ -127,9 +126,8 @@ namespace Squidex.Areas.Api.Controllers.Contents { var context = Context(); var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids); - var contentsList = ResultList.Create(contents.Count, contents); - var response = await ContentsDto.FromContentsAsync(contentsList, context, this, null, contentWorkflow); + var response = await ContentsDto.FromContentsAsync(contents, context, this, null, contentWorkflow); if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) { @@ -201,7 +199,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var context = Context(); var content = await contentQuery.FindContentAsync(context, name, id); - var response = await ContentDto.FromContentAsync(context, content, contentWorkflow, this); + var response = ContentDto.FromContent(context, content, this); if (controllerOptions.Value.EnableSurrogateKeys) { @@ -237,7 +235,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var context = Context(); var content = await contentQuery.FindContentAsync(context, name, id, version); - var response = await ContentDto.FromContentAsync(context, content, contentWorkflow, this); + var response = ContentDto.FromContent(context, content, this); if (controllerOptions.Value.EnableSurrogateKeys) { @@ -447,8 +445,8 @@ namespace Squidex.Areas.Api.Controllers.Contents { var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = await ContentDto.FromContentAsync(null, result, contentWorkflow, this); + var result = context.Result(); + var response = ContentDto.FromContent(null, result, this); return response; } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 2a6afaacf..26440c83f 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -7,7 +7,6 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; @@ -71,20 +70,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models public Instant LastModified { get; set; } /// - /// The the status of the content. + /// The status of the content. /// public Status Status { get; set; } + /// + /// The color of the status. + /// + public string StatusColor { get; set; } + /// /// The version of the content. /// public long Version { get; set; } - public static ValueTask FromContentAsync( - QueryContext context, - IContentEntity content, - IContentWorkflow contentWorkflow, - ApiController controller) + public static ContentDto FromContent(QueryContext context, IEnrichedContentEntity content, ApiController controller) { var response = SimpleMapper.Map(content, new ContentDto()); @@ -104,14 +104,10 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto()); } - return response.CreateLinksAsync(content, controller, content.AppId.Name, content.SchemaId.Name, contentWorkflow); + return response.CreateLinksAsync(content, controller, content.AppId.Name, content.SchemaId.Name); } - private async ValueTask CreateLinksAsync(IContentEntity content, - ApiController controller, - string app, - string schema, - IContentWorkflow contentWorkflow) + private ContentDto CreateLinksAsync(IEnrichedContentEntity content, ApiController controller, string app, string schema) { var values = new { app, name = schema, id = Id }; @@ -139,7 +135,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema)) { - if (await contentWorkflow.CanUpdateAsync(content)) + if (content.CanUpdate) { AddPutLink("update", controller.Url(x => nameof(x.PutContent), values)); } @@ -157,13 +153,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); } - var nextStatuses = await contentWorkflow.GetNextsAsync(content); - - foreach (var next in nextStatuses) + foreach (var next in content.Nexts) { - if (controller.HasPermission(Helper.StatusPermission(app, schema, next))) + if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status))) { - AddPutLink($"status/{next}", controller.Url(x => nameof(x.PutContentStatus), values)); + AddPutLink($"status/{next.Status}", controller.Url(x => nameof(x.PutContentStatus), values), next.Color); } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index afcecb7fe..c81ec7b0e 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -35,7 +35,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// The possible statuses. /// [Required] - public Status[] Statuses { get; set; } + public StatusInfoDto[] Statuses { get; set; } public string ToEtag() { @@ -47,20 +47,16 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models return Items.ToSurrogateKeys(); } - public static async Task FromContentsAsync(IResultList contents, QueryContext context, - ApiController controller, - ISchemaEntity schema, - IContentWorkflow contentWorkflow) + public static async Task FromContentsAsync(IResultList contents, + QueryContext context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow) { var result = new ContentsDto { Total = contents.Total, - Items = new ContentDto[contents.Count] + Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray() }; - await Task.WhenAll( - result.AssignContentsAsync(contentWorkflow, contents, context, controller), - result.AssignStatusesAsync(contentWorkflow, schema)); + await result.AssignStatusesAsync(contentWorkflow, schema); return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name); } @@ -69,15 +65,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { var allStatuses = await contentWorkflow.GetAllAsync(schema); - Statuses = allStatuses.ToArray(); - } - - private async Task AssignContentsAsync(IContentWorkflow contentWorkflow, IResultList contents, QueryContext context, ApiController controller) - { - for (var i = 0; i < Items.Length; i++) - { - Items[i] = await ContentDto.FromContentAsync(context, contents[i], contentWorkflow, controller); - } + Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray(); } private ContentsDto CreateLinks(ApiController controller, string app, string schema) diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs new file mode 100644 index 000000000..510ba8a7a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public sealed class StatusInfoDto + { + /// + /// The name of the status. + /// + [Required] + public Status Status { get; set; } + + /// + /// The color of the status. + /// + [Required] + public string Color { get; set; } + + public static StatusInfoDto FromStatusInfo(StatusInfo statusInfo) + { + return new StatusInfoDto { Status = statusInfo.Status, Color = statusInfo.Color }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs b/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs index cb2b1ecde..4a208143c 100644 --- a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs +++ b/src/Squidex/Areas/Api/Controllers/History/HistoryController.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 Squidex.Areas.Api.Controllers.History.Models; @@ -48,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.History { var events = await historyService.QueryByChannelAsync(AppId, channel, 100); - var response = events.ToArray(HistoryEventDto.FromHistoryEvent); + var response = events.Select(HistoryEventDto.FromHistoryEvent).Where(x => x.Message != null).ToArray(); return Ok(response); } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index d6965d669..47c2e44b9 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -32,7 +32,6 @@ using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.Text; @@ -97,9 +96,15 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -115,7 +120,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs() + services.AddSingletonAs() .AsOptional(); services.AddSingletonAs() @@ -222,6 +227,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -231,9 +239,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .As(); - services.AddSingletonAs>() - .As(); - services.AddSingletonAs>() .As(); diff --git a/src/Squidex/Config/Domain/SerializationServices.cs b/src/Squidex/Config/Domain/SerializationServices.cs index 44aadc738..9217ff2fb 100644 --- a/src/Squidex/Config/Domain/SerializationServices.cs +++ b/src/Squidex/Config/Domain/SerializationServices.cs @@ -46,7 +46,8 @@ namespace Squidex.Config.Domain new RuleConverter(), new SchemaConverter(), new StatusConverter(), - new StringEnumConverter()); + new StringEnumConverter(), + new WorkflowConverter()); settings.NullValueHandling = NullValueHandling.Ignore; diff --git a/src/Squidex/app-config/karma-test-shim.js b/src/Squidex/app-config/karma-test-shim.js index af16a868c..773d09e87 100644 --- a/src/Squidex/app-config/karma-test-shim.js +++ b/src/Squidex/app-config/karma-test-shim.js @@ -18,7 +18,7 @@ testing.TestBed.initTestEnvironment( browser.platformBrowserDynamicTesting() ); -var testContext = require.context('../app', true, /workflows\.service\.spec\.ts/); +var testContext = require.context('../app', true, /\.spec\.ts/); /** * Get all the files, for each file, call the context function diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index b072231ea..8c46363b7 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/src/Squidex/app-config/webpack.config.js @@ -43,7 +43,7 @@ module.exports = function (env) { * * See: https://webpack.js.org/configuration/devtool/ */ - devtool: isProduction ? false : (isTests ? 'inline-source-map' : 'source-map'), + devtool: isProduction ? false : 'inline-source-map', /** * Options affecting the resolving of modules. @@ -93,11 +93,6 @@ module.exports = function (env) { loader: 'ignore-loader' }], include: [/node_modules/] - }, { - test: /\.html$/, - use: [{ - loader: 'raw-loader' - }] }, { test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/, use: [{ @@ -256,12 +251,15 @@ module.exports = function (env) { waitForLinting: isProduction }) ); + } + if (!isCoverage) { config.plugins.push( new plugins.NgToolsWebpack.AngularCompilerPlugin({ + directTemplateLoading: true, entryModule: 'app/app.module#AppModule', sourceMap: !isProduction, - skipSourceGeneration: !isAot, + skipCodeGeneration: !isAot, tsConfigPath: './tsconfig.json' }) ); diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index 4992334f1..c7975aa9b 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -19,7 +19,7 @@ import { ResultSet } from '@app/shared'; -export class UsersDto extends ResultSet { +export class UsersDto extends ResultSet { public get canCreate() { return hasAnyLink(this._links, 'create'); } diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 22454ce68..71bd34cf4 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -36,6 +36,7 @@ [class.active]="dropdown.isOpen | async" #optionsButton> - - Status to {{status}} + + Change to {{info.status}} diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html index ea615ec67..600764d90 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html @@ -17,7 +17,7 @@ - {{query.name}} + {{query.name}} diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss index 00bb31427..684ee4478 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss @@ -3,4 +3,4 @@ .text-muted { pointer-events: none; -} \ No newline at end of file +} diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts index a95df25f2..f9f08e2c3 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts @@ -207,8 +207,8 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { const allActions = {}; for (let content of this.contentsState.snapshot.contents.values) { - for (let status of content.statusUpdates) { - allActions[status] = true; + for (let info of content.statusUpdates) { + allActions[info.status] = info.color; } } diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index 2569c05e1..201ff85f9 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -24,6 +24,7 @@ @@ -55,8 +56,8 @@