Browse Source

Merge pull request #308 from Squidex/feature-tags

Feature tags
pull/310/head
Sebastian Stehle 8 years ago
committed by GitHub
parent
commit
b667d424c0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs
  2. 2
      src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs
  3. 5
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  4. 15
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  5. 26
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  6. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  7. 6
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs
  8. 6
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  9. 44
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  10. 105
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  11. 16
      src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs
  12. 1
      src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs
  13. 5
      src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs
  14. 1
      src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs
  15. 20
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  16. 9
      src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs
  17. 6
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  18. 46
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs
  19. 58
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  20. 11
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  21. 6
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  22. 9
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  23. 23
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  24. 16
      src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs
  25. 54
      src/Squidex.Domain.Apps.Entities/Query.cs
  26. 55
      src/Squidex.Domain.Apps.Entities/QueryContext.cs
  27. 6
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  28. 54
      src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs
  29. 24
      src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs
  30. 24
      src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs
  31. 151
      src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs
  32. 14
      src/Squidex.Domain.Apps.Entities/Tags/TagGroups.cs
  33. 18
      src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs
  34. 4
      src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs
  35. 74
      src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs
  36. 12
      src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs
  37. 6
      src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs
  38. 24
      src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ValueConversion.cs
  39. 2
      src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  40. 19
      src/Squidex.Infrastructure/HashSet.cs
  41. 7
      src/Squidex.Infrastructure/ResultList.cs
  42. 65
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  43. 6
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  44. 19
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs
  45. 35
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  46. 1
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  47. 7
      src/Squidex/Config/Domain/EntitiesServices.cs
  48. 4
      src/Squidex/app/features/administration/pages/users/users-page.component.html
  49. 29
      src/Squidex/app/features/assets/pages/assets-page.component.html
  50. 26
      src/Squidex/app/features/assets/pages/assets-page.component.scss
  51. 8
      src/Squidex/app/features/assets/pages/assets-page.component.ts
  52. 8
      src/Squidex/app/features/content/shared/assets-editor.component.html
  53. 6
      src/Squidex/app/features/content/shared/assets-editor.component.scss
  54. 2
      src/Squidex/app/features/content/shared/assets-editor.component.ts
  55. 2
      src/Squidex/app/features/rules/pages/rules/rules-page.component.html
  56. 6
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  57. 2
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  58. 6
      src/Squidex/app/features/settings/pages/clients/client.component.html
  59. 43
      src/Squidex/app/features/settings/pages/clients/client.component.scss
  60. 19
      src/Squidex/app/framework/angular/forms/tag-editor.component.html
  61. 72
      src/Squidex/app/framework/angular/forms/tag-editor.component.scss
  62. 65
      src/Squidex/app/framework/angular/forms/tag-editor.component.ts
  63. 6
      src/Squidex/app/framework/angular/image-source.directive.ts
  64. 2
      src/Squidex/app/framework/angular/panel.component.html
  65. 3
      src/Squidex/app/framework/angular/panel.component.ts
  66. 109
      src/Squidex/app/shared/components/asset.component.html
  67. 174
      src/Squidex/app/shared/components/asset.component.scss
  68. 72
      src/Squidex/app/shared/components/asset.component.ts
  69. 3
      src/Squidex/app/shared/components/assets-list.component.html
  70. 5
      src/Squidex/app/shared/components/assets-list.component.scss
  71. 4
      src/Squidex/app/shared/components/assets-list.component.ts
  72. 83
      src/Squidex/app/shared/services/assets.service.spec.ts
  73. 50
      src/Squidex/app/shared/services/assets.service.ts
  74. 26
      src/Squidex/app/shared/state/assets.forms.ts
  75. 50
      src/Squidex/app/shared/state/assets.state.spec.ts
  76. 100
      src/Squidex/app/shared/state/assets.state.ts
  77. 58
      src/Squidex/app/theme/_forms.scss
  78. 5
      src/Squidex/app/theme/_panels.scss
  79. 6
      src/Squidex/app/theme/_vars.scss
  80. 33
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs
  81. 7
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs
  82. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  83. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs
  84. 117
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs
  85. 35
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/OData/ODataQueryTests.cs
  86. 14
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  87. 55
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  88. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  89. 3
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs
  90. 75
      tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs
  91. 92
      tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs
  92. 2
      tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs
  93. 2
      tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs
  94. 2
      tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs

10
src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs

@ -29,11 +29,6 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
return field.Accept(new ReferencesCleaner(value, oldReferences)); return field.Accept(new ReferencesCleaner(value, oldReferences));
} }
public JToken Visit(IArrayField field)
{
return value;
}
public JToken Visit(IField<AssetsFieldProperties> field) public JToken Visit(IField<AssetsFieldProperties> field)
{ {
return CleanIds(); return CleanIds();
@ -97,5 +92,10 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
return value; return value;
} }
public JToken Visit(IArrayField field)
{
return value;
}
} }
} }

2
src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs

@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
foreach (var nestedField in field.Fields) foreach (var nestedField in field.Fields)
{ {
if (item.TryGetValue(field.Name, out var value)) if (item.TryGetValue(nestedField.Name, out var value))
{ {
result.AddRange(nestedField.Accept(new ReferencesExtractor(value))); result.AddRange(nestedField.Accept(new ReferencesExtractor(value)));
} }

5
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent;
@ -70,6 +71,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
[BsonElement] [BsonElement]
public RefToken LastModifiedBy { get; set; } public RefToken LastModifiedBy { get; set; }
[BsonIgnoreIfNull]
[BsonElement]
public HashSet<string> Tags { get; set; }
[BsonElement] [BsonElement]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }

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

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Edm;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors; using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
@ -22,9 +23,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository
{ {
public MongoAssetRepository(IMongoDatabase database) private readonly ITagService tagService;
public MongoAssetRepository(IMongoDatabase database, ITagService tagService)
: base(database) : base(database)
{ {
Guard.NotNull(tagService, nameof(tagService));
this.tagService = tagService;
} }
protected override string CollectionName() protected override string CollectionName()
@ -40,6 +46,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
.Ascending(x => x.AppId) .Ascending(x => x.AppId)
.Ascending(x => x.IsDeleted) .Ascending(x => x.IsDeleted)
.Ascending(x => x.FileName) .Ascending(x => x.FileName)
.Ascending(x => x.Tags)
.Descending(x => x.LastModified))); .Descending(x => x.LastModified)));
} }
@ -51,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
var odataQuery = EdmAssetModel.Edm.ParseQuery(query); var odataQuery = EdmAssetModel.Edm.ParseQuery(query);
var filter = FindExtensions.BuildQuery(odataQuery, appId); var filter = FindExtensions.BuildQuery(odataQuery, appId, tagService);
var contentCount = Collection.Find(filter).CountDocumentsAsync(); var contentCount = Collection.Find(filter).CountDocumentsAsync();
var contentItems = var contentItems =
@ -63,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Task.WhenAll(contentItems, contentCount); await Task.WhenAll(contentItems, contentCount);
return ResultList.Create<IAssetEntity>(contentItems.Result, contentCount.Result); return ResultList.Create<IAssetEntity>(contentCount.Result, contentItems.Result);
} }
catch (NotSupportedException) catch (NotSupportedException)
{ {
@ -98,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Task.WhenAll(assetItems, assetCount); await Task.WhenAll(assetItems, assetCount);
return ResultList.Create(assetItems.Result.OfType<IAssetEntity>().ToList(), assetCount.Result); return ResultList.Create(assetCount.Result, assetItems.Result.OfType<IAssetEntity>());
} }
} }

26
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs

@ -7,9 +7,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.OData.UriParser; using Microsoft.OData.UriParser;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb.OData; using Squidex.Infrastructure.MongoDb.OData;
@ -18,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
public static class FindExtensions public static class FindExtensions
{ {
private static readonly FilterDefinitionBuilder<MongoAssetEntity> Filter = Builders<MongoAssetEntity>.Filter; private static readonly FilterDefinitionBuilder<MongoAssetEntity> Filter = Builders<MongoAssetEntity>.Filter;
private static readonly PropertyCalculator PropertyCalculator = propertyNames => private static readonly ConvertProperty PropertyCalculator = propertyNames =>
{ {
if (propertyNames.Length > 0) if (propertyNames.Length > 0)
{ {
@ -47,15 +50,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
return cursor.Skip(query); return cursor.Skip(query);
} }
public static FilterDefinition<MongoAssetEntity> BuildQuery(ODataUriParser query, Guid appId) public static FilterDefinition<MongoAssetEntity> BuildQuery(ODataUriParser query, Guid appId, ITagService tagService)
{ {
var convertValue = CreateValueConverter(appId, tagService);
var filters = new List<FilterDefinition<MongoAssetEntity>> var filters = new List<FilterDefinition<MongoAssetEntity>>
{ {
Filter.Eq(x => x.IndexedAppId, appId), Filter.Eq(x => x.IndexedAppId, appId),
Filter.Eq(x => x.IsDeleted, false) Filter.Eq(x => x.IsDeleted, false)
}; };
var filter = query.BuildFilter<MongoAssetEntity>(PropertyCalculator, false); var filter = query.BuildFilter<MongoAssetEntity>(PropertyCalculator, convertValue, false);
if (filter.Filter != null) if (filter.Filter != null)
{ {
@ -82,5 +87,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
return new BsonDocument(); return new BsonDocument();
} }
} }
public static ConvertValue CreateValueConverter(Guid appId, ITagService tagService)
{
return new ConvertValue((field, value) =>
{
if (string.Equals(field, nameof(MongoAssetEntity.Tags), StringComparison.OrdinalIgnoreCase))
{
var tagNames = Task.Run(() => tagService.GetTagIdsAsync(appId, TagGroups.Assets, HashSet.Of(value.ToString()))).Result;
return tagNames?.FirstOrDefault() ?? value;
}
return value;
});
}
} }
} }

4
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
entity.ParseData(schema.SchemaDef); entity.ParseData(schema.SchemaDef);
} }
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result); return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result);
} }
catch (NotSupportedException) catch (NotSupportedException)
{ {
@ -106,7 +106,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
entity.ParseData(schema.SchemaDef); entity.ParseData(schema.SchemaDef);
} }
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result); return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result);
} }
public Task CleanupAsync(Guid id) public Task CleanupAsync(Guid id)

6
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs

@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
typeof(MongoContentEntity).GetProperties() typeof(MongoContentEntity).GetProperties()
.ToDictionary(x => x.Name, x => x.GetCustomAttribute<BsonElementAttribute>()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase); .ToDictionary(x => x.Name, x => x.GetCustomAttribute<BsonElementAttribute>()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase);
public static PropertyCalculator CreatePropertyCalculator(Schema schema, bool useDraft) public static ConvertProperty CreatePropertyCalculator(Schema schema, bool useDraft)
{ {
return propertyNames => return propertyNames =>
{ {
@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
}; };
} }
public static IFindFluent<MongoContentEntity, MongoContentEntity> ContentSort(this IFindFluent<MongoContentEntity, MongoContentEntity> cursor, ODataUriParser query, PropertyCalculator propertyCalculator) public static IFindFluent<MongoContentEntity, MongoContentEntity> ContentSort(this IFindFluent<MongoContentEntity, MongoContentEntity> cursor, ODataUriParser query, ConvertProperty propertyCalculator)
{ {
var sort = query.BuildSort<MongoContentEntity>(propertyCalculator); var sort = query.BuildSort<MongoContentEntity>(propertyCalculator);
@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
return cursor.Skip(query); return cursor.Skip(query);
} }
public static FilterDefinition<MongoContentEntity> BuildQuery(ODataUriParser query, Guid schemaId, Status[] status, PropertyCalculator propertyCalculator) public static FilterDefinition<MongoContentEntity> BuildQuery(ODataUriParser query, Guid schemaId, Status[] status, ConvertProperty propertyCalculator)
{ {
var filters = new List<FilterDefinition<MongoContentEntity>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {

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

@ -72,14 +72,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
}); });
case AssignContributor assigneContributor: case AssignContributor assigneContributor:
return UpdateReturnAsync(assigneContributor, (Func<AssignContributor, Task<object>>)(async c => return UpdateReturnAsync(assigneContributor, async c =>
{ {
await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId)); await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId));
AssignContributor(c); AssignContributor(c);
return EntityCreatedResult.Create(c.ContributorId, (long)base.Version); return EntityCreatedResult.Create(c.ContributorId, Version);
})); });
case RemoveContributor removeContributor: case RemoveContributor removeContributor:
return UpdateAsync(removeContributor, c => return UpdateAsync(removeContributor, c =>

44
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.Guards; using Squidex.Domain.Apps.Entities.Assets.Guards;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -24,33 +25,40 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public sealed class AssetGrain : SquidexDomainObjectGrainLogSnapshots<AssetState>, IAssetGrain public sealed class AssetGrain : SquidexDomainObjectGrainLogSnapshots<AssetState>, IAssetGrain
{ {
public AssetGrain(IStore<Guid> store, ISemanticLog log) private readonly ITagService tagService;
public AssetGrain(IStore<Guid> store, ITagService tagService, ISemanticLog log)
: base(store, log) : base(store, log)
{ {
Guard.NotNull(tagService, nameof(tagService));
this.tagService = tagService;
} }
protected override Task<object> ExecuteAsync(IAggregateCommand command) protected override Task<object> ExecuteAsync(IAggregateCommand command)
{ {
VerifyNotDeleted();
switch (command) switch (command)
{ {
case CreateAsset createRule: case CreateAsset createRule:
return CreateReturnAsync(createRule, (Func<CreateAsset, object>)(c => return CreateReturnAsync(createRule, c =>
{ {
GuardAsset.CanCreate(c); GuardAsset.CanCreate(c);
Create(c); Create(c);
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion); return new AssetSavedResult(Version, Snapshot.FileVersion);
})); });
case UpdateAsset updateRule: case UpdateAsset updateRule:
return UpdateReturnAsync(updateRule, (Func<UpdateAsset, object>)(c => return UpdateAsync(updateRule, c =>
{ {
GuardAsset.CanUpdate(c); GuardAsset.CanUpdate(c);
Update(c); Update(c);
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion); return new AssetSavedResult(Version, Snapshot.FileVersion);
})); });
case RenameAsset renameAsset: case RenameAsset renameAsset:
return UpdateAsync(renameAsset, c => return UpdateAsync(renameAsset, c =>
{ {
@ -59,12 +67,23 @@ namespace Squidex.Domain.Apps.Entities.Assets
Rename(c); Rename(c);
}); });
case DeleteAsset deleteAsset: case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, c => return UpdateAsync(deleteAsset, async c =>
{ {
GuardAsset.CanDelete(c); GuardAsset.CanDelete(c);
await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags);
Delete(c); Delete(c);
}); });
case TagAsset tagAsset:
return UpdateAsync(tagAsset, async c =>
{
GuardAsset.CanTag(c);
c.Tags = await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, c.Tags, Snapshot.Tags);
Tag(c);
});
default: default:
throw new NotSupportedException(); throw new NotSupportedException();
} }
@ -105,18 +124,19 @@ namespace Squidex.Domain.Apps.Entities.Assets
public void Delete(DeleteAsset command) public void Delete(DeleteAsset command)
{ {
VerifyNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize })); RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize }));
} }
public void Rename(RenameAsset command) public void Rename(RenameAsset command)
{ {
VerifyNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new AssetRenamed())); RaiseEvent(SimpleMapper.Map(command, new AssetRenamed()));
} }
public void Tag(TagAsset command)
{
RaiseEvent(SimpleMapper.Map(command, new AssetTagged()));
}
private void RaiseEvent(AppEvent @event) private void RaiseEvent(AppEvent @event)
{ {
if (@event.AppId == null) if (@event.AppId == null)

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

@ -0,0 +1,105 @@
// ==========================================================================
// 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.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetQueryService : IAssetQueryService
{
private readonly ITagService tagService;
private readonly IAssetRepository assetRepository;
public AssetQueryService(ITagService tagService, IAssetRepository assetRepository)
{
Guard.NotNull(tagService, nameof(tagService));
Guard.NotNull(assetRepository, nameof(assetRepository));
this.tagService = tagService;
this.assetRepository = assetRepository;
}
public async Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id)
{
Guard.NotNull(context, nameof(context));
var asset = await assetRepository.FindAssetAsync(id);
if (asset != null)
{
await DenormalizeTagsAsync(context.App.Id, Enumerable.Repeat(asset, 1));
}
return asset;
}
public async Task<IResultList<IAssetEntity>> QueryAsync(QueryContext context, Query query)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query));
IResultList<IAssetEntity> assets;
if (query.Ids != null)
{
assets = await assetRepository.QueryAsync(context.App.Id, new HashSet<Guid>(query.Ids));
assets = Sort(assets, query.Ids);
}
else
{
assets = await assetRepository.QueryAsync(context.App.Id, query.ODataQuery);
}
await DenormalizeTagsAsync(context.App.Id, assets);
return assets;
}
private IResultList<IAssetEntity> Sort(IResultList<IAssetEntity> assets, IList<Guid> ids)
{
var sorted = ids.Select(id => assets.FirstOrDefault(x => x.Id == id)).Where(x => x != null);
return ResultList.Create(assets.Total, sorted);
}
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();
}
}
}
}
}

16
src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public sealed class TagAsset : AssetCommand
{
public HashSet<string> Tags { get; set; }
}
}

1
src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs

@ -30,6 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Edm
entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String); entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32); entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32);
entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32); entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32);
entityType.AddStructuralProperty(nameof(IAssetEntity.Tags).ToCamelCase(), EdmPrimitiveTypeKind.String);
var container = new EdmEntityContainer("Squidex", "Container"); var container = new EdmEntityContainer("Squidex", "Container");

