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

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

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

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

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

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

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

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

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

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

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

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

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

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.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using System.ComponentModel;
namespace Squidex.Domain.Apps.Core.Contents
{
[TypeConverter(typeof(StatusConverter))]
public struct Status : IEquatable<Status>
{
public static readonly Status Archived = new Status("Archived");
@ -45,7 +46,7 @@ namespace Squidex.Domain.Apps.Core.Contents
public override string ToString()
{
return name;
return Name;
}
public static bool operator ==(Status lhs, Status rhs)

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.
// ==========================================================================
using Squidex.Infrastructure;
using System.Collections.ObjectModel;
using Squidex.Infrastructure;
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.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
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 static readonly FieldCollection<T> Empty = new FieldCollection<T>();
private static readonly Dictionary<long, T> EmptyById = new Dictionary<long, 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.
// ==========================================================================
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure;
using NamedIdStatic = Squidex.Infrastructure.NamedId;
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.
// ==========================================================================
using System;
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System;
using P = Squidex.Domain.Apps.Core.Partitioning;
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.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Schemas
{
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.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using Squidex.Infrastructure;
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>
</PackageReference>
<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.ComponentModel.Annotations" 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>())
{

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)
{
return ResultList.Create<IContentEntity>(0);
return ResultList.CreateFrom<IContentEntity>(0);
}
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)
{
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () =>
@ -78,14 +89,7 @@ namespace Squidex.Domain.Apps.Entities
return null;
}
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
return await GetAppByIdAsync(appId);
}
});
}
@ -184,6 +188,18 @@ namespace Squidex.Domain.Apps.Entities
});
}
private async Task<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)
{
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;
});
case ConfigureWorkflow configureWorkflow:
return UpdateReturn(configureWorkflow, c =>
{
GuardAppWorkflows.CanConfigure(c);
ConfigureWorkflow(c);
return Snapshot;
});
case AddLanguage addLanguage:
return UpdateReturn(addLanguage, c =>
{
@ -319,6 +329,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
}
public void ConfigureWorkflow(ConfigureWorkflow command)
{
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured()));
}
public void AddLanguage(AddLanguage command)
{
RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded()));

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

