Browse Source

Merge pull request #377 from Squidex/temp2

Enrich content
pull/372/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
9eb8846b71
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  2. 16
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs
  3. 23
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs
  4. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  5. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  6. 9
      src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
  7. 67
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  8. 11
      src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs
  9. 82
      src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs
  10. 9
      src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs
  11. 97
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  12. 19
      src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs
  13. 6
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  14. 13
      src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs
  15. 2
      src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  16. 41
      src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs
  17. 95
      src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs
  18. 8
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  19. 4
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  20. 144
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  21. 61
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  22. 10
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  23. 1
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs
  24. 10
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs
  25. 14
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  26. 19
      src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs
  27. 6
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  28. 8
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  29. 20
      src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs
  30. 8
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  31. 4
      src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs
  32. 7
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  33. 13
      src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs
  34. 6
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs
  35. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs
  36. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  37. 10
      src/Squidex.Infrastructure/CollectionExtensions.cs
  38. 11
      src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs
  39. 2
      src/Squidex.Infrastructure/ResultList.cs
  40. 24
      src/Squidex.Web/Resource.cs
  41. 4
      src/Squidex.Web/ResourceLink.cs
  42. 2
      src/Squidex.Web/UrlHelperExtensions.cs
  43. 10
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  44. 7
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  45. 2
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs
  46. 12
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  47. 32
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  48. 24
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  49. 32
      src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs
  50. 3
      src/Squidex/Areas/Api/Controllers/History/HistoryController.cs
  51. 13
      src/Squidex/Config/Domain/EntitiesServices.cs
  52. 2
      src/Squidex/app-config/webpack.config.js
  53. 5
      src/Squidex/app/features/content/pages/content/content-page.component.html
  54. 2
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html
  55. 4
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  56. 5
      src/Squidex/app/features/content/shared/content-item.component.html
  57. 4
      src/Squidex/app/features/content/shared/content-status.component.html
  58. 12
      src/Squidex/app/features/content/shared/content-status.component.scss
  59. 3
      src/Squidex/app/features/content/shared/content-status.component.ts
  60. 2
      src/Squidex/app/framework/utils/hateos.ts
  61. 2
      src/Squidex/app/shared/components/schema-category.component.ts
  62. 8
      src/Squidex/app/shared/services/contents.service.spec.ts
  63. 12
      src/Squidex/app/shared/services/contents.service.ts
  64. 14
      src/Squidex/app/shared/state/contents.state.ts
  65. 4
      src/Squidex/app/theme/_bootstrap.scss
  66. 33
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/StatusTests.cs
  67. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs
  68. 155
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  69. 101
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetEnricherTests.cs
  70. 92
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs
  71. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  72. 91
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentCommandMiddlewareTests.cs
  73. 85
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherTests.cs
  74. 60
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs
  75. 31
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  76. 18
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs
  77. 69
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs
  78. 38
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  79. 11
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  80. 7
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs
  81. 7
      tools/Migrate_01/OldEvents/AppPlanChanged.cs
  82. 38
      tools/Migrate_01/OldEvents/ContentCreated.cs
  83. 47
      tools/Migrate_01/OldEvents/ContentStatusChanged.cs

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

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System; using System;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
@ -45,7 +44,7 @@ namespace Squidex.Domain.Apps.Core.Contents
public override string ToString() public override string ToString()
{ {
return name; return Name;
} }
public static bool operator ==(Status lhs, Status rhs) public static bool operator ==(Status lhs, Status rhs)

16
src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Contents
{
public static class StatusColors
{
public const string Archived = "#eb3142";
public const string Draft = "#8091a5";
public const string Published = "#4bb958";
}
}

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

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

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

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

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

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

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

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

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

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

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

82
src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs

@ -0,0 +1,82 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetEnricher : IAssetEnricher
{
private readonly ITagService tagService;
public AssetEnricher(ITagService tagService)
{
Guard.NotNull(tagService, nameof(tagService));
this.tagService = tagService;
}
public async Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset)
{
Guard.NotNull(asset, nameof(asset));
var enriched = await EnrichAsync(Enumerable.Repeat(asset, 1));
return enriched[0];
}
public async Task<IReadOnlyList<IEnrichedAssetEntity>> EnrichAsync(IEnumerable<IAssetEntity> assets)
{
Guard.NotNull(assets, nameof(assets));
using (Profiler.TraceMethod<AssetEnricher>())
{
var results = new List<IEnrichedAssetEntity>();
foreach (var group in assets.GroupBy(x => x.AppId.Id))
{
var tagsById = await CalculateTags(group);
foreach (var asset in group)
{
var result = SimpleMapper.Map(asset, new AssetEntity());
result.TagNames = new HashSet<string>();
if (asset.Tags != null)
{
foreach (var id in asset.Tags)
{
if (tagsById.TryGetValue(id, out var name))
{
result.TagNames.Add(name);
}
}
}
results.Add(result);
}
}
return results;
}
}
private async Task<Dictionary<string, string>> CalculateTags(IGrouping<System.Guid, IAssetEntity> group)
{
var uniqueIds = group.Where(x => x.Tags != null).SelectMany(x => x.Tags).ToHashSet();
return await tagService.DenormalizeTagsAsync(group.Key, TagGroups.Assets, uniqueIds);
}
}
}

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

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

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

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

19
src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetEnricher
{
Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset);
Task<IReadOnlyList<IEnrichedAssetEntity>> EnrichAsync(IEnumerable<IAssetEntity> assets);
}
}

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

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

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

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

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

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

41
src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs

@ -0,0 +1,41 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentCommandMiddleware : GrainCommandMiddleware<ContentCommand, IContentGrain>
{
private readonly IContentEnricher contentEnricher;
public ContentCommandMiddleware(IGrainFactory grainFactory, IContentEnricher contentEnricher)
: base(grainFactory)
{
Guard.NotNull(contentEnricher, nameof(contentEnricher));
this.contentEnricher = contentEnricher;
}
public override async Task HandleAsync(CommandContext context, Func<Task> next)
{
await base.HandleAsync(context, next);
if (context.PlainResult is IContentEntity content && !(context.PlainResult is IEnrichedContentEntity))
{
var enriched = await contentEnricher.EnrichAsync(content);
context.Complete(enriched);
}
}
}
}

95
src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs

@ -0,0 +1,95 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
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);
}
private async Task ResolveColorAsync(IContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache)
{
result.StatusColor = await GetColorAsync(content.SchemaId, content.Status, cache);
}
private async Task<string> GetColorAsync(NamedId<Guid> schemaId, Status status, Dictionary<(Guid, Status), StatusInfo> cache)
{
if (!cache.TryGetValue((schemaId.Id, status), out var info))
{
info = await contentWorkflow.GetInfoAsync(status);
if (info == null)
{
info = new StatusInfo(status, DefaultColor);
}
cache[(schemaId.Id, status)] = info;
}
return info.Color;
}
}
}

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

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

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

@ -83,9 +83,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data); await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data);
} }
var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema); var statusInfo = await contentWorkflow.GetInitialStatusAsync(ctx.Schema);
Create(c, status); Create(c, statusInfo.Status);
return Snapshot; return Snapshot;
}); });

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

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

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

@ -11,42 +11,77 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class DefaultContentWorkflow : IContentWorkflow public sealed class DefaultContentWorkflow : IContentWorkflow
{ {
private static readonly Status[] All = { Status.Archived, Status.Draft, Status.Published }; private static readonly StatusInfo InfoArchived = new StatusInfo(Status.Archived, StatusColors.Archived);
private static readonly StatusInfo InfoDraft = new StatusInfo(Status.Draft, StatusColors.Draft);
private static readonly StatusInfo InfoPublished = new StatusInfo(Status.Published, StatusColors.Published);
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]> private static readonly StatusInfo[] All =
{ {
[Status.Draft] = new[] { Status.Archived, Status.Published }, InfoArchived,
[Status.Archived] = new[] { Status.Draft }, InfoDraft,
[Status.Published] = new[] { Status.Draft, Status.Archived } InfoPublished
}; };
public Task<Status> GetInitialStatusAsync(ISchemaEntity schema) private static readonly Dictionary<Status, (StatusInfo Info, StatusInfo[] Transitions)> Flow =
new Dictionary<Status, (StatusInfo Info, StatusInfo[] Transitions)>
{
[Status.Archived] = (InfoArchived, new[]
{
InfoDraft
}),
[Status.Draft] = (InfoDraft, new[]
{
InfoArchived,
InfoPublished
}),
[Status.Published] = (InfoPublished, new[]
{
InfoDraft,
InfoArchived
})
};
public Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
{ {
return Task.FromResult(Status.Draft); var result = InfoDraft;
return Task.FromResult(result);
} }
public Task<bool> CanMoveToAsync(IContentEntity content, Status next) public Task<bool> CanMoveToAsync(IContentEntity content, Status next)
{ {
return Task.FromResult(Flow.TryGetValue(content.Status, out var state) && state.Contains(next)); var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next);
return Task.FromResult(result);
} }
public Task<bool> CanUpdateAsync(IContentEntity content) public Task<bool> CanUpdateAsync(IContentEntity content)
{ {
return Task.FromResult(content.Status != Status.Archived); var result = content.Status != Status.Archived;
return Task.FromResult(result);
} }
public Task<Status[]> GetNextsAsync(IContentEntity content) public Task<StatusInfo> GetInfoAsync(Status status)
{ {
return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty<Status>()); var result = Flow[status].Info;
return Task.FromResult(result);
}
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content)
{
var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty<StatusInfo>();
return Task.FromResult(result);
} }
public Task<Status[]> GetAllAsync(ISchemaEntity schema) public Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema)
{ {
return Task.FromResult(All); return Task.FromResult(All);
} }