5
src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs

@ -35,6 +35,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
} }
public static void CanTag(TagAsset command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanUpdate(UpdateAsset command) public static void CanUpdate(UpdateAsset command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));

1
src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs

@ -16,6 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
IEntityWithCreatedBy, IEntityWithCreatedBy,
IEntityWithLastModifiedBy, IEntityWithLastModifiedBy,
IEntityWithVersion, IEntityWithVersion,
IEntityWithTags,
IAssetInfo IAssetInfo
{ {
NamedId<Guid> AppId { get; } NamedId<Guid> AppId { get; }

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

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetQueryService
{
Task<IResultList<IAssetEntity>> QueryAsync(QueryContext contex, Query query);
Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id);
}
}

9
src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
@ -49,6 +50,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
[JsonProperty] [JsonProperty]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
[JsonProperty]
public HashSet<string> Tags { get; set; }
Guid IAssetInfo.AssetId Guid IAssetInfo.AssetId
{ {
get { return Id; } get { return Id; }
@ -70,6 +74,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
TotalSize += @event.FileSize; TotalSize += @event.FileSize;
} }
protected void On(AssetTagged @event)
{
Tags = @event.Tags;
}
protected void On(AssetRenamed @event) protected void On(AssetRenamed @event)
{ {
FileName = @event.FileName; FileName = @event.FileName;

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

@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
switch (command) switch (command)
{ {
case CreateContent createContent: case CreateContent createContent:
return CreateReturnAsync(createContent, (Func<CreateContent, Task<object>>)(async c => return CreateReturnAsync(createContent, async c =>
{ {
var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content."); var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content.");
@ -77,8 +77,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
Create(c); Create(c);
return EntityCreatedResult.Create(c.Data, (long)base.Version); return EntityCreatedResult.Create(c.Data, Version);
})); });
case UpdateContent updateContent: case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, c => return UpdateReturnAsync(updateContent, c =>

46
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentQueryContext : Cloneable<ContentQueryContext>
{
public string SchemaIdOrName { get; private set; }
public QueryContext Base { get; private set; }
public ContentQueryContext(QueryContext @base)
{
Guard.NotNull(@base, nameof(@base));
Base = @base;
}
public ContentQueryContext WithSchemaName(string name)
{
return Clone(c => c.SchemaIdOrName = name);
}
public ContentQueryContext WithArchived(bool archived)
{
return Clone(c => c.Base = c.Base.WithArchived(archived));
}
public ContentQueryContext WithFlatten(bool flatten)
{
return Clone(c => c.Base = c.Base.WithFlatten(flatten));
}
public ContentQueryContext WithSchemaId(Guid id)
{
return Clone(c => c.SchemaIdOrName = id.ToString());
}
}
}

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

@ -55,12 +55,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
} }
public Task ThrowIfSchemaNotExistsAsync(QueryContext context) public Task ThrowIfSchemaNotExistsAsync(ContentQueryContext context)
{ {
return GetSchemaAsync(context); return GetSchemaAsync(context);
} }
public async Task<IContentEntity> FindContentAsync(QueryContext context, Guid id, long version = -1) public async Task<IContentEntity> FindContentAsync(ContentQueryContext context, Guid id, long version = -1)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
@ -70,53 +70,47 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var isVersioned = version > EtagVersion.Empty; var isVersioned = version > EtagVersion.Empty;
var parsedStatus = context.IsFrontendClient ? StatusAll : StatusPublished; var parsedStatus = context.Base.IsFrontendClient ? StatusAll : StatusPublished;
var content = var content =
isVersioned ? isVersioned ?
await FindContentByVersionAsync(id, version) : await FindContentByVersionAsync(id, version) :
await FindContentAsync(context, id, parsedStatus, schema); await FindContentAsync(context.Base, id, parsedStatus, schema);
if (content == null || (content.Status != Status.Published && !context.IsFrontendClient) || content.SchemaId.Id != schema.Id) if (content == null || (content.Status != Status.Published && !context.Base.IsFrontendClient) || content.SchemaId.Id != schema.Id)
{ {
throw new DomainObjectNotFoundException(id.ToString(), typeof(ISchemaEntity)); throw new DomainObjectNotFoundException(id.ToString(), typeof(ISchemaEntity));
} }
return Transform(context, schema, true, content); return Transform(context.Base, schema, true, content);
} }
} }
public async Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string query) public async Task<IResultList<IContentEntity>> QueryAsync(ContentQueryContext context, Query query)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context); var schema = await GetSchemaAsync(context);
using (Profiler.TraceMethod<ContentQueryService>("QueryAsyncByQuery")) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var parsedQuery = ParseQuery(context, query, schema); var parsedStatus = ParseStatus(context.Base);
var parsedStatus = ParseStatus(context);
var contents = await contentRepository.QueryAsync(context.App, schema, parsedStatus, parsedQuery);
return Transform(context, schema, true, contents);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, IList<Guid> ids)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(ids, nameof(ids));
var schema = await GetSchemaAsync(context); IResultList<IContentEntity> contents;
using (Profiler.TraceMethod<ContentQueryService>("QueryAsyncByIds")) if (query.Ids?.Count > 0)
{ {
var parsedStatus = ParseStatus(context); contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, new HashSet<Guid>(query.Ids));
contents = Sort(contents, query.Ids);
}
else
{
var parsedQuery = ParseQuery(context.Base, query.ODataQuery, schema);
var contents = await contentRepository.QueryAsync(context.App, schema, parsedStatus, new HashSet<Guid>(ids)); contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, parsedQuery);
}
return Sort(Transform(context, schema, false, contents), ids); return Transform(context.Base, schema, true, contents);
} }
} }
@ -129,14 +123,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var transformed = Transform(context, schema, checkType, (IEnumerable<IContentEntity>)contents); var transformed = Transform(context, schema, checkType, (IEnumerable<IContentEntity>)contents);
return ResultList.Create(transformed, contents.Total); return ResultList.Create(contents.Total, transformed);
} }
private IResultList<IContentEntity> Sort(IResultList<IContentEntity> contents, IList<Guid> ids) private IResultList<IContentEntity> Sort(IResultList<IContentEntity> contents, IList<Guid> ids)
{ {
var sorted = ids.Select(id => contents.FirstOrDefault(x => x.Id == id)).Where(x => x != null); var sorted = ids.Select(id => contents.FirstOrDefault(x => x.Id == id)).Where(x => x != null);
return ResultList.Create(sorted, contents.Total); return ResultList.Create(contents.Total, sorted);
} }
private IEnumerable<IContentEntity> Transform(QueryContext context, ISchemaEntity schema, bool checkType, IEnumerable<IContentEntity> contents) private IEnumerable<IContentEntity> Transform(QueryContext context, ISchemaEntity schema, bool checkType, IEnumerable<IContentEntity> contents)
@ -218,18 +212,18 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
public async Task<ISchemaEntity> GetSchemaAsync(QueryContext context) public async Task<ISchemaEntity> GetSchemaAsync(ContentQueryContext context)
{ {
ISchemaEntity schema = null; ISchemaEntity schema = null;
if (Guid.TryParse(context.SchemaIdOrName, out var id)) if (Guid.TryParse(context.SchemaIdOrName, out var id))
{ {
schema = await appProvider.GetSchemaAsync(context.App.Id, id); schema = await appProvider.GetSchemaAsync(context.Base.App.Id, id);
} }
if (schema == null) if (schema == null)
{ {
schema = await appProvider.GetSchemaAsync(context.App.Id, context.SchemaIdOrName); schema = await appProvider.GetSchemaAsync(context.Base.App.Id, context.SchemaIdOrName);
} }
if (schema == null) if (schema == null)

11
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs

@ -10,6 +10,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -20,23 +21,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
private readonly IContentQueryService contentQuery; private readonly IContentQueryService contentQuery;
private readonly IGraphQLUrlGenerator urlGenerator; private readonly IGraphQLUrlGenerator urlGenerator;
private readonly IAssetRepository assetRepository; private readonly IAssetQueryService assetQuery;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
public CachingGraphQLService(IMemoryCache cache, public CachingGraphQLService(IMemoryCache cache,
IAppProvider appProvider, IAppProvider appProvider,
IAssetRepository assetRepository, IAssetQueryService assetQuery,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IGraphQLUrlGenerator urlGenerator) IGraphQLUrlGenerator urlGenerator)
: base(cache) : base(cache)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(assetRepository, nameof(assetRepository)); Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery)); Guard.NotNull(contentQuery, nameof(contentQuery));
Guard.NotNull(urlGenerator, nameof(urlGenerator)); Guard.NotNull(urlGenerator, nameof(urlGenerator));
this.appProvider = appProvider; this.appProvider = appProvider;
this.assetRepository = assetRepository; this.assetQuery = assetQuery;
this.contentQuery = contentQuery; this.contentQuery = contentQuery;
this.urlGenerator = urlGenerator; this.urlGenerator = urlGenerator;
} }
@ -53,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var modelContext = await GetModelAsync(context.App); var modelContext = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetRepository, contentQuery, urlGenerator); var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator);
return await modelContext.ExecuteAsync(ctx, query); return await modelContext.ExecuteAsync(ctx, query);
} }

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

@ -10,8 +10,6 @@ using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
public sealed class GraphQLExecutionContext : QueryExecutionContext public sealed class GraphQLExecutionContext : QueryExecutionContext
@ -19,10 +17,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public IGraphQLUrlGenerator UrlGenerator { get; } public IGraphQLUrlGenerator UrlGenerator { get; }
public GraphQLExecutionContext(QueryContext context, public GraphQLExecutionContext(QueryContext context,
IAssetRepository assetRepository, IAssetQueryService assetQueryService,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IGraphQLUrlGenerator urlGenerator) IGraphQLUrlGenerator urlGenerator)
: base(context, assetRepository, contentQuery) : base(context, assetQueryService, contentQuery)
{ {
UrlGenerator = urlGenerator; UrlGenerator = urlGenerator;
} }

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

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -14,12 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public interface IContentQueryService public interface IContentQueryService
{ {
Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, IList<Guid> ids); Task<IResultList<IContentEntity>> QueryAsync(ContentQueryContext context, Query query);
Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string query); Task<IContentEntity> FindContentAsync(ContentQueryContext context, Guid id, long version = EtagVersion.Any);
Task<IContentEntity> FindContentAsync(QueryContext context, Guid id, long version = EtagVersion.Any); Task ThrowIfSchemaNotExistsAsync(ContentQueryContext context);
Task ThrowIfSchemaNotExistsAsync(QueryContext context);
} }
} }

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

