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));
}
public JToken Visit(IArrayField field)
{
return value;
}
public JToken Visit(IField<AssetsFieldProperties> field)
{
return CleanIds();
@ -97,5 +92,10 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{
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)
{
if (item.TryGetValue(field.Name, out var value))
if (item.TryGetValue(nestedField.Name, out var 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.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Squidex.Domain.Apps.Core.ValidateContent;
@ -70,6 +71,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
[BsonElement]
public RefToken LastModifiedBy { get; set; }
[BsonIgnoreIfNull]
[BsonElement]
public HashSet<string> Tags { get; set; }
[BsonElement]
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.Repositories;
using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.MongoDb;
@ -22,9 +23,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{
public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository
{
public MongoAssetRepository(IMongoDatabase database)
private readonly ITagService tagService;
public MongoAssetRepository(IMongoDatabase database, ITagService tagService)
: base(database)
{
Guard.NotNull(tagService, nameof(tagService));
this.tagService = tagService;
}
protected override string CollectionName()
@ -40,6 +46,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
.Ascending(x => x.AppId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.FileName)
.Ascending(x => x.Tags)
.Descending(x => x.LastModified)));
}
@ -51,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{
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 contentItems =
@ -63,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Task.WhenAll(contentItems, contentCount);
return ResultList.Create<IAssetEntity>(contentItems.Result, contentCount.Result);
return ResultList.Create<IAssetEntity>(contentCount.Result, contentItems.Result);
}
catch (NotSupportedException)
{
@ -98,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
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.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.OData.UriParser;
using MongoDB.Bson;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb.OData;
@ -18,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
public static class FindExtensions
{
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)
{
@ -47,15 +50,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
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>>
{
Filter.Eq(x => x.IndexedAppId, appId),
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)
{
@ -82,5 +87,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
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);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result);
}
catch (NotSupportedException)
{
@ -106,7 +106,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
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)

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()
.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 =>
{
@ -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);
@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
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>>
{

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

@ -72,14 +72,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
});
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));
AssignContributor(c);
return EntityCreatedResult.Create(c.ContributorId, (long)base.Version);
}));
return EntityCreatedResult.Create(c.ContributorId, Version);
});
case RemoveContributor removeContributor:
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.Guards;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -24,33 +25,40 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
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)
{
Guard.NotNull(tagService, nameof(tagService));
this.tagService = tagService;
}
protected override Task<object> ExecuteAsync(IAggregateCommand command)
{
VerifyNotDeleted();
switch (command)
{
case CreateAsset createRule:
return CreateReturnAsync(createRule, (Func<CreateAsset, object>)(c =>
return CreateReturnAsync(createRule, c =>
{
GuardAsset.CanCreate(c);
Create(c);
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion);
}));
return new AssetSavedResult(Version, Snapshot.FileVersion);
});
case UpdateAsset updateRule:
return UpdateReturnAsync(updateRule, (Func<UpdateAsset, object>)(c =>
return UpdateAsync(updateRule, c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion);
}));
return new AssetSavedResult(Version, Snapshot.FileVersion);
});
case RenameAsset renameAsset:
return UpdateAsync(renameAsset, c =>
{
@ -59,12 +67,23 @@ namespace Squidex.Domain.Apps.Entities.Assets
Rename(c);
});
case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, c =>
return UpdateAsync(deleteAsset, async c =>
{
GuardAsset.CanDelete(c);
await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags);
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:
throw new NotSupportedException();
}
@ -105,18 +124,19 @@ namespace Squidex.Domain.Apps.Entities.Assets
public void Delete(DeleteAsset command)
{
VerifyNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize }));
}
public void Rename(RenameAsset command)
{
VerifyNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new AssetRenamed()));
}
public void Tag(TagAsset command)
{
RaiseEvent(SimpleMapper.Map(command, new AssetTagged()));
}
private void RaiseEvent(AppEvent @event)
{
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.PixelHeight).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");

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));
}
public static void CanTag(TagAsset command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanUpdate(UpdateAsset 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,
IEntityWithLastModifiedBy,
IEntityWithVersion,
IEntityWithTags,
IAssetInfo
{
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.Collections.Generic;
using Newtonsoft.Json;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Events;
@ -49,6 +50,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
[JsonProperty]
public bool IsDeleted { get; set; }
[JsonProperty]
public HashSet<string> Tags { get; set; }
Guid IAssetInfo.AssetId
{
get { return Id; }
@ -70,6 +74,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
TotalSize += @event.FileSize;
}
protected void On(AssetTagged @event)
{
Tags = @event.Tags;
}
protected void On(AssetRenamed @event)
{
FileName = @event.FileName;

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

@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
switch (command)
{
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.");
@ -77,8 +77,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
Create(c);
return EntityCreatedResult.Create(c.Data, (long)base.Version);
}));
return EntityCreatedResult.Create(c.Data, Version);
});
case UpdateContent updateContent:
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;
}
public Task ThrowIfSchemaNotExistsAsync(QueryContext context)
public Task ThrowIfSchemaNotExistsAsync(ContentQueryContext 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));
@ -70,53 +70,47 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var isVersioned = version > EtagVersion.Empty;
var parsedStatus = context.IsFrontendClient ? StatusAll : StatusPublished;
var parsedStatus = context.Base.IsFrontendClient ? StatusAll : StatusPublished;
var content =
isVersioned ?
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));
}
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));
var schema = await GetSchemaAsync(context);
using (Profiler.TraceMethod<ContentQueryService>("QueryAsyncByQuery"))
using (Profiler.TraceMethod<ContentQueryService>())
{
var parsedQuery = ParseQuery(context, query, schema);
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 parsedStatus = ParseStatus(context.Base);
var schema = await GetSchemaAsync(context);
IResultList<IContentEntity> contents;
using (Profiler.TraceMethod<ContentQueryService>("QueryAsyncByIds"))
{
var parsedStatus = ParseStatus(context);
if (query.Ids?.Count > 0)
{
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);
return ResultList.Create(transformed, contents.Total);
return ResultList.Create(contents.Total, transformed);
}
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);
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)
@ -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;
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)
{
schema = await appProvider.GetSchemaAsync(context.App.Id, context.SchemaIdOrName);
schema = await appProvider.GetSchemaAsync(context.Base.App.Id, context.SchemaIdOrName);
}
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 Microsoft.Extensions.Caching.Memory;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Infrastructure;
@ -20,23 +21,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
private readonly IContentQueryService contentQuery;
private readonly IGraphQLUrlGenerator urlGenerator;
private readonly IAssetRepository assetRepository;
private readonly IAssetQueryService assetQuery;
private readonly IAppProvider appProvider;
public CachingGraphQLService(IMemoryCache cache,
IAppProvider appProvider,
IAssetRepository assetRepository,
IAssetQueryService assetQuery,
IContentQueryService contentQuery,
IGraphQLUrlGenerator urlGenerator)
: base(cache)
{
Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(assetRepository, nameof(assetRepository));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery));
Guard.NotNull(urlGenerator, nameof(urlGenerator));
this.appProvider = appProvider;
this.assetRepository = assetRepository;
this.assetQuery = assetQuery;
this.contentQuery = contentQuery;
this.urlGenerator = urlGenerator;
}
@ -53,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
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);
}

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

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

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

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure;
@ -14,12 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
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(QueryContext context);
Task ThrowIfSchemaNotExistsAsync(ContentQueryContext context);
}
}

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