10
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

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

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

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

10
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs

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

14
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs

@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Entities.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public sealed class ContentGraphType : ObjectGraphType<IContentEntity> public sealed class ContentGraphType : ObjectGraphType<IEnrichedContentEntity>
{ {
public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType) public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType)
{ {
@ -78,6 +78,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = $"The the status of the {schemaName} content." Description = $"The the status of the {schemaName} content."
}); });
AddField(new FieldType
{
Name = "statusColor",
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.StatusColor),
Description = $"The color status of the {schemaName} content."
});
AddField(new FieldType AddField(new FieldType
{ {
Name = "url", Name = "url",
@ -108,9 +116,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = $"The structure of a {schemaName} content type."; Description = $"The structure of a {schemaName} content type.";
} }
private static IFieldResolver Resolve(Func<IContentEntity, object> action) private static IFieldResolver Resolve(Func<IEnrichedContentEntity, object> action)
{ {
return new FuncFieldResolver<IContentEntity, object>(c => action(c.Source)); return new FuncFieldResolver<IEnrichedContentEntity, object>(c => action(c.Source));
} }
} }
} }

19
src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentEnricher
{
Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content);
Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents);
}
}

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

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

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

@ -13,14 +13,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public interface IContentWorkflow public interface IContentWorkflow
{ {
Task<Status> GetInitialStatusAsync(ISchemaEntity schema); Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanMoveToAsync(IContentEntity content, Status next); Task<bool> CanMoveToAsync(IContentEntity content, Status next);
Task<bool> CanUpdateAsync(IContentEntity content); Task<bool> CanUpdateAsync(IContentEntity content);
Task<Status[]> GetNextsAsync(IContentEntity content); Task<StatusInfo> GetInfoAsync(Status status);
Task<Status[]> GetAllAsync(ISchemaEntity schema); Task<StatusInfo[]> GetNextsAsync(IContentEntity content);
Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema);
} }
} }

20
src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IEnrichedContentEntity : IContentEntity
{
bool CanUpdate { get; }
string StatusColor { get; }
StatusInfo[] Nexts { get; }
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

10
src/Squidex.Infrastructure/CollectionExtensions.cs

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

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

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

2
src/Squidex.Infrastructure/ResultList.cs

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

24
src/Squidex.Web/Resource.cs

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

4
src/Squidex.Web/ResourceLink.cs

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

2
src/Squidex.Web/UrlHelperExtensions.cs

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

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

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

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

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

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

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

12
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

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

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

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

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

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

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

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
public sealed class StatusInfoDto
{
/// <summary>
/// The name of the status.
/// </summary>
[Required]
public Status Status { get; set; }
/// <summary>
/// The color of the status.
/// </summary>
[Required]
public string Color { get; set; }
public static StatusInfoDto FromStatusInfo(StatusInfo statusInfo)
{
return new StatusInfoDto { Status = statusInfo.Status, Color = statusInfo.Color };
}
}
}

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.History.Models; using Squidex.Areas.Api.Controllers.History.Models;
@ -48,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.History
{ {
var events = await historyService.QueryByChannelAsync(AppId, channel, 100); var events = await historyService.QueryByChannelAsync(AppId, channel, 100);
var response = events.ToArray(HistoryEventDto.FromHistoryEvent); var response = events.Select(HistoryEventDto.FromHistoryEvent).Where(x => x.Message != null).ToArray();
return Ok(response); return Ok(response);
} }

13
src/Squidex/Config/Domain/EntitiesServices.cs

@ -32,7 +32,6 @@ using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.Edm;
using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
@ -97,9 +96,15 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AppProvider>() services.AddSingletonAs<AppProvider>()
.As<IAppProvider>(); .As<IAppProvider>();
services.AddSingletonAs<AssetEnricher>()
.As<IAssetEnricher>();
services.AddSingletonAs<AssetQueryService>() services.AddSingletonAs<AssetQueryService>()
.As<IAssetQueryService>(); .As<IAssetQueryService>();
services.AddSingletonAs<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<ContentQueryService>() services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>(); .As<IContentQueryService>();
@ -222,6 +227,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetCommandMiddleware>() services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<ContentCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<AppsByNameIndexCommandMiddleware>() services.AddSingletonAs<AppsByNameIndexCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
@ -231,9 +239,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<GrainCommandMiddleware<CommentsCommand, ICommentGrain>>() services.AddSingletonAs<GrainCommandMiddleware<CommentsCommand, ICommentGrain>>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<ContentCommand, IContentGrain>>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>() services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();

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

