Browse Source

Merge branch 'workflow-ui' of github.com:Squidex/squidex into workflow-ui

# Conflicts:
#	src/Squidex/app/shared/services/workflows.service.ts
pull/380/head
Sebastian 7 years ago
parent
commit
e9ca49f45e
  1. 2
      src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs
  2. 2
      src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs
  3. 6
      src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs
  4. 8
      src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs
  5. 4
      src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs
  6. 2
      src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  7. 4
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs
  8. 37
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs
  9. 5
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  10. 16
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs
  11. 36
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs
  12. 23
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs
  13. 88
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  14. 31
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs
  15. 23
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  16. 43
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs
  17. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  18. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs
  19. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs
  20. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs
  21. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs
  22. 1
      src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs
  23. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  24. 2
      src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  25. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  26. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  27. 32
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  28. 15
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  29. 9
      src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
  30. 16
      src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs
  31. 76
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs
  32. 3
      src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  33. 9
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  34. 67
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  35. 11
      src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs
  36. 82
      src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs
  37. 9
      src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs
  38. 97
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  39. 19
      src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs
  40. 6
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  41. 13
      src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs
  42. 2
      src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  43. 41
      src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs
  44. 96
      src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs
  45. 8
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  46. 4
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  47. 144
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  48. 64
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  49. 129
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  50. 10
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  51. 1
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs
  52. 10
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs
  53. 14
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  54. 2
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  55. 19
      src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs
  56. 6
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  57. 11
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  58. 20
      src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs
  59. 8
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  60. 4
      src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs
  61. 7
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  62. 13
      src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs
  63. 2
      src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  64. 6
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs
  65. 18
      src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs
  66. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs
  67. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  68. 10
      src/Squidex.Infrastructure/CollectionExtensions.cs
  69. 11
      src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs
  70. 2
      src/Squidex.Infrastructure/ResultList.cs
  71. 6
      src/Squidex.Shared/Permissions.cs
  72. 24
      src/Squidex.Web/Resource.cs
  73. 4
      src/Squidex.Web/ResourceLink.cs
  74. 2
      src/Squidex.Web/UrlHelperExtensions.cs
  75. 86
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  76. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  77. 45
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs
  78. 61
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  79. 32
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs
  80. 32
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs
  81. 22
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs
  82. 10
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  83. 7
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  84. 2
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs
  85. 12
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  86. 32
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  87. 24
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  88. 32
      src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs
  89. 3
      src/Squidex/Areas/Api/Controllers/History/HistoryController.cs
  90. 15
      src/Squidex/Config/Domain/EntitiesServices.cs
  91. 3
      src/Squidex/Config/Domain/SerializationServices.cs
  92. 2
      src/Squidex/app-config/karma-test-shim.js
  93. 12
      src/Squidex/app-config/webpack.config.js
  94. 2
      src/Squidex/app/features/administration/services/users.service.ts
  95. 5
      src/Squidex/app/features/content/pages/content/content-page.component.html
  96. 2
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html
  97. 2
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss
  98. 4
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  99. 5
      src/Squidex/app/features/content/shared/content-item.component.html
  100. 4
      src/Squidex/app/features/content/shared/content-status.component.html

2
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(secret, nameof(secret));
Guard.NotNullOrEmpty(role, nameof(role)); Guard.NotNullOrEmpty(role, nameof(role));
Role = role; Role = role;
Secret = secret; Secret = secret;

2
src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.Apps
: base(name) : base(name)
{ {
Guard.NotNullOrEmpty(pattern, nameof(pattern)); Guard.NotNullOrEmpty(pattern, nameof(pattern));
Pattern = pattern; Pattern = pattern;
Message = message; Message = message;

6
src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Security;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Security;
namespace Squidex.Domain.Apps.Core.Apps.Json namespace Squidex.Domain.Apps.Core.Apps.Json
{ {

8
src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs

@ -5,11 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using P = Squidex.Shared.Permissions; using P = Squidex.Shared.Permissions;
namespace Squidex.Domain.Apps.Core.Apps namespace Squidex.Domain.Apps.Core.Apps
@ -71,7 +71,8 @@ namespace Squidex.Domain.Apps.Core.Apps
return new Role(Editor, return new Role(Editor,
P.ForApp(P.AppAssets, app), P.ForApp(P.AppAssets, app),
P.ForApp(P.AppCommon, 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) public static Role CreateReader(string app)
@ -90,6 +91,7 @@ namespace Squidex.Domain.Apps.Core.Apps
P.ForApp(P.AppCommon, app), P.ForApp(P.AppCommon, app),
P.ForApp(P.AppContents, app), P.ForApp(P.AppContents, app),
P.ForApp(P.AppPatterns, app), P.ForApp(P.AppPatterns, app),
P.ForApp(P.AppWorkflows, app),
P.ForApp(P.AppRules, app), P.ForApp(P.AppRules, app),
P.ForApp(P.AppSchemas, app)); P.ForApp(P.AppSchemas, app));
} }

4
src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using System.Linq; using System.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Core.Apps namespace Squidex.Domain.Apps.Core.Apps
{ {

2
src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using NodaTime; using NodaTime;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Comments namespace Squidex.Domain.Apps.Core.Comments
{ {

4
src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
namespace Squidex.Domain.Apps.Core.Contents.Json namespace Squidex.Domain.Apps.Core.Contents.Json
{ {

37
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<Workflows>
{
protected override void WriteValue(JsonWriter writer, Workflows value, JsonSerializer serializer)
{
var json = new Dictionary<Guid, Workflow>(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<Dictionary<Guid, Workflow>>(reader);
return new Workflows(json.ToArray());
}
}
}

5
src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs

@ -5,11 +5,12 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System; using System;
using System.ComponentModel;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
{ {
[TypeConverter(typeof(StatusConverter))]
public struct Status : IEquatable<Status> public struct Status : IEquatable<Status>
{ {
public static readonly Status Archived = new Status("Archived"); public static readonly Status Archived = new Status("Archived");
@ -45,7 +46,7 @@ namespace Squidex.Domain.Apps.Core.Contents
public override string ToString() public override string ToString()
{ {
return name; return Name;
} }
public static bool operator ==(Status lhs, Status rhs) public static bool operator ==(Status lhs, Status rhs)

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

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

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

88
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<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
public static readonly Workflow Default = new Workflow(
new Dictionary<Status, WorkflowStep>
{
[Status.Archived] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Archived, true),
[Status.Draft] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Published] = new WorkflowTransition()
},
StatusColors.Draft),
[Status.Published] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Published)
}, Status.Draft);
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; }
public Status Initial { get; }
public Workflow(IReadOnlyDictionary<Status, WorkflowStep> 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]);
}
}
}

31
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<Status, WorkflowTransition> EmptyTransitions = new Dictionary<Status, WorkflowTransition>();
public IReadOnlyDictionary<Status, WorkflowTransition> Transitions { get; }
public string Color { get; }
public bool NoUpdate { get; }
public WorkflowStep(IReadOnlyDictionary<Status, WorkflowTransition> transitions = null, string color = null, bool noUpdate = false)
{
Transitions = transitions ?? EmptyTransitions;
Color = color;
NoUpdate = noUpdate;
}
}
}

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

43
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<Guid, Workflow>
{
public static readonly Workflows Empty = new Workflows();
private Workflows()
{
}
public Workflows(KeyValuePair<Guid, Workflow>[] 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;
}
}
}