@ -11,7 +11,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
@ -21,18 +20,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
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, IAssetEntity> cachedAssets = new ConcurrentDictionary<Guid, IAssetEntity>();
private readonly IContentQueryService contentQuery; private readonly IContentQueryService contentQuery;
private readonly IAssetRepository assetRepository; private readonly IAssetQueryService assetQuery;
private readonly QueryContext context; private readonly QueryContext context;
public QueryExecutionContext(QueryContext context, public QueryExecutionContext(QueryContext context, IAssetQueryService assetQuery, IContentQueryService contentQuery)
IAssetRepository assetRepository,
IContentQueryService contentQuery)
{ {
Guard.NotNull(assetRepository, nameof(assetRepository)); Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery)); Guard.NotNull(contentQuery, nameof(contentQuery));
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
this.assetRepository = assetRepository; this.assetQuery = assetQuery;
this.contentQuery = contentQuery; this.contentQuery = contentQuery;
this.context = context; this.context = context;
} }
@ -43,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (asset == null) if (asset == null)
{ {
asset = await assetRepository.FindAssetAsync(id); asset = await assetQuery.FindAssetAsync(context, id);
if (asset != null) if (asset != null)
{ {
@ -60,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (content == null) if (content == null)
{ {
content = await contentQuery.FindContentAsync(context.WithSchemaId(schemaId), id); content = await contentQuery.FindContentAsync(new ContentQueryContext(context).WithSchemaId(schemaId), id);
if (content != null) if (content != null)
{ {
@ -73,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<IResultList<IAssetEntity>> QueryAssetsAsync(string query) public async Task<IResultList<IAssetEntity>> QueryAssetsAsync(string query)
{ {
var assets = await assetRepository.QueryAsync(context.App.Id, query); var assets = await assetQuery.QueryAsync(context, Query.Empty.WithODataQuery(query));
foreach (var asset in assets) foreach (var asset in assets)
{ {
@ -85,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<IResultList<IContentEntity>> QueryContentsAsync(string schemaIdOrName, string query) public async Task<IResultList<IContentEntity>> QueryContentsAsync(string schemaIdOrName, string query)
{ {
var result = await contentQuery.QueryAsync(context.WithSchemaName(schemaIdOrName), query); var result = await contentQuery.QueryAsync(new ContentQueryContext(context).WithSchemaName(schemaIdOrName), Query.Empty.WithODataQuery(query));
foreach (var content in result) foreach (var content in result)
{ {
@ -103,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (notLoadedAssets.Count > 0) if (notLoadedAssets.Count > 0)
{ {
var assets = await assetRepository.QueryAsync(context.App.Id, notLoadedAssets); var assets = await assetQuery.QueryAsync(context, Query.Empty.WithIds(notLoadedAssets));
foreach (var asset in assets) foreach (var asset in assets)
{ {
@ -122,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (notLoadedContents.Count > 0) if (notLoadedContents.Count > 0)
{ {
var result = await contentQuery.QueryAsync(context.WithSchemaId(schemaId), notLoadedContents); var result = await contentQuery.QueryAsync(new ContentQueryContext(context).WithSchemaId(schemaId), Query.Empty.WithIds(notLoadedContents));
foreach (var content in result) foreach (var content in result)
{ {

16
src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities
{
public interface IEntityWithTags
{
HashSet<string> Tags { get; }
}
}

54
src/Squidex.Domain.Apps.Entities/Query.cs

@ -0,0 +1,54 @@
// ==========================================================================
// 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 Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities
{
public sealed class Query : Cloneable<Query>
{
public static readonly Query Empty = new Query();
public List<Guid> Ids { get; private set; }
public string ODataQuery { get; private set; }
public Query WithODataQuery(string odataQuery)
{
return Clone(c => c.ODataQuery = odataQuery);
}
public Query WithIds(IEnumerable<Guid> ids)
{
return Clone(c => c.Ids = ids.ToList());
}
public Query WithIds(string ids)
{
if (!string.IsNullOrEmpty(ids))
{
return Clone(c =>
{
c.Ids = new List<Guid>();
foreach (var id in ids.Split(','))
{
if (Guid.TryParse(id, out var guid))
{
c.Ids.Add(guid);
}
}
});
}
return this;
}
}
}

55
src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs → src/Squidex.Domain.Apps.Entities/QueryContext.cs

@ -5,14 +5,13 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Security.Claims; using System.Security.Claims;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities
{ {
public sealed class QueryContext : Cloneable<QueryContext> public sealed class QueryContext : Cloneable<QueryContext>
{ {
@ -20,38 +19,19 @@ namespace Squidex.Domain.Apps.Entities.Contents
public IAppEntity App { get; private set; } public IAppEntity App { get; private set; }
public IEnumerable<Language> Languages { get; private set; }
public string SchemaIdOrName { get; private set; }
public bool Archived { get; private set; } public bool Archived { get; private set; }
public bool Flatten { get; private set; } public bool Flatten { get; private set; }
public IEnumerable<Language> Languages { get; private set; }
private QueryContext() private QueryContext()
{ {
} }
public static QueryContext Create(IAppEntity app, ClaimsPrincipal user, IEnumerable<string> languageCodes = null) public static QueryContext Create(IAppEntity app, ClaimsPrincipal user)
{ {
var result = new QueryContext { App = app, User = user }; return new QueryContext { App = app, User = user };
if (languageCodes != null)
{
var languages = new List<Language>();
foreach (var iso2Code in languageCodes)
{
if (Language.TryGetLanguage(iso2Code, out var language))
{
languages.Add(language);
}
}
result.Languages = languages;
}
return result;
} }
public QueryContext WithArchived(bool archived) public QueryContext WithArchived(bool archived)
@ -64,14 +44,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Clone(c => c.Flatten = flatten); return Clone(c => c.Flatten = flatten);
} }
public QueryContext WithSchemaName(string name) public QueryContext WithLanguages(IEnumerable<string> languageCodes)
{ {
return Clone(c => c.SchemaIdOrName = name); if (languageCodes != null)
} {
return Clone(c =>
{
var languages = new List<Language>();
public QueryContext WithSchemaId(Guid id) foreach (var iso2Code in languageCodes)
{ {
return Clone(c => c.SchemaIdOrName = id.ToString()); if (Language.TryGetLanguage(iso2Code, out var language))
{
languages.Add(language);
}
}
c.Languages = languages;
});
}
return this;
} }
public bool IsFrontendClient public bool IsFrontendClient

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

@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
switch (command) switch (command)
{ {
case AddField addField: case AddField addField:
return UpdateReturnAsync(addField, (Func<AddField, object>)(c => return UpdateAsync(addField, c =>
{ {
GuardSchemaField.CanAdd(Snapshot.SchemaDef, c); GuardSchemaField.CanAdd(Snapshot.SchemaDef, c);
@ -64,8 +64,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas
id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id; id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id;
} }
return EntityCreatedResult.Create(id, (long)base.Version); return EntityCreatedResult.Create(id, Version);
})); });
case CreateSchema createSchema: case CreateSchema createSchema:
return CreateAsync(createSchema, async c => return CreateAsync(createSchema, async c =>

54
src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs

@ -0,0 +1,54 @@
// ==========================================================================
// 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 Orleans;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Tags
{
public sealed class GrainTagService : ITagService
{
private readonly IGrainFactory grainFactory;
public GrainTagService(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
this.grainFactory = grainFactory;
}
public Task<HashSet<string>> NormalizeTagsAsync(Guid appId, string category, HashSet<string> names, HashSet<string> ids)
{
return GetGrain(appId, category).NormalizeTagsAsync(names, ids);
}
public Task<HashSet<string>> GetTagIdsAsync(Guid appId, string category, HashSet<string> names)
{
return GetGrain(appId, category).GetTagIdsAsync(names);
}
public Task<Dictionary<string, string>> DenormalizeTagsAsync(Guid appId, string category, HashSet<string> ids)
{
return GetGrain(appId, category).DenormalizeTagsAsync(ids);
}
public Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string category)
{
return GetGrain(appId, category).GetTagsAsync();
}
private ITagGrain GetGrain(Guid appId, string category)
{
Guard.NotNullOrEmpty(category, nameof(category));
return grainFactory.GetGrain<ITagGrain>($"{appId}_{category}");
}
}
}

24
src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
namespace Squidex.Domain.Apps.Entities.Tags
{
public interface ITagGrain : IGrainWithStringKey
{
Task<HashSet<string>> NormalizeTagsAsync(HashSet<string> names, HashSet<string> ids);
Task<HashSet<string>> GetTagIdsAsync(HashSet<string> names);
Task<Dictionary<string, string>> DenormalizeTagsAsync(HashSet<string> ids);
Task<Dictionary<string, int>> GetTagsAsync();
}
}

24
src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs

@ -0,0 +1,24 @@
// ==========================================================================
// 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;
namespace Squidex.Domain.Apps.Entities.Tags
{
public interface ITagService
{
Task<HashSet<string>> NormalizeTagsAsync(Guid appId, string category, HashSet<string> names, HashSet<string> ids);
Task<HashSet<string>> GetTagIdsAsync(Guid appId, string category, HashSet<string> names);
Task<Dictionary<string, string>> DenormalizeTagsAsync(Guid appId, string category, HashSet<string> ids);
Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string category);
}
}

151
src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs

@ -0,0 +1,151 @@
// ==========================================================================
// 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.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Tags
{
public sealed class TagGrain : GrainOfString, ITagGrain
{
private readonly IStore<string> store;
private IPersistence<State> persistence;
private State state = new State();
[CollectionName("Index_Tags")]
public sealed class State
{
public Dictionary<string, TagInfo> Tags { get; set; } = new Dictionary<string, TagInfo>();
}
public sealed class TagInfo
{
public string Name { get; set; }
public int Count { get; set; } = 1;
}
public TagGrain(IStore<string> store)
{
Guard.NotNull(store, nameof(store));
this.store = store;
}
public override Task OnActivateAsync(string key)
{
persistence = store.WithSnapshots<TagGrain, State, string>(key, s =>
{
state = s;
});
return persistence.ReadAsync();
}
public async Task<HashSet<string>> NormalizeTagsAsync(HashSet<string> names, HashSet<string> ids)
{
var result = new HashSet<string>();
if (names != null)
{
foreach (var tag in names)
{
if (!string.IsNullOrWhiteSpace(tag))
{
var tagName = tag.ToLowerInvariant();
var tagId = string.Empty;
var found = state.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, tagName, StringComparison.OrdinalIgnoreCase));
if (found.Value != null)
{
tagId = found.Key;
if (ids == null || !ids.Contains(tagId))
{
found.Value.Count++;
}
}
else
{
tagId = Guid.NewGuid().ToString();
state.Tags.Add(tagId, new TagInfo { Name = tagName });
}
result.Add(tagId);
}
}
}
if (ids != null)
{
foreach (var id in ids)
{
if (!result.Contains(id))
{
if (state.Tags.TryGetValue(id, out var tagInfo))
{
tagInfo.Count--;
if (tagInfo.Count <= 0)
{
state.Tags.Remove(id);
}
}
}
}
}
await persistence.WriteSnapshotAsync(state);
return result;
}
public Task<HashSet<string>> GetTagIdsAsync(HashSet<string> names)
{
var result = new HashSet<string>();
foreach (var name in names)
{
var id = state.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)).Key;
if (!string.IsNullOrWhiteSpace(id))
{
result.Add(id);
}
}
return Task.FromResult(result);
}
public Task<Dictionary<string, string>> DenormalizeTagsAsync(HashSet<string> ids)
{
var result = new Dictionary<string, string>();
foreach (var id in ids)
{
if (state.Tags.TryGetValue(id, out var tagInfo))
{
result[id] = tagInfo.Name;
}
}
return Task.FromResult(result);
}
public Task<Dictionary<string, int>> GetTagsAsync()
{
return Task.FromResult(state.Tags.Values.ToDictionary(x => x.Name, x => x.Count));
}
}
}

14
src/Squidex.Domain.Apps.Entities/Tags/TagGroups.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Tags
{
public static class TagGroups
{
public const string Assets = "Assets";
}
}

18
src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Assets
{
[EventType(nameof(AssetTagged))]
public sealed class AssetTagged : AssetEvent
{
public HashSet<string> Tags { get; set; }
}
}

4
src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs

@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.MongoDb.OData
{ {
public static class FilterBuilder public static class FilterBuilder
{ {
public static (FilterDefinition<T> Filter, bool Last) BuildFilter<T>(this ODataUriParser query, PropertyCalculator propertyCalculator = null, bool supportsSearch = true) public static (FilterDefinition<T> Filter, bool Last) BuildFilter<T>(this ODataUriParser query, ConvertProperty convertProperty = null, ConvertValue convertValue = null, bool supportsSearch = true)
{ {
SearchClause search; SearchClause search;
try try
@ -47,7 +47,7 @@ namespace Squidex.Infrastructure.MongoDb.OData
if (filter != null) if (filter != null)
{ {
return (FilterVisitor<T>.Visit(filter.Expression, propertyCalculator), true); return (FilterVisitor<T>.Visit(filter.Expression, convertProperty, convertValue), true);
} }
return (null, false); return (null, false);

74
src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs

@ -16,16 +16,18 @@ namespace Squidex.Infrastructure.MongoDb.OData
public sealed class FilterVisitor<T> : QueryNodeVisitor<FilterDefinition<T>> public sealed class FilterVisitor<T> : QueryNodeVisitor<FilterDefinition<T>>
{ {
private static readonly FilterDefinitionBuilder<T> Filter = Builders<T>.Filter; private static readonly FilterDefinitionBuilder<T> Filter = Builders<T>.Filter;
private readonly PropertyCalculator propertyCalculator; private readonly ConvertProperty convertProperty;
private readonly ConvertValue convertValue;
private FilterVisitor(PropertyCalculator propertyCalculator) private FilterVisitor(ConvertProperty convertProperty, ConvertValue convertValue)
{ {
this.propertyCalculator = propertyCalculator; this.convertProperty = convertProperty;
this.convertValue = convertValue;
} }
public static FilterDefinition<T> Visit(QueryNode node, PropertyCalculator propertyCalculator) public static FilterDefinition<T> Visit(QueryNode node, ConvertProperty propertyCalculator, ConvertValue convertValue)
{ {
var visitor = new FilterVisitor<T>(propertyCalculator); var visitor = new FilterVisitor<T>(propertyCalculator, convertValue);
return node.Accept(visitor); return node.Accept(visitor);
} }
@ -52,23 +54,26 @@ namespace Squidex.Infrastructure.MongoDb.OData
if (string.Equals(nodeIn.Name, "endswith", StringComparison.OrdinalIgnoreCase)) if (string.Equals(nodeIn.Name, "endswith", StringComparison.OrdinalIgnoreCase))
{ {
var value = BuildRegex(valueNode, v => v + "$"); var f = BuildFieldDefinition(fieldNode);
var v = BuildRegex(f, valueNode, s => s + "$");
return Filter.Regex(BuildFieldDefinition(fieldNode), value); return Filter.Regex(f, v);
} }
if (string.Equals(nodeIn.Name, "startswith", StringComparison.OrdinalIgnoreCase)) if (string.Equals(nodeIn.Name, "startswith", StringComparison.OrdinalIgnoreCase))
{ {
var value = BuildRegex(valueNode, v => "^" + v); var f = BuildFieldDefinition(fieldNode);
var v = BuildRegex(f, valueNode, s => "^" + s);
return Filter.Regex(BuildFieldDefinition(fieldNode), value); return Filter.Regex(f, v);
} }
if (string.Equals(nodeIn.Name, "contains", StringComparison.OrdinalIgnoreCase)) if (string.Equals(nodeIn.Name, "contains", StringComparison.OrdinalIgnoreCase))
{ {
var value = BuildRegex(valueNode, v => v); var f = BuildFieldDefinition(fieldNode);
var v = BuildRegex(f, valueNode, s => s);
return Filter.Regex(BuildFieldDefinition(fieldNode), value); return Filter.Regex(f, v);
} }
throw new NotSupportedException(); throw new NotSupportedException();
@ -107,53 +112,72 @@ namespace Squidex.Infrastructure.MongoDb.OData
{ {
if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual) if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual)
{ {
var field = BuildFieldDefinition(nodeIn.Left); var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Or( return Filter.Or(Filter.Not(Filter.Exists(f)), Filter.Ne(f, v));
Filter.Not(Filter.Exists(field)),
Filter.Ne(field, BuildValue(nodeIn.Right)));
} }
if (nodeIn.OperatorKind == BinaryOperatorKind.Equal) if (nodeIn.OperatorKind == BinaryOperatorKind.Equal)
{ {
return Filter.Eq(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Eq(f, v);
} }
if (nodeIn.OperatorKind == BinaryOperatorKind.LessThan) if (nodeIn.OperatorKind == BinaryOperatorKind.LessThan)
{ {
return Filter.Lt(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Lt(f, v);
} }
if (nodeIn.OperatorKind == BinaryOperatorKind.LessThanOrEqual) if (nodeIn.OperatorKind == BinaryOperatorKind.LessThanOrEqual)
{ {
return Filter.Lte(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Lte(f, v);
} }
if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThan) if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThan)
{ {
return Filter.Gt(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Gt(f, v);
} }
if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThanOrEqual) if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThanOrEqual)
{ {
return Filter.Gte(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Gte(f, v);
} }
} }
throw new NotSupportedException(); throw new NotSupportedException();
} }
private static BsonRegularExpression BuildRegex(QueryNode node, Func<string, string> formatter) private BsonRegularExpression BuildRegex(string field, QueryNode node, Func<string, string> formatter)
{
return new BsonRegularExpression(formatter(BuildValue(field, node).ToString()), "i");
}
private string BuildFieldDefinition(QueryNode nodeIn)
{ {
return new BsonRegularExpression(formatter(BuildValue(node).ToString()), "i"); return nodeIn.BuildFieldDefinition(convertProperty);
} }
private FieldDefinition<T, object> BuildFieldDefinition(QueryNode nodeIn) private object BuildValue(string field, QueryNode nodeIn)
{ {
return nodeIn.BuildFieldDefinition<T>(propertyCalculator); return ValueConversion.Convert(field, ConstantVisitor.Visit(nodeIn), convertValue);
} }
private static object BuildValue(QueryNode nodeIn) private object BuildValue(QueryNode nodeIn)
{ {
return ConstantVisitor.Visit(nodeIn); return ConstantVisitor.Visit(nodeIn);
} }

12
src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs

@ -11,23 +11,23 @@ using MongoDB.Driver;
namespace Squidex.Infrastructure.MongoDb.OData namespace Squidex.Infrastructure.MongoDb.OData
{ {
public delegate string PropertyCalculator(string[] parts); public delegate string ConvertProperty(string[] parts);
public static class PropertyBuilder public static class PropertyBuilder
{ {
private static readonly PropertyCalculator DefaultCalculator = parts => private static readonly ConvertProperty Default = parts =>
{ {
return string.Join(".", parts).ToPascalCase(); return string.Join(".", parts).ToPascalCase();
}; };
public static StringFieldDefinition<T, object> BuildFieldDefinition<T>(this QueryNode node, PropertyCalculator propertyCalculator) public static string BuildFieldDefinition(this QueryNode node, ConvertProperty convertProperty)
{ {
propertyCalculator = propertyCalculator ?? DefaultCalculator; convertProperty = convertProperty ?? Default;
var propertyParts = node.Accept(PropertyNameVisitor.Instance).ToArray(); var propertyParts = node.Accept(PropertyNameVisitor.Instance).ToArray();
var propertyName = propertyCalculator(propertyParts); var propertyName = convertProperty(propertyParts);
return new StringFieldDefinition<T, object>(propertyName); return propertyName;
} }
} }
} }

6
src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs

@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.MongoDb.OData
{ {
public static class SortBuilder public static class SortBuilder
{ {
public static SortDefinition<T> BuildSort<T>(this ODataUriParser query, PropertyCalculator propertyCalculator = null) public static SortDefinition<T> BuildSort<T>(this ODataUriParser query, ConvertProperty propertyCalculator = null)
{ {
var orderBy = query.ParseOrderBy(); var orderBy = query.ParseOrderBy();
@ -41,9 +41,9 @@ namespace Squidex.Infrastructure.MongoDb.OData
return null; return null;
} }
public static SortDefinition<T> OrderBy<T>(OrderByClause clause, PropertyCalculator propertyCalculator = null) public static SortDefinition<T> OrderBy<T>(OrderByClause clause, ConvertProperty propertyCalculator = null)
{ {
var propertyName = clause.Expression.BuildFieldDefinition<T>(propertyCalculator); var propertyName = clause.Expression.BuildFieldDefinition(propertyCalculator);
if (clause.Direction == OrderByDirection.Ascending) if (clause.Direction == OrderByDirection.Ascending)
{ {

24
src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ValueConversion.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.MongoDb.OData
{
public delegate object ConvertValue(string field, object value);
public static class ValueConversion
{
public static object Convert(string field, object value, ConvertValue converter = null)
{
if (converter == null)
{
return value;
}
return converter(field, value);
}
}
}

2
src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs

@ -104,7 +104,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, true); return InvokeAsync(command, handler, true);
} }
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToAsync(), true); return InvokeAsync(command, handler?.ToAsync(), true);
} }

19
src/Squidex.Infrastructure/HashSet.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;
namespace Squidex.Infrastructure
{
public static class HashSet
{
public static HashSet<T> Of<T>(params T[] items)
{
return new HashSet<T>(items);
}
}
}

7
src/Squidex.Infrastructure/ResultList.cs

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

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

@ -14,10 +14,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NSwag.Annotations; using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Assets.Models; using Squidex.Areas.Api.Controllers.Assets.Models;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -34,23 +36,49 @@ namespace Squidex.Areas.Api.Controllers.Assets
[SwaggerTag(nameof(Assets))] [SwaggerTag(nameof(Assets))]
public sealed class AssetsController : ApiController public sealed class AssetsController : ApiController
{ {
private readonly IAssetRepository assetRepository; private readonly IAssetQueryService assetQuery;
private readonly IAssetStatsRepository assetStatsRepository; private readonly IAssetStatsRepository assetStatsRepository;
private readonly IAppPlansProvider appPlanProvider; private readonly IAppPlansProvider appPlanProvider;
private readonly ITagService tagService;
private readonly AssetConfig assetsConfig; private readonly AssetConfig assetsConfig;
public AssetsController( public AssetsController(
ICommandBus commandBus, ICommandBus commandBus,
IAssetRepository assetRepository, IAssetQueryService assetQuery,
IAssetStatsRepository assetStatsRepository, IAssetStatsRepository assetStatsRepository,
IAppPlansProvider appPlanProvider, IAppPlansProvider appPlanProvider,
IOptions<AssetConfig> assetsConfig) IOptions<AssetConfig> assetsConfig,
ITagService tagService)
: base(commandBus) : base(commandBus)
{ {
this.assetsConfig = assetsConfig.Value; this.assetsConfig = assetsConfig.Value;
this.assetRepository = assetRepository; this.assetQuery = assetQuery;
this.assetStatsRepository = assetStatsRepository; this.assetStatsRepository = assetStatsRepository;
this.appPlanProvider = appPlanProvider; this.appPlanProvider = appPlanProvider;
this.tagService = tagService;
}
/// <summary>
/// Get assets tags.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Assets returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// Get all tags for assets.
/// </remarks>
[MustBeAppReader]
[HttpGet]
[Route("apps/{app}/assets/tags")]
[ProducesResponseType(typeof(Dictionary<string, int>), 200)]
[ApiCosts(1)]
public async Task<IActionResult> GetTags(string app)
{
var response = await tagService.GetTagsAsync(App.Id, TagGroups.Assets);
return Ok(response);
} }
/// <summary> /// <summary>
@ -72,25 +100,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetAssets(string app, [FromQuery] string ids = null) public async Task<IActionResult> GetAssets(string app, [FromQuery] string ids = null)
{ {
HashSet<Guid> idsList = null; var context = Context();
if (!string.IsNullOrWhiteSpace(ids))
{
idsList = new HashSet<Guid>();
foreach (var id in ids.Split(','))
{
if (Guid.TryParse(id, out var guid))
{
idsList.Add(guid);
}
}
}
var assets = var assets = await assetQuery.QueryAsync(context, Query.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
idsList?.Count > 0 ?
await assetRepository.QueryAsync(App.Id, idsList) :
await assetRepository.QueryAsync(App.Id, Request.QueryString.ToString());
var response = AssetsDto.FromAssets(assets); var response = AssetsDto.FromAssets(assets);
@ -115,7 +127,9 @@ 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 entity = await assetRepository.FindAssetAsync(id); var context = Context();
var entity = await assetQuery.FindAssetAsync(context, id);
if (entity == null) if (entity == null)
{ {
@ -270,5 +284,10 @@ namespace Squidex.Areas.Api.Controllers.Assets
return assetFile; return assetFile;
} }
private QueryContext Context()
{
return QueryContext.Create(App, User);
}
} }
} }

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

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
@ -39,6 +40,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required] [Required]
public string FileType { get; set; } public string FileType { get; set; }
/// <summary>
/// The asset tags.
/// </summary>
public HashSet<string> Tags { get; set; }
/// <summary> /// <summary>
/// The size of the file in bytes. /// The size of the file in bytes.
/// </summary> /// </summary>

19
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs

@ -6,9 +6,9 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Assets.Models namespace Squidex.Areas.Api.Controllers.Assets.Models
{ {
@ -20,9 +20,22 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required] [Required]
public string FileName { get; set; } public string FileName { get; set; }
public RenameAsset ToCommand(Guid id) /// <summary>
/// The new asset tags.
/// </summary>
[Required]
public HashSet<string> Tags { get; set; }
public AssetCommand ToCommand(Guid id)
{ {
return SimpleMapper.Map(this, new RenameAsset { AssetId = id }); if (Tags != null)
{
return new TagAsset { AssetId = id, Tags = Tags };
}
else
{
return new RenameAsset { AssetId = id, FileName = FileName };
}
} }
} }
} }

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

@ -16,6 +16,7 @@ using NodaTime.Text;
using NSwag.Annotations; using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
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;
@ -65,7 +66,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)] [ApiCosts(2)]
public async Task<IActionResult> PostGraphQL(string app, [FromBody] GraphQLQuery query) public async Task<IActionResult> PostGraphQL(string app, [FromBody] GraphQLQuery query)
{ {
var result = await graphQl.QueryAsync(Context(), query); var result = await graphQl.QueryAsync(Context().Base, query);
if (result.Errors?.Length > 0) if (result.Errors?.Length > 0)
{ {
@ -97,32 +98,14 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)] [ApiCosts(2)]
public async Task<IActionResult> GetContents(string app, string name, [FromQuery] bool archived = false, [FromQuery] string ids = null) public async Task<IActionResult> GetContents(string app, string name, [FromQuery] bool archived = false, [FromQuery] string ids = null)
{ {
List<Guid> idsList = null; var context = Context().WithArchived(archived).WithSchemaName(name);
if (!string.IsNullOrWhiteSpace(ids)) var result = await contentQuery.QueryAsync(context, Query.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
{
idsList = new List<Guid>();
foreach (var id in ids.Split(','))
{
if (Guid.TryParse(id, out var guid))
{
idsList.Add(guid);
}
}
}
var context = Context().WithSchemaName(name).WithArchived(archived);
var result =
idsList?.Count > 0 ?
await contentQuery.QueryAsync(context, idsList) :
await contentQuery.QueryAsync(context, Request.QueryString.ToString());
var response = new ContentsDto var response = new ContentsDto
{ {
Total = result.Total, Total = result.Total,
Items = result.Take(200).Select(x => ContentDto.FromContent(x, context)).ToArray() Items = result.Take(200).Select(x => ContentDto.FromContent(x, context.Base)).ToArray()
}; };
var options = controllerOptions.Value; var options = controllerOptions.Value;
@ -157,7 +140,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context().WithSchemaName(name); var context = Context().WithSchemaName(name);
var content = await contentQuery.FindContentAsync(context, id); var content = await contentQuery.FindContentAsync(context, id);
var response = ContentDto.FromContent(content, context); var response = ContentDto.FromContent(content, context.Base);
Response.Headers["ETag"] = content.Version.ToString(); Response.Headers["ETag"] = content.Version.ToString();
@ -193,7 +176,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context().WithSchemaName(name); var context = Context().WithSchemaName(name);
var content = await contentQuery.FindContentAsync(context, id, version); var content = await contentQuery.FindContentAsync(context, id, version);
var response = ContentDto.FromContent(content, context); var response = ContentDto.FromContent(content, context.Base);
Response.Headers["ETag"] = content.Version.ToString(); Response.Headers["ETag"] = content.Version.ToString();
@ -498,9 +481,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt };
} }
private QueryContext Context() private ContentQueryContext Context()
{ {
return QueryContext.Create(App, User, Request.Headers["X-Languages"]).WithFlatten(Request.Headers.ContainsKey("X-Flatten")); return new ContentQueryContext(QueryContext.Create(App, User).WithLanguages(Request.Headers["X-Languages"])).WithFlatten(Request.Headers.ContainsKey("X-Flatten"));
} }
} }
} }

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

@ -10,6 +10,7 @@ using System.ComponentModel.DataAnnotations;
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;
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.Infrastructure; using Squidex.Infrastructure;

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

@ -32,6 +32,7 @@ using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Indexes; using Squidex.Domain.Apps.Entities.Rules.Indexes;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Indexes; using Squidex.Domain.Apps.Entities.Schemas.Indexes;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations; using Squidex.Infrastructure.Migrations;
@ -62,6 +63,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AppProvider>() services.AddSingletonAs<AppProvider>()
.As<IAppProvider>(); .As<IAppProvider>();
services.AddSingletonAs<AssetQueryService>()
.As<IAssetQueryService>();
services.AddSingletonAs<ContentQueryService>() services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>(); .As<IContentQueryService>();
@ -104,6 +108,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetCommandMiddleware>() services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<GrainTagService>()
.As<ITagService>();
services.AddSingletonAs<GrainCommandMiddleware<AppCommand, IAppGrain>>() services.AddSingletonAs<GrainCommandMiddleware<AppCommand, IAppGrain>>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();

4
src/Squidex/app/features/administration/pages/users/users-page.component.html

@ -6,7 +6,7 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Users (CTRL + SHIFT + R)"> <button class="btn btn-link btn-secondary mr-1" (click)="reload()" title="Refresh Users (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>
@ -14,7 +14,7 @@
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+shift+n" (trigger)="buttonNew.click()"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+n" (trigger)="buttonNew.click()"></sqx-shortcut>
<form class="form-inline" (ngSubmit)="search()"> <form class="form-inline mr-1" (ngSubmit)="search()">
<input class="form-control" #inputFind [formControl]="usersFilter" placeholder="Search for user" /> <input class="form-control" #inputFind [formControl]="usersFilter" placeholder="Search for user" />
</form> </form>

29
src/Squidex/app/features/assets/pages/assets-page.component.html

@ -1,12 +1,12 @@
<sqx-title message="{app} | Assets" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Assets" parameter1="app" [value1]="appsState.appName"></sqx-title>
<sqx-panel desiredWidth="*"> <sqx-panel desiredWidth="*" showSidebar="true" sidebarClass="wide">
<ng-container title> <ng-container title>
Assets Assets
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Assets (CTRL + SHIFT + R)"> <button class="btn btn-link btn-secondary mr-1" (click)="reload()" title="Refresh Assets (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>
@ -21,4 +21,29 @@
<ng-container content> <ng-container content>
<sqx-assets-list [state]="assetsState"></sqx-assets-list> <sqx-assets-list [state]="assetsState"></sqx-assets-list>
</ng-container> </ng-container>
<ng-container sidebar>
<div class="section">
<a class="row tag" (click)="resetTags()" [class.active]="assetsState.isTagSelectionEmpty()">
<div class="col">
All Assets
</div>
</a>
</div>
<div class="section">
<h3>Tags</h3>
<ng-container *ngIf="assetsState.tags | async; let tags">
<a class="row tag" *ngFor="let tag of tags | sqxKeys" (click)="toggleTag(tag)" [class.active]="assetsState.isTagSelected(tag)">
<div class="col">
{{tag}}
</div>
<div class="col col-auto">
{{tags[tag]}}
</div>
</a>
</ng-container>
</div>
</ng-container>
</sqx-panel> </sqx-panel>

26
src/Squidex/app/features/assets/pages/assets-page.component.scss

@ -1,2 +1,26 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
.section {
border-top: 1px solid $color-border;
padding: 1rem;
}
.tag {
& {
padding: .25rem 0;
}
&.active {
font-weight: bold;
}
&.active,
&:hover {
background: $color-background;
}
}
a.tag {
cursor: pointer !important;
}

8
src/Squidex/app/features/assets/pages/assets-page.component.ts

@ -39,6 +39,14 @@ export class AssetsPageComponent implements OnInit {
this.assetsState.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe(); this.assetsState.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe();
} }
public resetTags() {
this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe();
}
public toggleTag(tag: string) {
this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe();
}
public goNext() { public goNext() {
this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe(); this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe();
} }

8
src/Squidex/app/features/content/shared/assets-editor.component.html

@ -1,11 +1,9 @@
<div class="assets-container" [class.disabled]="isDisabled"> <div class="assets-container" [class.disabled]="isDisabled">
<div class="row"> <div class="row">
<div class="drop-area-container"> <div class="drop-area align-items-center" (sqxFileDrop)="addFiles($event)" (click)="assetsDialog.show()">
<div class="drop-area" (sqxFileDrop)="addFiles($event)" (click)="assetsDialog.show()"> Drop files here to add them.
Drop files here to add them.
</div>
</div> </div>
<sqx-asset *ngFor="let file of newAssets" [initFile]="file" <sqx-asset *ngFor="let file of newAssets" [initFile]="file"
(failed)="onAssetFailed(file)" (failed)="onAssetFailed(file)"
(loaded)="onAssetLoaded(file, $event)"> (loaded)="onAssetLoaded(file, $event)">

6
src/Squidex/app/features/content/shared/assets-editor.component.scss

@ -22,13 +22,15 @@
& { & {
@include transition(border-color .4s ease); @include transition(border-color .4s ease);
@include border-radius; @include border-radius;
@include flex-box;
border: 2px dashed $color-border; border: 2px dashed $color-border;
height: $asset-height; height: $asset-height;
width: $asset-height; width: $asset-width;
margin-left: 8px;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: normal; font-weight: normal;
text-align: center; text-align: center;
padding: 3.5rem 2rem; padding: 0 2rem;
cursor: pointer; cursor: pointer;
color: darken($color-border, 30%); color: darken($color-border, 30%);
} }

2
src/Squidex/app/features/content/shared/assets-editor.component.ts

@ -53,7 +53,7 @@ export class AssetsEditorComponent implements ControlValueAccessor {
if (!Types.isEquals(obj, this.oldAssets.map(x => x.id).values)) { if (!Types.isEquals(obj, this.oldAssets.map(x => x.id).values)) {
const assetIds: string[] = obj; const assetIds: string[] = obj;
this.assetsService.getAssets(this.appsState.appName, 0, 0, undefined, obj) this.assetsService.getAssets(this.appsState.appName, 0, 0, undefined, undefined, obj)
.subscribe(dtos => { .subscribe(dtos => {
this.oldAssets = ImmutableArray.of(assetIds.map(id => dtos.items.find(x => x.id === id)).filter(a => !!a).map(a => a!)); this.oldAssets = ImmutableArray.of(assetIds.map(id => dtos.items.find(x => x.id === id)).filter(a => !!a).map(a => a!));

2
src/Squidex/app/features/rules/pages/rules/rules-page.component.html

@ -6,7 +6,7 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Assets (CTRL + SHIFT + R)"> <button class="btn btn-link btn-secondary mr-1" (click)="reload()" title="Refresh Assets (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>

6
src/Squidex/app/features/schemas/pages/schema/schema-page.component.html

@ -2,15 +2,15 @@
<sqx-panel desiredWidth="60rem" [showSidebar]="true"> <sqx-panel desiredWidth="60rem" [showSidebar]="true">
<ng-container title> <ng-container title>
<i class="schema-edit icon-pencil" (click)="editSchemaDialog.show()"></i> {{schema.displayName}} <i class="schema-edit icon-pencil" (click)="editSchemaDialog.show()"></i> <span (dblclick)="editSchemaDialog.show()">{{schema.displayName}}</span>
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button type="button" class="btn btn-link btn-export" (click)="exportSchemaDialog.show()"> <button type="button" class="btn btn-link btn-export mr-1" (click)="exportSchemaDialog.show()">
JSON Preview JSON Preview
</button> </button>
<div class="btn-group" data-toggle="buttons" #buttonPublish> <div class="btn-group mr-1" data-toggle="buttons" #buttonPublish>
<button type="button" class="btn btn-publishing btn-toggle" [class.btn-success]="schema.isPublished" [disabled]="schema.isPublished" (click)="publish()"> <button type="button" class="btn btn-publishing btn-toggle" [class.btn-success]="schema.isPublished" [disabled]="schema.isPublished" (click)="publish()">
Published Published
</button> </button>

2
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -6,7 +6,7 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh backups (CTRL + SHIFT + R)"> <button class="btn btn-link btn-secondary mr-1" (click)="reload()" title="Refresh backups (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>

6
src/Squidex/app/features/settings/pages/clients/client.component.html

@ -6,7 +6,7 @@
<div class="form-group mr-1"> <div class="form-group mr-1">
<sqx-control-errors for="name"></sqx-control-errors> <sqx-control-errors for="name"></sqx-control-errors>
<input type="text" class="form-control client-name enabled" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" /> <input type="text" class="form-control client-name form-underlined" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" />
</div> </div>
<button type="submit" class="btn btn-primary" [disabled]="!renameForm.form.valid || !renameForm.form.dirty">Save</button> <button type="submit" class="btn btn-primary" [disabled]="!renameForm.form.valid || !renameForm.form.dirty">Save</button>
@ -17,8 +17,8 @@
</form> </form>
<ng-container *ngIf="!isRenaming"> <ng-container *ngIf="!isRenaming">
<h3 class="client-name"> <h3 class="client-name" (dblclick)="toggleRename()">
<span (dblclick)="toggleRename()">{{client.name}}</span> {{client.name}}
</h3> </h3>
<i class="client-edit icon-pencil" (click)="toggleRename()"></i> <i class="client-edit icon-pencil" (click)="toggleRename()"></i>

43
src/Squidex/app/features/settings/pages/clients/client.component.scss

@ -30,31 +30,13 @@ $color-editor: #eceeef;
} }
&-name { &-name {
& { padding: .375rem 0;
@include border-radius(.25rem); font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0; font-size: 1.2rem;
margin-left: -.6rem; font-weight: normal;
height: 2.5rem; line-height: 1.5rem;
padding: 0 .6rem; display: inline-block;
border: 0; margin: 0;
background: transparent;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1.2rem;
font-weight: normal;
display: inline-block;
line-height: 2.5rem;
}
&.enabled,
&:hover {
& {
background: $color-editor;
}
}
h3 {
font-size: 1.6rem;
}
} }
&-header { &-header {
@ -66,12 +48,15 @@ $color-editor: #eceeef;
} }
} }
.col-form-label { h3 {
text-align: left; &.client-name {
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
}
} }
.btn-cancel { .col-form-label {
padding: .4rem; text-align: left;
} }
.form-check { .form-check {

19
src/Squidex/app/framework/angular/forms/tag-editor.component.html

@ -1,12 +1,15 @@
<input type="text" class="form-control" [attr.name]="inputName" (keydown)="onKeyDown($event)" (blur)="markTouched()" <div class="form-control {{class}}" (click)="input.focus()" [class.focus]="hasFocus" [class.disabled]="addInput.disabled">
<span class="item" *ngFor="let item of items; let i = index" [class.disabled]="addInput.disabled">
{{item}} <i class="icon-close" (click)="remove(i)"></i>
</span>
<input type="text" class="blank" [attr.name]="inputName" (keydown)="onKeyDown($event)" [class.hidden]="addInput.disabled" #input
(focus)="focus()"
(blur)="markTouched()"
(input)="adjustSize()"
[formControl]="addInput" [formControl]="addInput"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
placeholder="Press enter to add new item"> placeholder="+Tag">
</div>
<div class="items">
<span class="item" *ngFor="let item of items; let i = index" [class.disabled]="addInput.disabled">
{{item}} <i class="icon-close" (click)="remove(i)"></i>
</span>
</div>

72
src/Squidex/app/framework/angular/forms/tag-editor.component.scss

@ -1,36 +1,76 @@
@import '_mixins'; @import '_mixins';
@import '_vars'; @import '_vars';
.items { .form-control {
margin-top: .4rem; & {
min-height: 1.6rem; cursor: text;
}
&.disabled {
cursor: inherit;
}
&.focus {
@include box-shadow-raw(0 0 0 0.2rem rgba(51, 137, 255, 0.25));
border-color: #b3d3ff;
}
}
.blank {
& {
padding: 0;
border: 0;
background: transparent;
min-width: 40px;
max-width: 100%;
}
&:focus,
&.focus {
@include box-shadow-none;
outline: none;
}
&:hover {
background: transparent;
}
}
.icon-close {
font-size: .6rem;
} }
.item { .item {
& { & {
@include border-radius(.8rem); @include border-radius(10px);
@include truncate;
display: inline-block; display: inline-block;
color: $color-dark-foreground; color: $color-dark-foreground;
margin-right: .4rem; cursor: default;
margin-bottom: .25rem; height: 20px;
min-height: 1.6rem;
padding: 0 .6rem; padding: 0 .6rem;
background: $color-theme-blue; background: $color-theme-blue;
border: 0; border: 0;
font-size: .8rem; font-size: .8rem;
font-weight: normal; font-weight: normal;
line-height: 1.6rem; line-height: 20px;
margin: 2px 2px 2px 0;
vertical-align: middle;
}
&,
&-container {
display: inline-block;
}
&-container {
height: 24px;
padding: 2px;
padding-left: 0;
} }
&.disabled { &.disabled {
& { pointer-events: none;
pointer-events: none;
}
&,
&:hover {
background: $color-theme-blue-light;
}
} }
&:hover { &:hover {

65
src/Squidex/app/framework/angular/forms/tag-editor.component.ts

@ -5,12 +5,13 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, forwardRef, Input } from '@angular/core'; import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Types } from '@app/framework/internal'; import { Types } from '@app/framework/internal';
const KEY_ENTER = 13; const KEY_SPACE = 32;
const KEY_DELETE = 8;
export interface Converter { export interface Converter {
convert(input: string): any; convert(input: string): any;
@ -81,9 +82,17 @@ export class TagEditorComponent implements ControlValueAccessor {
@Input() @Input()
public useDefaultValue = true; public useDefaultValue = true;
@Input()
public class: string;
@Input() @Input()
public inputName = 'tag-editor'; public inputName = 'tag-editor';
@ViewChild('input')
public inputElement: ElementRef;
public hasFocus = false;
public items: any[] = []; public items: any[] = [];
public addInput = new FormControl(); public addInput = new FormControl();
@ -114,27 +123,63 @@ export class TagEditorComponent implements ControlValueAccessor {
this.callTouched = fn; this.callTouched = fn;
} }
public remove(index: number) { public focus() {
this.updateItems([...this.items.slice(0, index), ...this.items.splice(index + 1)]); if (this.addInput.enabled) {
this.hasFocus = true;
}
}
private resetForm() {
this.addInput.reset();
this.adjustSize();
} }
public markTouched() { public markTouched() {
this.callTouched(); this.callTouched();
this.hasFocus = false;
} }
private resetForm() { public remove(index: number) {
this.addInput.reset(); this.updateItems([...this.items.slice(0, index), ...this.items.splice(index + 1)]);
}
public adjustSize() {
const style = window.getComputedStyle(this.inputElement.nativeElement);
if (!canvas) {
canvas = document.createElement('canvas');
}
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.font = `${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}`;
this.inputElement.nativeElement.style.width = <any>((ctx.measureText(this.inputElement.nativeElement.value).width + 20) + 'px');
}
}
} }
public onKeyDown(event: KeyboardEvent) { public onKeyDown(event: KeyboardEvent) {
if (event.keyCode === KEY_ENTER) { if (event.keyCode === KEY_SPACE) {
const value = <string>this.addInput.value; const value = <string>this.addInput.value;
if (this.converter.isValidInput(value)) { if (value && this.converter.isValidInput(value)) {
const converted = this.converter.convert(value); const converted = this.converter.convert(value);
this.updateItems([...this.items, converted]); this.updateItems([...this.items, converted]);
this.resetForm(); this.resetForm();
return false;
}
} else if (event.keyCode === KEY_DELETE) {
const value = <string>this.addInput.value;
if (!value || value.length === 0) {
this.updateItems(this.items.slice(0, this.items.length - 1));
return false; return false;
} }
} }
@ -151,4 +196,6 @@ export class TagEditorComponent implements ControlValueAccessor {
this.callChange(this.items); this.callChange(this.items);
} }
} }
} }
let canvas: HTMLCanvasElement | null = null;

6
src/Squidex/app/framework/angular/image-source.directive.ts

@ -48,12 +48,12 @@ export class ImageSourceDirective implements OnChanges, OnDestroy, OnInit, After
this.parentResizeListener = this.parentResizeListener =
this.renderer.listen(this.parent, 'resize', () => { this.renderer.listen(this.parent, 'resize', () => {
this.resize(this.parent); this.resize();
}); });
} }
public ngAfterViewInit() { public ngAfterViewInit() {
this.resize(this.parent); this.resize();
} }
public ngOnChanges() { public ngOnChanges() {
@ -75,7 +75,7 @@ export class ImageSourceDirective implements OnChanges, OnDestroy, OnInit, After
this.retryLoadingImage(); this.retryLoadingImage();
} }
private resize(parent: any) { private resize() {
this.size = this.parent.getBoundingClientRect(); this.size = this.parent.getBoundingClientRect();
this.renderer.setStyle(this.element.nativeElement, 'display', 'inline-block'); this.renderer.setStyle(this.element.nativeElement, 'display', 'inline-block');

2
src/Squidex/app/framework/angular/panel.component.html

@ -30,7 +30,7 @@
<ng-content select=[content]></ng-content> <ng-content select=[content]></ng-content>
</div> </div>
<div class="panel-sidebar" *ngIf="showSidebar"> <div class="panel-sidebar {{sidebarClass}}" *ngIf="showSidebar">
<ng-content select=[sidebar]></ng-content> <ng-content select=[sidebar]></ng-content>
</div> </div>
</div> </div>

3
src/Squidex/app/framework/angular/panel.component.ts

@ -54,6 +54,9 @@ export class PanelComponent implements AfterViewInit, OnDestroy, OnInit {
@Input() @Input()
public contentClass = ''; public contentClass = '';
@Input()
public sidebarClass = '';
@ViewChild('panel') @ViewChild('panel')
public panel: ElementRef; public panel: ElementRef;

109
src/Squidex/app/shared/components/asset.component.html

@ -1,6 +1,6 @@
<div class="card" [class.selectable]="isSelectable" [class.border-primary]="isSelected" (click)="selected.emit(asset)" (sqxFileDrop)="updateFile($event)"> <div class="card" [class.selectable]="isSelectable" [class.border-primary]="isSelected" (click)="selected.emit(asset)" (sqxFileDrop)="updateFile($event)">
<div class="card-body"> <div class="card-body">
<div class="file-preview" *ngIf="asset && progress == 0" @fade> <div class="file-preview" *ngIf="asset && progress === 0" @fade>
<span class="file-type" *ngIf="asset.fileType"> <span class="file-type" *ngIf="asset.fileType">
{{asset.fileType}} {{asset.fileType}}
</span> </span>
@ -8,82 +8,71 @@
<div *ngIf="asset.isImage" class="file-image"> <div *ngIf="asset.isImage" class="file-image">
<img [sqxImageSource]="asset | sqxAssetPreviewUrl"> <img [sqxImageSource]="asset | sqxAssetPreviewUrl">
</div> </div>
<div *ngIf="!asset.isImage" class="file-icon-container"> <div *ngIf="!asset.isImage" class="file-icon">
<img class="file-icon" [attr.src]="asset | sqxFileIcon"> <img [attr.src]="asset | sqxFileIcon">
</div> </div>
<div class="file-overlay"> <div class="overlay">
<div class="file-overlay-background"></div> <div class="overlay-background"></div>
<div class="file-menu"> <div class="overlay-menu">
<a class="file-edit ml-1" *ngIf="!isDisabled" (click)="renameDialog.show()"> <a class="file-download" [attr.href]="asset | sqxAssetUrl" target="_blank" (click)="$event.stopPropagation()">
<i class="icon-pencil"></i>
</a>
<a class="file-download ml-1" [attr.href]="asset | sqxAssetUrl" target="_blank" (click)="$event.stopPropagation()">
<i class="icon-download"></i> <i class="icon-download"></i>
</a> </a>
<a class="file-delete ml-1" (click)="deleting.emit(asset); $event.stopPropagation()" *ngIf="!isDisabled && !removeMode"> <a class="file-delete ml-2" (click)="deleting.emit(asset); $event.stopPropagation()" *ngIf="!isDisabled && !removeMode">
<i class="icon-delete"></i> <i class="icon-delete"></i>
</a> </a>
<a class="file-delete ml-1" (click)="removing.emit(asset); $event.stopPropagation()" *ngIf="removeMode"> <a class="file-delete ml-2" (click)="removing.emit(asset); $event.stopPropagation()" *ngIf="removeMode">
<i class="icon-close"></i> <i class="icon-close"></i>
</a> </a>
</div> </div>
<span class="file-overlay-type" *ngIf="asset.fileType"> <span class="overlay-type" *ngIf="asset.fileType">
{{asset.fileType}} {{asset.fileType}}
</span> </span>
<span class="file-user">
<i class="icon-user"></i> {{asset.lastModifiedBy | sqxUserNameRef}} <div class="overlay-user">
</span> <div>
<span class="file-modified"> <i class="icon-user"></i> {{asset.lastModifiedBy | sqxUserNameRef}}
{{asset.lastModified | sqxFromNow}} </div>
</span> <div>
{{asset.lastModified | sqxFromNow}}
</div>
</div>
</div> </div>
</div> </div>
</div>
<div class="card-footer" *ngIf="asset && progress == 0"> <div class="upload-progress" *ngIf="progress > 0">
<div class="file-name" [attr.title]="asset.fileName"> <sqx-progress-bar mode="Circle" [value]="progress"></sqx-progress-bar>
{{asset.fileName}}
</div> </div>
<div class="file-info">
<ng-container *ngIf="asset.pixelWidth">{{asset.pixelWidth}}x{{asset.pixelHeight}}px, </ng-container> {{asset.fileSize | sqxFileSize}} <div class="drop-overlay" *ngIf="asset && progress === 0">
<div class="drop-overlay-background"></div>
<span class="drop-overlay-text">Drop to update</span>
</div> </div>
</div> </div>
<div class="card-footer">
<div class="upload-progress" *ngIf="progress > 0"> <ng-container *ngIf="asset">
<sqx-progress-bar mode="Circle" [value]="progress"></sqx-progress-bar> <div>
</div> <div *ngIf="!renaming" class="file-name editable" [attr.title]="asset.fileName" (dblclick)="renameStart()">
{{asset.fileName}}
<div class="drop-overlay" *ngIf="asset && progress == 0">
<div class="drop-overlay-background"></div>
<span class="drop-overlay-text">Drop to update</span>
</div>
</div>
<ng-container *sqxModalView="renameDialog;onRoot:true">
<form [formGroup]="renameForm.form" (ngSubmit)="renameAsset()">
<sqx-modal-dialog (closed)="cancelRenameAsset()">
<ng-container title>
Rename asset
</ng-container>
<ng-container content>
<div class="form-group">
<label for="assetName">Name</label>
<sqx-control-errors for="name" [submitted]="renameForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" id="assetName" formControlName="name" autocomplete="off" sqxFocusOnInit />
</div> </div>
</ng-container> <div *ngIf="renaming">
<form [formGroup]="renameForm.form" (ngSubmit)="renameAsset()">
<sqx-control-errors for="name" [submitted]="renameForm.submitted | async"></sqx-control-errors>
<ng-container footer> <input type="text" class="form-control form-underlined editable" id="assetName" formControlName="name" autocomplete="off" sqxFocusOnInit (blur)="renameCancel()" />
<button type="reset" class="float-left btn btn-secondary" (click)="cancelRenameAsset()">Cancel</button> </form>
<button type="submit" class="float-right btn btn-success">Rename</button> </div>
</ng-container> </div>
</sqx-modal-dialog> <div class="file-tags tags">
</form> <sqx-tag-editor [useDefaultValue]="false" [formControl]="tagInput" class="blank"></sqx-tag-editor>
</ng-container> </div>
<div class="file-info">
<ng-container *ngIf="asset.pixelWidth">{{asset.pixelWidth}}x{{asset.pixelHeight}}px, </ng-container> {{asset.fileSize | sqxFileSize}}
</div>
</ng-container>
</div>
</div>

174
src/Squidex/app/shared/components/asset.component.scss

@ -32,28 +32,7 @@
} }
@mixin asset-link { @mixin asset-link {
& {
font-size: 1.1rem;
font-weight: normal;
cursor: pointer;
color: darken($color-dark-foreground, 10%);
}
&:hover {
color: $color-dark-foreground;
}
&:focus,
&:hover {
text-decoration: none;
}
}
:host {
width: $asset-height;
padding-bottom: 1rem;
padding-left: 8px;
padding-right: 8px;
} }
.drop-overlay { .drop-overlay {
@ -70,49 +49,63 @@
} }
} }
:host {
padding-bottom: 1rem;
padding-left: 8px;
padding-right: 8px;
}
.card { .card {
& { & {
@include overlay-container; @include overlay-container;
height: $asset-height; width: $asset-width;
} }
&.selectable { &-body,
cursor: pointer; &-footer {
position: relative;
padding: 1rem;
} }
&-body { &-body {
position: relative; padding: 0;
min-height: $asset-header;
max-height: $asset-header;
} }
&-footer { &-footer {
border: 0; padding-top: .5rem;
background: transparent; min-height: $asset-footer;
padding: .8rem; max-height: auto;
padding-top: .4rem;
height: 70px;
} }
}
.upload-progress { &.drag {
@include absolute(2rem, 2rem, 2rem, 2rem); .drop-overlay {
} @include opacity(1);
}
.file { .file-type {
&-info { @include opacity(0);
font-size: .8rem; }
} }
&-image { &.selectable {
height: 100%; cursor: pointer;
} }
}
.upload-progress {
@include absolute(1rem, 3em, 1rem, 3rem);
}
.file {
&-preview { &-preview {
& { & {
@include absolute(.8rem, .8rem, 0, .8rem); @include absolute(0, 0, 0, 0);
} }
&:hover { &:hover {
.file-overlay { .overlay {
@include opacity(1); @include opacity(1);
} }
@ -122,60 +115,89 @@
} }
} }
&-user {
@include absolute(auto, auto, 1.7rem, .5rem);
}
&-modified {
@include absolute(auto, auto, .5rem, .5rem);
}
&-menu {
@include absolute(.5rem, 1rem, auto, auto);
a {
@include asset-link;
}
}
&-type { &-type {
@include transition(opacity.4s ease); @include transition(opacity .4s ease);
@include absolute(.7rem, auto, auto, .5rem); @include absolute(1rem, auto, auto, 1rem);
@include asset-type; @include asset-type;
@include border-radius(3px); @include border-radius(3px);
background: $color-dark-black; background: $color-dark-black;
} }
&-name { &-name,
&-info {
@include truncate; @include truncate;
font-size: 1rem;
font-weight: normal;
line-height: 2rem;
} }
&-image,
&-icon { &-icon {
margin-top: 10%; height: $asset-image;
margin-bottom: 0;
height: 70%;
} }
&-icon-container { &-name {
line-height: 2rem;
}
&-tags {
margin-bottom: .25rem;
}
&-info {
font-size: .75rem;
}
&-icon {
line-height: $asset-image;
background: $color-border; background: $color-border;
border: 0; border: 0;
text-align: center; text-align: center;
height: 100%;
} }
}
&-overlay { .overlay {
& {
@include overlay;
font-size: .8rem;
font-weight: normal;
}
&-menu {
& { & {
@include overlay; @include absolute(1rem, 1rem, auto, auto);
font-size: .8rem;
font-weight: normal;
} }
&-type { a {
@include absolute(.7rem, auto, auto, .5rem); & {
@include asset-type; font-size: 1.1rem;
font-weight: normal;
cursor: pointer;
color: darken($color-dark-foreground, 10%);
}
&:hover {
color: $color-dark-foreground;
}
&:focus,
&:hover {
text-decoration: none;
}
} }
} }
&-type {
@include absolute(1rem, auto, auto, 1rem);
@include asset-type;
}
&-user {
@include absolute(auto, auto, 1rem, 1rem);
}
}
.editable {
height: 2rem;
}
.tags {
min-height: 26px;
} }

72
src/Squidex/app/shared/components/asset.component.ts

@ -5,8 +5,10 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder, FormControl } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { import {
AppsState, AppsState,
@ -14,14 +16,14 @@ import {
AssetsService, AssetsService,
AuthService, AuthService,
DateTime, DateTime,
DialogModel,
DialogService, DialogService,
fadeAnimation, fadeAnimation,
RenameAssetDto,
RenameAssetForm, RenameAssetForm,
Types, Types,
UpdateAssetDto,
Versioned Versioned
} from '@app/shared/internal'; } from '@app/shared/internal';
import { TagAssetDto } from '@appshared/services/assets.service';
@Component({ @Component({
selector: 'sqx-asset', selector: 'sqx-asset',
@ -31,7 +33,9 @@ import {
fadeAnimation fadeAnimation
] ]
}) })
export class AssetComponent implements OnInit { export class AssetComponent implements OnDestroy, OnInit {
private tagSubscription: Subscription;
@Input() @Input()
public initFile: File; public initFile: File;
@ -68,9 +72,13 @@ export class AssetComponent implements OnInit {
@Output() @Output()
public failed = new EventEmitter(); public failed = new EventEmitter();
public renameDialog = new DialogModel(); public renaming = false;
public isTagging = false;
public renameForm = new RenameAssetForm(this.formBuilder); public renameForm = new RenameAssetForm(this.formBuilder);
public tagInput = new FormControl();
public progress = 0; public progress = 0;
constructor( constructor(
@ -101,6 +109,22 @@ export class AssetComponent implements OnInit {
} else { } else {
this.updateAsset(this.asset, false); this.updateAsset(this.asset, false);
} }
if (this.isDisabled) {
this.tagInput.disable();
}
this.tagSubscription =
this.tagInput.valueChanges.pipe(
distinctUntilChanged(),
debounceTime(2000)
).subscribe(tags => {
this.tagAsset(tags);
});
}
public ngOnDestroy() {
this.tagSubscription.unsubscribe();
} }
public updateFile(files: FileList) { public updateFile(files: FileList) {
@ -121,17 +145,16 @@ export class AssetComponent implements OnInit {
} }
public renameAsset() { public renameAsset() {
const value = this.renameForm.submit(); const value = this.renameForm.submit(this.asset);
if (value) { if (value) {
const requestDto = new UpdateAssetDto(value.name); const requestDto = new RenameAssetDto(value.name);
this.assetsService.putAsset(this.appsState.appName, this.asset.id, requestDto, this.asset.version) this.assetsService.putAsset(this.appsState.appName, this.asset.id, requestDto, this.asset.version)
.subscribe(dto => { .subscribe(dto => {
this.updateAsset(this.asset.rename(requestDto.fileName, this.authState.user!.token, dto.version), true); this.updateAsset(this.asset.rename(requestDto.fileName, this.authState.user!.token, dto.version), true);
this.renameForm.submitCompleted(); this.renameCancel();
this.renameDialog.hide();
}, error => { }, error => {
this.dialogs.notifyError(error); this.dialogs.notifyError(error);
@ -140,9 +163,29 @@ export class AssetComponent implements OnInit {
} }
} }
public cancelRenameAsset() { public tagAsset(tags: string[]) {
if (tags) {
const requestDto = new TagAssetDto(tags);
this.assetsService.putAsset(this.appsState.appName, this.asset.id, requestDto, this.asset.version)
.subscribe(dto => {
this.updateAsset(this.asset.tag(tags, this.authState.user!.token, dto.version), true);
}, error => {
this.dialogs.notifyError(error);
});
}
}
public renameStart() {
if (!this.isDisabled) {
this.renameForm.load(this.asset);
this.renaming = true;
}
}
public renameCancel() {
this.renameForm.submitCompleted(); this.renameForm.submitCompleted();
this.renameDialog.hide(); this.renaming = false;
} }
private setProgress(progress = 0) { private setProgress(progress = 0) {
@ -162,14 +205,15 @@ export class AssetComponent implements OnInit {
} }
private updateAsset(asset: AssetDto, emitEvent: boolean) { private updateAsset(asset: AssetDto, emitEvent: boolean) {
this.renameForm.load({ name: asset.fileName });
this.asset = asset; this.asset = asset;
this.progress = 0; this.progress = 0;
this.tagInput.setValue(asset.tags);
if (emitEvent) { if (emitEvent) {
this.emitUpdated(asset); this.emitUpdated(asset);
} }
this.cancelRenameAsset(); this.renameCancel();
} }
} }

3
src/Squidex/app/shared/components/assets-list.component.html

@ -14,7 +14,7 @@
<div class="file-drop-info">Drop file on existing item to replace the asset with a newer version.</div> <div class="file-drop-info">Drop file on existing item to replace the asset with a newer version.</div>
</div> </div>
<div class="row"> <div class="row assets">
<sqx-asset *ngFor="let file of newFiles" [initFile]="file" <sqx-asset *ngFor="let file of newFiles" [initFile]="file"
(failed)="remove(file)" (failed)="remove(file)"
(loaded)="add(file, $event)"> (loaded)="add(file, $event)">
@ -25,6 +25,7 @@
[isDisabled]="isDisabled" [isDisabled]="isDisabled"
[isSelectable]="selectedIds" [isSelectable]="selectedIds"
[isSelected]="isSelected(asset)" [isSelected]="isSelected(asset)"
(updated)="update($event)"
(selected)="select($event)" (selected)="select($event)"
(deleting)="delete($event)"> (deleting)="delete($event)">
</sqx-asset> </sqx-asset>

5
src/Squidex/app/shared/components/assets-list.component.scss

@ -35,6 +35,11 @@
} }
} }
.assets {
margin-left: -8px;
margin-right: -8px;
}
.btn { .btn {
cursor: default; cursor: default;
} }

4
src/Squidex/app/shared/components/assets-list.component.ts

@ -58,6 +58,10 @@ export class AssetsListComponent {
this.state.goPrev().pipe(onErrorResumeNext()).subscribe(); this.state.goPrev().pipe(onErrorResumeNext()).subscribe();
} }
public update(asset: AssetDto) {
this.state.update(asset);
}
public trackByAsset(index: number, asset: AssetDto) { public trackByAsset(index: number, asset: AssetDto) {
return asset.id; return asset.id;
} }

83
src/Squidex/app/shared/services/assets.service.spec.ts

@ -16,7 +16,8 @@ import {
AssetsDto, AssetsDto,
AssetsService, AssetsService,
DateTime, DateTime,
UpdateAssetDto, RenameAssetDto,
TagAssetDto,
Version, Version,
Versioned Versioned
} from './../'; } from './../';
@ -30,7 +31,7 @@ describe('AssetDto', () => {
const newVersion = new Version('2'); const newVersion = new Version('2');
it('should update name property and user info when renaming', () => { it('should update name property and user info when renaming', () => {
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, 'url', version); const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, [], 'url', version);
const asset_2 = asset_1.rename('new-name.png', modifier, newVersion, modified); const asset_2 = asset_1.rename('new-name.png', modifier, newVersion, modified);
expect(asset_2.fileName).toEqual('new-name.png'); expect(asset_2.fileName).toEqual('new-name.png');
@ -39,10 +40,20 @@ describe('AssetDto', () => {
expect(asset_2.version).toEqual(newVersion); expect(asset_2.version).toEqual(newVersion);
}); });
it('should update tag property and user info when tagged', () => {
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, [], 'url', version);
const asset_2 = asset_1.tag(['tag1', 'tag2'], modifier, newVersion, modified);
expect(asset_2.tags).toEqual(['tag1', 'tag2']);
expect(asset_2.lastModified).toEqual(modified);
expect(asset_2.lastModifiedBy).toEqual(modifier);
expect(asset_2.version).toEqual(newVersion);
});
it('should update file properties when uploading', () => { it('should update file properties when uploading', () => {
const update = new AssetReplacedDto(2, 2, 'image/jpeg', true, 2, 2); const update = new AssetReplacedDto(2, 2, 'image/jpeg', true, 2, 2);
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, 'url', version); const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, [], 'url', version);
const asset_2 = asset_1.update(update, modifier, newVersion, modified); const asset_2 = asset_1.update(update, modifier, newVersion, modified);
expect(asset_2.fileSize).toEqual(2); expect(asset_2.fileSize).toEqual(2);
@ -79,6 +90,31 @@ describe('AssetsService', () => {
httpMock.verify(); httpMock.verify();
})); }));
it('should make get request to get asset tags',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let tags: any;
assetsService.getTags('my-app').subscribe(result => {
tags = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/tags');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
tag1: 1,
tag2: 4
});
expect(tags!).toEqual({
tag1: 1,
tag2: 4
});
}));
it('should make get request to get assets', it('should make get request to get assets',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
@ -110,6 +146,7 @@ describe('AssetsService', () => {
isImage: true, isImage: true,
pixelWidth: 1024, pixelWidth: 1024,
pixelHeight: 2048, pixelHeight: 2048,
tags: undefined,
version: 11 version: 11
}, },
{ {
@ -126,6 +163,7 @@ describe('AssetsService', () => {
isImage: true, isImage: true,
pixelWidth: 1024, pixelWidth: 1024,
pixelHeight: 2048, pixelHeight: 2048,
tags: ['tag1', 'tag2'],
version: 22 version: 22
} }
] ]
@ -145,6 +183,7 @@ describe('AssetsService', () => {
true, true,
1024, 1024,
2048, 2048,
[],
'http://service/p/api/assets/id1', 'http://service/p/api/assets/id1',
new Version('11')), new Version('11')),
new AssetDto('id2', 'Created2', 'LastModifiedBy2', new AssetDto('id2', 'Created2', 'LastModifiedBy2',
@ -158,6 +197,7 @@ describe('AssetsService', () => {
true, true,
1024, 1024,
2048, 2048,
['tag1', 'tag2'],
'http://service/p/api/assets/id2', 'http://service/p/api/assets/id2',
new Version('22')) new Version('22'))
])); ]));
@ -190,7 +230,8 @@ describe('AssetsService', () => {
mimeType: 'image/png', mimeType: 'image/png',
isImage: true, isImage: true,
pixelWidth: 1024, pixelWidth: 1024,
pixelHeight: 2048 pixelHeight: 2048,
tags: ['tag1', 'tag2']
}, { }, {
headers: { headers: {
etag: '2' etag: '2'
@ -210,6 +251,7 @@ describe('AssetsService', () => {
true, true,
1024, 1024,
2048, 2048,
['tag1', 'tag2'],
'http://service/p/api/assets/id1', 'http://service/p/api/assets/id1',
new Version('2'))); new Version('2')));
})); }));
@ -227,10 +269,23 @@ describe('AssetsService', () => {
req.flush({ total: 10, items: [] }); req.flush({ total: 10, items: [] });
})); }));
it('should append query to find by name and tag',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.getAssets('my-app', 17, 13, 'my-query', ['tag1', 'tag2']).subscribe();
const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?$filter=contains(fileName,'my-query') and tags eq 'tag1' and tags eq 'tag2'&$top=17&$skip=13`);
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ total: 10, items: [] });
}));
it('should append ids query to find by ids', it('should append ids query to find by ids',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.getAssets('my-app', 0, 0, undefined, ['12', '23']).subscribe(); assetsService.getAssets('my-app', 0, 0, undefined, undefined, ['12', '23']).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?ids=12,23'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?ids=12,23');
@ -284,6 +339,7 @@ describe('AssetsService', () => {
true, true,
1024, 1024,
2048, 2048,
[],
'http://service/p/api/assets/id1', 'http://service/p/api/assets/id1',
new Version('2'))); new Version('2')));
})); }));
@ -323,7 +379,22 @@ describe('AssetsService', () => {
it('should make put request to update asset', it('should make put request to update asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const dto = new UpdateAssetDto('My-Asset.pdf'); const dto = new RenameAssetDto('My-Asset.pdf');
assetsService.putAsset('my-app', '123', dto, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
}));
it('should make put request to update asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const dto = new TagAssetDto(['tag1', 'tag2']);
assetsService.putAsset('my-app', '123', dto, version).subscribe(); assetsService.putAsset('my-app', '123', dto, version).subscribe();

50
src/Squidex/app/shared/services/assets.service.ts

@ -50,6 +50,7 @@ export class AssetDto extends Model {
public readonly isImage: boolean, public readonly isImage: boolean,
public readonly pixelWidth: number | null, public readonly pixelWidth: number | null,
public readonly pixelHeight: number | null, public readonly pixelHeight: number | null,
public readonly tags: string[],
public readonly url: string, public readonly url: string,
public readonly version: Version public readonly version: Version
) { ) {
@ -69,6 +70,15 @@ export class AssetDto extends Model {
}); });
} }
public tag(tags: string[], user: string, version: Version, now?: DateTime): AssetDto {
return this.with({
tags,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
}
public rename(fileName: string, user: string, version: Version, now?: DateTime): AssetDto { public rename(fileName: string, user: string, version: Version, now?: DateTime): AssetDto {
return this.with({ return this.with({
fileName, fileName,
@ -79,13 +89,20 @@ export class AssetDto extends Model {
} }
} }
export class UpdateAssetDto { export class RenameAssetDto {
constructor( constructor(
public readonly fileName: string public readonly fileName: string
) { ) {
} }
} }
export class TagAssetDto {
constructor(
public readonly tags: string[]
) {
}
}
export class AssetReplacedDto { export class AssetReplacedDto {
constructor( constructor(
public readonly fileSize: number, public readonly fileSize: number,
@ -107,7 +124,14 @@ export class AssetsService {
) { ) {
} }
public getAssets(appName: string, take: number, skip: number, query?: string, ids?: string[]): Observable<AssetsDto> { public getTags(appName: string): Observable<{ [name: string]: number }> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/tags`);
return this.http.get(url).pipe(
map(response => <any>response));
}
public getAssets(appName: string, take: number, skip: number, query?: string, tags?: string[], ids?: string[]): Observable<AssetsDto> {
let fullQuery = ''; let fullQuery = '';
if (ids) { if (ids) {
@ -115,8 +139,22 @@ export class AssetsService {
} else { } else {
const queries: string[] = []; const queries: string[] = [];
const filters: string[] = [];
if (query && query.length > 0) { if (query && query.length > 0) {
queries.push(`$filter=contains(fileName,'${encodeURIComponent(query)}')`); filters.push(`contains(fileName,'${encodeURIComponent(query)}')`);
}
if (tags) {
for (let tag of tags) {
if (tag && tag.length > 0) {
filters.push(`tags eq '${encodeURIComponent(tag)}'`);
}
}
}
if (filters.length > 0) {
queries.push(`$filter=${filters.join(' and ')}`);
} }
queries.push(`$top=${take}`); queries.push(`$top=${take}`);
@ -125,7 +163,6 @@ export class AssetsService {
fullQuery = queries.join('&'); fullQuery = queries.join('&');
} }
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets?${fullQuery}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets?${fullQuery}`);
return HTTP.getVersioned<any>(this.http, url).pipe( return HTTP.getVersioned<any>(this.http, url).pipe(
@ -151,6 +188,7 @@ export class AssetsService {
item.isImage, item.isImage,
item.pixelWidth, item.pixelWidth,
item.pixelHeight, item.pixelHeight,
item.tags || [],
assetUrl, assetUrl,
new Version(item.version.toString())); new Version(item.version.toString()));
})); }));
@ -194,6 +232,7 @@ export class AssetsService {
response.isImage, response.isImage,
response.pixelWidth, response.pixelWidth,
response.pixelHeight, response.pixelHeight,
[],
assetUrl, assetUrl,
new Version(event.headers.get('etag')!)); new Version(event.headers.get('etag')!));
@ -231,6 +270,7 @@ export class AssetsService {
body.isImage, body.isImage,
body.pixelWidth, body.pixelWidth,
body.pixelHeight, body.pixelHeight,
body.tags || [],
assetUrl, assetUrl,
response.version); response.version);
}), }),
@ -288,7 +328,7 @@ export class AssetsService {
pretifyError('Failed to delete asset. Please reload.')); pretifyError('Failed to delete asset. Please reload.'));
} }
public putAsset(appName: string, id: string, dto: UpdateAssetDto, version: Version): Observable<Versioned<any>> { public putAsset(appName: string, id: string, dto: RenameAssetDto | TagAssetDto, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`);
return HTTP.putVersioned(this.http, url, dto, version).pipe( return HTTP.putVersioned(this.http, url, dto, version).pipe(

26
src/Squidex/app/shared/state/assets.forms.ts

@ -9,6 +9,8 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Form } from '@app/framework'; import { Form } from '@app/framework';
import { AssetDto } from './../services/assets.service';
export class RenameAssetForm extends Form<FormGroup> { export class RenameAssetForm extends Form<FormGroup> {
constructor(formBuilder: FormBuilder) { constructor(formBuilder: FormBuilder) {
super(formBuilder.group({ super(formBuilder.group({
@ -19,4 +21,28 @@ export class RenameAssetForm extends Form<FormGroup> {
] ]
})); }));
} }
public submit(asset?: AssetDto) {
const result = super.submit();
if (asset) {
let index = asset.fileName.lastIndexOf('.');
if (index > 0) {
result.name += asset.fileName.substr(index);
}
}
return result;
}
public load(asset: AssetDto) {
let name = asset.fileName;
let index = name.lastIndexOf('.');
if (index > 0) {
name = name.substr(0, index);
}
super.load({ name });
}
} }

50
src/Squidex/app/shared/state/assets.state.spec.ts

@ -30,8 +30,8 @@ describe('AssetsState', () => {
const newVersion = new Version('2'); const newVersion = new Version('2');
const oldAssets = [ const oldAssets = [
new AssetDto('id1', creator, creator, creation, creation, 'name1', 'type1', 1, 1, 'mime1', false, null, null, 'url1', version), new AssetDto('id1', creator, creator, creation, creation, 'name1', 'type1', 1, 1, 'mime1', false, null, null, ['tag1', 'shared'], 'url1', version),
new AssetDto('id2', creator, creator, creation, creation, 'name2', 'type2', 2, 2, 'mime2', false, null, null, 'url2', version) new AssetDto('id2', creator, creator, creation, creation, 'name2', 'type2', 2, 2, 'mime2', false, null, null, ['tag2', 'shared'], 'url2', version)
]; ];
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
@ -49,9 +49,12 @@ describe('AssetsState', () => {
assetsService = Mock.ofType<AssetsService>(); assetsService = Mock.ofType<AssetsService>();
assetsService.setup(x => x.getAssets(app, 30, 0, undefined)) assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(200, oldAssets))); .returns(() => of(new AssetsDto(200, oldAssets)));
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 }));
assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object); assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object);
assetsState.load().subscribe(); assetsState.load().subscribe();
}); });
@ -63,7 +66,8 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.assetsPager.numberOfItems).toEqual(200); expect(assetsState.snapshot.assetsPager.numberOfItems).toEqual(200);
expect(assetsState.snapshot.isLoaded).toBeTruthy(); expect(assetsState.snapshot.isLoaded).toBeTruthy();
assetsService.verify(x => x.getAssets(app, 30, 0, undefined), Times.exactly(2)); assetsService.verify(x => x.getAssets(app, 30, 0, undefined, []), Times.exactly(2));
assetsService.verify(x => x.getTags(app), Times.exactly(2));
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
}); });
@ -77,7 +81,7 @@ describe('AssetsState', () => {
}); });
it('should add asset to snapshot when created', () => { it('should add asset to snapshot when created', () => {
const newAsset = new AssetDto('id3', creator, creator, creation, creation, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, 'url3', version); const newAsset = new AssetDto('id3', creator, creator, creation, creation, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, [], 'url3', version);
assetsState.add(newAsset); assetsState.add(newAsset);
@ -86,13 +90,14 @@ describe('AssetsState', () => {
}); });
it('should update properties when updated', () => { it('should update properties when updated', () => {
const newAsset = new AssetDto('id1', modifier, modifier, modified, modified, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, 'url3', version); const newAsset = new AssetDto('id1', modifier, modifier, modified, modified, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, ['new'], 'url3', version);
assetsState.update(newAsset); assetsState.update(newAsset);
const asset_1 = assetsState.snapshot.assets.at(0); const asset_1 = assetsState.snapshot.assets.at(0);
expect(asset_1).toBe(newAsset); expect(asset_1).toBe(newAsset);
expect(assetsState.snapshot.tags).toEqual({ tag2: 1, shared: 1, new: 1 });
}); });
it('should remove asset from snapshot when deleted', () => { it('should remove asset from snapshot when deleted', () => {
@ -103,10 +108,11 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.assets.values.length).toBe(1); expect(assetsState.snapshot.assets.values.length).toBe(1);
expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(199); expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(199);
expect(assetsState.snapshot.tags).toEqual({ shared: 1, tag2: 1 });
}); });
it('should load next page and prev page when paging', () => { it('should load next page and prev page when paging', () => {
assetsService.setup(x => x.getAssets(app, 30, 30, undefined)) assetsService.setup(x => x.getAssets(app, 30, 30, undefined, []))
.returns(() => of(new AssetsDto(200, []))); .returns(() => of(new AssetsDto(200, [])));
assetsState.goNext().subscribe(); assetsState.goNext().subscribe();
@ -114,18 +120,40 @@ describe('AssetsState', () => {
expect().nothing(); expect().nothing();
assetsService.verify(x => x.getAssets(app, 30, 30, undefined), Times.once()); assetsService.verify(x => x.getAssets(app, 30, 30, undefined, []), Times.once());
assetsService.verify(x => x.getAssets(app, 30, 0, undefined), Times.exactly(2)); assetsService.verify(x => x.getAssets(app, 30, 0, undefined, []), Times.exactly(2));
}); });
it('should load with query when searching', () => { it('should load with query when searching', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query')) assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', []))
.returns(() => of(new AssetsDto(0, []))); .returns(() => of(new AssetsDto(0, [])));
assetsState.search('my-query').subscribe(); assetsState.search('my-query').subscribe();
expect(assetsState.snapshot.assetsQuery).toEqual('my-query'); expect(assetsState.snapshot.assetsQuery).toEqual('my-query');
assetsService.verify(x => x.getAssets(app, 30, 0, 'my-query'), Times.once()); assetsService.verify(x => x.getAssets(app, 30, 0, 'my-query', []), Times.once());
});
it('should load with tags when tag toggled', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1']))
.returns(() => of(new AssetsDto(0, [])));
assetsState.toggleTag('tag1').subscribe();
expect(assetsState.isTagSelected('tag1')).toBeTruthy();
assetsService.verify(x => x.getAssets(app, 30, 0, undefined, ['tag1']), Times.once());
});
it('should load without tags when tags reset', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(0, [])));
assetsState.resetTags().subscribe();
expect(assetsState.isTagSelectionEmpty()).toBeTruthy();
assetsService.verify(x => x.getAssets(app, 30, 0, undefined, []), Times.exactly(2));
}); });
}); });