@ -251,7 +251,9 @@ module.exports = function (env) {
waitForLinting: isProduction waitForLinting: isProduction
}) })
); );
}
if (!isCoverage) {
config.plugins.push( config.plugins.push(
new plugins.NgToolsWebpack.AngularCompilerPlugin({ new plugins.NgToolsWebpack.AngularCompilerPlugin({
directTemplateLoading: true, directTemplateLoading: true,

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

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

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

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

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

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

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

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

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

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

12
src/Squidex/app/features/content/shared/content-status.component.scss

@ -6,19 +6,11 @@
vertical-align: middle; vertical-align: middle;
} }
&-published { &.default {
color: $color-theme-green;
}
&-draft {
color: $color-text-decent; color: $color-text-decent;
} }
&-archived { &.pending {
color: $color-theme-error;
}
&-pending {
color: $color-dark-black; color: $color-dark-black;
} }

3
src/Squidex/app/features/content/shared/content-status.component.ts

@ -19,6 +19,9 @@ export class ContentStatusComponent {
@Input() @Input()
public status: string; public status: string;
@Input()
public statusColor: string;
@Input() @Input()
public scheduledTo?: string; public scheduledTo?: string;

2
src/Squidex/app/framework/utils/hateos.ts

@ -13,7 +13,7 @@ export interface Resource {
} }
export type ResourceLinks = { [rel: string]: ResourceLink }; export type ResourceLinks = { [rel: string]: ResourceLink };
export type ResourceLink = { href: string; method: ResourceMethod; }; export type ResourceLink = { href: string; method: ResourceMethod; metadata?: string; };
export type Metadata = { [rel: string]: string }; export type Metadata = { [rel: string]: string };

2
src/Squidex/app/shared/components/schema-category.component.ts

@ -74,7 +74,7 @@ export class SchemaCategoryComponent extends StatefulComponent<State> implements
let filtered = this.schemaCategory.schemas; let filtered = this.schemaCategory.schemas;
if (this.forContent) { if (this.forContent) {
filtered = filtered.filter(x => x.canReadContents); filtered = filtered.filter(x => x.canReadContents && x.isPublished);
} }
let isOpen = false; let isOpen = false;

8
src/Squidex/app/shared/services/contents.service.spec.ts

@ -62,11 +62,13 @@ describe('ContentsService', () => {
contentResponse(12), contentResponse(12),
contentResponse(13) contentResponse(13)
], ],
statuses: ['Draft', 'Published'] statuses: [{
status: 'Draft', color: 'Gray'
}]
}); });
expect(contents!).toEqual( expect(contents!).toEqual(
new ContentsDto(['Draft', 'Published'], 10, [ new ContentsDto([{ status: 'Draft', color: 'Gray' }], 10, [
createContent(12), createContent(12),
createContent(13) createContent(13)
])); ]));
@ -351,6 +353,7 @@ describe('ContentsService', () => {
return { return {
id: `id${id}`, id: `id${id}`,
status: `Status${id}`, status: `Status${id}`,
statusColor: 'black',
created: `${id % 1000 + 2000}-12-12T10:10:00`, created: `${id % 1000 + 2000}-12-12T10:10:00`,
createdBy: `creator-${id}`, createdBy: `creator-${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`, lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
@ -379,6 +382,7 @@ export function createContent(id: number, suffix = '') {
return new ContentDto(links, return new ContentDto(links,
`id${id}`, `id${id}`,
`Status${id}${suffix}`, `Status${id}${suffix}`,
'black',
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`,
new ScheduleDto('Draft', `Scheduler${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`)), new ScheduleDto('Draft', `Scheduler${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`)),

12
src/Squidex/app/shared/services/contents.service.ts

@ -34,9 +34,11 @@ export class ScheduleDto {
} }
} }
export type StatusInfo = { status: string; color: string; };
export class ContentsDto extends ResultSet<ContentDto> { export class ContentsDto extends ResultSet<ContentDto> {
constructor( constructor(
public readonly statuses: string[], public readonly statuses: StatusInfo[],
total: number, total: number,
items: ContentDto[], items: ContentDto[],
links?: ResourceLinks links?: ResourceLinks
@ -56,7 +58,7 @@ export class ContentsDto extends ResultSet<ContentDto> {
export class ContentDto { export class ContentDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly statusUpdates: string[]; public readonly statusUpdates: StatusInfo[];
public readonly canDelete: boolean; public readonly canDelete: boolean;
public readonly canDraftDiscard: boolean; public readonly canDraftDiscard: boolean;
@ -67,6 +69,7 @@ export class ContentDto {
constructor(links: ResourceLinks, constructor(links: ResourceLinks,
public readonly id: string, public readonly id: string,
public readonly status: string, public readonly status: string,
public readonly statusColor: string,
public readonly created: DateTime, public readonly created: DateTime,
public readonly createdBy: string, public readonly createdBy: string,
public readonly lastModified: DateTime, public readonly lastModified: DateTime,
@ -85,7 +88,7 @@ export class ContentDto {
this.canDraftPublish = hasAnyLink(links, 'draft/publish'); this.canDraftPublish = hasAnyLink(links, 'draft/publish');
this.canUpdate = hasAnyLink(links, 'update'); this.canUpdate = hasAnyLink(links, 'update');
this.statusUpdates = Object.keys(links).filter(x => x.startsWith('status/')).map(x => x.substr(7)); this.statusUpdates = Object.keys(links).filter(x => x.startsWith('status/')).map(x => ({ status: x.substr(7), color: links[x].metadata! }));
} }
} }
@ -133,7 +136,7 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}?${fullQuery}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}?${fullQuery}`);
return this.http.get<{ total: number, items: [], statuses: string[] } & Resource>(url).pipe( return this.http.get<{ total: number, items: [], statuses: StatusInfo[] } & Resource>(url).pipe(
map(({ total, items, statuses, _links }) => { map(({ total, items, statuses, _links }) => {
const contents = items.map(x => parseContent(x)); const contents = items.map(x => parseContent(x));
@ -282,6 +285,7 @@ function parseContent(response: any) {
return new ContentDto(response._links, return new ContentDto(response._links,
response.id, response.id,
response.status, response.status,
response.statusColor,
DateTime.parseISO_UTC(response.created), response.createdBy, DateTime.parseISO_UTC(response.created), response.createdBy,
DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy, DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy,
response.scheduleJob response.scheduleJob

14
src/Squidex/app/shared/state/contents.state.ts

@ -24,7 +24,7 @@ import { SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { SchemasState } from './schemas.state'; import { SchemasState } from './schemas.state';
import { ContentDto, ContentsService } from './../services/contents.service'; import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service';
interface Snapshot { interface Snapshot {
// The current comments. // The current comments.
@ -40,7 +40,7 @@ interface Snapshot {
isLoaded?: boolean; isLoaded?: boolean;
// The statuses. // The statuses.
statuses?: string[]; statuses?: StatusInfo[];
// The selected content. // The selected content.
selectedContent?: ContentDto | null; selectedContent?: ContentDto | null;
@ -348,10 +348,12 @@ export class ManualContentsState extends ContentsStateBase {
} }
} }
function buildQueries(x: string[] | undefined): { name: string; filter: string; }[] { export type ContentQuery = { color: string; name: string; filter: string; };
return x ? x.map(s => buildQuery(s)) : [];
function buildQueries(statuses: StatusInfo[] | undefined): ContentQuery[] {
return statuses ? statuses.map(s => buildQuery(s)) : [];
} }
function buildQuery(s: string) { function buildQuery(s: StatusInfo) {
return ({ name: s, filter: `$filter=status eq '${s}'` }); return ({ name: s.status, color: s.color, filter: `$filter=status eq '${s}'` });
} }

4
src/Squidex/app/theme/_bootstrap.scss

@ -304,6 +304,10 @@ a {
} }
} }
.icon-sm {
font-size: 70%;
}
// //
// Button improvements // Button improvements
// //

33
tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/StatusTests.cs

@ -12,40 +12,49 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
{ {
public class StatusTests public class StatusTests
{ {
[Fact]
public void Should_initialize_default()
{
Status status = default;
Assert.Equal("Unknown", status.Name);
Assert.Equal("Unknown", status.ToString());
}
[Fact] [Fact]
public void Should_initialize_status_from_string() public void Should_initialize_status_from_string()
{ {
var result = new Status("Custom"); var status = new Status("Custom");
Assert.Equal("Custom", result.Name); Assert.Equal("Custom", status.Name);
Assert.Equal("Custom", result.ToString()); Assert.Equal("Custom", status.ToString());
} }
[Fact] [Fact]
public void Should_provide_draft_status() public void Should_provide_draft_status()
{ {
var result = Status.Draft; var status = Status.Draft;
Assert.Equal("Draft", result.Name); Assert.Equal("Draft", status.Name);
Assert.Equal("Draft", result.ToString()); Assert.Equal("Draft", status.ToString());
} }
[Fact] [Fact]
public void Should_provide_archived_status() public void Should_provide_archived_status()
{ {
var result = Status.Archived; var status = Status.Archived;
Assert.Equal("Archived", result.Name); Assert.Equal("Archived", status.Name);
Assert.Equal("Archived", result.ToString()); Assert.Equal("Archived", status.ToString());
} }
[Fact] [Fact]
public void Should_provide_published_status() public void Should_provide_published_status()
{ {
var result = Status.Published; var status = Status.Published;
Assert.Equal("Published", result.Name); Assert.Equal("Published", status.Name);
Assert.Equal("Published", result.ToString()); Assert.Equal("Published", status.ToString());
} }
[Fact] [Fact]

2
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs

@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
.Returns(assetGrain); .Returns(assetGrain);
A.CallTo(() => assetGrain.GetStateAsync(12)) A.CallTo(() => assetGrain.GetStateAsync(12))
.Returns(A.Fake<IAssetEntity>().AsJ()); .Returns(J.Of<IAssetEntity>(new AssetEntity()));
var result = await sut.CreateEnrichedEventAsync(envelope); var result = await sut.CreateEnrichedEventAsync(envelope);

155
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs

@ -11,6 +11,7 @@ using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using FluentAssertions;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
@ -20,6 +21,7 @@ using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
@ -27,6 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
public class AssetCommandMiddlewareTests : HandlerTestBase<AssetState> public class AssetCommandMiddlewareTests : HandlerTestBase<AssetState>
{ {
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>(); private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IAssetEnricher assetEnricher = A.Fake<IAssetEnricher>();
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly IAssetStore assetStore = A.Fake<MemoryAssetStore>(); private readonly IAssetStore assetStore = A.Fake<MemoryAssetStore>();
private readonly ITagService tagService = A.Fake<ITagService>(); private readonly ITagService tagService = A.Fake<ITagService>();
@ -39,6 +42,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly AssetFile file; private readonly AssetFile file;
private readonly AssetCommandMiddleware sut; private readonly AssetCommandMiddleware sut;
public sealed class MyCommand : SquidexCommand
{
}
protected override Guid Id protected override Guid Id
{ {
get { return assetId; } get { return assetId; }
@ -51,52 +58,95 @@ namespace Squidex.Domain.Apps.Entities.Assets
asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>()); asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
asset.ActivateAsync(Id).Wait(); asset.ActivateAsync(Id).Wait();
A.CallTo(() => assetQuery.QueryByHashAsync(AppId, A<string>.Ignored)) A.CallTo(() => assetEnricher.EnrichAsync(A<IAssetEntity>.Ignored))
.Returns(new List<IAssetEntity>()); .ReturnsLazily(() => SimpleMapper.Map(asset.Snapshot, new AssetEntity()));
A.CallTo(() => tagService.DenormalizeTagsAsync(AppId, TagGroups.Assets, A<HashSet<string>>.Ignored)) A.CallTo(() => assetQuery.QueryByHashAsync(AppId, A<string>.Ignored))
.Returns(new Dictionary<string, string> .Returns(new List<IEnrichedAssetEntity>());
{
["1"] = "foundTag1",
["2"] = "foundTag2"
});
A.CallTo(() => grainFactory.GetGrain<IAssetGrain>(Id, null)) A.CallTo(() => grainFactory.GetGrain<IAssetGrain>(Id, null))
.Returns(asset); .Returns(asset);
sut = new AssetCommandMiddleware(grainFactory, assetQuery, assetStore, assetThumbnailGenerator, new[] { tagGenerator }, tagService); A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream))
.Returns(image);
sut = new AssetCommandMiddleware(grainFactory,
assetEnricher,
assetQuery,
assetStore,
assetThumbnailGenerator, new[] { tagGenerator });
} }
[Fact] [Fact]
public async Task Create_should_create_domain_object() public async Task Should_not_invoke_enricher_for_other_result()
{ {
var command = CreateCommand(new CreateAsset { AssetId = assetId, File = file }); var command = CreateCommand(new MyCommand());
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupTags(command); context.Complete(12);
SetupImageInfo();
await sut.HandleAsync(context); await sut.HandleAsync(context);
var result = context.Result<AssetCreatedResult>(); A.CallTo(() => assetEnricher.EnrichAsync(A<IEnrichedAssetEntity>.Ignored))
.MustNotHaveHappened();
}
Assert.Equal(assetId, result.Asset.Id); [Fact]
Assert.Contains("tag1", command.Tags); public async Task Should_not_invoke_enricher_if_already_enriched()
Assert.Contains("tag2", command.Tags); {
var result = new AssetEntity();
Assert.Equal(new HashSet<string> { "tag1", "tag2" }, result.Tags); var command = CreateCommand(new MyCommand());
var context = CreateContextForCommand(command);
AssertAssetHasBeenUploaded(0, context.ContextId); context.Complete(result);
AssertAssetImageChecked();
await sut.HandleAsync(context);
Assert.Same(result, context.Result<IEnrichedAssetEntity>());
A.CallTo(() => assetEnricher.EnrichAsync(A<IEnrichedAssetEntity>.Ignored))
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Create_should_calculate_hash() public async Task Should_enrich_asset_result()
{
var result = A.Fake<IAssetEntity>();
var command = CreateCommand(new MyCommand());
var context = CreateContextForCommand(command);
context.Complete(result);
var enriched = new AssetEntity();
A.CallTo(() => assetEnricher.EnrichAsync(result))
.Returns(enriched);
await sut.HandleAsync(context);
Assert.Same(enriched, context.Result<IEnrichedAssetEntity>());
}
[Fact]
public async Task Create_should_create_domain_object()
{ {
var command = CreateCommand(new CreateAsset { AssetId = assetId, File = file }); var command = CreateCommand(new CreateAsset { AssetId = assetId, File = file });
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupImageInfo(); await sut.HandleAsync(context);
var result = context.Result<AssetCreatedResult>();
result.Asset.Should().BeEquivalentTo(asset.Snapshot, x => x.ExcludingMissingMembers());
}
[Fact]
public async Task Create_should_calculate_hash()
{
var command = CreateCommand(new CreateAsset { AssetId = assetId, File = file });
var context = CreateContextForCommand(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -110,7 +160,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupSameHashAsset(file.FileName, file.FileSize, out _); SetupSameHashAsset(file.FileName, file.FileSize, out _);
SetupImageInfo();
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -126,7 +175,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupSameHashAsset("other-name", file.FileSize, out _); SetupSameHashAsset("other-name", file.FileSize, out _);
SetupImageInfo();
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -136,19 +184,20 @@ namespace Squidex.Domain.Apps.Entities.Assets
} }
[Fact] [Fact]
public async Task Create_should_resolve_tag_names_for_duplicate() public async Task Create_should_pass_through_duplicate()
{ {
var command = CreateCommand(new CreateAsset { AssetId = assetId, File = file }); var command = CreateCommand(new CreateAsset { AssetId = assetId, File = file });
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupSameHashAsset(file.FileName, file.FileSize, out _); SetupSameHashAsset(file.FileName, file.FileSize, out var duplicate);
SetupImageInfo();
await sut.HandleAsync(context); await sut.HandleAsync(context);
var result = context.Result<AssetCreatedResult>(); var result = context.Result<AssetCreatedResult>();
Assert.Equal(new HashSet<string> { "foundTag1", "foundTag2" }, result.Tags); Assert.True(result.IsDuplicate);
result.Should().BeEquivalentTo(duplicate, x => x.ExcludingMissingMembers());
} }
[Fact] [Fact]
@ -158,7 +207,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupSameHashAsset(file.FileName, 12345, out _); SetupSameHashAsset(file.FileName, 12345, out _);
SetupImageInfo();
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -171,8 +219,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
var command = CreateCommand(new UpdateAsset { AssetId = assetId, File = file }); var command = CreateCommand(new UpdateAsset { AssetId = assetId, File = file });
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupImageInfo();
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -187,8 +233,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
var command = CreateCommand(new UpdateAsset { AssetId = assetId, File = file }); var command = CreateCommand(new UpdateAsset { AssetId = assetId, File = file });
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupImageInfo();
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -197,37 +241,33 @@ namespace Squidex.Domain.Apps.Entities.Assets
} }
[Fact] [Fact]
public async Task Update_should_resolve_tags() public async Task Update_should_enrich_asset()
{ {
var command = CreateCommand(new UpdateAsset { AssetId = assetId, File = file }); var command = CreateCommand(new UpdateAsset { AssetId = assetId, File = file });
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupImageInfo();
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await sut.HandleAsync(context); await sut.HandleAsync(context);
var result = context.Result<AssetResult>(); var result = context.Result<IEnrichedAssetEntity>();
Assert.Equal(new HashSet<string> { "foundTag1", "foundTag2" }, result.Tags); result.Should().BeEquivalentTo(asset.Snapshot, x => x.ExcludingMissingMembers());
} }
[Fact] [Fact]
public async Task AnnotateAsset_should_resolve_tags() public async Task AnnotateAsset_should_enrich_asset()
{ {
var command = CreateCommand(new AnnotateAsset { AssetId = assetId, FileName = "newName" }); var command = CreateCommand(new AnnotateAsset { AssetId = assetId, FileName = "newName" });
var context = CreateContextForCommand(command); var context = CreateContextForCommand(command);
SetupImageInfo();
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await sut.HandleAsync(context); await sut.HandleAsync(context);
var result = context.Result<AssetResult>(); var result = context.Result<IEnrichedAssetEntity>();
Assert.Equal(new HashSet<string> { "foundTag1", "foundTag2" }, result.Tags); result.Should().BeEquivalentTo(asset.Snapshot, x => x.ExcludingMissingMembers());
} }
private Task ExecuteCreateAsync() private Task ExecuteCreateAsync()
@ -235,16 +275,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
return asset.ExecuteAsync(CreateCommand(new CreateAsset { AssetId = Id, File = file })); return asset.ExecuteAsync(CreateCommand(new CreateAsset { AssetId = Id, File = file }));
} }
private void SetupTags(CreateAsset command)
{
A.CallTo(() => tagGenerator.GenerateTags(command, A<HashSet<string>>.Ignored))
.Invokes(new Action<CreateAsset, HashSet<string>>((c, tags) =>
{
tags.Add("tag1");
tags.Add("tag2");
}));
}
private void AssertAssetHasBeenUploaded(long version, Guid commitId) private void AssertAssetHasBeenUploaded(long version, Guid commitId)
{ {
var fileName = AssetStoreExtensions.GetFileName(assetId.ToString(), version); var fileName = AssetStoreExtensions.GetFileName(assetId.ToString(), version);
@ -257,21 +287,16 @@ namespace Squidex.Domain.Apps.Entities.Assets
.MustHaveHappened(); .MustHaveHappened();
} }
private void SetupSameHashAsset(string fileName, long fileSize, out IAssetEntity existing) private void SetupSameHashAsset(string fileName, long fileSize, out IEnrichedAssetEntity duplicate)
{ {
var temp = existing = A.Fake<IAssetEntity>(); duplicate = new AssetEntity
{
A.CallTo(() => temp.FileName).Returns(fileName); FileName = fileName,
A.CallTo(() => temp.FileSize).Returns(fileSize); FileSize = fileSize
};
A.CallTo(() => assetQuery.QueryByHashAsync(A<Guid>.Ignored, A<string>.Ignored)) A.CallTo(() => assetQuery.QueryByHashAsync(A<Guid>.Ignored, A<string>.Ignored))
.Returns(new List<IAssetEntity> { existing }); .Returns(new List<IEnrichedAssetEntity> { duplicate });
}
private void SetupImageInfo()
{
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream))
.Returns(image);
} }
private void AssertAssetImageChecked() private void AssertAssetImageChecked()

101
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetEnricherTests.cs

@ -0,0 +1,101 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetEnricherTests
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly AssetEnricher sut;
public AssetEnricherTests()
{
sut = new AssetEnricher(tagService);
}
[Fact]
public async Task Should_not_enrich_if_asset_contains_null_tags()
{
var source = new AssetEntity { AppId = appId };
var result = await sut.EnrichAsync(source);
Assert.Empty(result.TagNames);
}
[Fact]
public async Task Should_enrich_asset_with_tag_names()
{
var source = new AssetEntity
{
Tags = new HashSet<string>
{
"id1",
"id2"
},
AppId = appId
};
A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.IsSameSequenceAs("id1", "id2")))
.Returns(new Dictionary<string, string>
{
["id1"] = "name1",
["id2"] = "name2"
});
var result = await sut.EnrichAsync(source);
Assert.Equal(new HashSet<string> { "name1", "name2" }, result.TagNames);
}
[Fact]
public async Task Should_enrich_multiple_assets_with_tag_names()
{
var source1 = new AssetEntity
{
Tags = new HashSet<string>
{
"id1",
"id2"
},
AppId = appId
};
var source2 = new AssetEntity
{
Tags = new HashSet<string>
{
"id2",
"id3"
},
AppId = appId
};
A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.IsSameSequenceAs("id1", "id2", "id3")))
.Returns(new Dictionary<string, string>
{
["id1"] = "name1",
["id2"] = "name2",
["id3"] = "name3"
});
var result = await sut.EnrichAsync(new[] { source1, source2 });
Assert.Equal(new HashSet<string> { "name1", "name2" }, result[0].TagNames);
Assert.Equal(new HashSet<string> { "name2", "name3" }, result[1].TagNames);
}
}
}

92
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
@ -25,16 +26,16 @@ namespace Squidex.Domain.Apps.Entities.Assets
public class AssetQueryServiceTests public class AssetQueryServiceTests
{ {
private readonly ITagService tagService = A.Fake<ITagService>(); private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IAssetEnricher assetEnricher = A.Fake<IAssetEnricher>();
private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>(); private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>();
private readonly IAppEntity app = A.Fake<IAppEntity>(); private readonly IAppEntity app = A.Fake<IAppEntity>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly ClaimsIdentity identity = new ClaimsIdentity();
private readonly QueryContext context; private readonly QueryContext context;
private readonly AssetQueryService sut; private readonly AssetQueryService sut;
public AssetQueryServiceTests() public AssetQueryServiceTests()
{ {
var user = new ClaimsPrincipal(identity); var user = new ClaimsPrincipal(new ClaimsIdentity());
A.CallTo(() => app.Id).Returns(appId.Id); A.CallTo(() => app.Id).Returns(appId.Id);
A.CallTo(() => app.Name).Returns(appId.Name); A.CallTo(() => app.Name).Returns(appId.Name);
@ -42,17 +43,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
context = QueryContext.Create(app, user); context = QueryContext.Create(app, user);
A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.IsSameSequenceAs("id1", "id2", "id3")))
.Returns(new Dictionary<string, string>
{
["id1"] = "name1",
["id2"] = "name2",
["id3"] = "name3"
});
var options = Options.Create(new AssetOptions { DefaultPageSize = 30 }); var options = Options.Create(new AssetOptions { DefaultPageSize = 30 });
sut = new AssetQueryService(tagService, assetRepository, options); sut = new AssetQueryService(tagService, assetEnricher, assetRepository, options);
} }
[Fact] [Fact]
@ -64,68 +57,85 @@ namespace Squidex.Domain.Apps.Entities.Assets
} }
[Fact] [Fact]
public async Task Should_find_asset_by_id_and_resolve_tags() public async Task Should_find_asset_by_id_and_enrich_it()
{ {
var id = Guid.NewGuid(); var found = new AssetEntity { Id = Guid.NewGuid() };
var enriched = new AssetEntity();
A.CallTo(() => assetRepository.FindAssetAsync(id, false)) A.CallTo(() => assetRepository.FindAssetAsync(found.Id, false))
.Returns(CreateAsset(id, "id1", "id2", "id3")); .Returns(found);
var result = await sut.FindAssetAsync(context, id); A.CallTo(() => assetEnricher.EnrichAsync(found))
.Returns(enriched);
Assert.Equal(HashSet.Of("name1", "name2", "name3"), result.Tags); var result = await sut.FindAssetAsync(found.Id);
Assert.Same(enriched, result);
} }
[Fact] [Fact]
public async Task Should_find_asset_by_hash_and_resolve_tags() public async Task Should_find_assets_by_hash_and_and_enrich_it()
{ {
var id = Guid.NewGuid(); var found = new AssetEntity { Id = Guid.NewGuid() };
var enriched = new AssetEntity();
A.CallTo(() => assetRepository.QueryByHashAsync(appId.Id, "hash")) A.CallTo(() => assetRepository.QueryByHashAsync(appId.Id, "hash"))
.Returns(new List<IAssetEntity> { CreateAsset(id, "id1", "id2", "id3") }); .Returns(new List<IAssetEntity> { found });
A.CallTo(() => assetEnricher.EnrichAsync(A<IEnumerable<IAssetEntity>>.That.IsSameSequenceAs(found)))
.Returns(new List<IEnrichedAssetEntity> { enriched });
var result = await sut.QueryByHashAsync(appId.Id, "hash"); var result = await sut.QueryByHashAsync(appId.Id, "hash");
Assert.Equal(HashSet.Of("name1", "name2", "name3"), result[0].Tags); Assert.Same(enriched, result.Single());
} }
[Fact] [Fact]
public async Task Should_load_assets_from_ids_and_resolve_tags() public async Task Should_load_assets_from_ids_and_resolve_tags()
{ {
var id1 = Guid.NewGuid(); var found1 = new AssetEntity { Id = Guid.NewGuid() };
var id2 = Guid.NewGuid(); var found2 = new AssetEntity { Id = Guid.NewGuid() };
var enriched1 = new AssetEntity();
var enriched2 = new AssetEntity();
var ids = HashSet.Of(id1, id2); var ids = HashSet.Of(found1.Id, found2.Id);
A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<HashSet<Guid>>.That.IsSameSequenceAs(ids))) A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<HashSet<Guid>>.That.IsSameSequenceAs(ids)))
.Returns(ResultList.Create(8, .Returns(ResultList.CreateFrom(8, found1, found2));
CreateAsset(id1, "id1", "id2", "id3"),
CreateAsset(id2))); A.CallTo(() => assetEnricher.EnrichAsync(A<IEnumerable<IAssetEntity>>.That.IsSameSequenceAs(found1, found2)))
.Returns(new List<IEnrichedAssetEntity> { enriched1, enriched2 });
var result = await sut.QueryAsync(context, Q.Empty.WithIds(ids)); var result = await sut.QueryAsync(context, Q.Empty.WithIds(ids));
Assert.Equal(8, result.Total); Assert.Equal(8, result.Total);
Assert.Equal(2, result.Count);
Assert.Equal(HashSet.Of("name1", "name2", "name3"), result[0].Tags); Assert.Equal(new[] { enriched1, enriched2 }, result.ToArray());
Assert.Empty(result[1].Tags);
} }
[Fact] [Fact]
public async Task Should_load_assets_with_query_and_resolve_tags() public async Task Should_load_assets_with_query_and_resolve_tags()
{ {
var found1 = new AssetEntity { Id = Guid.NewGuid() };
var found2 = new AssetEntity { Id = Guid.NewGuid() };
var enriched1 = new AssetEntity();
var enriched2 = new AssetEntity();
A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<Query>.Ignored)) A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<Query>.Ignored))
.Returns(ResultList.Create(8, .Returns(ResultList.CreateFrom(8, found1, found2));
CreateAsset(Guid.NewGuid(), "id1", "id2"),
CreateAsset(Guid.NewGuid(), "id2", "id3"))); A.CallTo(() => assetEnricher.EnrichAsync(A<IEnumerable<IAssetEntity>>.That.IsSameSequenceAs(found1, found2)))
.Returns(new List<IEnrichedAssetEntity> { enriched1, enriched2 });
var result = await sut.QueryAsync(context, Q.Empty); var result = await sut.QueryAsync(context, Q.Empty);
Assert.Equal(8, result.Total); Assert.Equal(8, result.Total);
Assert.Equal(2, result.Count);
Assert.Equal(HashSet.Of("name1", "name2"), result[0].Tags); Assert.Equal(new[] { enriched1, enriched2 }, result.ToArray());
Assert.Equal(HashSet.Of("name2", "name3"), result[1].Tags);
} }
[Fact] [Fact]
@ -171,15 +181,5 @@ namespace Squidex.Domain.Apps.Entities.Assets
A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<Query>.That.Is("Skip: 20; Take: 200; Sort: lastModified Descending"))) A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<Query>.That.Is("Skip: 20; Take: 200; Sort: lastModified Descending")))
.MustHaveHappened(); .MustHaveHappened();
} }
private static IAssetEntity CreateAsset(Guid id, params string[] tags)
{
var asset = A.Fake<IAssetEntity>();
A.CallTo(() => asset.Id).Returns(id);
A.CallTo(() => asset.Tags).Returns(HashSet.Of(tags));
return asset;
}
} }
} }