2
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Rules.Triggers namespace Squidex.Domain.Apps.Core.Rules.Triggers
{ {

2
src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Schemas namespace Squidex.Domain.Apps.Core.Schemas
{ {

2
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs

@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
public sealed class FieldCollection<T> : Cloneable<FieldCollection<T>> where T : IField public sealed class FieldCollection<T> : Cloneable<FieldCollection<T>> where T : IField
{ {
public static readonly FieldCollection<T> Empty = new FieldCollection<T>(); public static readonly FieldCollection<T> Empty = new FieldCollection<T>();
private static readonly Dictionary<long, T> EmptyById = new Dictionary<long, T>(); private static readonly Dictionary<long, T> EmptyById = new Dictionary<long, T>();
private static readonly Dictionary<string, T> EmptyByString = new Dictionary<string, T>(); private static readonly Dictionary<string, T> EmptyByString = new Dictionary<string, T>();

2
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Infrastructure;
using NamedIdStatic = Squidex.Infrastructure.NamedId; using NamedIdStatic = Squidex.Infrastructure.NamedId;
namespace Squidex.Domain.Apps.Core.Schemas namespace Squidex.Domain.Apps.Core.Schemas

2
src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using System;
using P = Squidex.Domain.Apps.Core.Partitioning; using P = Squidex.Domain.Apps.Core.Partitioning;
namespace Squidex.Domain.Apps.Core.Schemas.Json namespace Squidex.Domain.Apps.Core.Schemas.Json

1
src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Domain.Apps.Core.Schemas namespace Squidex.Domain.Apps.Core.Schemas
{ {
public sealed class JsonFieldProperties : FieldProperties public sealed class JsonFieldProperties : FieldProperties

2
src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System; using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Schemas namespace Squidex.Domain.Apps.Core.Schemas
{ {

2
src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -14,6 +14,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Freezable.Fody" Version="1.9.3" /> <PackageReference Include="Freezable.Fody" Version="1.9.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="1.5.0" /> <PackageReference Include="System.Collections.Immutable" Version="1.5.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />

2
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
} }
} }
public async Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash) public async Task<IReadOnlyList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash)
{ {
using (Profiler.TraceMethod<MongoAssetRepository>()) using (Profiler.TraceMethod<MongoAssetRepository>())
{ {

2
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
if (fullTextIds?.Count == 0) if (fullTextIds?.Count == 0)
{ {
return ResultList.Create<IContentEntity>(0); return ResultList.CreateFrom<IContentEntity>(0);
} }
return await contents.QueryAsync(schema, query, fullTextIds, status, inDraft, includeDraft); return await contents.QueryAsync(schema, query, fullTextIds, status, inDraft, includeDraft);

32
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -65,6 +65,17 @@ namespace Squidex.Domain.Apps.Entities
}); });
} }
public Task<IAppEntity> GetAppAsync(Guid appId)
{
return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () =>
{
using (Profiler.TraceMethod<AppProvider>())
{
return await GetAppByIdAsync(appId);
}
});
}
public Task<IAppEntity> GetAppAsync(string appName) public Task<IAppEntity> GetAppAsync(string appName)
{ {
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () =>
@ -78,14 +89,7 @@ namespace Squidex.Domain.Apps.Entities
return null; return null;
} }
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync(); return await GetAppByIdAsync(appId);
if (!IsExisting(app))
{
return null;
}
return app.Value;
} }
}); });
} }
@ -184,6 +188,18 @@ namespace Squidex.Domain.Apps.Entities
}); });
} }
private async Task<IAppEntity> GetAppByIdAsync(Guid appId)
{
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
}
private async Task<List<Guid>> GetAppIdsByUserAsync(string userId) private async Task<List<Guid>> GetAppIdsByUserAsync(string userId)
{ {
using (Profiler.TraceMethod<AppProvider>()) using (Profiler.TraceMethod<AppProvider>())

15
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -119,6 +119,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
return Snapshot; return Snapshot;
}); });
case ConfigureWorkflow configureWorkflow:
return UpdateReturn(configureWorkflow, c =>
{
GuardAppWorkflows.CanConfigure(c);
ConfigureWorkflow(c);
return Snapshot;
});
case AddLanguage addLanguage: case AddLanguage addLanguage:
return UpdateReturn(addLanguage, c => return UpdateReturn(addLanguage, c =>
{ {
@ -319,6 +329,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
} }
public void ConfigureWorkflow(ConfigureWorkflow command)
{
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured()));
}
public void AddLanguage(AddLanguage command) public void AddLanguage(AddLanguage command)
{ {
RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded())); RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded()));

9
src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs

@ -19,15 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry) public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry)
: base(typeNameRegistry) : base(typeNameRegistry)
{ {
AddEventMessage("AppContributorAssignedEvent",
"assigned {user:[Contributor]} as {[Role]}");
AddEventMessage("AppClientUpdatedEvent",
"updated client {[Id]}");
AddEventMessage("AppPlanChanged",
"changed plan to {[Plan]}");
AddEventMessage<AppContributorAssigned>( AddEventMessage<AppContributorAssigned>(
"assigned {user:[Contributor]} as {[Role]}"); "assigned {user:[Contributor]} as {[Role]}");

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

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

3
src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
@ -29,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
LanguagesConfig LanguagesConfig { get; } LanguagesConfig LanguagesConfig { get; }
Workflows Workflows { get; }
bool IsArchived { get; } bool IsArchived { get; }
} }
} }

9
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -7,6 +7,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.Dispatching;
@ -42,6 +43,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
[DataMember] [DataMember]
public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English; public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English;
[DataMember]
public Workflows Workflows { get; set; } = Workflows.Empty;
[DataMember] [DataMember]
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
@ -92,6 +96,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
Clients = Clients.Revoke(@event.Id); Clients = Clients.Revoke(@event.Id);
} }
protected void On(AppWorkflowConfigured @event)
{
Workflows = Workflows.Set(@event.Workflow);
}
protected void On(AppPatternAdded @event) protected void On(AppPatternAdded @event)
{ {
Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message); Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message);

67
src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Tags; using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -22,31 +21,31 @@ namespace Squidex.Domain.Apps.Entities.Assets
public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain> public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain>
{ {
private readonly IAssetStore assetStore; private readonly IAssetStore assetStore;
private readonly IAssetEnricher assetEnricher;
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators; private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators;
private readonly ITagService tagService;
public AssetCommandMiddleware( public AssetCommandMiddleware(
IGrainFactory grainFactory, IGrainFactory grainFactory,
IAssetEnricher assetEnricher,
IAssetQueryService assetQuery, IAssetQueryService assetQuery,
IAssetStore assetStore, IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator, IAssetThumbnailGenerator assetThumbnailGenerator,
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators, IEnumerable<ITagGenerator<CreateAsset>> tagGenerators)
ITagService tagService)
: base(grainFactory) : base(grainFactory)
{ {
Guard.NotNull(assetEnricher, nameof(assetEnricher));
Guard.NotNull(assetStore, nameof(assetStore)); Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator));
Guard.NotNull(tagGenerators, nameof(tagGenerators)); Guard.NotNull(tagGenerators, nameof(tagGenerators));
Guard.NotNull(tagService, nameof(tagService));
this.assetStore = assetStore; this.assetStore = assetStore;
this.assetEnricher = assetEnricher;
this.assetQuery = assetQuery; this.assetQuery = assetQuery;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetThumbnailGenerator = assetThumbnailGenerator;
this.tagGenerators = tagGenerators; this.tagGenerators = tagGenerators;
this.tagService = tagService;
} }
public override async Task HandleAsync(CommandContext context, Func<Task> next) public override async Task HandleAsync(CommandContext context, Func<Task> next)
@ -67,35 +66,30 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
var existings = await assetQuery.QueryByHashAsync(createAsset.AppId.Id, createAsset.FileHash); var existings = await assetQuery.QueryByHashAsync(createAsset.AppId.Id, createAsset.FileHash);
AssetCreatedResult result = null;
foreach (var existing in existings) foreach (var existing in existings)
{ {
if (IsDuplicate(createAsset, existing)) 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<string>(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 finally
{ {
@ -112,11 +106,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
try 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 finally
{ {
@ -126,34 +120,23 @@ namespace Squidex.Domain.Apps.Entities.Assets
break; break;
} }
case AssetCommand command:
{
var result = await ExecuteAndAdjustTagsAsync(command);
context.Complete(result);
break;
}
default: default:
await base.HandleAsync(context, next); await HandleCoreAsync(context, next);
break; break;
} }
} }
private async Task<object> ExecuteAndAdjustTagsAsync(AssetCommand command) private async Task HandleCoreAsync(CommandContext context, Func<Task> 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<string>(denormalizedTags.Values)); context.Complete(enriched);
} }
return result;
} }
private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset) private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset)

11
src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs

@ -5,17 +5,18 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public sealed class AssetCreatedResult : AssetResult public sealed class AssetCreatedResult
{ {
public IEnrichedAssetEntity Asset { get; }
public bool IsDuplicate { get; } public bool IsDuplicate { get; }
public AssetCreatedResult(IAssetEntity asset, bool isDuplicate, HashSet<string> tags) public AssetCreatedResult(IEnrichedAssetEntity asset, bool isDuplicate)
: base(asset, tags)
{ {
Asset = asset;
IsDuplicate = isDuplicate; IsDuplicate = isDuplicate;
} }
} }

82
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<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset)
{
Guard.NotNull(asset, nameof(asset));
var enriched = await EnrichAsync(Enumerable.Repeat(asset, 1));
return enriched[0];
}
public async Task<IReadOnlyList<IEnrichedAssetEntity>> EnrichAsync(IEnumerable<IAssetEntity> assets)
{
Guard.NotNull(assets, nameof(assets));
using (Profiler.TraceMethod<AssetEnricher>())
{
var results = new List<IEnrichedAssetEntity>();
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<string>();
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<Dictionary<string, string>> CalculateTags(IGrouping<System.Guid, IAssetEntity> group)
{
var uniqueIds = group.Where(x => x.Tags != null).SelectMany(x => x.Tags).ToHashSet();
return await tagService.DenormalizeTagsAsync(group.Key, TagGroups.Assets, uniqueIds);
}
}
}

9
tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs → src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs

@ -1,19 +1,18 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure; 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<Guid> AppId { get; set; } public NamedId<Guid> AppId { get; set; }
@ -31,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
public HashSet<string> Tags { get; set; } public HashSet<string> Tags { get; set; }
public HashSet<string> TagNames { get; set; }
public long Version { get; set; } public long Version { get; set; }
public string MimeType { get; set; } public string MimeType { get; set; }

97
src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs

@ -7,7 +7,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.OData; using Microsoft.OData;
@ -21,9 +20,10 @@ using Squidex.Infrastructure.Queries.OData;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public class AssetQueryService : IAssetQueryService public sealed class AssetQueryService : IAssetQueryService
{ {
private readonly ITagService tagService; private readonly ITagService tagService;
private readonly IAssetEnricher assetEnricher;
private readonly IAssetRepository assetRepository; private readonly IAssetRepository assetRepository;
private readonly AssetOptions options; private readonly AssetOptions options;
@ -32,76 +32,82 @@ namespace Squidex.Domain.Apps.Entities.Assets
get { return options.DefaultPageSizeGraphQl; } get { return options.DefaultPageSizeGraphQl; }
} }
public AssetQueryService(ITagService tagService, IAssetRepository assetRepository, IOptions<AssetOptions> options) public AssetQueryService(
ITagService tagService,
IAssetEnricher assetEnricher,
IAssetRepository assetRepository,
IOptions<AssetOptions> options)
{ {
Guard.NotNull(tagService, nameof(tagService)); Guard.NotNull(tagService, nameof(tagService));
Guard.NotNull(options, nameof(options)); Guard.NotNull(assetEnricher, nameof(assetEnricher));
Guard.NotNull(assetRepository, nameof(assetRepository)); Guard.NotNull(assetRepository, nameof(assetRepository));
Guard.NotNull(options, nameof(options));
this.tagService = tagService;
this.assetEnricher = assetEnricher;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.options = options.Value; this.options = options.Value;
this.tagService = tagService;
}
public Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id)
{
Guard.NotNull(context, nameof(context));
return FindAssetAsync(context.App.Id, id);
} }
public async Task<IAssetEntity> FindAssetAsync(Guid appId, Guid id) public async Task<IEnrichedAssetEntity> FindAssetAsync( Guid id)
{ {
var asset = await assetRepository.FindAssetAsync(id); var asset = await assetRepository.FindAssetAsync(id);
if (asset != null) if (asset != null)
{ {
await DenormalizeTagsAsync(appId, Enumerable.Repeat(asset, 1)); return await assetEnricher.EnrichAsync(asset);
} }
return asset; return null;
} }
public async Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash) public async Task<IReadOnlyList<IEnrichedAssetEntity>> QueryByHashAsync(Guid appId, string hash)
{ {
Guard.NotNull(hash, nameof(hash)); Guard.NotNull(hash, nameof(hash));
var assets = await assetRepository.QueryByHashAsync(appId, hash); var assets = await assetRepository.QueryByHashAsync(appId, hash);
await DenormalizeTagsAsync(appId, assets); return await assetEnricher.EnrichAsync(assets);
return assets;
} }
public async Task<IResultList<IAssetEntity>> QueryAsync(QueryContext context, Q query) public async Task<IResultList<IEnrichedAssetEntity>> QueryAsync(QueryContext context, Q query)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query)); Guard.NotNull(query, nameof(query));
IResultList<IAssetEntity> assets; IResultList<IAssetEntity> assets;
if (query.Ids != null) if (query.Ids != null && query.Ids.Count > 0)
{ {
assets = await assetRepository.QueryAsync(context.App.Id, new HashSet<Guid>(query.Ids)); assets = await QueryByIdsAsync(context, query);
assets = Sort(assets, query.Ids);
} }
else else
{ {
var parsedQuery = ParseQuery(context, query.ODataQuery); assets = await QueryByQueryAsync(context, query);
assets = await assetRepository.QueryAsync(context.App.Id, parsedQuery);
} }
await DenormalizeTagsAsync(context.App.Id, assets); var enriched = await assetEnricher.EnrichAsync(assets);
return assets; return ResultList.Create(assets.Total, enriched);
} }
private static IResultList<IAssetEntity> Sort(IResultList<IAssetEntity> assets, IReadOnlyList<Guid> ids) private async Task<IResultList<IAssetEntity>> QueryByQueryAsync(QueryContext context, Q query)
{
var parsedQuery = ParseQuery(context, query.ODataQuery);
return await assetRepository.QueryAsync(context.App.Id, parsedQuery);
}
private async Task<IResultList<IAssetEntity>> 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<Guid>(query.Ids));
return ResultList.Create(assets.Total, sorted); return Sort(assets, query.Ids);
}
private static IResultList<IAssetEntity> Sort(IResultList<IAssetEntity> assets, IReadOnlyList<Guid> ids)
{
return assets.SortSet(x => x.Id, ids);
} }
private Query ParseQuery(QueryContext context, string query) 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); throw new ValidationException($"Failed to parse query: {ex.Message}", ex);
} }
} }
private async Task DenormalizeTagsAsync(Guid appId, IEnumerable<IAssetEntity> assets)
{
var tags = new HashSet<string>(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();
}
}
}
} }
} }

19
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<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset);
Task<IReadOnlyList<IEnrichedAssetEntity>> EnrichAsync(IEnumerable<IAssetEntity> assets);
}
}

6
src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -16,10 +16,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
int DefaultPageSizeGraphQl { get; } int DefaultPageSizeGraphQl { get; }
Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash); Task<IReadOnlyList<IEnrichedAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IAssetEntity>> QueryAsync(QueryContext contex, Q query); Task<IResultList<IEnrichedAssetEntity>> QueryAsync(QueryContext contex, Q query);
Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id); Task<IEnrichedAssetEntity> FindAssetAsync(Guid id);
} }
} }

13
src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs → src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs

@ -9,17 +9,8 @@ using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public class AssetResult public interface IEnrichedAssetEntity : IAssetEntity
{ {
public IAssetEntity Asset { get; } HashSet<string> TagNames { get; }
public HashSet<string> Tags { get; }
public AssetResult(IAssetEntity asset, HashSet<string> tags)
{
Asset = asset;
Tags = tags;
}
} }
} }

2
src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
{ {
public interface IAssetRepository public interface IAssetRepository
{ {
Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash); Task<IReadOnlyList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, Query query); Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, Query query);

41
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<ContentCommand, IContentGrain>
{
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<Task> 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);
}
}
}
}

96
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<IEnrichedContentEntity> EnrichAsync(IContentEntity content)
{
Guard.NotNull(content, nameof(content));
var enriched = await EnrichAsync(Enumerable.Repeat(content, 1));
return enriched[0];
}
public async Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents)
{
Guard.NotNull(contents, nameof(contents));
using (Profiler.TraceMethod<ContentEnricher>())
{
var results = new List<ContentEntity>();
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<string> 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;
}
}
}

8
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentEntity : IContentEntity public sealed class ContentEntity : IEnrichedContentEntity
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
@ -38,6 +38,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Status Status { get; set; } 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; } public bool IsPending { get; set; }
} }
} }