100
src/Squidex/app/shared/state/assets.state.ts

@ -6,7 +6,7 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import { import {
@ -21,6 +21,9 @@ import { AssetDto, AssetsService} from './../services/assets.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
interface Snapshot { interface Snapshot {
tags: { [name: string]: number };
tagsSelected: { [name: string]: boolean };
assets: ImmutableArray<AssetDto>; assets: ImmutableArray<AssetDto>;
assetsPager: Pager; assetsPager: Pager;
assetsQuery?: string; assetsQuery?: string;
@ -30,6 +33,10 @@ interface Snapshot {
@Injectable() @Injectable()
export class AssetsState extends State<Snapshot> { export class AssetsState extends State<Snapshot> {
public tags =
this.changes.pipe(map(x => x.tags),
distinctUntilChanged());
public assets = public assets =
this.changes.pipe(map(x => x.assets), this.changes.pipe(map(x => x.assets),
distinctUntilChanged()); distinctUntilChanged());
@ -47,7 +54,7 @@ export class AssetsState extends State<Snapshot> {
private readonly assetsService: AssetsService, private readonly assetsService: AssetsService,
private readonly dialogs: DialogService private readonly dialogs: DialogService
) { ) {
super({ assets: ImmutableArray.empty(), assetsPager: new Pager(0, 0, 30) }); super({ assets: ImmutableArray.empty(), assetsPager: new Pager(0, 0, 30), tags: {}, tagsSelected: {} });
} }
public load(isReload = false): Observable<any> { public load(isReload = false): Observable<any> {
@ -59,17 +66,24 @@ export class AssetsState extends State<Snapshot> {
} }
private loadInternal(isReload = false): Observable<any> { private loadInternal(isReload = false): Observable<any> {
return this.assetsService.getAssets(this.appName, this.snapshot.assetsPager.pageSize, this.snapshot.assetsPager.skip, this.snapshot.assetsQuery).pipe( return combineLatest(
this.assetsService.getAssets(this.appName,
this.snapshot.assetsPager.pageSize,
this.snapshot.assetsPager.skip,
this.snapshot.assetsQuery,
Object.keys(this.snapshot.tagsSelected)),
this.assetsService.getTags(this.appName)
).pipe(
tap(dtos => { tap(dtos => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('Assets reloaded.'); this.dialogs.notifyInfo('Assets reloaded.');
} }
this.next(s => { this.next(s => {
const assets = ImmutableArray.of(dtos.items); const assets = ImmutableArray.of(dtos[0].items);
const assetsPager = s.assetsPager.setCount(dtos.total); const assetsPager = s.assetsPager.setCount(dtos[0].total);
return { ...s, assets, assetsPager, isLoaded: true }; return { ...s, assets, assetsPager, isLoaded: true, tags: dtos[1] };
}); });
}), }),
notify(this.dialogs)); notify(this.dialogs));
@ -86,12 +100,24 @@ export class AssetsState extends State<Snapshot> {
public delete(asset: AssetDto): Observable<any> { public delete(asset: AssetDto): Observable<any> {
return this.assetsService.deleteAsset(this.appName, asset.id, asset.version).pipe( return this.assetsService.deleteAsset(this.appName, asset.id, asset.version).pipe(
tap(dto => { tap(() => {
return this.next(s => { return this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id); const assets = s.assets.filter(x => x.id !== asset.id);
const assetsPager = s.assetsPager.decrementCount(); const assetsPager = s.assetsPager.decrementCount();
return { ...s, assets, assetsPager }; const tags = { ...s.tags };
const tagsSelected = { ...s.tagsSelected };
for (let tag of asset.tags) {
if (tags[tag] === 1) {
delete tags[tag];
delete tagsSelected[tag];
} else {
tags[tag]--;
}
}
return { ...s, assets, assetsPager, tags, tagsSelected };
}); });
}), }),
notify(this.dialogs)); notify(this.dialogs));
@ -99,10 +125,58 @@ export class AssetsState extends State<Snapshot> {
public update(asset: AssetDto) { public update(asset: AssetDto) {
this.next(s => { this.next(s => {
const previous = s.assets.find(x => x.id === asset.id);
const tags = { ...s.tags };
const tagsSelected = { ...s.tagsSelected };
if (previous) {
for (let tag of previous.tags) {
if (tags[tag] === 1) {
delete tags[tag];
delete tagsSelected[tag];
} else {
tags[tag]--;
}
}
}
if (asset) {
for (let tag of asset.tags) {
if (tags[tag]) {
tags[tag]++;
} else {
tags[tag] = 1;
}
}
}
const assets = s.assets.replaceBy('id', asset); const assets = s.assets.replaceBy('id', asset);
return { ...s, assets }; return { ...s, assets, tags, tagsSelected };
});
}
public toggleTag(tag: string): Observable<any> {
this.next(s => {
const tagsSelected = { ...s.tagsSelected };
if (tagsSelected[tag]) {
delete tagsSelected[tag];
} else {
tagsSelected[tag] = true;
}
return { ...s, assetsPager: new Pager(0, 0, 30), tagsSelected };
}); });
return this.loadInternal();
}
public resetTags(): Observable<any> {
this.next(s => ({ ...s, assetsPager: new Pager(0, 0, 30), tagsSelected: {} }));
return this.loadInternal();
} }
public search(query: string): Observable<any> { public search(query: string): Observable<any> {
@ -123,6 +197,14 @@ export class AssetsState extends State<Snapshot> {
return this.loadInternal(); return this.loadInternal();
} }
public isTagSelected(tag: string) {
return this.snapshot.tagsSelected[tag] === true;
}
public isTagSelectionEmpty() {
return Object.keys(this.snapshot.tagsSelected).length === 0;
}
private get appName() { private get appName() {
return this.appsState.appName; return this.appsState.appName;
} }

58
src/Squidex/app/theme/_forms.scss

@ -4,16 +4,18 @@
// //
// Support for Angular validation states. // Support for Angular validation states.
// //
.ng-invalid { .form-control {
&.ng-dirty { &.ng-invalid {
& { &.ng-dirty {
border-color: $color-theme-error; & {
} border-color: $color-theme-error;
}
&:hover,
&:focus { &:hover,
@include box-shadow-colored(0, 0, .2rem, $color-theme-error); &:focus {
border-color: $color-theme-error-dark; @include box-shadow-colored(0, 0, .2rem, $color-theme-error);
border-color: $color-theme-error-dark;
}
} }
} }
} }
@ -176,3 +178,39 @@
color: $color-dark2-focus-foreground; color: $color-dark2-focus-foreground;
} }
} }
.form-underlined {
& {
@include border-radius(0);
padding-left: 0;
padding-right: 0;
border-color: transparent;
border-bottom: 1px solid $color-input-border;
}
&:focus,
&.focus {
@include box-shadow-none;
background: transparent;
border-color: transparent;
border-bottom-color: $color-theme-blue;
outline: none;
}
&.ng-invalid.ng-dirty {
& {
@include box-shadow-none;
background: transparent;
border-color: transparent;
border-bottom-color: $color-theme-error;
outline: none;
}
&:hover,
&:focus {
@include box-shadow-none;
border-color: transparent;
border-bottom-color: $color-theme-error-dark;
}
}
}

5
src/Squidex/app/theme/_panels.scss

@ -152,6 +152,11 @@
max-width: $panel-sidebar; max-width: $panel-sidebar;
} }
&.wide {
min-width: 16rem;
max-width: 16rem;
}
& .panel-link { & .panel-link {
& { & {
@include transition(background-color .3s ease); @include transition(background-color .3s ease);

6
src/Squidex/app/theme/_vars.scss

@ -97,4 +97,8 @@ $panel-header: 5.4rem;
$panel-sidebar: 3.75rem; $panel-sidebar: 3.75rem;
$panel-light-background: #fff; $panel-light-background: #fff;
$asset-height: 13rem; $asset-width: 16rem;
$asset-height: 19rem;
$asset-header: 12rem;
$asset-image: 12rem;
$asset-footer: 7rem;

33
tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs

@ -36,6 +36,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
.AddNumber(3, "field3", Partitioning.Invariant) .AddNumber(3, "field3", Partitioning.Invariant)
.AddAssets(5, "assets1", Partitioning.Invariant) .AddAssets(5, "assets1", Partitioning.Invariant)
.AddAssets(6, "assets2", Partitioning.Invariant) .AddAssets(6, "assets2", Partitioning.Invariant)
.AddArray(7, "array", Partitioning.Invariant, a => a
.AddAssets(71, "assets71"))
.AddJson(4, "json", Partitioning.Language) .AddJson(4, "json", Partitioning.Language)
.UpdateField(3, f => f.Hide()); .UpdateField(3, f => f.Hide());
} }
@ -140,7 +142,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant); var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var result = sut.CleanReferences(CreateValue(id1, id2), new HashSet<Guid>(new[] { id2 })); var result = sut.CleanReferences(CreateValue(id1, id2), HashSet.Of(id2));
Assert.Equal(CreateValue(id1), result); Assert.Equal(CreateValue(id1), result);
} }
@ -154,11 +156,32 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant); var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var token = CreateValue(id1, id2); var token = CreateValue(id1, id2);
var result = sut.CleanReferences(token, new HashSet<Guid>(new[] { Guid.NewGuid() })); var result = sut.CleanReferences(token, HashSet.Of(Guid.NewGuid()));
Assert.Same(token, result); Assert.Same(token, result);
} }
[Fact]
public void Should_return_ids_from_nested_references_field()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var sut =
Fields.Array(1, "my-array", Partitioning.Invariant,
Fields.References(1, "my-refs",
new ReferencesFieldProperties { SchemaId = schemaId }));
var value =
new JArray(
new JObject(
new JProperty("my-refs", CreateValue(id1, id2))));
var result = sut.ExtractReferences(value).ToArray();
Assert.Equal(new[] { id1, id2, schemaId }, result);
}
[Fact] [Fact]
public void Should_return_ids_from_references_field() public void Should_return_ids_from_references_field()
{ {
@ -214,7 +237,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, var sut = Fields.References(1, "my-refs", Partitioning.Invariant,
new ReferencesFieldProperties { SchemaId = schemaId }); new ReferencesFieldProperties { SchemaId = schemaId });
var result = sut.CleanReferences(CreateValue(id1, id2), new HashSet<Guid>(new[] { id2 })); var result = sut.CleanReferences(CreateValue(id1, id2), HashSet.Of(id2));
Assert.Equal(CreateValue(id1), result); Assert.Equal(CreateValue(id1), result);
} }
@ -228,7 +251,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, var sut = Fields.References(1, "my-refs", Partitioning.Invariant,
new ReferencesFieldProperties { SchemaId = schemaId }); new ReferencesFieldProperties { SchemaId = schemaId });
var result = sut.CleanReferences(CreateValue(id1, id2), new HashSet<Guid>(new[] { schemaId })); var result = sut.CleanReferences(CreateValue(id1, id2), HashSet.Of(schemaId));
Assert.Equal(CreateValue(), result); Assert.Equal(CreateValue(), result);
} }
@ -242,7 +265,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.References(1, "my-refs", Partitioning.Invariant); var sut = Fields.References(1, "my-refs", Partitioning.Invariant);
var token = CreateValue(id1, id2); var token = CreateValue(id1, id2);
var result = sut.CleanReferences(token, new HashSet<Guid>(new[] { Guid.NewGuid() })); var result = sut.CleanReferences(token, HashSet.Of(Guid.NewGuid()));
Assert.Same(token, result); Assert.Same(token, result);
} }