2
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs

@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns(contentGrain); .Returns(contentGrain);
A.CallTo(() => contentGrain.GetStateAsync(12)) A.CallTo(() => contentGrain.GetStateAsync(12))
.Returns(A.Fake<IContentEntity>().AsJ()); .Returns(J.Of<IContentEntity>(new ContentEntity { SchemaId = SchemaMatch }));
var result = await sut.CreateEnrichedEventAsync(envelope); var result = await sut.CreateEnrichedEventAsync(envelope);

91
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentCommandMiddlewareTests.cs

@ -0,0 +1,91 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Commands;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentCommandMiddlewareTests : HandlerTestBase<ContentState>
{
private readonly IContentEnricher contentEnricher = A.Fake<IContentEnricher>();
private readonly Guid contentId = Guid.NewGuid();
private readonly ContentCommandMiddleware sut;
public sealed class MyCommand : SquidexCommand
{
}
protected override Guid Id
{
get { return contentId; }
}
public ContentCommandMiddlewareTests()
{
sut = new ContentCommandMiddleware(A.Fake<IGrainFactory>(), contentEnricher);
}
[Fact]
public async Task Should_not_invoke_enricher_for_other_result()
{
var command = CreateCommand(new MyCommand());
var context = CreateContextForCommand(command);
context.Complete(12);
await sut.HandleAsync(context);
A.CallTo(() => contentEnricher.EnrichAsync(A<IEnrichedContentEntity>.Ignored))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_invoke_enricher_if_already_enriched()
{
var result = new ContentEntity();
var command = CreateCommand(new MyCommand());
var context = CreateContextForCommand(command);
context.Complete(result);
await sut.HandleAsync(context);
Assert.Same(result, context.Result<IEnrichedContentEntity>());
A.CallTo(() => contentEnricher.EnrichAsync(A<IEnrichedContentEntity>.Ignored))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_enrich_content_result()
{
var result = A.Fake<IContentEntity>();
var command = CreateCommand(new MyCommand());
var context = CreateContextForCommand(command);
context.Complete(result);
var enriched = new ContentEntity();
A.CallTo(() => contentEnricher.EnrichAsync(result))
.Returns(enriched);
await sut.HandleAsync(context);
Assert.Same(enriched, context.Result<IEnrichedContentEntity>());
}
}
}

85
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherTests.cs

@ -0,0 +1,85 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentEnricherTests
{
private readonly IContentWorkflow workflow = A.Fake<IContentWorkflow>();
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly ContentEnricher sut;
public ContentEnricherTests()
{
sut = new ContentEnricher(workflow);
}
[Fact]
public async Task Should_enrich_content_with_status_color()
{
var source = new ContentEntity { Status = Status.Published, SchemaId = schemaId };
A.CallTo(() => workflow.GetInfoAsync(Status.Published))
.Returns(new StatusInfo(Status.Published, StatusColors.Published));
var result = await sut.EnrichAsync(source);
Assert.Equal(StatusColors.Published, result.StatusColor);
}
[Fact]
public async Task Should_enrich_content_with_default_color_if_not_found()
{
var source = new ContentEntity { Status = Status.Published, SchemaId = schemaId };
A.CallTo(() => workflow.GetInfoAsync(Status.Published))
.Returns(Task.FromResult<StatusInfo>(null));
var result = await sut.EnrichAsync(source);
Assert.Equal(StatusColors.Draft, result.StatusColor);
}
[Fact]
public async Task Should_enrich_content_with_can_update()
{
var source = new ContentEntity { SchemaId = schemaId };
A.CallTo(() => workflow.CanUpdateAsync(source))
.Returns(true);
var result = await sut.EnrichAsync(source);
Assert.True(result.CanUpdate);
}
[Fact]
public async Task Should_enrich_multiple_contents_and_cache_color()
{
var source1 = new ContentEntity { Status = Status.Published, SchemaId = schemaId };
var source2 = new ContentEntity { Status = Status.Published, SchemaId = schemaId };
A.CallTo(() => workflow.GetInfoAsync(Status.Published))
.Returns(new StatusInfo(Status.Published, StatusColors.Published));
var result = await sut.EnrichAsync(new[] { source1, source2 });
Assert.Equal(StatusColors.Published, result[0].StatusColor);
Assert.Equal(StatusColors.Published, result[1].StatusColor);
A.CallTo(() => workflow.GetInfoAsync(Status.Published))
.MustHaveHappenedOnceExactly();
}
}
}