@ -11,7 +11,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Infrastructure;
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, IAssetEntity> cachedAssets = new ConcurrentDictionary<Guid, IAssetEntity>();
private readonly IContentQueryService contentQuery;
private readonly IAssetRepository assetRepository;
private readonly IAssetQueryService assetQuery;
private readonly QueryContext context;
public QueryExecutionContext(QueryContext context,
IAssetRepository assetRepository,
IContentQueryService contentQuery)
public QueryExecutionContext(QueryContext context, IAssetQueryService assetQuery, IContentQueryService contentQuery)
{
Guard.NotNull(assetRepository, nameof(assetRepository));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery));
Guard.NotNull(context, nameof(context));
this.assetRepository = assetRepository;
this.assetQuery = assetQuery;
this.contentQuery = contentQuery;
this.context = context;
}
@ -43,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (asset == null)
{
asset = await assetRepository.FindAssetAsync(id);
asset = await assetQuery.FindAssetAsync(context, id);
if (asset != null)
{
@ -60,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (content == null)
{
content = await contentQuery.FindContentAsync(context.WithSchemaId(schemaId), id);
content = await contentQuery.FindContentAsync(new ContentQueryContext(context).WithSchemaId(schemaId), id);
if (content != null)
{
@ -73,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{
@ -85,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{
@ -103,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{
@ -122,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{

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.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Security.Claims;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
namespace Squidex.Domain.Apps.Entities.Contents
namespace Squidex.Domain.Apps.Entities
{
public sealed class QueryContext : Cloneable<QueryContext>
{
@ -20,38 +19,19 @@ namespace Squidex.Domain.Apps.Entities.Contents
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 Flatten { get; private set; }
public IEnumerable<Language> Languages { get; private set; }
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 };
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;
return new QueryContext { App = app, User = user };
}
public QueryContext WithArchived(bool archived)
@ -64,14 +44,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{
return Clone(c => c.SchemaIdOrName = id.ToString());
foreach (var iso2Code in languageCodes)
{
if (Language.TryGetLanguage(iso2Code, out var language))
{
languages.Add(language);
}
}
c.Languages = languages;
});
}
return this;
}
public bool IsFrontendClient

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

@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
switch (command)
{
case AddField addField:
return UpdateReturnAsync(addField, (Func<AddField, object>)(c =>
return UpdateAsync(addField, 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;
}
return EntityCreatedResult.Create(id, (long)base.Version);
}));
return EntityCreatedResult.Create(id, Version);
});
case CreateSchema createSchema:
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 (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;
try
@ -47,7 +47,7 @@ namespace Squidex.Infrastructure.MongoDb.OData
if (filter != null)
{
return (FilterVisitor<T>.Visit(filter.Expression, propertyCalculator), true);
return (FilterVisitor<T>.Visit(filter.Expression, convertProperty, convertValue), true);
}
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>>
{
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);
}
@ -52,23 +54,26 @@ namespace Squidex.Infrastructure.MongoDb.OData
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))
{
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))
{
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();
@ -107,53 +112,72 @@ namespace Squidex.Infrastructure.MongoDb.OData
{
if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual)
{
var field = BuildFieldDefinition(nodeIn.Left);
var f = BuildFieldDefinition(nodeIn.Left);
var v = BuildValue(f, nodeIn.Right);
return Filter.Or(
Filter.Not(Filter.Exists(field)),
Filter.Ne(field, BuildValue(nodeIn.Right)));
return Filter.Or(Filter.Not(Filter.Exists(f)), Filter.Ne(f, v));
}
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)
{
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)
{
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)
{
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)
{
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();
}
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);
}

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