4
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); 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; return Snapshot;
}); });

144
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -34,10 +34,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
public sealed class ContentQueryService : IContentQueryService public sealed class ContentQueryService : IContentQueryService
{ {
private static readonly Status[] StatusPublishedOnly = { Status.Published }; private static readonly Status[] StatusPublishedOnly = { Status.Published };
private readonly IContentRepository contentRepository; private static readonly IResultList<IEnrichedContentEntity> EmptyContents = ResultList.CreateFrom<IEnrichedContentEntity>(0);
private readonly IContentVersionLoader contentVersionLoader;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IAssetUrlGenerator assetUrlGenerator; private readonly IAssetUrlGenerator assetUrlGenerator;
private readonly IContentEnricher contentEnricher;
private readonly IContentRepository contentRepository;
private readonly IContentVersionLoader contentVersionLoader;
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly ContentOptions options; private readonly ContentOptions options;
private readonly EdmModelBuilder modelBuilder; private readonly EdmModelBuilder modelBuilder;
@ -50,6 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public ContentQueryService( public ContentQueryService(
IAppProvider appProvider, IAppProvider appProvider,
IAssetUrlGenerator assetUrlGenerator, IAssetUrlGenerator assetUrlGenerator,
IContentEnricher contentEnricher,
IContentRepository contentRepository, IContentRepository contentRepository,
IContentVersionLoader contentVersionLoader, IContentVersionLoader contentVersionLoader,
IScriptEngine scriptEngine, IScriptEngine scriptEngine,
@ -58,6 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(assetUrlGenerator, nameof(assetUrlGenerator)); Guard.NotNull(assetUrlGenerator, nameof(assetUrlGenerator));
Guard.NotNull(contentEnricher, nameof(contentEnricher));
Guard.NotNull(contentRepository, nameof(contentRepository)); Guard.NotNull(contentRepository, nameof(contentRepository));
Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader)); Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader));
Guard.NotNull(modelBuilder, nameof(modelBuilder)); Guard.NotNull(modelBuilder, nameof(modelBuilder));
@ -66,6 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.appProvider = appProvider; this.appProvider = appProvider;
this.assetUrlGenerator = assetUrlGenerator; this.assetUrlGenerator = assetUrlGenerator;
this.contentEnricher = contentEnricher;
this.contentRepository = contentRepository; this.contentRepository = contentRepository;
this.contentVersionLoader = contentVersionLoader; this.contentVersionLoader = contentVersionLoader;
this.modelBuilder = modelBuilder; this.modelBuilder = modelBuilder;
@ -73,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
} }
public async Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1) public async Task<IEnrichedContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
@ -83,25 +88,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var isVersioned = version > EtagVersion.Empty; IContentEntity content;
var status = GetStatus(context);
var content = if (version > EtagVersion.Empty)
isVersioned ? {
await FindContentByVersionAsync(id, version) : content = await FindByVersionAsync(id, version);
await FindContentAsync(context, id, status, schema); }
else
{
content = await FindCoreAsync(context, id, schema);
}
if (content == null || (content.Status != Status.Published && !context.IsFrontendClient) || content.SchemaId.Id != schema.Id) if (content == null || (content.Status != Status.Published && !context.IsFrontendClient) || content.SchemaId.Id != schema.Id)
{ {
throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity)); throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity));
} }
return Transform(context, schema, content); return await TransformAsync(context, schema, content);
} }
} }
public async Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query) public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
@ -111,95 +118,88 @@ namespace Squidex.Domain.Apps.Entities.Contents
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var status = GetStatus(context);
IResultList<IContentEntity> contents; IResultList<IContentEntity> contents;
if (query.Ids?.Count > 0) if (query.Ids != null && query.Ids.Count > 0)
{ {
contents = await QueryAsync(context, schema, query.Ids.ToHashSet(), status); contents = await QueryByIdsAsync(context, query, schema);
contents = SortSet(contents, query.Ids);
} }
else else
{ {
var parsedQuery = ParseQuery(context, query.ODataQuery, schema); contents = await QueryByQueryAsync(context, query, schema);
contents = await QueryAsync(context, schema, parsedQuery, status);
} }
return Transform(context, schema, contents); return await TransformAsync(context, schema, contents);
} }
} }
public async Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids) public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var status = GetStatus(context); if (ids == null || ids.Count == 0)
List<IContentEntity> result;
if (ids?.Count > 0)
{ {
var contents = await QueryAsync(context, ids, status); return EmptyContents;
}
var permissions = context.User.Permissions(); var results = new List<IEnrichedContentEntity>();
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(); var permissions = context.User.Permissions();
result = SortList(result, ids).ToList();
} foreach (var group in contents.GroupBy(x => x.Schema.Id))
else
{ {
result = new List<IContentEntity>(); 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<IContentEntity> Transform(QueryContext context, ISchemaEntity schema, IResultList<IContentEntity> contents) private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(QueryContext context, ISchemaEntity schema, IResultList<IContentEntity> contents)
{ {
var transformed = TransformCore(context, schema, contents); var transformed = await TransformCoreAsync(context, schema, contents);
return ResultList.Create(contents.Total, transformed); return ResultList.Create(contents.Total, transformed);
} }
private IContentEntity Transform(QueryContext context, ISchemaEntity schema, IContentEntity content) private async Task<IEnrichedContentEntity> 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<IContentEntity> SortSet(IResultList<IContentEntity> contents, IReadOnlyList<Guid> ids) return transformed[0];
{
return ResultList.Create(contents.Total, SortList(contents, ids));
} }
private static IEnumerable<IContentEntity> SortList(IEnumerable<IContentEntity> contents, IReadOnlyList<Guid> ids) private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(QueryContext context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
{
return ids.Select(id => contents.FirstOrDefault(x => x.Id == id)).Where(x => x != null);
}
private IEnumerable<IContentEntity> TransformCore(QueryContext context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
{ {
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var results = new List<IEnrichedContentEntity>();
var converters = GenerateConverters(context).ToArray(); var converters = GenerateConverters(context).ToArray();
var scriptText = schema.SchemaDef.Scripts.Query; 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()); var result = SimpleMapper.Map(content, new ContentEntity());
if (result.Data != null) 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 }; 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; result.DataDraft = null;
} }
yield return result; results.Add(result);
} }
return results;
} }
} }
@ -344,32 +346,46 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids, Status[] status) private async Task<IResultList<IContentEntity>> QueryByQueryAsync(QueryContext context, Q query, ISchemaEntity schema)
{
var parsedQuery = ParseQuery(context, query.ODataQuery, schema);
return await QueryCoreAsync(context, schema, parsedQuery);
}
private async Task<IResultList<IContentEntity>> 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<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryCoreAsync(QueryContext context, IReadOnlyList<Guid> ids)
{ {
return contentRepository.QueryAsync(context.App, status, new HashSet<Guid>(ids), ShouldIncludeDraft(context)); return contentRepository.QueryAsync(context.App, GetStatus(context), new HashSet<Guid>(ids), WithDraft(context));
} }
private Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, ISchemaEntity schema, Query query, Status[] status) private Task<IResultList<IContentEntity>> 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<IResultList<IContentEntity>> QueryAsync(QueryContext context, ISchemaEntity schema, HashSet<Guid> ids, Status[] status) private Task<IResultList<IContentEntity>> QueryCoreAsync(QueryContext context, ISchemaEntity schema, HashSet<Guid> ids)
{ {
return contentRepository.QueryAsync(context.App, schema, status, ids, ShouldIncludeDraft(context)); return contentRepository.QueryAsync(context.App, schema, GetStatus(context), ids, WithDraft(context));
} }
private Task<IContentEntity> FindContentAsync(QueryContext context, Guid id, Status[] status, ISchemaEntity schema) private Task<IContentEntity> 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<IContentEntity> FindContentByVersionAsync(Guid id, long version) private Task<IContentEntity> FindByVersionAsync(Guid id, long version)
{ {
return contentVersionLoader.LoadAsync(id, 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; return context.ApiStatus == StatusForApi.All || context.IsFrontendClient;
} }

64
src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -8,45 +8,81 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class DefaultContentWorkflow : IContentWorkflow 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<Status, Status[]> Flow = new Dictionary<Status, Status[]> private static readonly StatusInfo[] All =
{ {
[Status.Draft] = new[] { Status.Archived, Status.Published }, InfoArchived,
[Status.Archived] = new[] { Status.Draft }, InfoDraft,
[Status.Published] = new[] { Status.Draft, Status.Archived } InfoPublished
}; };
public Task<Status> GetInitialStatusAsync(ISchemaEntity schema) private static readonly Dictionary<Status, (StatusInfo Info, StatusInfo[] Transitions)> Flow =
new Dictionary<Status, (StatusInfo Info, StatusInfo[] Transitions)>
{
[Status.Archived] = (InfoArchived, new[]
{
InfoDraft
}),
[Status.Draft] = (InfoDraft, new[]
{
InfoArchived,
InfoPublished
}),
[Status.Published] = (InfoPublished, new[]
{
InfoDraft,
InfoArchived
})
};
public Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
{ {
return Task.FromResult(Status.Draft); var result = InfoDraft;
return Task.FromResult(result);
} }
public Task<bool> CanMoveToAsync(IContentEntity content, Status next) public Task<bool> 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<bool> CanUpdateAsync(IContentEntity content) public Task<bool> CanUpdateAsync(IContentEntity content)
{ {
return Task.FromResult(content.Status != Status.Archived); var result = content.Status != Status.Archived;
return Task.FromResult(result);
} }
public Task<Status[]> GetNextsAsync(IContentEntity content) public Task<StatusInfo> GetInfoAsync(IContentEntity content)
{ {
return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty<Status>()); var result = Flow[content.Status].Info;
return Task.FromResult(result);
}
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty<StatusInfo>();
return Task.FromResult(result);
} }
public Task<Status[]> GetAllAsync(ISchemaEntity schema) public Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema)
{ {
return Task.FromResult(All); return Task.FromResult(All);
} }