60
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs

@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly ISchemaEntity schema = A.Fake<ISchemaEntity>(); private readonly ISchemaEntity schema = A.Fake<ISchemaEntity>();
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IContentRepository contentRepository = A.Dummy<IContentRepository>(); private readonly IContentRepository contentRepository = A.Dummy<IContentRepository>();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(); private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(x => x.Wrapping(new DefaultContentWorkflow()));
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppEntity app = A.Fake<IAppEntity>(); private readonly IAppEntity app = A.Fake<IAppEntity>();
private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.DE); private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.DE);
@ -102,12 +102,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, A<string>.Ignored)) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, A<string>.Ignored))
.ReturnsLazily(x => x.GetArgument<ScriptContext>(0).Data); .ReturnsLazily(x => x.GetArgument<ScriptContext>(0).Data);
A.CallTo(() => contentWorkflow.CanUpdateAsync(A<IContentEntity>.Ignored))
.Returns(true);
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>.Ignored, A<Status>.Ignored))
.Returns(true);
patched = patch.MergeInto(data); patched = patch.MergeInto(data);
sut = new ContentGrain(Store, A.Dummy<ISemanticLog>(), appProvider, A.Dummy<IAssetRepository>(), scriptEngine, contentWorkflow, contentRepository); sut = new ContentGrain(Store, A.Dummy<ISemanticLog>(), appProvider, A.Dummy<IAssetRepository>(), scriptEngine, contentWorkflow, contentRepository);
@ -132,9 +126,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(Status.Draft, sut.Snapshot.Status);
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data }) CreateContentEvent(new ContentCreated { Data = data, Status = Status.Draft })
); );
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<create-script>")) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<create-script>"))
@ -143,26 +139,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact]
public async Task Create_should_create_events_and_update_state_with_custom_initial_status()
{
var command = new CreateContent { Data = data };
A.CallTo(() => contentWorkflow.GetInitialStatusAsync(schema))
.Returns(Status.Archived);
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(Status.Archived, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data, Status = Status.Archived })
);
}
[Fact] [Fact]
public async Task Create_should_also_publish() public async Task Create_should_also_publish()
{ {
@ -172,9 +148,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(Status.Published, sut.Snapshot.Status);
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data }), CreateContentEvent(new ContentCreated { Data = data, Status = Status.Draft }),
CreateContentEvent(new ContentStatusChanged { Status = Status.Published }) CreateContentEvent(new ContentStatusChanged { Status = Status.Published })
); );
@ -246,10 +224,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
LastEvents Assert.Single(LastEvents);
.ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>")) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -319,10 +294,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
LastEvents Assert.Single(LastEvents);
.ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>")) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -343,7 +315,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published }) CreateContentEvent(new ContentStatusChanged { Change = StatusChange.Published, Status = Status.Published })
); );
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>")) A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
@ -388,7 +360,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft, Change = StatusChange.Unpublished }) CreateContentEvent(new ContentStatusChanged { Change = StatusChange.Unpublished, Status = Status.Draft })
); );
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>")) A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
@ -472,11 +444,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task ChangeStatus_should_refresh_properties_and_revert_scheduling_when_invoked_by_scheduler() public async Task ChangeStatus_should_refresh_properties_and_revert_scheduling_when_invoked_by_scheduler()
{ {
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await ExecuteScheduledAsync(); await ExecuteChangeStatusAsync(Status.Published, Instant.MaxValue);
var command = new ChangeContentStatus { Status = Status.Draft, JobId = sut.Snapshot.ScheduleJob.Id }; var command = new ChangeContentStatus { Status = Status.Published, JobId = sut.Snapshot.ScheduleJob.Id };
A.CallTo(() => contentWorkflow.CanMoveToAsync(sut.Snapshot, command.Status)) A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>.Ignored, Status.Published))
.Returns(false); .Returns(false);
var result = await sut.ExecuteAsync(CreateContentCommand(command)); var result = await sut.ExecuteAsync(CreateContentCommand(command));
@ -549,9 +521,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return sut.ExecuteAsync(CreateContentCommand(new UpdateContent { Data = otherData, AsDraft = true })); return sut.ExecuteAsync(CreateContentCommand(new UpdateContent { Data = otherData, AsDraft = true }));
} }
private Task ExecuteScheduledAsync() private Task ExecuteChangeStatusAsync(Status status, Instant? dueTime = null)
{ {
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published, DueTime = Instant.MaxValue })); return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = status, DueTime = dueTime }));
} }
private Task ExecuteDeleteAsync() private Task ExecuteDeleteAsync()