@ -19,15 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry)
: base(typeNameRegistry)
{
AddEventMessage("AppContributorAssignedEvent",
"assigned {user:[Contributor]} as {[Role]}");
AddEventMessage("AppClientUpdatedEvent",
"updated client {[Id]}");
AddEventMessage("AppPlanChanged",
"changed plan to {[Plan]}");
AddEventMessage<AppContributorAssigned>(
"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.Contents;
namespace Squidex.Domain.Apps.Entities.Apps
{
@ -29,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
LanguagesConfig LanguagesConfig { get; }
Workflows Workflows { get; }
bool IsArchived { get; }
}
}

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

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

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

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

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

@ -5,17 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCreatedResult : AssetResult
public sealed class AssetCreatedResult
{
public IEnrichedAssetEntity Asset { get; }
public bool IsDuplicate { get; }
public AssetCreatedResult(IAssetEntity asset, bool isDuplicate, HashSet<string> tags)
: base(asset, tags)
public AssetCreatedResult(IEnrichedAssetEntity asset, bool isDuplicate)
{
Asset = asset;
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
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using NodaTime;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.TestData
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class FakeAssetEntity : IAssetEntity
public sealed class AssetEntity : IEnrichedAssetEntity
{
public NamedId<Guid> AppId { get; set; }
@ -31,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
public HashSet<string> Tags { get; set; }
public HashSet<string> TagNames { get; set; }
public long Version { get; set; }
public string MimeType { get; set; }

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

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

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

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
{
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);

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

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

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

@ -8,45 +8,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DefaultContentWorkflow : IContentWorkflow
{
private static readonly Status[] All = { Status.Archived, Status.Draft, Status.Published };
private static readonly StatusInfo InfoArchived = new StatusInfo(Status.Archived, StatusColors.Archived);
private static readonly StatusInfo InfoDraft = new StatusInfo(Status.Draft, StatusColors.Draft);
private static readonly StatusInfo InfoPublished = new StatusInfo(Status.Published, StatusColors.Published);
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
private static readonly StatusInfo[] All =
{
[Status.Draft] = new[] { Status.Archived, Status.Published },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
InfoArchived,
InfoDraft,
InfoPublished
};
public Task<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)
{
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);
}

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
{
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 readonly IDataLoaderContextAccessor dataLoaderContextAccessor;
private readonly IDependencyResolver resolver;
@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
execution.UserContext = this;
}
public override Task<IAssetEntity> FindAssetAsync(Guid id)
public override Task<IEnrichedAssetEntity> FindAssetAsync(Guid id)
{
var dataLoader = GetAssetsLoader();
@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return dataLoader.LoadAsync(id);
}
public async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(IJsonValue value)
public async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IJsonValue value)
{
var ids = ParseIds(value);
@ -95,9 +95,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return await dataLoader.LoadManyAsync(ids);
}
private IDataLoader<Guid, IAssetEntity> GetAssetsLoader()
private IDataLoader<Guid, IEnrichedAssetEntity> GetAssetsLoader()
{
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IAssetEntity>("Assets",
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IEnrichedAssetEntity>("Assets",
async 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 GraphQL.Types;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types

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
{
public sealed class AssetGraphType : ObjectGraphType<IAssetEntity>
public sealed class AssetGraphType : ObjectGraphType<IEnrichedAssetEntity>
{
public AssetGraphType(IGraphModel model)
{
@ -167,8 +167,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
Name = "tags",
ResolvedType = null,
Resolver = Resolve(x => x.Tags),
Description = "The height of the image in pixels if the asset is an image.",
Resolver = Resolve(x => x.TagNames),
Description = "The asset tags.",
Type = AllTypes.NonNullTagsType
});
@ -186,9 +186,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = "An asset";
}
private static IFieldResolver Resolve(Func<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
{
public sealed class ContentGraphType : ObjectGraphType<IContentEntity>
public sealed class ContentGraphType : ObjectGraphType<IEnrichedContentEntity>
{
public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType)
{
@ -78,6 +78,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = $"The the status of the {schemaName} content."
});
AddField(new FieldType
{
Name = "statusColor",
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.StatusColor),
Description = $"The color status of the {schemaName} content."
});
AddField(new FieldType
{
Name = "url",
@ -108,9 +116,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = $"The structure of a {schemaName} content type.";
}
private static IFieldResolver Resolve(Func<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 =>
{
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)
{

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

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
@ -13,14 +14,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentWorkflow
{
Task<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<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
{
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 IAssetQueryService assetQuery;
private readonly QueryContext context;
@ -34,13 +34,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.context = context;
}
public virtual async Task<IAssetEntity> FindAssetAsync(Guid id)
public virtual async Task<IEnrichedAssetEntity> FindAssetAsync(Guid id)
{
var asset = cachedAssets.GetOrDefault(id);
if (asset == null)
{
asset = await assetQuery.FindAssetAsync(context, id);
asset = await assetQuery.FindAssetAsync(id);
if (asset != null)
{
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result;
}
public virtual async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids)
public virtual async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> 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 Instant DueTime { get; }
public Status Status { get; }
public RefToken ScheduledBy { get; }
public Instant DueTime { get; }
public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime)
{
Id = id;

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

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

@ -53,14 +53,17 @@ namespace Squidex.Domain.Apps.Entities.History
message = new Lazy<string>(() =>
{
var result = texts[item.Message];
foreach (var kvp in item.Parameters)
if (texts.TryGetValue(item.Message, out var result))
{
result = result.Replace("[" + kvp.Key + "]", kvp.Value);
foreach (var kvp in item.Parameters)
{
result = result.Replace("[" + kvp.Key + "]", kvp.Value);
}
return result;
}
return result;
return null;
});
}
}

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> GetAppAsync(Guid appId);
Task<IAppEntity> GetAppAsync(string appName);
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)
: base(typeNameRegistry)
{
AddEventMessage("SchemaCreatedEvent",
"created schema {[Name]}.");
AddEventMessage("ScriptsConfiguredEvent",
"configured script of schema {[Name]}.");
AddEventMessage<SchemaFieldsReordered>(
"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
{
[EventType(nameof(ContentCreated))]
[EventType(nameof(ContentCreated), 2)]
public sealed class ContentCreated : ContentEvent
{
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
{
[EventType(nameof(ContentStatusChanged))]
[EventType(nameof(ContentStatusChanged), 2)]
public sealed class ContentStatusChanged : ContentEvent
{
public StatusChange Change { get; set; }

10
src/Squidex.Infrastructure/CollectionExtensions.cs

@ -13,6 +13,16 @@ namespace Squidex.Infrastructure
{
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)
{
foreach (var value in source)

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

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

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 AppPatternsDelete = "squidex.apps.{app}.patterns.delete";
public const string AppWorkflows = "squidex.apps.{app}.workflows";
public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read";
public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create";
public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update";
public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete";
public const string AppBackups = "squidex.apps.{app}.backups";
public const string AppBackupsRead = "squidex.apps.{app}.backups.read";
public const string AppBackupsCreate = "squidex.apps.{app}.backups.create";

24
src/Squidex.Web/Resource.cs

@ -24,38 +24,38 @@ namespace Squidex.Web
AddGetLink("self", href);
}
public void AddGetLink(string rel, string href)
public void AddGetLink(string rel, string href, string metadata = null)
{
AddLink(rel, "GET", href);
AddLink(rel, "GET", href, metadata);
}
public void AddPatchLink(string rel, string href)
public void AddPatchLink(string rel, string href, string metadata = null)
{
AddLink(rel, "PATCH", href);
AddLink(rel, "PATCH", href, metadata);
}
public void AddPostLink(string rel, string href)
public void AddPostLink(string rel, string href, string metadata = null)
{
AddLink(rel, "POST", href);
AddLink(rel, "POST", href, metadata);
}
public void AddPutLink(string rel, string href)
public void AddPutLink(string rel, string href, string metadata = null)
{
AddLink(rel, "PUT", href);
AddLink(rel, "PUT", href, metadata);
}
public void AddDeleteLink(string rel, string href)
public void AddDeleteLink(string rel, string href, string metadata = null)
{
AddLink(rel, "DELETE", href);
AddLink(rel, "DELETE", href, metadata);
}
public void AddLink(string rel, string method, string href)
public void AddLink(string rel, string method, string href, string metadata = null)
{
Guard.NotNullOrEmpty(rel, nameof(rel));
Guard.NotNullOrEmpty(href, nameof(href));
Guard.NotNullOrEmpty(method, nameof(method));
Links[rel] = new ResourceLink { Href = href, Method = method };
Links[rel] = new ResourceLink { Href = href, Method = method, Metadata = metadata };
}
}
}

4
src/Squidex.Web/ResourceLink.cs

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

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

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

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

@ -7,7 +7,6 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
@ -71,20 +70,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
public Instant LastModified { get; set; }
/// <summary>
/// The the status of the content.
/// The status of the content.
/// </summary>
public Status Status { get; set; }
/// <summary>
/// The color of the status.
/// </summary>
public string StatusColor { get; set; }
/// <summary>
/// The version of the content.
/// </summary>
public long Version { get; set; }
public static ValueTask<ContentDto> FromContentAsync(
QueryContext context,
IContentEntity content,
IContentWorkflow contentWorkflow,
ApiController controller)
public static ContentDto FromContent(QueryContext context, IEnrichedContentEntity content, ApiController controller)
{
var response = SimpleMapper.Map(content, new ContentDto());
@ -104,14 +104,10 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto());
}
return response.CreateLinksAsync(content, controller, content.AppId.Name, content.SchemaId.Name, contentWorkflow);
return response.CreateLinksAsync(content, controller, content.AppId.Name, content.SchemaId.Name);
}
private async ValueTask<ContentDto> CreateLinksAsync(IContentEntity content,
ApiController controller,
string app,
string schema,
IContentWorkflow contentWorkflow)
private ContentDto CreateLinksAsync(IEnrichedContentEntity content, ApiController controller, string app, string schema)
{
var values = new { app, name = schema, id = Id };
@ -139,7 +135,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema))
{
if (await contentWorkflow.CanUpdateAsync(content))
if (content.CanUpdate)
{
AddPutLink("update", controller.Url<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));
}
var nextStatuses = await contentWorkflow.GetNextsAsync(content);
foreach (var next in nextStatuses)
foreach (var next in content.Nexts)
{
if (controller.HasPermission(Helper.StatusPermission(app, schema, next)))
if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status)))
{
AddPutLink($"status/{next}", controller.Url<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.
/// </summary>
[Required]
public Status[] Statuses { get; set; }
public StatusInfoDto[] Statuses { get; set; }
public string ToEtag()
{
@ -47,20 +47,16 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
return Items.ToSurrogateKeys();
}
public static async Task<ContentsDto> FromContentsAsync(IResultList<IContentEntity> contents, QueryContext context,
ApiController controller,
ISchemaEntity schema,
IContentWorkflow contentWorkflow)
public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents,
QueryContext context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
{
var result = new ContentsDto
{
Total = contents.Total,
Items = new ContentDto[contents.Count]
Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray()
};
await Task.WhenAll(
result.AssignContentsAsync(contentWorkflow, contents, context, controller),
result.AssignStatusesAsync(contentWorkflow, schema));
await result.AssignStatusesAsync(contentWorkflow, schema);
return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name);
}
@ -69,15 +65,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
{
var allStatuses = await contentWorkflow.GetAllAsync(schema);
Statuses = allStatuses.ToArray();
}
private async Task AssignContentsAsync(IContentWorkflow contentWorkflow, IResultList<IContentEntity> contents, QueryContext context, ApiController controller)
{
for (var i = 0; i < Items.Length; i++)
{
Items[i] = await ContentDto.FromContentAsync(context, contents[i], contentWorkflow, controller);
}
Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray();
}
private ContentsDto CreateLinks(ApiController controller, string app, string schema)

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.
// ==========================================================================
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.History.Models;
@ -48,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.History
{
var events = await historyService.QueryByChannelAsync(AppId, channel, 100);
var response = events.ToArray(HistoryEventDto.FromHistoryEvent);
var response = events.Select(HistoryEventDto.FromHistoryEvent).Where(x => x.Message != null).ToArray();
return Ok(response);
}

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

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

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

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

@ -18,7 +18,7 @@ testing.TestBed.initTestEnvironment(
browser.platformBrowserDynamicTesting()
);
var testContext = require.context('../app', true, /workflows\.service\.spec\.ts/);
var testContext = require.context('../app', true, /\.spec\.ts/);
/**
* Get all the files, for each file, call the context function

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

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

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

@ -19,7 +19,7 @@ import {
ResultSet
} from '@app/shared';
export class UsersDto extends ResultSet<UserDto> {
export class UsersDto extends ResultSet<UserDto> {
public get canCreate() {
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>
<sqx-content-status
[status]="content.status"
[statusColor]="content.statusColor"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
[isPending]="content.isPending"
@ -56,8 +57,8 @@
</a>
<ng-container *ngIf="!schema.isSingleton">
<a class="dropdown-item" *ngFor="let status of content.statusUpdates" (click)="changeStatus(status)">
Status to {{status}}
<a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="changeStatus(info.status)">
Change to <i class="icon-circle icon-sm" [style.color]="info.color"></i> {{info.status}}
</a>
<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)"
[class.active]="isSelectedQuery(query.filter)">
{{query.name}}
<i class="icon-circle" [style.color]="query.color"></i> {{query.name}}
</a>
</div>

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

@ -3,4 +3,4 @@
.text-muted {
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 = {};
for (let content of this.contentsState.snapshot.contents.values) {
for (let status of content.statusUpdates) {
allActions[status] = true;
for (let info of content.statusUpdates) {
allActions[info.status] = info.color;
}
}

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

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

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

@ -1,11 +1,11 @@
<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>
</span>
</ng-container>
<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>
</span>
</ng-template>

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

Loading…
Cancel
Save