129
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<StatusInfo[]> 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<bool> 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<bool> 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<StatusInfo> 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<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
{
var workflow = await GetWorkflowAsync(schema.AppId.Id);
var (status, step) = workflow.GetInitialStep();
return new StatusInfo(status, GetColor(step));
}
public async Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
{
var result = new List<StatusInfo>();
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<Workflow> GetWorkflowAsync(Guid appId)
{
var app = await appProvider.GetAppAsync(appId);
return app?.Workflows.GetFirst();
}
private static string GetColor(WorkflowStep step)
{
return step.Color ?? StatusColors.Draft;
}
}
}

10
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 public sealed class GraphQLExecutionContext : QueryExecutionContext
{ {
private static readonly List<IAssetEntity> EmptyAssets = new List<IAssetEntity>(); private static readonly List<IEnrichedAssetEntity> EmptyAssets = new List<IEnrichedAssetEntity>();
private static readonly List<IContentEntity> EmptyContents = new List<IContentEntity>(); private static readonly List<IContentEntity> EmptyContents = new List<IContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaderContextAccessor; private readonly IDataLoaderContextAccessor dataLoaderContextAccessor;
private readonly IDependencyResolver resolver; private readonly IDependencyResolver resolver;
@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
execution.UserContext = this; execution.UserContext = this;
} }
public override Task<IAssetEntity> FindAssetAsync(Guid id) public override Task<IEnrichedAssetEntity> FindAssetAsync(Guid id)
{ {
var dataLoader = GetAssetsLoader(); var dataLoader = GetAssetsLoader();
@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return dataLoader.LoadAsync(id); return dataLoader.LoadAsync(id);
} }
public async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(IJsonValue value) public async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IJsonValue value)
{ {
var ids = ParseIds(value); var ids = ParseIds(value);
@ -95,9 +95,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return await dataLoader.LoadManyAsync(ids); return await dataLoader.LoadManyAsync(ids);
} }
private IDataLoader<Guid, IAssetEntity> GetAssetsLoader() private IDataLoader<Guid, IEnrichedAssetEntity> GetAssetsLoader()
{ {
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IAssetEntity>("Assets", return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IEnrichedAssetEntity>("Assets",
async batch => async batch =>
{ {
var result = await GetReferencedAssetsAsync(new List<Guid>(batch)); var result = await GetReferencedAssetsAsync(new List<Guid>(batch));

1
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs

@ -7,7 +7,6 @@
using System; using System;
using GraphQL.Types; using GraphQL.Types;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types

10
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 namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public sealed class AssetGraphType : ObjectGraphType<IAssetEntity> public sealed class AssetGraphType : ObjectGraphType<IEnrichedAssetEntity>
{ {
public AssetGraphType(IGraphModel model) public AssetGraphType(IGraphModel model)
{ {
@ -167,8 +167,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
Name = "tags", Name = "tags",
ResolvedType = null, ResolvedType = null,
Resolver = Resolve(x => x.Tags), Resolver = Resolve(x => x.TagNames),
Description = "The height of the image in pixels if the asset is an image.", Description = "The asset tags.",
Type = AllTypes.NonNullTagsType Type = AllTypes.NonNullTagsType
}); });
@ -186,9 +186,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = "An asset"; Description = "An asset";
} }
private static IFieldResolver Resolve(Func<IAssetEntity, object> action) private static IFieldResolver Resolve(Func<IEnrichedAssetEntity, object> action)
{ {
return new FuncFieldResolver<IAssetEntity, object>(c => action(c.Source)); return new FuncFieldResolver<IEnrichedAssetEntity, object>(c => action(c.Source));
} }
} }
} }

14
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 namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public sealed class ContentGraphType : ObjectGraphType<IContentEntity> public sealed class ContentGraphType : ObjectGraphType<IEnrichedContentEntity>
{ {
public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType) 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." 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 AddField(new FieldType
{ {
Name = "url", Name = "url",
@ -108,9 +116,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = $"The structure of a {schemaName} content type."; Description = $"The structure of a {schemaName} content type.";
} }
private static IFieldResolver Resolve(Func<IContentEntity, object> action) private static IFieldResolver Resolve(Func<IEnrichedContentEntity, object> action)
{ {
return new FuncFieldResolver<IContentEntity, object>(c => action(c.Source)); return new FuncFieldResolver<IEnrichedContentEntity, object>(c => action(c.Source));
} }
} }
} }

2
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 => 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) if (content.Status == command.Status && content.Status == Status.Published)
{ {

19
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<IEnrichedContentEntity> EnrichAsync(IContentEntity content);
Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents);
}
}

6
src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -17,11 +17,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
int DefaultPageSizeGraphQl { get; } int DefaultPageSizeGraphQl { get; }
Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids); Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query); Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query);
Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); Task<IEnrichedContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName); Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName);
} }

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
@ -13,14 +14,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public interface IContentWorkflow public interface IContentWorkflow
{ {
Task<Status> GetInitialStatusAsync(ISchemaEntity schema); Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanMoveToAsync(IContentEntity content, Status next); Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content); Task<bool> CanUpdateAsync(IContentEntity content);
Task<Status[]> GetNextsAsync(IContentEntity content); Task<StatusInfo> GetInfoAsync(IContentEntity content);
Task<Status[]> GetAllAsync(ISchemaEntity schema); Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user);
Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema);
} }
} }

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