31
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs

@ -25,6 +25,7 @@ using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Identity; using Squidex.Shared.Identity;
@ -36,6 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public class ContentQueryServiceTests public class ContentQueryServiceTests
{ {
private readonly IContentEnricher contentEnricher = A.Fake<IContentEnricher>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>(); private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly IContentVersionLoader contentVersionLoader = A.Fake<IContentVersionLoader>(); private readonly IContentVersionLoader contentVersionLoader = A.Fake<IContentVersionLoader>();
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
@ -76,6 +78,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => schema.AppId).Returns(appId); A.CallTo(() => schema.AppId).Returns(appId);
A.CallTo(() => schema.SchemaDef).Returns(schemaDef); A.CallTo(() => schema.SchemaDef).Returns(schemaDef);
SetupEnricher();
context = QueryContext.Create(app, user); context = QueryContext.Create(app, user);
var options = Options.Create(new ContentOptions { DefaultPageSize = 30 }); var options = Options.Create(new ContentOptions { DefaultPageSize = 30 });
@ -83,6 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
sut = new ContentQueryService( sut = new ContentQueryService(
appProvider, appProvider,
urlGenerator, urlGenerator,
contentEnricher,
contentRepository, contentRepository,
contentVersionLoader, contentVersionLoader,
scriptEngine, scriptEngine,
@ -520,6 +525,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns((ISchemaEntity)null); .Returns((ISchemaEntity)null);
} }
private void SetupEnricher()
{
A.CallTo(() => contentEnricher.EnrichAsync(A<IEnumerable<IContentEntity>>.Ignored))
.ReturnsLazily(x =>
{
var input = (IEnumerable<IContentEntity>)x.Arguments[0];
return Task.FromResult<IReadOnlyList<IEnrichedContentEntity>>(input.Select(c => SimpleMapper.Map(c, new ContentEntity())).ToList());
});
}
private IContentEntity CreateContent(Guid id) private IContentEntity CreateContent(Guid id)
{ {
return CreateContent(id, Status.Published); return CreateContent(id, Status.Published);
@ -527,13 +543,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
private IContentEntity CreateContent(Guid id, Status status) private IContentEntity CreateContent(Guid id, Status status)
{ {
var content = A.Fake<IContentEntity>(); var content = new ContentEntity
{
A.CallTo(() => content.Id).Returns(id); Id = id,
A.CallTo(() => content.Data).Returns(contentData); Data = contentData,
A.CallTo(() => content.DataDraft).Returns(contentData); DataDraft = contentData,
A.CallTo(() => content.Status).Returns(status); SchemaId = schemaId,
A.CallTo(() => content.SchemaId).Returns(schemaId); Status = status,
};
return content; return content;
} }

18
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs

@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task Should_throw_exception_if_no_state_returned() public async Task Should_throw_exception_if_no_state_returned()
{ {
A.CallTo(() => grain.GetStateAsync(10)) A.CallTo(() => grain.GetStateAsync(10))
.Returns(new J<IContentEntity>(null)); .Returns(J.Of<IContentEntity>(null));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10)); await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10));
} }
@ -42,13 +42,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_throw_exception_if_state_has_other_version() public async Task Should_throw_exception_if_state_has_other_version()
{ {
var entity = A.Fake<IContentEntity>(); var content = new ContentEntity { Version = 5 };
A.CallTo(() => entity.Version)
.Returns(5);
A.CallTo(() => grain.GetStateAsync(10)) A.CallTo(() => grain.GetStateAsync(10))
.Returns(J.Of(entity)); .Returns(J.Of<IContentEntity>(content));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10)); await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10));
} }
@ -56,17 +53,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_return_content_from_state() public async Task Should_return_content_from_state()
{ {
var entity = A.Fake<IContentEntity>(); var content = new ContentEntity { Version = 10 };
A.CallTo(() => entity.Version)
.Returns(10);
A.CallTo(() => grain.GetStateAsync(10)) A.CallTo(() => grain.GetStateAsync(10))
.Returns(J.Of(entity)); .Returns(J.Of<IContentEntity>(content));
var result = await sut.LoadAsync(id, 10); var result = await sut.LoadAsync(id, 10);
Assert.Same(entity, result); Assert.Same(content, result);
} }
} }
} }