@ -11,23 +11,23 @@ using MongoDB.Driver;
namespace Squidex.Infrastructure.MongoDb.OData
{
public delegate string PropertyCalculator(string[] parts);
public delegate string ConvertProperty(string[] parts);
public static class PropertyBuilder
{
private static readonly PropertyCalculator DefaultCalculator = parts =>
private static readonly ConvertProperty Default = parts =>
{
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 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 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();
@ -41,9 +41,9 @@ namespace Squidex.Infrastructure.MongoDb.OData
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)
{

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

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

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

@ -14,10 +14,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Assets.Models;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
@ -34,23 +36,49 @@ namespace Squidex.Areas.Api.Controllers.Assets
[SwaggerTag(nameof(Assets))]
public sealed class AssetsController : ApiController
{
private readonly IAssetRepository assetRepository;
private readonly IAssetQueryService assetQuery;
private readonly IAssetStatsRepository assetStatsRepository;
private readonly IAppPlansProvider appPlanProvider;
private readonly ITagService tagService;
private readonly AssetConfig assetsConfig;
public AssetsController(
ICommandBus commandBus,
IAssetRepository assetRepository,
IAssetQueryService assetQuery,
IAssetStatsRepository assetStatsRepository,
IAppPlansProvider appPlanProvider,
IOptions<AssetConfig> assetsConfig)
IOptions<AssetConfig> assetsConfig,
ITagService tagService)
: base(commandBus)
{
this.assetsConfig = assetsConfig.Value;
this.assetRepository = assetRepository;
this.assetQuery = assetQuery;
this.assetStatsRepository = assetStatsRepository;
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>
@ -72,25 +100,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)]
public async Task<IActionResult> GetAssets(string app, [FromQuery] string ids = null)
{
HashSet<Guid> idsList = null;
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 context = Context();
var assets =
idsList?.Count > 0 ?
await assetRepository.QueryAsync(App.Id, idsList) :
await assetRepository.QueryAsync(App.Id, Request.QueryString.ToString());
var assets = await assetQuery.QueryAsync(context, Query.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var response = AssetsDto.FromAssets(assets);
@ -115,7 +127,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)]
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)
{
@ -270,5 +284,10 @@ namespace Squidex.Areas.Api.Controllers.Assets
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.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using NodaTime;
using Squidex.Domain.Apps.Entities.Assets;
@ -39,6 +40,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
public string FileType { get; set; }
/// <summary>
/// The asset tags.
/// </summary>
public HashSet<string> Tags { get; set; }
/// <summary>
/// The size of the file in bytes.
/// </summary>

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

@ -6,9 +6,9 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
@ -20,9 +20,22 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
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 Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
@ -65,7 +66,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)]
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)
{
@ -97,32 +98,14 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)]
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))
{
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 result = await contentQuery.QueryAsync(context, Query.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var response = new ContentsDto
{
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;
@ -157,7 +140,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context().WithSchemaName(name);
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();
@ -193,7 +176,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context().WithSchemaName(name);
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();
@ -498,9 +481,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
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 Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
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.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Indexes;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations;
@ -62,6 +63,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AppProvider>()
.As<IAppProvider>();
services.AddSingletonAs<AssetQueryService>()
.As<IAssetQueryService>();
services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>();
@ -104,6 +108,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainTagService>()
.As<ITagService>();
services.AddSingletonAs<GrainCommandMiddleware<AppCommand, IAppGrain>>()
.As<ICommandMiddleware>();

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

@ -6,7 +6,7 @@
</ng-container>
<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
</button>
@ -14,7 +14,7 @@
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></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" />
</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-panel desiredWidth="*">
<sqx-panel desiredWidth="*" showSidebar="true" sidebarClass="wide">
<ng-container title>
Assets
</ng-container>
<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
</button>
@ -21,4 +21,29 @@
<ng-container content>
<sqx-assets-list [state]="assetsState"></sqx-assets-list>
</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>

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

@ -1,2 +1,26 @@
@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();
}
public resetTags() {
this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe();
}
public toggleTag(tag: string) {
this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe();
}
public goNext() {
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="row">
<div class="drop-area-container">
<div class="drop-area" (sqxFileDrop)="addFiles($event)" (click)="assetsDialog.show()">
Drop files here to add them.
</div>
<div class="drop-area align-items-center" (sqxFileDrop)="addFiles($event)" (click)="assetsDialog.show()">
Drop files here to add them.
</div>
<sqx-asset *ngFor="let file of newAssets" [initFile]="file"
(failed)="onAssetFailed(file)"
(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 border-radius;
@include flex-box;
border: 2px dashed $color-border;
height: $asset-height;
width: $asset-height;
width: $asset-width;
margin-left: 8px;
font-size: 1.2rem;
font-weight: normal;
text-align: center;
padding: 3.5rem 2rem;
padding: 0 2rem;
cursor: pointer;
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)) {
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 => {
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 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
</button>

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

@ -2,15 +2,15 @@
<sqx-panel desiredWidth="60rem" [showSidebar]="true">
<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 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
</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()">
Published
</button>

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

@ -6,7 +6,7 @@
</ng-container>
<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
</button>

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

@ -6,7 +6,7 @@
<div class="form-group mr-1">
<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>
<button type="submit" class="btn btn-primary" [disabled]="!renameForm.form.valid || !renameForm.form.dirty">Save</button>
@ -17,8 +17,8 @@
</form>
<ng-container *ngIf="!isRenaming">
<h3 class="client-name">
<span (dblclick)="toggleRename()">{{client.name}}</span>
<h3 class="client-name" (dblclick)="toggleRename()">
{{client.name}}
</h3>
<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 {
& {
@include border-radius(.25rem);
margin: 0;
margin-left: -.6rem;
height: 2.5rem;
padding: 0 .6rem;
border: 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;
}
padding: .375rem 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1.2rem;
font-weight: normal;
line-height: 1.5rem;
display: inline-block;
margin: 0;
}
&-header {
@ -66,12 +48,15 @@ $color-editor: #eceeef;
}
}
.col-form-label {
text-align: left;
h3 {
&.client-name {
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
}
}
.btn-cancel {
padding: .4rem;
.col-form-label {
text-align: left;
}
.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"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
placeholder="Press enter to add new item">
<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>
placeholder="+Tag">
</div>

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

@ -1,36 +1,76 @@
@import '_mixins';
@import '_vars';
.items {
margin-top: .4rem;
min-height: 1.6rem;
.form-control {
& {
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 {
& {
@include border-radius(.8rem);
@include border-radius(10px);
@include truncate;
display: inline-block;
color: $color-dark-foreground;
margin-right: .4rem;
margin-bottom: .25rem;
min-height: 1.6rem;
cursor: default;
height: 20px;
padding: 0 .6rem;
background: $color-theme-blue;
border: 0;
font-size: .8rem;
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 {
& {
pointer-events: none;
}
&,
&:hover {
background: $color-theme-blue-light;
}
pointer-events: none;
}
&: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.
*/
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 { Types } from '@app/framework/internal';
const KEY_ENTER = 13;
const KEY_SPACE = 32;
const KEY_DELETE = 8;
export interface Converter {
convert(input: string): any;
@ -81,9 +82,17 @@ export class TagEditorComponent implements ControlValueAccessor {
@Input()
public useDefaultValue = true;
@Input()
public class: string;
@Input()
public inputName = 'tag-editor';
@ViewChild('input')
public inputElement: ElementRef;
public hasFocus = false;
public items: any[] = [];
public addInput = new FormControl();
@ -114,27 +123,63 @@ export class TagEditorComponent implements ControlValueAccessor {
this.callTouched = fn;
}
public remove(index: number) {
this.updateItems([...this.items.slice(0, index), ...this.items.splice(index + 1)]);
public focus() {
if (this.addInput.enabled) {
this.hasFocus = true;
}
}
private resetForm() {
this.addInput.reset();
this.adjustSize();
}
public markTouched() {
this.callTouched();
this.hasFocus = false;
}
private resetForm() {
this.addInput.reset();
public remove(index: number) {
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) {
if (event.keyCode === KEY_ENTER) {
if (event.keyCode === KEY_SPACE) {
const value = <string>this.addInput.value;
if (this.converter.isValidInput(value)) {
if (value && this.converter.isValidInput(value)) {
const converted = this.converter.convert(value);
this.updateItems([...this.items, converted]);
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;
}
}
@ -151,4 +196,6 @@ export class TagEditorComponent implements ControlValueAccessor {
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.renderer.listen(this.parent, 'resize', () => {
this.resize(this.parent);
this.resize();
});
}
public ngAfterViewInit() {
this.resize(this.parent);
this.resize();
}
public ngOnChanges() {
@ -75,7 +75,7 @@ export class ImageSourceDirective implements OnChanges, OnDestroy, OnInit, After
this.retryLoadingImage();
}
private resize(parent: any) {
private resize() {
this.size = this.parent.getBoundingClientRect();
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>
</div>
<div class="panel-sidebar" *ngIf="showSidebar">
<div class="panel-sidebar {{sidebarClass}}" *ngIf="showSidebar">
<ng-content select=[sidebar]></ng-content>
</div>
</div>

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

@ -54,6 +54,9 @@ export class PanelComponent implements AfterViewInit, OnDestroy, OnInit {
@Input()
public contentClass = '';
@Input()
public sidebarClass = '';
@ViewChild('panel')
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="file-preview" *ngIf="asset && progress == 0" @fade>
<div class="file-preview" *ngIf="asset && progress === 0" @fade>
<span class="file-type" *ngIf="asset.fileType">
{{asset.fileType}}
</span>
@ -8,82 +8,71 @@
<div *ngIf="asset.isImage" class="file-image">
<img [sqxImageSource]="asset | sqxAssetPreviewUrl">
</div>
<div *ngIf="!asset.isImage" class="file-icon-container">
<img class="file-icon" [attr.src]="asset | sqxFileIcon">
<div *ngIf="!asset.isImage" class="file-icon">
<img [attr.src]="asset | sqxFileIcon">
</div>
<div class="file-overlay">
<div class="file-overlay-background"></div>
<div class="overlay">
<div class="overlay-background"></div>
<div class="file-menu">
<a class="file-edit ml-1" *ngIf="!isDisabled" (click)="renameDialog.show()">
<i class="icon-pencil"></i>
</a>
<a class="file-download ml-1" [attr.href]="asset | sqxAssetUrl" target="_blank" (click)="$event.stopPropagation()">
<div class="overlay-menu">
<a class="file-download" [attr.href]="asset | sqxAssetUrl" target="_blank" (click)="$event.stopPropagation()">
<i class="icon-download"></i>
</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>
</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>
</a>
</div>
<span class="file-overlay-type" *ngIf="asset.fileType">
<span class="overlay-type" *ngIf="asset.fileType">
{{asset.fileType}}
</span>
<span class="file-user">
<i class="icon-user"></i> {{asset.lastModifiedBy | sqxUserNameRef}}
</span>
<span class="file-modified">
{{asset.lastModified | sqxFromNow}}
</span>
<div class="overlay-user">
<div>
<i class="icon-user"></i> {{asset.lastModifiedBy | sqxUserNameRef}}
</div>
<div>
{{asset.lastModified | sqxFromNow}}
</div>
</div>
</div>
</div>
</div>
<div class="card-footer" *ngIf="asset && progress == 0">
<div class="file-name" [attr.title]="asset.fileName">
{{asset.fileName}}
<div class="upload-progress" *ngIf="progress > 0">
<sqx-progress-bar mode="Circle" [value]="progress"></sqx-progress-bar>
</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 class="upload-progress" *ngIf="progress > 0">
<sqx-progress-bar mode="Circle" [value]="progress"></sqx-progress-bar>
</div>
<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 class="card-footer">
<ng-container *ngIf="asset">
<div>
<div *ngIf="!renaming" class="file-name editable" [attr.title]="asset.fileName" (dblclick)="renameStart()">
{{asset.fileName}}
</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>
<button type="reset" class="float-left btn btn-secondary" (click)="cancelRenameAsset()">Cancel</button>
<button type="submit" class="float-right btn btn-success">Rename</button>
</ng-container>
</sqx-modal-dialog>
</form>
</ng-container>
<input type="text" class="form-control form-underlined editable" id="assetName" formControlName="name" autocomplete="off" sqxFocusOnInit (blur)="renameCancel()" />
</form>
</div>
</div>
<div class="file-tags tags">
<sqx-tag-editor [useDefaultValue]="false" [formControl]="tagInput" class="blank"></sqx-tag-editor>
</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 {
& {
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 {
@ -70,49 +49,63 @@
}
}
:host {
padding-bottom: 1rem;
padding-left: 8px;
padding-right: 8px;
}
.card {
& {
@include overlay-container;
height: $asset-height;
width: $asset-width;
}
&.selectable {
cursor: pointer;
&-body,
&-footer {
position: relative;
padding: 1rem;
}
&-body {
position: relative;
padding: 0;
min-height: $asset-header;
max-height: $asset-header;
}
&-footer {
border: 0;
background: transparent;
padding: .8rem;
padding-top: .4rem;
height: 70px;
padding-top: .5rem;
min-height: $asset-footer;
max-height: auto;
}
}
.upload-progress {
@include absolute(2rem, 2rem, 2rem, 2rem);
}
&.drag {
.drop-overlay {
@include opacity(1);
}
.file {
&-info {
font-size: .8rem;
.file-type {
@include opacity(0);
}
}
&-image {
height: 100%;
&.selectable {
cursor: pointer;
}
}
.upload-progress {
@include absolute(1rem, 3em, 1rem, 3rem);
}
.file {
&-preview {
& {
@include absolute(.8rem, .8rem, 0, .8rem);
@include absolute(0, 0, 0, 0);
}
&:hover {
.file-overlay {
.overlay {
@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 {
@include transition(opacity.4s ease);
@include absolute(.7rem, auto, auto, .5rem);
@include transition(opacity .4s ease);
@include absolute(1rem, auto, auto, 1rem);
@include asset-type;
@include border-radius(3px);
background: $color-dark-black;
}
&-name {
&-name,
&-info {
@include truncate;
font-size: 1rem;
font-weight: normal;
line-height: 2rem;
}
&-image,
&-icon {
margin-top: 10%;
margin-bottom: 0;
height: 70%;
height: $asset-image;
}
&-icon-container {
&-name {
line-height: 2rem;
}
&-tags {
margin-bottom: .25rem;
}
&-info {
font-size: .75rem;
}
&-icon {
line-height: $asset-image;
background: $color-border;
border: 0;
text-align: center;
height: 100%;
}
}
&-overlay {
.overlay {
& {
@include overlay;
font-size: .8rem;
font-weight: normal;
}
&-menu {
& {
@include overlay;
font-size: .8rem;
font-weight: normal;
@include absolute(1rem, 1rem, auto, auto);
}
&-type {
@include absolute(.7rem, auto, auto, .5rem);
@include asset-type;
a {
& {
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.
*/
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import {
AppsState,
@ -14,14 +16,14 @@ import {
AssetsService,
AuthService,
DateTime,
DialogModel,
DialogService,
fadeAnimation,
RenameAssetDto,
RenameAssetForm,
Types,
UpdateAssetDto,
Versioned
} from '@app/shared/internal';
import { TagAssetDto } from '@appshared/services/assets.service';
@Component({
selector: 'sqx-asset',
@ -31,7 +33,9 @@ import {
fadeAnimation
]
})
export class AssetComponent implements OnInit {
export class AssetComponent implements OnDestroy, OnInit {
private tagSubscription: Subscription;
@Input()
public initFile: File;
@ -68,9 +72,13 @@ export class AssetComponent implements OnInit {
@Output()
public failed = new EventEmitter();
public renameDialog = new DialogModel();
public renaming = false;
public isTagging = false;
public renameForm = new RenameAssetForm(this.formBuilder);
public tagInput = new FormControl();
public progress = 0;
constructor(
@ -101,6 +109,22 @@ export class AssetComponent implements OnInit {
} else {
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) {
@ -121,17 +145,16 @@ export class AssetComponent implements OnInit {
}
public renameAsset() {
const value = this.renameForm.submit();
const value = this.renameForm.submit(this.asset);
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)
.subscribe(dto => {
this.updateAsset(this.asset.rename(requestDto.fileName, this.authState.user!.token, dto.version), true);
this.renameForm.submitCompleted();
this.renameDialog.hide();
this.renameCancel();
}, 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.renameDialog.hide();
this.renaming = false;
}
private setProgress(progress = 0) {
@ -162,14 +205,15 @@ export class AssetComponent implements OnInit {
}
private updateAsset(asset: AssetDto, emitEvent: boolean) {
this.renameForm.load({ name: asset.fileName });
this.asset = asset;
this.progress = 0;
this.tagInput.setValue(asset.tags);
if (emitEvent) {
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>
<div class="row">
<div class="row assets">
<sqx-asset *ngFor="let file of newFiles" [initFile]="file"
(failed)="remove(file)"
(loaded)="add(file, $event)">
@ -25,6 +25,7 @@
[isDisabled]="isDisabled"
[isSelectable]="selectedIds"
[isSelected]="isSelected(asset)"
(updated)="update($event)"
(selected)="select($event)"
(deleting)="delete($event)">
</sqx-asset>

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

@ -35,6 +35,11 @@
}
}
.assets {
margin-left: -8px;
margin-right: -8px;
}
.btn {
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();
}
public update(asset: AssetDto) {
this.state.update(asset);
}
public trackByAsset(index: number, asset: AssetDto) {
return asset.id;
}

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

@ -16,7 +16,8 @@ import {
AssetsDto,
AssetsService,
DateTime,
UpdateAssetDto,
RenameAssetDto,
TagAssetDto,
Version,
Versioned
} from './../';
@ -30,7 +31,7 @@ describe('AssetDto', () => {
const newVersion = new Version('2');
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);
expect(asset_2.fileName).toEqual('new-name.png');
@ -39,10 +40,20 @@ describe('AssetDto', () => {
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', () => {
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);
expect(asset_2.fileSize).toEqual(2);
@ -79,6 +90,31 @@ describe('AssetsService', () => {
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',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
@ -110,6 +146,7 @@ describe('AssetsService', () => {
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
tags: undefined,
version: 11
},
{
@ -126,6 +163,7 @@ describe('AssetsService', () => {
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
tags: ['tag1', 'tag2'],
version: 22
}
]
@ -145,6 +183,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
[],
'http://service/p/api/assets/id1',
new Version('11')),
new AssetDto('id2', 'Created2', 'LastModifiedBy2',
@ -158,6 +197,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
['tag1', 'tag2'],
'http://service/p/api/assets/id2',
new Version('22'))
]));
@ -190,7 +230,8 @@ describe('AssetsService', () => {
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048
pixelHeight: 2048,
tags: ['tag1', 'tag2']
}, {
headers: {
etag: '2'
@ -210,6 +251,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
['tag1', 'tag2'],
'http://service/p/api/assets/id1',
new Version('2')));
}));
@ -227,10 +269,23 @@ describe('AssetsService', () => {
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',
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');
@ -284,6 +339,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
[],
'http://service/p/api/assets/id1',
new Version('2')));
}));
@ -323,7 +379,22 @@ describe('AssetsService', () => {
it('should make put request to update asset',
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();

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

@ -50,6 +50,7 @@ export class AssetDto extends Model {
public readonly isImage: boolean,
public readonly pixelWidth: number | null,
public readonly pixelHeight: number | null,
public readonly tags: string[],
public readonly url: string,
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 {
return this.with({
fileName,
@ -79,13 +89,20 @@ export class AssetDto extends Model {
}
}
export class UpdateAssetDto {
export class RenameAssetDto {
constructor(
public readonly fileName: string
) {
}
}
export class TagAssetDto {
constructor(
public readonly tags: string[]
) {
}
}
export class AssetReplacedDto {
constructor(
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 = '';
if (ids) {
@ -115,8 +139,22 @@ export class AssetsService {
} else {
const queries: string[] = [];
const filters: string[] = [];
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}`);
@ -125,7 +163,6 @@ export class AssetsService {
fullQuery = queries.join('&');
}
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets?${fullQuery}`);
return HTTP.getVersioned<any>(this.http, url).pipe(
@ -151,6 +188,7 @@ export class AssetsService {
item.isImage,
item.pixelWidth,
item.pixelHeight,
item.tags || [],
assetUrl,
new Version(item.version.toString()));
}));
@ -194,6 +232,7 @@ export class AssetsService {
response.isImage,
response.pixelWidth,
response.pixelHeight,
[],
assetUrl,
new Version(event.headers.get('etag')!));
@ -231,6 +270,7 @@ export class AssetsService {
body.isImage,
body.pixelWidth,
body.pixelHeight,
body.tags || [],
assetUrl,
response.version);
}),
@ -288,7 +328,7 @@ export class AssetsService {
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}`);
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 { AssetDto } from './../services/assets.service';
export class RenameAssetForm extends Form<FormGroup> {
constructor(formBuilder: FormBuilder) {
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 oldAssets = [
new AssetDto('id1', creator, creator, creation, creation, 'name1', 'type1', 1, 1, 'mime1', false, null, null, 'url1', version),
new AssetDto('id2', creator, creator, creation, creation, 'name2', 'type2', 2, 2, 'mime2', false, null, null, 'url2', 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, ['tag2', 'shared'], 'url2', version)
];
let dialogs: IMock<DialogService>;
@ -49,9 +49,12 @@ describe('AssetsState', () => {
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)));
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 }));
assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object);
assetsState.load().subscribe();
});
@ -63,7 +66,8 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.assetsPager.numberOfItems).toEqual(200);
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());
});
@ -77,7 +81,7 @@ describe('AssetsState', () => {
});
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);
@ -86,13 +90,14 @@ describe('AssetsState', () => {
});
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);
const asset_1 = assetsState.snapshot.assets.at(0);
expect(asset_1).toBe(newAsset);
expect(assetsState.snapshot.tags).toEqual({ tag2: 1, shared: 1, new: 1 });
});
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.assetsPager.numberOfItems).toBe(199);
expect(assetsState.snapshot.tags).toEqual({ shared: 1, tag2: 1 });
});
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, [])));
assetsState.goNext().subscribe();
@ -114,18 +120,40 @@ describe('AssetsState', () => {
expect().nothing();
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, 30, undefined, []), Times.once());
assetsService.verify(x => x.getAssets(app, 30, 0, undefined, []), Times.exactly(2));
});
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, [])));
assetsState.search('my-query').subscribe();
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 { Observable } from 'rxjs';
import { combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
@ -21,6 +21,9 @@ import { AssetDto, AssetsService} from './../services/assets.service';
import { AppsState } from './apps.state';
interface Snapshot {
tags: { [name: string]: number };
tagsSelected: { [name: string]: boolean };
assets: ImmutableArray<AssetDto>;
assetsPager: Pager;
assetsQuery?: string;
@ -30,6 +33,10 @@ interface Snapshot {
@Injectable()
export class AssetsState extends State<Snapshot> {
public tags =
this.changes.pipe(map(x => x.tags),
distinctUntilChanged());
public assets =
this.changes.pipe(map(x => x.assets),
distinctUntilChanged());
@ -47,7 +54,7 @@ export class AssetsState extends State<Snapshot> {
private readonly assetsService: AssetsService,
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> {
@ -59,17 +66,24 @@ export class AssetsState extends State<Snapshot> {
}
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 => {
if (isReload) {
this.dialogs.notifyInfo('Assets reloaded.');
}
this.next(s => {
const assets = ImmutableArray.of(dtos.items);
const assetsPager = s.assetsPager.setCount(dtos.total);
const assets = ImmutableArray.of(dtos[0].items);
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));
@ -86,12 +100,24 @@ export class AssetsState extends State<Snapshot> {
public delete(asset: AssetDto): Observable<any> {
return this.assetsService.deleteAsset(this.appName, asset.id, asset.version).pipe(
tap(dto => {
tap(() => {
return this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id);
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));
@ -99,10 +125,58 @@ export class AssetsState extends State<Snapshot> {
public update(asset: AssetDto) {
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);
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> {
@ -123,6 +197,14 @@ export class AssetsState extends State<Snapshot> {
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() {
return this.appsState.appName;
}

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

@ -4,16 +4,18 @@
//
// Support for Angular validation states.
//
.ng-invalid {
&.ng-dirty {
& {
border-color: $color-theme-error;
}
&:hover,
&:focus {
@include box-shadow-colored(0, 0, .2rem, $color-theme-error);
border-color: $color-theme-error-dark;
.form-control {
&.ng-invalid {
&.ng-dirty {
& {
border-color: $color-theme-error;
}
&:hover,
&:focus {
@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;
}
}
.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;
}
&.wide {
min-width: 16rem;
max-width: 16rem;
}
& .panel-link {
& {
@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-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)
.AddAssets(5, "assets1", Partitioning.Invariant)
.AddAssets(6, "assets2", Partitioning.Invariant)
.AddArray(7, "array", Partitioning.Invariant, a => a
.AddAssets(71, "assets71"))
.AddJson(4, "json", Partitioning.Language)
.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 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);
}
@ -154,11 +156,32 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
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);
}
[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]
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,
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);
}
@ -228,7 +251,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.References(1, "my-refs", Partitioning.Invariant,
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);
}
@ -242,7 +265,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var sut = Fields.References(1, "my-refs", Partitioning.Invariant);
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);
}

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

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

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

@ -13,6 +13,7 @@ using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
@ -26,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly Guid assetId = Guid.NewGuid();
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);
asset = new AssetGrain(Store, A.Dummy<ISemanticLog>());
asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
asset.OnActivateAsync(Id).Wait();
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 Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -23,6 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetGrainTests : HandlerTestBase<AssetGrain, AssetState>
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly ImageInfo image = new ImageInfo(2048, 2048);
private readonly Guid assetId = Guid.NewGuid();
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()
{
sut = new AssetGrain(Store, A.Dummy<ISemanticLog>());
sut = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
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.
// ==========================================================================
using System;
using System.Collections.Generic;
using FakeItEasy;
using Microsoft.OData.Edm;
using MongoDB.Bson.Serialization;
@ -12,6 +14,8 @@ using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Assets.Edm;
using Squidex.Domain.Apps.Entities.MongoDb.Assets;
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.OData;
using Xunit;
@ -20,15 +24,26 @@ namespace Squidex.Domain.Apps.Entities.Assets.OData
{
public class ODataQueryTests
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry;
private readonly IBsonSerializer<MongoAssetEntity> serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoAssetEntity>();
private readonly IEdmModel edmModel = EdmAssetModel.Edm;
private readonly Guid appId = Guid.NewGuid();
private readonly ConvertValue valueConverter;
static ODataQueryTests()
{
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]
public void Should_parse_query()
{
@ -82,6 +97,24 @@ namespace Squidex.Domain.Apps.Entities.Assets.OData
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]
public void Should_make_query_with_fileName()
{
@ -246,7 +279,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.OData
var parser = edmModel.ParseQuery(value);
var query =
parser.BuildFilter<MongoAssetEntity>()
parser.BuildFilter<MongoAssetEntity>(convertValue: valueConverter)
.Filter.Render(serializer, registry).ToString();
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 ClaimsIdentity identity = new ClaimsIdentity();
private readonly EdmModelBuilder modelBuilder = A.Fake<EdmModelBuilder>();
private readonly QueryContext context;
private readonly ContentQueryContext context;
private readonly ContentQueryService sut;
public ContentQueryServiceTests()
@ -58,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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);
}
@ -185,9 +185,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns(schema);
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(content.Id, result[0].Id);
@ -215,7 +215,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => modelBuilder.BuildEdmModel(schema, app))
.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[]
@ -239,9 +239,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns(schema);
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(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 assets = new List<IAssetEntity> { asset };
A.CallTo(() => assetRepository.QueryAsync(app.Id, "?$take=30&$skip=5&$search=my-query"))
.Returns(ResultList.Create(assets, 0));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$take=30&$skip=5&$search=my-query")))
.Returns(ResultList.Create(0, asset));
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 assets = new List<IAssetEntity> { asset };
A.CallTo(() => assetRepository.QueryAsync(app.Id, "?$take=30&$skip=5&$search=my-query"))
.Returns(ResultList.Create(assets, 10));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$take=30&$skip=5&$search=my-query")))
.Returns(ResultList.Create(10, asset));
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);
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 contents = new List<IContentEntity> { content };
A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), "?$top=30&$skip=5"))
.Returns(ResultList.Create(contents, 0));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5")))
.Returns(ResultList.Create(0, content));
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 contents = new List<IContentEntity> { content };
A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), "?$top=30&$skip=5"))
.Returns(ResultList.Create(contents, 10));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Query>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5")))
.Returns(ResultList.Create(10, content));
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);
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(ContextMatch(), contentId, EtagVersion.Any))
A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), contentId, EtagVersion.Any))
.Returns(content);
A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), A<IList<Guid>>.That.IsSameSequenceAs(new[] { contentRefId })))
.Returns(ResultList.Create(refContents, 0));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Query>.Ignored))
.Returns(ResultList.Create(0, contentRef));
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(ContextMatch(), contentId, EtagVersion.Any))
A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), contentId, EtagVersion.Any))
.Returns(content);
A.CallTo(() => assetRepository.QueryAsync(app.Id, A<HashSet<Guid>>.That.Matches(x => x.Contains(assetRefId))))
.Returns(ResultList.Create(refAssets, 0));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A<Query>.Ignored))
.Returns(ResultList.Create(0, assetRef));
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);
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);
}
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 readonly Schema schemaDef;
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 IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
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);
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)

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

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using NodaTime;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure;
@ -28,6 +29,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
public RefToken LastModifiedBy { get; set; }
public HashSet<string> Tags { get; set; }
public long Version { 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:
return UpdateReturnAsync(updateCustom, c =>
return UpdateAsync(updateCustom, c =>
{
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:
return UpdateReturnAsync(updateCustom, c =>
return UpdateAsync(updateCustom, c =>
{
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:
return UpdateReturnAsync(updateCustom, c =>
return UpdateAsync(updateCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });

Loading…
Cancel
Save