7
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Xunit; using Xunit;
@ -63,11 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_replace_app_ids_on_rebuild() public async Task Should_replace_app_ids_on_rebuild()
{ {
var state = new HashSet<Guid> var state = HashSet.Of(appId1, appId2);
{
appId1,
appId2
};
await sut.RebuildAsync(state); await sut.RebuildAsync(state);

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

@ -13,6 +13,7 @@ using FakeItEasy;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -26,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly IAssetStore assetStore = A.Fake<IAssetStore>(); private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>(); private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly Guid assetId = Guid.NewGuid(); private readonly Guid assetId = Guid.NewGuid();
private readonly Stream stream = new MemoryStream(); private readonly Stream stream = new MemoryStream();
@ -43,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
file = new AssetFile("my-image.png", "image/png", 1024, () => stream); file = new AssetFile("my-image.png", "image/png", 1024, () => stream);
asset = new AssetGrain(Store, A.Dummy<ISemanticLog>()); asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
asset.OnActivateAsync(Id).Wait(); asset.OnActivateAsync(Id).Wait();
A.CallTo(() => grainFactory.GetGrain<IAssetGrain>(Id, null)) A.CallTo(() => grainFactory.GetGrain<IAssetGrain>(Id, null))

4
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs

@ -11,6 +11,7 @@ using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -23,6 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public class AssetGrainTests : HandlerTestBase<AssetGrain, AssetState> public class AssetGrainTests : HandlerTestBase<AssetGrain, AssetState>
{ {
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly ImageInfo image = new ImageInfo(2048, 2048); private readonly ImageInfo image = new ImageInfo(2048, 2048);
private readonly Guid assetId = Guid.NewGuid(); private readonly Guid assetId = Guid.NewGuid();
private readonly AssetFile file = new AssetFile("my-image.png", "image/png", 1024, () => new MemoryStream()); private readonly AssetFile file = new AssetFile("my-image.png", "image/png", 1024, () => new MemoryStream());
@ -35,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
public AssetGrainTests() public AssetGrainTests()
{ {
sut = new AssetGrain(Store, A.Dummy<ISemanticLog>()); sut = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
sut.OnActivateAsync(Id).Wait(); sut.OnActivateAsync(Id).Wait();
} }

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

@ -0,0 +1,117 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetQueryServiceTests
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>();
private readonly IAppEntity app = A.Fake<IAppEntity>();
private readonly Guid appId = Guid.NewGuid();
private readonly string appName = "my-app";
private readonly ClaimsPrincipal user;
private readonly ClaimsIdentity identity = new ClaimsIdentity();
private readonly QueryContext context;
private readonly AssetQueryService sut;
public AssetQueryServiceTests()
{
user = new ClaimsPrincipal(identity);
A.CallTo(() => app.Id).Returns(appId);
A.CallTo(() => app.Name).Returns(appName);
A.CallTo(() => app.LanguagesConfig).Returns(LanguagesConfig.English);
context = QueryContext.Create(app, user);
A.CallTo(() => tagService.DenormalizeTagsAsync(appId, TagGroups.Assets, A<HashSet<string>>.That.IsSameSequenceAs("id1", "id2", "id3")))
.Returns(new Dictionary<string, string>
{
["id1"] = "name1",
["id2"] = "name2",
["id3"] = "name3"
});
sut = new AssetQueryService(tagService, assetRepository);
}
[Fact]
public async Task Should_find_asset_by_id_and_resolve_tags()
{
var id = Guid.NewGuid();
A.CallTo(() => assetRepository.FindAssetAsync(id))
.Returns(CreateAsset(id, "id1", "id2", "id3"));
var result = await sut.FindAssetAsync(context, id);
Assert.Equal(HashSet.Of("name1", "name2", "name3"), result.Tags);
}
[Fact]
public async Task Should_load_assets_from_ids_and_resolve_tags()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var ids = HashSet.Of(id1, id2);
A.CallTo(() => assetRepository.QueryAsync(appId, A<HashSet<Guid>>.That.IsSameSequenceAs(ids)))
.Returns(ResultList.Create(8,
CreateAsset(id1, "id1", "id2", "id3"),
CreateAsset(id2)));
var result = await sut.QueryAsync(context, Query.Empty.WithIds(ids));
Assert.Equal(8, result.Total);
Assert.Equal(2, result.Count);
Assert.Equal(HashSet.Of("name1", "name2", "name3"), result[0].Tags);
Assert.Empty(result[1].Tags);
}
[Fact]
public async Task Should_load_assets_with_query_and_resolve_tags()
{
A.CallTo(() => assetRepository.QueryAsync(appId, "my-query"))
.Returns(ResultList.Create(8,
CreateAsset(Guid.NewGuid(), "id1", "id2"),
CreateAsset(Guid.NewGuid(), "id2", "id3")));
var result = await sut.QueryAsync(context, Query.Empty.WithODataQuery("my-query"));
Assert.Equal(8, result.Total);
Assert.Equal(2, result.Count);
Assert.Equal(HashSet.Of("name1", "name2"), result[0].Tags);
Assert.Equal(HashSet.Of("name2", "name3"), result[1].Tags);
}
private 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;
}
}
}