8
src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public class QueryExecutionContext public class QueryExecutionContext
{ {
private readonly ConcurrentDictionary<Guid, IContentEntity> cachedContents = new ConcurrentDictionary<Guid, IContentEntity>(); private readonly ConcurrentDictionary<Guid, IContentEntity> cachedContents = new ConcurrentDictionary<Guid, IContentEntity>();
private readonly ConcurrentDictionary<Guid, IAssetEntity> cachedAssets = new ConcurrentDictionary<Guid, IAssetEntity>(); private readonly ConcurrentDictionary<Guid, IEnrichedAssetEntity> cachedAssets = new ConcurrentDictionary<Guid, IEnrichedAssetEntity>();
private readonly IContentQueryService contentQuery; private readonly IContentQueryService contentQuery;
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
private readonly QueryContext context; private readonly QueryContext context;
@ -34,13 +34,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.context = context; this.context = context;
} }
public virtual async Task<IAssetEntity> FindAssetAsync(Guid id) public virtual async Task<IEnrichedAssetEntity> FindAssetAsync(Guid id)
{ {
var asset = cachedAssets.GetOrDefault(id); var asset = cachedAssets.GetOrDefault(id);
if (asset == null) if (asset == null)
{ {
asset = await assetQuery.FindAssetAsync(context, id); asset = await assetQuery.FindAssetAsync(id);
if (asset != null) if (asset != null)
{ {
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result; return result;
} }
public virtual async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids) public virtual async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids)
{ {
Guard.NotNull(ids, nameof(ids)); Guard.NotNull(ids, nameof(ids));

4
src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs

@ -16,12 +16,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public Guid Id { get; } public Guid Id { get; }
public Instant DueTime { get; }
public Status Status { get; } public Status Status { get; }
public RefToken ScheduledBy { get; } public RefToken ScheduledBy { get; }
public Instant DueTime { get; }
public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime) public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime)
{ {
Id = id; Id = id;

7
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -50,11 +50,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
SimpleMapper.Map(@event, this); SimpleMapper.Map(@event, this);
UpdateData(null, @event.Data, false); UpdateData(null, @event.Data, false);
if (Status == default)
{
Status = Status.Draft;
}
} }
protected void On(ContentChangesPublished @event) protected void On(ContentChangesPublished @event)
@ -68,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
{ {
ScheduleJob = null; ScheduleJob = null;
Status = @event.Status; SimpleMapper.Map(@event, this);
if (@event.Status == Status.Published) if (@event.Status == Status.Published)
{ {

13
src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs

@ -53,14 +53,17 @@ namespace Squidex.Domain.Apps.Entities.History
message = new Lazy<string>(() => message = new Lazy<string>(() =>
{ {
var result = texts[item.Message]; if (texts.TryGetValue(item.Message, out var result))
foreach (var kvp in item.Parameters)
{ {
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;
}); });
} }
} }

2
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<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id);
Task<IAppEntity> GetAppAsync(Guid appId);
Task<IAppEntity> GetAppAsync(string appName); Task<IAppEntity> GetAppAsync(string appName);
Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false);

6
src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs

@ -19,12 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas
public SchemaHistoryEventsCreator(TypeNameRegistry typeNameRegistry) public SchemaHistoryEventsCreator(TypeNameRegistry typeNameRegistry)
: base(typeNameRegistry) : base(typeNameRegistry)
{ {
AddEventMessage("SchemaCreatedEvent",
"created schema {[Name]}.");
AddEventMessage("ScriptsConfiguredEvent",
"configured script of schema {[Name]}.");
AddEventMessage<SchemaFieldsReordered>( AddEventMessage<SchemaFieldsReordered>(
"reordered fields of schema {[Name]}."); "reordered fields of schema {[Name]}.");

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

2
src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents namespace Squidex.Domain.Apps.Events.Contents
{ {
[EventType(nameof(ContentCreated))] [EventType(nameof(ContentCreated), 2)]
public sealed class ContentCreated : ContentEvent public sealed class ContentCreated : ContentEvent
{ {
public Status Status { get; set; } public Status Status { get; set; }

2
src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents namespace Squidex.Domain.Apps.Events.Contents
{ {
[EventType(nameof(ContentStatusChanged))] [EventType(nameof(ContentStatusChanged), 2)]
public sealed class ContentStatusChanged : ContentEvent public sealed class ContentStatusChanged : ContentEvent
{ {
public StatusChange Change { get; set; } public StatusChange Change { get; set; }

10
src/Squidex.Infrastructure/CollectionExtensions.cs

@ -13,6 +13,16 @@ namespace Squidex.Infrastructure
{ {
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static IResultList<T> SortSet<T, TKey>(this IResultList<T> input, Func<T, TKey> idProvider, IReadOnlyList<TKey> ids) where T : class
{
return ResultList.Create(input.Total, SortList(input, idProvider, ids));
}
public static IEnumerable<T> SortList<T, TKey>(this IEnumerable<T> input, Func<T, TKey> idProvider, IReadOnlyList<TKey> ids) where T : class
{
return ids.Select(id => input.FirstOrDefault(x => Equals(idProvider(x), id))).Where(x => x != null);
}
public static void AddRange<T>(this ICollection<T> target, IEnumerable<T> source) public static void AddRange<T>(this ICollection<T> target, IEnumerable<T> source)
{ {
foreach (var value in source) foreach (var value in source)

11
src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Diagnostics;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
@ -33,6 +34,11 @@ namespace Squidex.Infrastructure.EventSourcing
if (payloadObj is IMigrated<IEvent> migratedEvent) if (payloadObj is IMigrated<IEvent> migratedEvent)
{ {
payloadObj = migratedEvent.Migrate(); payloadObj = migratedEvent.Migrate();
if (ReferenceEquals(migratedEvent, payloadObj))
{
Debug.WriteLine("Migration should return new event.");
}
} }
var envelope = new Envelope<IEvent>(payloadObj, eventData.Headers); var envelope = new Envelope<IEvent>(payloadObj, eventData.Headers);
@ -47,6 +53,11 @@ namespace Squidex.Infrastructure.EventSourcing
if (migrate && eventPayload is IMigrated<IEvent> migratedEvent) if (migrate && eventPayload is IMigrated<IEvent> migratedEvent)
{ {
eventPayload = migratedEvent.Migrate(); eventPayload = migratedEvent.Migrate();
if (ReferenceEquals(migratedEvent, eventPayload))
{
Debug.WriteLine("Migration should return new event.");
}
} }
var payloadType = typeNameRegistry.GetName(eventPayload.GetType()); var payloadType = typeNameRegistry.GetName(eventPayload.GetType());

2
src/Squidex.Infrastructure/ResultList.cs

@ -27,7 +27,7 @@ namespace Squidex.Infrastructure
return new Impl<T>(items, total); return new Impl<T>(items, total);
} }
public static IResultList<T> Create<T>(long total, params T[] items) public static IResultList<T> CreateFrom<T>(long total, params T[] items)
{ {
return new Impl<T>(items, total); return new Impl<T>(items, total);
} }

6
src/Squidex.Shared/Permissions.cs

@ -81,6 +81,12 @@ namespace Squidex.Shared
public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update";
public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; 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 AppBackups = "squidex.apps.{app}.backups";
public const string AppBackupsRead = "squidex.apps.{app}.backups.read"; public const string AppBackupsRead = "squidex.apps.{app}.backups.read";
public const string AppBackupsCreate = "squidex.apps.{app}.backups.create"; public const string AppBackupsCreate = "squidex.apps.{app}.backups.create";

24
src/Squidex.Web/Resource.cs

@ -24,38 +24,38 @@ namespace Squidex.Web
AddGetLink("self", href); 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(rel, nameof(rel));
Guard.NotNullOrEmpty(href, nameof(href)); Guard.NotNullOrEmpty(href, nameof(href));
Guard.NotNullOrEmpty(method, nameof(method)); Guard.NotNullOrEmpty(method, nameof(method));
Links[rel] = new ResourceLink { Href = href, Method = method }; Links[rel] = new ResourceLink { Href = href, Method = method, Metadata = metadata };
} }
} }
} }

4
src/Squidex.Web/ResourceLink.cs

@ -18,5 +18,9 @@ namespace Squidex.Web
[Required] [Required]
[Display(Description = "The link method.")] [Display(Description = "The link method.")]
public string Method { get; set; } public string Method { get; set; }
[Required]
[Display(Description = "Additional data about the link.")]
public string Metadata { get; set; }
} }
} }

2
src/Squidex.Web/UrlHelperExtensions.cs

@ -38,7 +38,7 @@ namespace Squidex.Web
public static string Url<T>(this Controller controller, Func<T, string> action, object values = null) where T : Controller public static string Url<T>(this Controller controller, Func<T, string> action, object values = null) where T : Controller
{ {
return controller.Url.Url<T>(action, values); return controller.Url.Url(action, values);
} }
} }
} }