69
tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs

@ -6,7 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FluentAssertions;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Xunit; using Xunit;
@ -19,17 +19,19 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_draft_as_initial_status() public async Task Should_draft_as_initial_status()
{ {
var expected = new StatusInfo(Status.Draft, StatusColors.Draft);
var result = await sut.GetInitialStatusAsync(null); var result = await sut.GetInitialStatusAsync(null);
Assert.Equal(Status.Draft, result); result.Should().BeEquivalentTo(expected);
} }
[Fact] [Fact]
public async Task Should_check_is_valid_next() public async Task Should_check_is_valid_next()
{ {
var entity = CreateContent(Status.Published); var content = new ContentEntity { Status = Status.Published };
var result = await sut.CanMoveToAsync(entity, Status.Draft); var result = await sut.CanMoveToAsync(content, Status.Draft);
Assert.True(result); Assert.True(result);
} }
@ -37,9 +39,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_be_able_to_update_published() public async Task Should_be_able_to_update_published()
{ {
var entity = CreateContent(Status.Published); var content = new ContentEntity { Status = Status.Published };
var result = await sut.CanUpdateAsync(entity); var result = await sut.CanUpdateAsync(content);
Assert.True(result); Assert.True(result);
} }
@ -47,9 +49,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_be_able_to_update_draft() public async Task Should_be_able_to_update_draft()
{ {
var entity = CreateContent(Status.Published); var content = new ContentEntity { Status = Status.Published };
var result = await sut.CanUpdateAsync(entity); var result = await sut.CanUpdateAsync(content);
Assert.True(result); Assert.True(result);
} }
@ -57,9 +59,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_not_be_able_to_update_archived() public async Task Should_not_be_able_to_update_archived()
{ {
var entity = CreateContent(Status.Archived); var content = new ContentEntity { Status = Status.Archived };
var result = await sut.CanUpdateAsync(entity); var result = await sut.CanUpdateAsync(content);
Assert.False(result); Assert.False(result);
} }
@ -67,56 +69,63 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_get_next_statuses_for_draft() public async Task Should_get_next_statuses_for_draft()
{ {
var content = CreateContent(Status.Draft); var content = new ContentEntity { Status = Status.Draft };
var expected = new[] { Status.Archived, Status.Published }; var expected = new[]
{
new StatusInfo(Status.Archived, StatusColors.Archived),
new StatusInfo(Status.Published, StatusColors.Published)
};
var result = await sut.GetNextsAsync(content); var result = await sut.GetNextsAsync(content);
Assert.Equal(expected, result); result.Should().BeEquivalentTo(expected);
} }
[Fact] [Fact]
public async Task Should_get_next_statuses_for_archived() public async Task Should_get_next_statuses_for_archived()
{ {
var content = CreateContent(Status.Archived); var content = new ContentEntity { Status = Status.Archived };
var expected = new[] { Status.Draft }; var expected = new[]
{
new StatusInfo(Status.Draft, StatusColors.Draft)
};
var result = await sut.GetNextsAsync(content); var result = await sut.GetNextsAsync(content);
Assert.Equal(expected, result); result.Should().BeEquivalentTo(expected);
} }
[Fact] [Fact]
public async Task Should_get_next_statuses_for_published() public async Task Should_get_next_statuses_for_published()
{ {
var content = CreateContent(Status.Published); var content = new ContentEntity { Status = Status.Published };
var expected = new[] { Status.Draft, Status.Archived }; var expected = new[]
{
new StatusInfo(Status.Archived, StatusColors.Archived),
new StatusInfo(Status.Draft, StatusColors.Draft)
};
var result = await sut.GetNextsAsync(content); var result = await sut.GetNextsAsync(content);
Assert.Equal(expected, result); result.Should().BeEquivalentTo(expected);
} }
[Fact] [Fact]
public async Task Should_return_all_statuses() public async Task Should_return_all_statuses()
{ {
var expected = new[] { Status.Archived, Status.Draft, Status.Published }; var expected = new[]
{
new StatusInfo(Status.Archived, StatusColors.Archived),
new StatusInfo(Status.Draft, StatusColors.Draft),
new StatusInfo(Status.Published, StatusColors.Published)
};
var result = await sut.GetAllAsync(null); var result = await sut.GetAllAsync(null);
Assert.Equal(expected, result); result.Should().BeEquivalentTo(expected);
}
private IContentEntity CreateContent(Status status)
{
var content = A.Fake<IContentEntity>();
A.CallTo(() => content.Status).Returns(status);
return content;
} }
} }
} }