35
tests/Squidex.Domain.Apps.Entities.Tests/Assets/OData/ODataQueryTests.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic;
using FakeItEasy; using FakeItEasy;
using Microsoft.OData.Edm; using Microsoft.OData.Edm;
using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization;
@ -12,6 +14,8 @@ using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Edm;
using Squidex.Domain.Apps.Entities.MongoDb.Assets; using Squidex.Domain.Apps.Entities.MongoDb.Assets;
using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors; using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.MongoDb.OData; using Squidex.Infrastructure.MongoDb.OData;
using Xunit; using Xunit;
@ -20,15 +24,26 @@ namespace Squidex.Domain.Apps.Entities.Assets.OData
{ {
public class ODataQueryTests public class ODataQueryTests
{ {
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry; private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry;
private readonly IBsonSerializer<MongoAssetEntity> serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoAssetEntity>(); private readonly IBsonSerializer<MongoAssetEntity> serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoAssetEntity>();
private readonly IEdmModel edmModel = EdmAssetModel.Edm; private readonly IEdmModel edmModel = EdmAssetModel.Edm;
private readonly Guid appId = Guid.NewGuid();
private readonly ConvertValue valueConverter;
static ODataQueryTests() static ODataQueryTests()
{ {
InstantSerializer.Register(); InstantSerializer.Register();
} }
public ODataQueryTests()
{
A.CallTo(() => tagService.GetTagIdsAsync(appId, TagGroups.Assets, A<HashSet<string>>.That.Contains("tag1")))
.Returns(HashSet.Of("normalized1"));
valueConverter = FindExtensions.CreateValueConverter(appId, tagService);
}
[Fact] [Fact]
public void Should_parse_query() public void Should_parse_query()
{ {
@ -82,6 +97,24 @@ namespace Squidex.Domain.Apps.Entities.Assets.OData
Assert.Equal(o, i); Assert.Equal(o, i);
} }
[Fact]
public void Should_make_query_with_normalized_tags()
{
var i = F("$filter=tags eq 'tag1'");
var o = C("{ 'Tags' : 'normalized1' }");
Assert.Equal(o, i);
}
[Fact]
public void Should_make_query_with_tags()
{
var i = F("$filter=tags eq 'tag2'");
var o = C("{ 'Tags' : 'tag2' }");
Assert.Equal(o, i);
}
[Fact] [Fact]
public void Should_make_query_with_fileName() public void Should_make_query_with_fileName()
{ {
@ -246,7 +279,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.OData
var parser = edmModel.ParseQuery(value); var parser = edmModel.ParseQuery(value);
var query = var query =
parser.BuildFilter<MongoAssetEntity>() parser.BuildFilter<MongoAssetEntity>(convertValue: valueConverter)
.Filter.Render(serializer, registry).ToString(); .Filter.Render(serializer, registry).ToString();
return query; return query;

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

@ -45,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly ClaimsPrincipal user; private readonly ClaimsPrincipal user;
private readonly ClaimsIdentity identity = new ClaimsIdentity(); private readonly ClaimsIdentity identity = new ClaimsIdentity();
private readonly EdmModelBuilder modelBuilder = A.Fake<EdmModelBuilder>(); private readonly EdmModelBuilder modelBuilder = A.Fake<EdmModelBuilder>();
private readonly QueryContext context; private readonly ContentQueryContext context;
private readonly ContentQueryService sut; private readonly ContentQueryService sut;
public ContentQueryServiceTests() public ContentQueryServiceTests()
@ -58,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => schema.SchemaDef).Returns(new Schema("my-schema")); A.CallTo(() => schema.SchemaDef).Returns(new Schema("my-schema"));
context = QueryContext.Create(app, user); context = new ContentQueryContext(QueryContext.Create(app, user));
sut = new ContentQueryService(contentRepository, contentVersionLoader, appProvider, scriptEngine, modelBuilder); sut = new ContentQueryService(contentRepository, contentVersionLoader, appProvider, scriptEngine, modelBuilder);
} }
@ -185,9 +185,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns(schema); .Returns(schema);
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<ODataUriParser>.Ignored)) A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<ODataUriParser>.Ignored))
.Returns(ResultList.Create(Enumerable.Repeat(content, count), total)); .Returns(ResultList.Create(total, Enumerable.Repeat(content, count)));
var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), string.Empty); var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), Query.Empty);
Assert.Equal(contentData, result[0].Data); Assert.Equal(contentData, result[0].Data);
Assert.Equal(content.Id, result[0].Id); Assert.Equal(content.Id, result[0].Id);
@ -215,7 +215,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => modelBuilder.BuildEdmModel(schema, app)) A.CallTo(() => modelBuilder.BuildEdmModel(schema, app))
.Throws(new ODataException()); .Throws(new ODataException());
return Assert.ThrowsAsync<ValidationException>(() => sut.QueryAsync(context.WithSchemaId(schemaId), "query")); return Assert.ThrowsAsync<ValidationException>(() => sut.QueryAsync(context.WithSchemaId(schemaId), Query.Empty.WithODataQuery("query")));
} }
public static IEnumerable<object[]> ManyIdRequestData = new[] public static IEnumerable<object[]> ManyIdRequestData = new[]
@ -239,9 +239,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns(schema); .Returns(schema);
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<HashSet<Guid>>.Ignored)) A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<HashSet<Guid>>.Ignored))
.Returns(ResultList.Create(ids.Select(x => CreateContent(x)).Shuffle(), total)); .Returns(ResultList.Create(total, ids.Select(x => CreateContent(x)).Shuffle()));
var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), ids); var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), Query.Empty.WithIds(ids));
Assert.Equal(ids, result.Select(x => x.Id).ToList()); Assert.Equal(ids, result.Select(x => x.Id).ToList());
Assert.Equal(total, result.Total); Assert.Equal(total, result.Total);

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