86
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
{
/// <summary>
/// Manages and configures apps.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppWorkflowsController : ApiController
{
public AppWorkflowsController(ICommandBus commandBus)
: base(commandBus)
{
}
/// <summary>
/// Get app workflow.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => App workflows returned.
/// 404 => App not found.
/// </returns>
[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);
}
/// <summary>
/// Configure workflow of the app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The new workflow.</param>
/// <returns>
/// 200 => Workflow configured.
/// 400 => Workflow is not valid.
/// 404 => App not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/workflow/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)]
[ApiPermission(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutWorkflow(string app, [FromBody] UpsertWorkflowDto request)
{
var command = request.ToCommand();
var response = await InvokeCommandAsync(command);
return Ok(response);
}
private async Task<WorkflowResponseDto> InvokeCommandAsync(ICommand command)
{
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>();
var response = WorkflowResponseDto.FromApp(result, this);
return response;
}
}
}

5
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -177,6 +177,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
AddGetLink("schemas", controller.Url<SchemasController>(x => nameof(x.GetSchemas), values)); AddGetLink("schemas", controller.Url<SchemasController>(x => nameof(x.GetSchemas), values));
} }
if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions))
{
AddGetLink("workflows", controller.Url<AppWorkflowsController>(x => nameof(x.GetWorkflow), values));
}
if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions)) if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions))
{ {
AddPostLink("schemas/create", controller.Url<SchemasController>(x => nameof(x.PostSchema), values)); AddPostLink("schemas/create", controller.Url<SchemasController>(x => nameof(x.PostSchema), values));

45
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
{
/// <summary>
/// The workflow steps.
/// </summary>
[Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The initial step.
/// </summary>
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 };
}
}
}

61
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
{
/// <summary>
/// The workflow steps.
/// </summary>
[Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The initial step.
/// </summary>
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<AppWorkflowsController>(x => nameof(x.PutWorkflow), values));
}
return this;
}
}
}

32
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
{
/// <summary>
/// The workflow.
/// </summary>
[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;
}
}
}

32
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
{
/// <summary>
/// The transitions.
/// </summary>
[Required]
public Dictionary<Status, WorkflowTransitionDto> Transitions { get; set; }
/// <summary>
/// The optional color.
/// </summary>
public string Color { get; set; }
/// <summary>
/// Indicates if updates should not be allowed.
/// </summary>
public bool NoUpdate { get; set; }
}
}

22
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
{
/// <summary>
/// The optional expression.
/// </summary>
public string Expression { get; set; }
/// <summary>
/// The optional restricted role.
/// </summary>
public string Role { get; set; }
}
}

10
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -133,9 +133,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetAsset(string app, Guid id) public async Task<IActionResult> GetAsset(string app, Guid id)
{ {
var context = Context(); var asset = await assetQuery.FindAssetAsync(id);
var asset = await assetQuery.FindAssetAsync(context, id);
if (asset == null) if (asset == null)
{ {
@ -182,7 +180,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetCreatedResult>(); var result = context.Result<AssetCreatedResult>();
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); 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 context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetResult>(); var result = context.Result<IEnrichedAssetEntity>();
var response = AssetDto.FromAsset(result.Asset, this, app, result.Tags); var response = AssetDto.FromAsset(result, this, app);
return response; return response;
} }

7
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs

@ -118,14 +118,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[JsonProperty("_meta")] [JsonProperty("_meta")]
public AssetMetadata Metadata { get; set; } public AssetMetadata Metadata { get; set; }
public static AssetDto FromAsset(IAssetEntity asset, ApiController controller, string app, HashSet<string> 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() }); var response = SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() });
if (tags != null) response.Tags = asset.TagNames;
{
response.Tags = tags;
}
if (isDuplicate) if (isDuplicate)
{ {

2
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs

@ -37,7 +37,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
return Items.ToSurrogateKeys(); return Items.ToSurrogateKeys();
} }
public static AssetsDto FromAssets(IResultList<IAssetEntity> assets, ApiController controller, string app) public static AssetsDto FromAssets(IResultList<IEnrichedAssetEntity> assets, ApiController controller, string app)
{ {
var response = new AssetsDto var response = new AssetsDto
{ {

12
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;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -127,9 +126,8 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var context = Context(); var context = Context();
var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids); var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids);
var contentsList = ResultList.Create<IContentEntity>(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) if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{ {
@ -201,7 +199,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context(); var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id); 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) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
@ -237,7 +235,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context(); var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id, version); 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) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
@ -447,8 +445,8 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IContentEntity>(); var result = context.Result<IEnrichedContentEntity>();
var response = await ContentDto.FromContentAsync(null, result, contentWorkflow, this); var response = ContentDto.FromContent(null, result, this);
return response; return response;
} }

32
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -7,7 +7,6 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
@ -71,20 +70,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
public Instant LastModified { get; set; } public Instant LastModified { get; set; }
/// <summary> /// <summary>
/// The the status of the content. /// The status of the content.
/// </summary> /// </summary>
public Status Status { get; set; } public Status Status { get; set; }
/// <summary>
/// The color of the status.
/// </summary>
public string StatusColor { get; set; }
/// <summary> /// <summary>
/// The version of the content. /// The version of the content.
/// </summary> /// </summary>
public long Version { get; set; } public long Version { get; set; }
public static ValueTask<ContentDto> FromContentAsync( public static ContentDto FromContent(QueryContext context, IEnrichedContentEntity content, ApiController controller)
QueryContext context,
IContentEntity content,
IContentWorkflow contentWorkflow,
ApiController controller)
{ {
var response = SimpleMapper.Map(content, new ContentDto()); 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()); 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<ContentDto> CreateLinksAsync(IContentEntity content, private ContentDto CreateLinksAsync(IEnrichedContentEntity content, ApiController controller, string app, string schema)
ApiController controller,
string app,
string schema,
IContentWorkflow contentWorkflow)
{ {
var values = new { app, name = schema, id = Id }; 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 (controller.HasPermission(Permissions.AppContentsUpdate, app, schema))
{ {
if (await contentWorkflow.CanUpdateAsync(content)) if (content.CanUpdate)
{ {
AddPutLink("update", controller.Url<ContentsController>(x => nameof(x.PutContent), values)); AddPutLink("update", controller.Url<ContentsController>(x => nameof(x.PutContent), values));
} }
@ -157,13 +153,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values)); AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
} }
var nextStatuses = await contentWorkflow.GetNextsAsync(content); foreach (var next in content.Nexts)
foreach (var next in nextStatuses)
{ {
if (controller.HasPermission(Helper.StatusPermission(app, schema, next))) if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status)))
{ {
AddPutLink($"status/{next}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values)); AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
} }
} }

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

@ -35,7 +35,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// The possible statuses. /// The possible statuses.
/// </summary> /// </summary>
[Required] [Required]
public Status[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public string ToEtag() public string ToEtag()
{ {
@ -47,20 +47,16 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
return Items.ToSurrogateKeys(); return Items.ToSurrogateKeys();
} }
public static async Task<ContentsDto> FromContentsAsync(IResultList<IContentEntity> contents, QueryContext context, public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents,
ApiController controller, QueryContext context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
ISchemaEntity schema,
IContentWorkflow contentWorkflow)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
Total = contents.Total, Total = contents.Total,
Items = new ContentDto[contents.Count] Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray()
}; };
await Task.WhenAll( await result.AssignStatusesAsync(contentWorkflow, schema);
result.AssignContentsAsync(contentWorkflow, contents, context, controller),
result.AssignStatusesAsync(contentWorkflow, schema));
return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name); 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); var allStatuses = await contentWorkflow.GetAllAsync(schema);
Statuses = allStatuses.ToArray(); Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray();
}
private async Task AssignContentsAsync(IContentWorkflow contentWorkflow, IResultList<IContentEntity> contents, QueryContext context, ApiController controller)
{
for (var i = 0; i < Items.Length; i++)
{
Items[i] = await ContentDto.FromContentAsync(context, contents[i], contentWorkflow, controller);
}
} }
private ContentsDto CreateLinks(ApiController controller, string app, string schema) private ContentsDto CreateLinks(ApiController controller, string app, string schema)

32
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
{
/// <summary>
/// The name of the status.
/// </summary>
[Required]
public Status Status { get; set; }
/// <summary>
/// The color of the status.
/// </summary>
[Required]
public string Color { get; set; }
public static StatusInfoDto FromStatusInfo(StatusInfo statusInfo)
{
return new StatusInfoDto { Status = statusInfo.Status, Color = statusInfo.Color };
}
}
}