38
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var asset = CreateAsset(Guid.NewGuid()); var asset = CreateAsset(Guid.NewGuid());
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query"))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query")))
.Returns(ResultList.Create(0, asset)); .Returns(ResultList.CreateFrom(0, asset));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -138,7 +138,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var asset = CreateAsset(Guid.NewGuid()); var asset = CreateAsset(Guid.NewGuid());
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query"))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query")))
.Returns(ResultList.Create(10, asset)); .Returns(ResultList.CreateFrom(10, asset));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -213,7 +213,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", assetId.ToString()); }".Replace("<ID>", assetId.ToString());
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchId(assetId))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchId(assetId)))
.Returns(ResultList.Create(1, asset)); .Returns(ResultList.CreateFrom(1, asset));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -262,6 +262,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
lastModified lastModified
lastModifiedBy lastModifiedBy
status status
statusColor
url url
data { data {
myString { myString {
@ -301,7 +302,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var content = CreateContent(Guid.NewGuid(), Guid.Empty, Guid.Empty); var content = CreateContent(Guid.NewGuid(), Guid.Empty, Guid.Empty);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5"))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5")))
.Returns(ResultList.Create(0, content)); .Returns(ResultList.CreateFrom(0, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -320,6 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
lastModified = content.LastModified, lastModified = content.LastModified,
lastModifiedBy = "subject:user2", lastModifiedBy = "subject:user2",
status = "DRAFT", status = "DRAFT",
statusColor = "red",
url = $"contents/my-schema/{content.Id}", url = $"contents/my-schema/{content.Id}",
data = new data = new
{ {
@ -406,6 +408,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
lastModified lastModified
lastModifiedBy lastModifiedBy
status status
statusColor
url url
data { data {
myString { myString {
@ -440,7 +443,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var content = CreateContent(Guid.NewGuid(), Guid.Empty, Guid.Empty); var content = CreateContent(Guid.NewGuid(), Guid.Empty, Guid.Empty);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5"))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5")))
.Returns(ResultList.Create(10, content)); .Returns(ResultList.CreateFrom(10, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -462,6 +465,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
lastModified = content.LastModified, lastModified = content.LastModified,
lastModifiedBy = "subject:user2", lastModifiedBy = "subject:user2",
status = "DRAFT", status = "DRAFT",
statusColor = "red",
url = $"contents/my-schema/{content.Id}", url = $"contents/my-schema/{content.Id}",
data = new data = new
{ {
@ -545,7 +549,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -605,6 +609,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
lastModified lastModified
lastModifiedBy lastModifiedBy
status status
statusColor
url url
data { data {
myString { myString {
@ -636,7 +641,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -653,6 +658,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
lastModified = content.LastModified, lastModified = content.LastModified,
lastModifiedBy = "subject:user2", lastModifiedBy = "subject:user2",
status = "DRAFT", status = "DRAFT",
statusColor = "red",
url = $"contents/my-schema/{content.Id}", url = $"contents/my-schema/{content.Id}",
data = new data = new
{ {
@ -731,10 +737,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), A<Q>.Ignored)) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), A<Q>.Ignored))
.Returns(ResultList.Create(0, contentRef)); .Returns(ResultList.CreateFrom(0, contentRef));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -789,10 +795,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Q>.Ignored)) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Q>.Ignored))
.Returns(ResultList.Create(0, assetRef)); .Returns(ResultList.CreateFrom(0, assetRef));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -845,10 +851,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", assetId2.ToString()); }".Replace("<ID>", assetId2.ToString());
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchId(assetId1))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchId(assetId1)))
.Returns(ResultList.Create(0, asset1)); .Returns(ResultList.CreateFrom(0, asset1));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchId(assetId2))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchId(assetId2)))
.Returns(ResultList.Create(0, asset2)); .Returns(ResultList.CreateFrom(0, asset2));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query1 }, new GraphQLQuery { Query = query2 }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query1 }, new GraphQLQuery { Query = query2 });
@ -904,7 +910,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -942,7 +948,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -988,7 +994,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), schemaId.ToString(), MatchId(contentId)))
.Returns(ResultList.Create(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });

11
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -96,7 +96,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sut = CreateSut(); sut = CreateSut();
} }
protected static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null, NamedContentData dataDraft = null) protected static IEnrichedContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null, NamedContentData dataDraft = null)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
@ -157,17 +157,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
LastModifiedBy = new RefToken(RefTokenType.Subject, "user2"), LastModifiedBy = new RefToken(RefTokenType.Subject, "user2"),
Data = data, Data = data,
DataDraft = dataDraft, DataDraft = dataDraft,
Status = Status.Draft Status = Status.Draft,
StatusColor = "red"
}; };
return content; return content;
} }
protected static IAssetEntity CreateAsset(Guid id) protected static IEnrichedAssetEntity CreateAsset(Guid id)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var asset = new FakeAssetEntity var asset = new AssetEntity
{ {
Id = id, Id = id,
Version = 1, Version = 1,
@ -184,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
IsImage = true, IsImage = true,
PixelWidth = 800, PixelWidth = 800,
PixelHeight = 600, PixelHeight = 600,
Tags = new[] { "tag1", "tag2" }.ToHashSet() TagNames = new[] { "tag1", "tag2" }.ToHashSet()
}; };
return asset; return asset;

7
tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

@ -270,12 +270,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
private IContentEntity CreateContent(Status status, bool isPending) private IContentEntity CreateContent(Status status, bool isPending)
{ {
var content = A.Fake<IContentEntity>(); return new ContentEntity { Status = status, IsPending = isPending };
A.CallTo(() => content.Status).Returns(status);
A.CallTo(() => content.IsPending).Returns(isPending);
return content;
} }
} }
} }

7
tools/Migrate_01/OldEvents/AppPlanChangedOld.cs → tools/Migrate_01/OldEvents/AppPlanChanged.cs

@ -5,16 +5,19 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using AppPlanChangedV2 = Squidex.Domain.Apps.Events.Apps.AppPlanChanged;
namespace Migrate_01.OldEvents namespace Migrate_01.OldEvents
{ {
[TypeName("AppPlanChanged")] [TypeName("AppPlanChanged")]
public sealed class AppPlanChangedOld : AppEvent, IMigrated<IEvent> [Obsolete]
public sealed class AppPlanChanged : AppEvent, IMigrated<IEvent>
{ {
public string PlanId { get; set; } public string PlanId { get; set; }
@ -22,7 +25,7 @@ namespace Migrate_01.OldEvents
{ {
if (!string.IsNullOrWhiteSpace(PlanId)) if (!string.IsNullOrWhiteSpace(PlanId))
{ {
return SimpleMapper.Map(this, new AppPlanChanged()); return SimpleMapper.Map(this, new AppPlanChangedV2());
} }
else else
{ {

38
tools/Migrate_01/OldEvents/ContentCreated.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using ContentCreatedV2 = Squidex.Domain.Apps.Events.Contents.ContentCreated;
namespace Migrate_01.OldEvents
{
[EventType(nameof(ContentCreated))]
[Obsolete]
public sealed class ContentCreated : ContentEvent, IMigrated<IEvent>
{
public Status Status { get; set; }
public NamedContentData Data { get; set; }
public IEvent Migrate()
{
var migrated = SimpleMapper.Map(this, new ContentCreatedV2());
if (migrated.Status == default)
{
migrated.Status = Status.Draft;
}
return migrated;
}
}
}

47
tools/Migrate_01/OldEvents/ContentStatusChanged.cs

@ -0,0 +1,47 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using ContentStatusChangedV2 = Squidex.Domain.Apps.Events.Contents.ContentStatusChanged;
namespace Migrate_01.OldEvents
{
[EventType(nameof(ContentStatusChanged))]
[Obsolete]
public sealed class ContentStatusChanged : ContentEvent, IMigrated<IEvent>
{
public string Change { get; set; }
public Status Status { get; set; }
public IEvent Migrate()
{
var migrated = SimpleMapper.Map(this, new ContentStatusChangedV2());
if (migrated.Status == default)
{
migrated.Status = Status.Draft;
}
if (Enum.TryParse<StatusChange>(Change, out var result))
{
migrated.Change = result;
}
else
{
migrated.Change = StatusChange.Change;
}
return migrated;
}
}
}
Loading…
Cancel
Save