@ -63,10 +63,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var asset = CreateAsset(Guid.NewGuid()); var asset = CreateAsset(Guid.NewGuid());
var assets = new List<IAssetEntity> { asset }; A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$take=30&$skip=5&$search=my-query")))
.Returns(ResultList.Create(0, asset));
A.CallTo(() => assetRepository.QueryAsync(app.Id, "?$take=30&$skip=5&$search=my-query"))
.Returns(ResultList.Create(assets, 0));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -132,10 +130,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var asset = CreateAsset(Guid.NewGuid()); var asset = CreateAsset(Guid.NewGuid());
var assets = new List<IAssetEntity> { asset }; A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$take=30&$skip=5&$search=my-query")))
.Returns(ResultList.Create(10, asset));
A.CallTo(() => assetRepository.QueryAsync(app.Id, "?$take=30&$skip=5&$search=my-query"))
.Returns(ResultList.Create(assets, 10));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -203,7 +199,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}} }}
}}"; }}";
A.CallTo(() => assetRepository.FindAssetAsync(assetId)) A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId))
.Returns(asset); .Returns(asset);
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -285,10 +281,8 @@ 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);
var contents = new List<IContentEntity> { content }; A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5")))
.Returns(ResultList.Create(0, content));
A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), "?$top=30&$skip=5"))
.Returns(ResultList.Create(contents, 0));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -419,10 +413,8 @@ 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);
var contents = new List<IContentEntity> { content }; A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5")))
.Returns(ResultList.Create(10, content));
A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), "?$top=30&$skip=5"))
.Returns(ResultList.Create(contents, 10));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -539,7 +531,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}} }}
}}"; }}";
A.CallTo(() => contentQuery.FindContentAsync(ContextMatch(), contentId, EtagVersion.Any)) A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), contentId, EtagVersion.Any))
.Returns(content); .Returns(content);
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -630,13 +622,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}} }}
}}"; }}";
var refContents = new List<IContentEntity> { contentRef }; A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), contentId, EtagVersion.Any))
A.CallTo(() => contentQuery.FindContentAsync(ContextMatch(), contentId, EtagVersion.Any))
.Returns(content); .Returns(content);
A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), A<IList<Guid>>.That.IsSameSequenceAs(new[] { contentRefId }))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Query>.Ignored))
.Returns(ResultList.Create(refContents, 0)); .Returns(ResultList.Create(0, contentRef));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -690,13 +680,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}} }}
}}"; }}";
var refAssets = new List<IAssetEntity> { assetRef }; A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), contentId, EtagVersion.Any))
A.CallTo(() => contentQuery.FindContentAsync(ContextMatch(), contentId, EtagVersion.Any))
.Returns(content); .Returns(content);
A.CallTo(() => assetRepository.QueryAsync(app.Id, A<HashSet<Guid>>.That.Matches(x => x.Contains(assetRefId)))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Query>.Ignored))
.Returns(ResultList.Create(refAssets, 0)); .Returns(ResultList.Create(0, assetRef));
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -751,7 +739,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}} }}
}}"; }}";
A.CallTo(() => contentQuery.FindContentAsync(ContextMatch(), contentId, EtagVersion.Any)) A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), contentId, EtagVersion.Any))
.Returns(content); .Returns(content);
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
@ -764,9 +752,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
AssertResult(expected, result, false); AssertResult(expected, result, false);
} }
private QueryContext ContextMatch() private QueryContext MatchsAssetContext()
{
return A<QueryContext>.That.Matches(x => x.App == app && x.User == user && !x.Archived);
}
private ContentQueryContext MatchsContentContext()
{ {
return A<QueryContext>.That.Matches(x => x.App == app && x.SchemaIdOrName == schema.Id.ToString() && x.User == user && !x.Archived); return A<ContentQueryContext>.That.Matches(x => x.Base.App == app && x.Base.User == user && !x.Base.Archived && x.SchemaIdOrName == schema.Id.ToString());
} }
} }
} }

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