3
src/Squidex/Areas/Api/Controllers/History/HistoryController.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.History.Models; 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 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); return Ok(response);
} }

15
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;
using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Contents; 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.Edm;
using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
@ -97,9 +96,15 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AppProvider>() services.AddSingletonAs<AppProvider>()
.As<IAppProvider>(); .As<IAppProvider>();
services.AddSingletonAs<AssetEnricher>()
.As<IAssetEnricher>();
services.AddSingletonAs<AssetQueryService>() services.AddSingletonAs<AssetQueryService>()
.As<IAssetQueryService>(); .As<IAssetQueryService>();
services.AddSingletonAs<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<ContentQueryService>() services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>(); .As<IContentQueryService>();
@ -115,7 +120,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SchemaHistoryEventsCreator>() services.AddSingletonAs<SchemaHistoryEventsCreator>()
.As<IHistoryEventsCreator>(); .As<IHistoryEventsCreator>();
services.AddSingletonAs<DefaultContentWorkflow>() services.AddSingletonAs<DynamicContentWorkflow>()
.AsOptional<IContentWorkflow>(); .AsOptional<IContentWorkflow>();
services.AddSingletonAs<RolePermissionsProvider>() services.AddSingletonAs<RolePermissionsProvider>()
@ -222,6 +227,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetCommandMiddleware>() services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<ContentCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<AppsByNameIndexCommandMiddleware>() services.AddSingletonAs<AppsByNameIndexCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
@ -231,9 +239,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<GrainCommandMiddleware<CommentsCommand, ICommentGrain>>() services.AddSingletonAs<GrainCommandMiddleware<CommentsCommand, ICommentGrain>>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<ContentCommand, IContentGrain>>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>() services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();

3
src/Squidex/Config/Domain/SerializationServices.cs

@ -46,7 +46,8 @@ namespace Squidex.Config.Domain
new RuleConverter(), new RuleConverter(),
new SchemaConverter(), new SchemaConverter(),
new StatusConverter(), new StatusConverter(),
new StringEnumConverter()); new StringEnumConverter(),
new WorkflowConverter());
settings.NullValueHandling = NullValueHandling.Ignore; settings.NullValueHandling = NullValueHandling.Ignore;

2
src/Squidex/app-config/karma-test-shim.js

@ -18,7 +18,7 @@ testing.TestBed.initTestEnvironment(
browser.platformBrowserDynamicTesting() 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 * Get all the files, for each file, call the context function

12
src/Squidex/app-config/webpack.config.js

@ -43,7 +43,7 @@ module.exports = function (env) {
* *
* See: https://webpack.js.org/configuration/devtool/ * 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. * Options affecting the resolving of modules.
@ -93,11 +93,6 @@ module.exports = function (env) {
loader: 'ignore-loader' loader: 'ignore-loader'
}], }],
include: [/node_modules/] include: [/node_modules/]
}, {
test: /\.html$/,
use: [{
loader: 'raw-loader'
}]
}, { }, {
test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/, test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/,
use: [{ use: [{
@ -256,12 +251,15 @@ module.exports = function (env) {
waitForLinting: isProduction waitForLinting: isProduction
}) })
); );
}
if (!isCoverage) {
config.plugins.push( config.plugins.push(
new plugins.NgToolsWebpack.AngularCompilerPlugin({ new plugins.NgToolsWebpack.AngularCompilerPlugin({
directTemplateLoading: true,
entryModule: 'app/app.module#AppModule', entryModule: 'app/app.module#AppModule',
sourceMap: !isProduction, sourceMap: !isProduction,
skipSourceGeneration: !isAot, skipCodeGeneration: !isAot,
tsConfigPath: './tsconfig.json' tsConfigPath: './tsconfig.json'
}) })
); );

2
src/Squidex/app/features/administration/services/users.service.ts

@ -19,7 +19,7 @@ import {
ResultSet ResultSet
} from '@app/shared'; } from '@app/shared';
export class UsersDto extends ResultSet<UserDto> { export class UsersDto extends ResultSet<UserDto> {
public get canCreate() { public get canCreate() {
return hasAnyLink(this._links, 'create'); return hasAnyLink(this._links, 'create');
} }

5
src/Squidex/app/features/content/pages/content/content-page.component.html

@ -36,6 +36,7 @@
[class.active]="dropdown.isOpen | async" #optionsButton> [class.active]="dropdown.isOpen | async" #optionsButton>
<sqx-content-status <sqx-content-status
[status]="content.status" [status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status" [scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime" [scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending" [isPending]="content.isPending"
@ -56,8 +57,8 @@
</a> </a>
<ng-container *ngIf="!schema.isSingleton"> <ng-container *ngIf="!schema.isSingleton">
<a class="dropdown-item" *ngFor="let status of content.statusUpdates" (click)="changeStatus(status)"> <a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="changeStatus(info.status)">
Status to {{status}} Change to <i class="icon-circle icon-sm" [style.color]="info.color"></i> {{info.status}}
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>

2
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html

@ -17,7 +17,7 @@
<a class="sidebar-item" *ngFor="let query of contentsState.statusQueries | async; trackBy: trackByQuery" (click)="search(query.filter)" <a class="sidebar-item" *ngFor="let query of contentsState.statusQueries | async; trackBy: trackByQuery" (click)="search(query.filter)"
[class.active]="isSelectedQuery(query.filter)"> [class.active]="isSelectedQuery(query.filter)">
{{query.name}} <i class="icon-circle" [style.color]="query.color"></i> {{query.name}}
</a> </a>
</div> </div>

2
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss

@ -3,4 +3,4 @@
.text-muted { .text-muted {
pointer-events: none; pointer-events: none;
} }

4
src/Squidex/app/features/content/pages/contents/contents-page.component.ts

@ -207,8 +207,8 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
const allActions = {}; const allActions = {};
for (let content of this.contentsState.snapshot.contents.values) { for (let content of this.contentsState.snapshot.contents.values) {
for (let status of content.statusUpdates) { for (let info of content.statusUpdates) {
allActions[status] = true; allActions[info.status] = info.color;
} }
} }

5
src/Squidex/app/features/content/shared/content-item.component.html

@ -24,6 +24,7 @@
<td class="cell-time" *ngIf="!isCompact" [sqxStopClick]="isDirty"> <td class="cell-time" *ngIf="!isCompact" [sqxStopClick]="isDirty">
<sqx-content-status <sqx-content-status
[status]="content.status" [status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status" [scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime" [scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending"> [isPending]="content.isPending">
@ -55,8 +56,8 @@
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
<div class="dropdown-menu" *sqxModalView="dropdown;closeAlways:true" [sqxModalTarget]="optionsButton" @fade> <div class="dropdown-menu" *sqxModalView="dropdown;closeAlways:true" [sqxModalTarget]="optionsButton" @fade>
<a class="dropdown-item" *ngFor="let status of content.statusUpdates" (click)="emitChangeStatus(status)"> <a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="emitChangeStatus(info.status)">
Status to {{status}} Change to <i class="icon-circle icon-sm" [style.color]="info.color"></i> {{info.status}}
</a> </a>
<a class="dropdown-item" (click)="emitClone(); dropdown.hide()" *ngIf="canClone"> <a class="dropdown-item" (click)="emitClone(); dropdown.hide()" *ngIf="canClone">
Clone Clone

4
src/Squidex/app/features/content/shared/content-status.component.html

@ -1,11 +1,11 @@
<ng-container *ngIf="scheduledTo; else noSchedule"> <ng-container *ngIf="scheduledTo; else noSchedule">
<span class="content-status content-status-{{scheduledTo | lowercase}} mr-1" title="{{tooltipText}}" titlePosition="top"> <span class="content-status pending mr-1"title="{{tooltipText}}" titlePosition="top">
<i class="icon-clock"></i> <i class="icon-clock"></i>
</span> </span>
</ng-container> </ng-container>
<ng-template #noSchedule> <ng-template #noSchedule>
<span class="content-status content-status-{{displayStatus | lowercase}} mr-1" title="{{tooltipText}}" titlePosition="top"> <span class="content-status default mr-1" [style.color]="statusColor" title="{{tooltipText}}" titlePosition="top">
<i class="icon-circle"></i> <i class="icon-circle"></i>
</span> </span>
</ng-template> </ng-template>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save