@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
protected static readonly string appName = "my-app"; protected static readonly string appName = "my-app";
protected readonly Schema schemaDef; protected readonly Schema schemaDef;
protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>(); protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
protected readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>(); protected readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
protected readonly ISchemaEntity schema = A.Fake<ISchemaEntity>(); protected readonly ISchemaEntity schema = A.Fake<ISchemaEntity>();
protected readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); protected readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
protected readonly IAppProvider appProvider = A.Fake<IAppProvider>(); protected readonly IAppProvider appProvider = A.Fake<IAppProvider>();
@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => appProvider.GetSchemasAsync(appId)).Returns(allSchemas); A.CallTo(() => appProvider.GetSchemasAsync(appId)).Returns(allSchemas);
sut = new CachingGraphQLService(cache, appProvider, assetRepository, contentQuery, new FakeUrlGenerator()); sut = new CachingGraphQLService(cache, appProvider, assetQuery, contentQuery, new FakeUrlGenerator());
} }
protected static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null) protected static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null)

3
tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -28,6 +29,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
public RefToken LastModifiedBy { get; set; } public RefToken LastModifiedBy { get; set; }
public HashSet<string> Tags { get; set; }
public long Version { get; set; } public long Version { get; set; }
public string MimeType { get; set; } public string MimeType { get; set; }

75
tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs

@ -0,0 +1,75 @@
// ==========================================================================
// 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 Orleans;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Tags
{
public class GrainTagServiceTests
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly ITagGrain grain = A.Fake<ITagGrain>();
private readonly Guid appId = Guid.NewGuid();
private readonly GrainTagService sut;
public GrainTagServiceTests()
{
A.CallTo(() => grainFactory.GetGrain<ITagGrain>($"{appId}_Assets", null))
.Returns(grain);
sut = new GrainTagService(grainFactory);
}
[Fact]
public async Task Should_call_grain_when_retrieving_tas()
{
await sut.GetTagsAsync(appId, TagGroups.Assets);
A.CallTo(() => grain.GetTagsAsync())
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_when_resolving_tag_ids()
{
var tagNames = new HashSet<string>();
await sut.GetTagIdsAsync(appId, TagGroups.Assets, tagNames);
A.CallTo(() => grain.GetTagIdsAsync(tagNames))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_when_denormalizing_tags()
{
var tagIds = new HashSet<string>();
await sut.DenormalizeTagsAsync(appId, TagGroups.Assets, tagIds);
A.CallTo(() => grain.DenormalizeTagsAsync(tagIds))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_when_normalizing_tags()
{
var tagIds = new HashSet<string>();
var tagNames = new HashSet<string>();
await sut.NormalizeTagsAsync(appId, TagGroups.Assets, tagNames, tagIds);
A.CallTo(() => grain.NormalizeTagsAsync(tagNames, tagIds))
.MustHaveHappened();
}
}
}

92
tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs

@ -0,0 +1,92 @@
// ==========================================================================
// 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 FakeItEasy;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Tags
{
public class TagGrainTests
{
private readonly IStore<string> store = A.Fake<IStore<string>>();
private readonly IPersistence<TagGrain.State> persistence = A.Fake<IPersistence<TagGrain.State>>();
private readonly TagGrain sut;
public TagGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<string>.Ignored, A<Func<TagGrain.State, Task>>.Ignored))
.Returns(persistence);
sut = new TagGrain(store);
sut.OnActivateAsync(string.Empty).Wait();
}
[Fact]
public async Task Should_add_tags_to_grain()
{
var result1 = await sut.NormalizeTagsAsync(HashSet.Of("tag1", "tag2"), null);
var result2 = await sut.NormalizeTagsAsync(HashSet.Of("tag2", "tag3"), null);
var allTags = await sut.GetTagsAsync();
Assert.Equal(new Dictionary<string, int>
{
["tag1"] = 1,
["tag2"] = 2,
["tag3"] = 1
}, allTags);
}
[Fact]
public async Task Should_not_add_tags_if_already_added()
{
var result1 = await sut.NormalizeTagsAsync(HashSet.Of("tag1", "tag2"), null);
var result2 = await sut.NormalizeTagsAsync(HashSet.Of("tag1", "tag2", "tag3"), result1);
var allTags = await sut.GetTagsAsync();
Assert.Equal(new Dictionary<string, int>
{
["tag1"] = 1,
["tag2"] = 1,
["tag3"] = 1
}, allTags);
}
[Fact]
public async Task Should_remove_tags_from_grain()
{
var result1 = await sut.NormalizeTagsAsync(HashSet.Of("tag1", "tag2"), null);
var result2 = await sut.NormalizeTagsAsync(HashSet.Of("tag2", "tag3"), null);
await sut.NormalizeTagsAsync(null, result1);
var allTags = await sut.GetTagsAsync();
Assert.Equal(new Dictionary<string, int>
{
["tag2"] = 1,
["tag3"] = 1
}, allTags);
}
[Fact]
public async Task Should_resolve_tag_names()
{
var tagIds = await sut.NormalizeTagsAsync(HashSet.Of("tag1", "tag2"), null);
var tagNames = await sut.GetTagIdsAsync(HashSet.Of("tag1", "tag2", "invalid1"));
Assert.Equal(tagIds, tagNames);
}
}
}

2
tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs

@ -90,7 +90,7 @@ namespace Squidex.Infrastructure.Commands
}); });
case UpdateCustom updateCustom: case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c => return UpdateAsync(updateCustom, c =>
{ {
RaiseEvent(new ValueChanged { Value = c.Value }); RaiseEvent(new ValueChanged { Value = c.Value });

2
tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs

@ -85,7 +85,7 @@ namespace Squidex.Infrastructure.Commands
}); });
case UpdateCustom updateCustom: case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c => return UpdateAsync(updateCustom, c =>
{ {
RaiseEvent(new ValueChanged { Value = c.Value }); RaiseEvent(new ValueChanged { Value = c.Value });

2
tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs

@ -72,7 +72,7 @@ namespace Squidex.Infrastructure.TestHelpers
}); });
case UpdateCustom updateCustom: case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c => return UpdateAsync(updateCustom, c =>
{ {
RaiseEvent(new ValueChanged { Value = c.Value }); RaiseEvent(new ValueChanged { Value = c.Value });

Loading…
Cancel
Save