diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs index 4c072e9ab..a817aba20 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs +++ b/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 field) { return CleanIds(); @@ -97,5 +92,10 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { return value; } + + public JToken Visit(IArrayField field) + { + return value; + } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs index c39dc66fd..663f1f529 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs +++ b/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))); } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 0691fb391..5d4e69f60 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/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 Tags { get; set; } + [BsonElement] public bool IsDeleted { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 6763827a2..d3e85b6e8 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/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, 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(contentItems.Result, contentCount.Result); + return ResultList.Create(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().ToList(), assetCount.Result); + return ResultList.Create(assetCount.Result, assetItems.Result.OfType()); } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index 914c1b1f2..3ba817edf 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/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 Filter = Builders.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 BuildQuery(ODataUriParser query, Guid appId) + public static FilterDefinition BuildQuery(ODataUriParser query, Guid appId, ITagService tagService) { + var convertValue = CreateValueConverter(appId, tagService); + var filters = new List> { Filter.Eq(x => x.IndexedAppId, appId), Filter.Eq(x => x.IsDeleted, false) }; - var filter = query.BuildFilter(PropertyCalculator, false); + var filter = query.BuildFilter(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; + }); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 1195376f5..ad6854d8b 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/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(contentItems.Result, contentCount.Result); + return ResultList.Create(contentCount.Result, contentItems.Result); } catch (NotSupportedException) { @@ -106,7 +106,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents entity.ParseData(schema.SchemaDef); } - return ResultList.Create(contentItems.Result, contentCount.Result); + return ResultList.Create(contentCount.Result, contentItems.Result); } public Task CleanupAsync(Guid id) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs index 35f218538..de6b94581 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/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()?.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 ContentSort(this IFindFluent cursor, ODataUriParser query, PropertyCalculator propertyCalculator) + public static IFindFluent ContentSort(this IFindFluent cursor, ODataUriParser query, ConvertProperty propertyCalculator) { var sort = query.BuildSort(propertyCalculator); @@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors return cursor.Skip(query); } - public static FilterDefinition BuildQuery(ODataUriParser query, Guid schemaId, Status[] status, PropertyCalculator propertyCalculator) + public static FilterDefinition BuildQuery(ODataUriParser query, Guid schemaId, Status[] status, ConvertProperty propertyCalculator) { var filters = new List> { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 4ebf19747..c45e1bd03 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/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>)(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 => diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs index 15d059cf1..fac3ca651 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs +++ b/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, IAssetGrain { - public AssetGrain(IStore store, ISemanticLog log) + private readonly ITagService tagService; + + public AssetGrain(IStore store, ITagService tagService, ISemanticLog log) : base(store, log) { + Guard.NotNull(tagService, nameof(tagService)); + + this.tagService = tagService; } protected override Task ExecuteAsync(IAggregateCommand command) { + VerifyNotDeleted(); + switch (command) { case CreateAsset createRule: - return CreateReturnAsync(createRule, (Func)(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)(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) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs new file mode 100644 index 000000000..739b70cff --- /dev/null +++ b/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 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> QueryAsync(QueryContext context, Query query) + { + Guard.NotNull(context, nameof(context)); + Guard.NotNull(query, nameof(query)); + + IResultList assets; + + if (query.Ids != null) + { + assets = await assetRepository.QueryAsync(context.App.Id, new HashSet(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 Sort(IResultList assets, IList 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 assets) + { + var tags = new HashSet(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(); + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs new file mode 100644 index 000000000..7ca591f53 --- /dev/null +++ b/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 Tags { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs index ffb58c065..b6b77f59a 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs +++ b/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"); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs index dc63eb46b..f9970aa1f 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs +++ b/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)); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs index c61c52cc0..02890b87e 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs @@ -16,6 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Assets IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion, + IEntityWithTags, IAssetInfo { NamedId AppId { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs new file mode 100644 index 000000000..e0068df52 --- /dev/null +++ b/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> QueryAsync(QueryContext contex, Query query); + + Task FindAssetAsync(QueryContext context, Guid id); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs index d1cf6df53..defa18436 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs +++ b/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 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; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 16f9841ea..615d438de 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/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>)(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 => diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs new file mode 100644 index 000000000..f7461d617 --- /dev/null +++ b/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 + { + 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()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index 5bcb4c004..032ab7c36 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/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 FindContentAsync(QueryContext context, Guid id, long version = -1) + public async Task 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> QueryAsync(QueryContext context, string query) + public async Task> QueryAsync(ContentQueryContext context, Query query) { Guard.NotNull(context, nameof(context)); var schema = await GetSchemaAsync(context); - using (Profiler.TraceMethod("QueryAsyncByQuery")) + using (Profiler.TraceMethod()) { - 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> QueryAsync(QueryContext context, IList ids) - { - Guard.NotNull(context, nameof(context)); - Guard.NotNull(ids, nameof(ids)); + var parsedStatus = ParseStatus(context.Base); - var schema = await GetSchemaAsync(context); + IResultList contents; - using (Profiler.TraceMethod("QueryAsyncByIds")) - { - var parsedStatus = ParseStatus(context); + if (query.Ids?.Count > 0) + { + contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, new HashSet(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(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)contents); - return ResultList.Create(transformed, contents.Total); + return ResultList.Create(contents.Total, transformed); } private IResultList Sort(IResultList contents, IList 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 Transform(QueryContext context, ISchemaEntity schema, bool checkType, IEnumerable contents) @@ -218,18 +212,18 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public async Task GetSchemaAsync(QueryContext context) + public async Task 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) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 68bad5840..9ec78bc7c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/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); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index 31acd1523..2c1242877 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/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; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index 99658ba3b..55ec74748 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/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> QueryAsync(QueryContext context, IList ids); + Task> QueryAsync(ContentQueryContext context, Query query); - Task> QueryAsync(QueryContext context, string query); + Task FindContentAsync(ContentQueryContext context, Guid id, long version = EtagVersion.Any); - Task FindContentAsync(QueryContext context, Guid id, long version = EtagVersion.Any); - - Task ThrowIfSchemaNotExistsAsync(QueryContext context); + Task ThrowIfSchemaNotExistsAsync(ContentQueryContext context); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs index fa8764f53..fecb6eb5a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs +++ b/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 cachedContents = new ConcurrentDictionary(); private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); 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> 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> 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) { diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs new file mode 100644 index 000000000..1049ae23d --- /dev/null +++ b/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 Tags { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Query.cs b/src/Squidex.Domain.Apps.Entities/Query.cs new file mode 100644 index 000000000..a6afff384 --- /dev/null +++ b/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 + { + public static readonly Query Empty = new Query(); + + public List 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 ids) + { + return Clone(c => c.Ids = ids.ToList()); + } + + public Query WithIds(string ids) + { + if (!string.IsNullOrEmpty(ids)) + { + return Clone(c => + { + c.Ids = new List(); + + foreach (var id in ids.Split(',')) + { + if (Guid.TryParse(id, out var guid)) + { + c.Ids.Add(guid); + } + } + }); + } + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/QueryContext.cs similarity index 65% rename from src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs rename to src/Squidex.Domain.Apps.Entities/QueryContext.cs index b8754a86b..8c86dbc30 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs +++ b/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 { @@ -20,38 +19,19 @@ namespace Squidex.Domain.Apps.Entities.Contents public IAppEntity App { get; private set; } - public IEnumerable Languages { get; private set; } - - public string SchemaIdOrName { get; private set; } - public bool Archived { get; private set; } public bool Flatten { get; private set; } + public IEnumerable Languages { get; private set; } + private QueryContext() { } - public static QueryContext Create(IAppEntity app, ClaimsPrincipal user, IEnumerable 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(); - - 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 languageCodes) { - return Clone(c => c.SchemaIdOrName = name); - } + if (languageCodes != null) + { + return Clone(c => + { + var languages = new List(); - 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 diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index 2d6924531..879282c22 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/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)(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 => diff --git a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs new file mode 100644 index 000000000..ff869cf80 --- /dev/null +++ b/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> NormalizeTagsAsync(Guid appId, string category, HashSet names, HashSet ids) + { + return GetGrain(appId, category).NormalizeTagsAsync(names, ids); + } + + public Task> GetTagIdsAsync(Guid appId, string category, HashSet names) + { + return GetGrain(appId, category).GetTagIdsAsync(names); + } + + public Task> DenormalizeTagsAsync(Guid appId, string category, HashSet ids) + { + return GetGrain(appId, category).DenormalizeTagsAsync(ids); + } + + public Task> 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($"{appId}_{category}"); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs new file mode 100644 index 000000000..a37bf83cb --- /dev/null +++ b/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> NormalizeTagsAsync(HashSet names, HashSet ids); + + Task> GetTagIdsAsync(HashSet names); + + Task> DenormalizeTagsAsync(HashSet ids); + + Task> GetTagsAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs new file mode 100644 index 000000000..52dae1dc4 --- /dev/null +++ b/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> NormalizeTagsAsync(Guid appId, string category, HashSet names, HashSet ids); + + Task> GetTagIdsAsync(Guid appId, string category, HashSet names); + + Task> DenormalizeTagsAsync(Guid appId, string category, HashSet ids); + + Task> GetTagsAsync(Guid appId, string category); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs new file mode 100644 index 000000000..3517b9476 --- /dev/null +++ b/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 store; + private IPersistence persistence; + private State state = new State(); + + [CollectionName("Index_Tags")] + public sealed class State + { + public Dictionary Tags { get; set; } = new Dictionary(); + } + + public sealed class TagInfo + { + public string Name { get; set; } + + public int Count { get; set; } = 1; + } + + public TagGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(string key) + { + persistence = store.WithSnapshots(key, s => + { + state = s; + }); + + return persistence.ReadAsync(); + } + + public async Task> NormalizeTagsAsync(HashSet names, HashSet ids) + { + var result = new HashSet(); + + 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> GetTagIdsAsync(HashSet names) + { + var result = new HashSet(); + + 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> DenormalizeTagsAsync(HashSet ids) + { + var result = new Dictionary(); + + foreach (var id in ids) + { + if (state.Tags.TryGetValue(id, out var tagInfo)) + { + result[id] = tagInfo.Name; + } + } + + return Task.FromResult(result); + } + + public Task> GetTagsAsync() + { + return Task.FromResult(state.Tags.Values.ToDictionary(x => x.Name, x => x.Count)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGroups.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGroups.cs new file mode 100644 index 000000000..99742c077 --- /dev/null +++ b/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"; + } +} diff --git a/src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs b/src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs new file mode 100644 index 000000000..fb555b515 --- /dev/null +++ b/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 Tags { get; set; } + } +} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs index ffc82c1dc..c0562708e 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs @@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.MongoDb.OData { public static class FilterBuilder { - public static (FilterDefinition Filter, bool Last) BuildFilter(this ODataUriParser query, PropertyCalculator propertyCalculator = null, bool supportsSearch = true) + public static (FilterDefinition Filter, bool Last) BuildFilter(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.Visit(filter.Expression, propertyCalculator), true); + return (FilterVisitor.Visit(filter.Expression, convertProperty, convertValue), true); } return (null, false); diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs index cfe498c4e..826068b0e 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs @@ -16,16 +16,18 @@ namespace Squidex.Infrastructure.MongoDb.OData public sealed class FilterVisitor : QueryNodeVisitor> { private static readonly FilterDefinitionBuilder Filter = Builders.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 Visit(QueryNode node, PropertyCalculator propertyCalculator) + public static FilterDefinition Visit(QueryNode node, ConvertProperty propertyCalculator, ConvertValue convertValue) { - var visitor = new FilterVisitor(propertyCalculator); + var visitor = new FilterVisitor(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 formatter) + private BsonRegularExpression BuildRegex(string field, QueryNode node, Func 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 BuildFieldDefinition(QueryNode nodeIn) + private object BuildValue(string field, QueryNode nodeIn) { - return nodeIn.BuildFieldDefinition(propertyCalculator); + return ValueConversion.Convert(field, ConstantVisitor.Visit(nodeIn), convertValue); } - private static object BuildValue(QueryNode nodeIn) + private object BuildValue(QueryNode nodeIn) { return ConstantVisitor.Visit(nodeIn); } diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs index ede103c64..c1fa51466 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs +++ b/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 BuildFieldDefinition(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(propertyName); + return propertyName; } } } diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs index c19ca4305..9a43597e1 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs @@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.MongoDb.OData { public static class SortBuilder { - public static SortDefinition BuildSort(this ODataUriParser query, PropertyCalculator propertyCalculator = null) + public static SortDefinition BuildSort(this ODataUriParser query, ConvertProperty propertyCalculator = null) { var orderBy = query.ParseOrderBy(); @@ -41,9 +41,9 @@ namespace Squidex.Infrastructure.MongoDb.OData return null; } - public static SortDefinition OrderBy(OrderByClause clause, PropertyCalculator propertyCalculator = null) + public static SortDefinition OrderBy(OrderByClause clause, ConvertProperty propertyCalculator = null) { - var propertyName = clause.Expression.BuildFieldDefinition(propertyCalculator); + var propertyName = clause.Expression.BuildFieldDefinition(propertyCalculator); if (clause.Direction == OrderByDirection.Ascending) { diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ValueConversion.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ValueConversion.cs new file mode 100644 index 000000000..8492c7098 --- /dev/null +++ b/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); + } + } +} diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs index 042f6d4e5..a6b542645 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs @@ -104,7 +104,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler, true); } - protected Task UpdateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToAsync(), true); } diff --git a/src/Squidex.Infrastructure/HashSet.cs b/src/Squidex.Infrastructure/HashSet.cs new file mode 100644 index 000000000..697d2bb39 --- /dev/null +++ b/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 Of(params T[] items) + { + return new HashSet(items); + } + } +} diff --git a/src/Squidex.Infrastructure/ResultList.cs b/src/Squidex.Infrastructure/ResultList.cs index 07d80e75b..957ecc48b 100644 --- a/src/Squidex.Infrastructure/ResultList.cs +++ b/src/Squidex.Infrastructure/ResultList.cs @@ -22,7 +22,12 @@ namespace Squidex.Infrastructure } } - public static IResultList Create(IEnumerable items, long total) + public static IResultList Create(long total, IEnumerable items) + { + return new Impl(items, total); + } + + public static IResultList Create(long total, params T[] items) { return new Impl(items, total); } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index a8f064eb3..4633f0c14 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/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 assetsConfig) + IOptions assetsConfig, + ITagService tagService) : base(commandBus) { this.assetsConfig = assetsConfig.Value; - this.assetRepository = assetRepository; + this.assetQuery = assetQuery; this.assetStatsRepository = assetStatsRepository; this.appPlanProvider = appPlanProvider; + this.tagService = tagService; + } + + /// + /// Get assets tags. + /// + /// The name of the app. + /// + /// 200 => Assets returned. + /// 404 => App not found. + /// + /// + /// Get all tags for assets. + /// + [MustBeAppReader] + [HttpGet] + [Route("apps/{app}/assets/tags")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ApiCosts(1)] + public async Task GetTags(string app) + { + var response = await tagService.GetTagsAsync(App.Id, TagGroups.Assets); + + return Ok(response); } /// @@ -72,25 +100,9 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetAssets(string app, [FromQuery] string ids = null) { - HashSet idsList = null; - - if (!string.IsNullOrWhiteSpace(ids)) - { - idsList = new HashSet(); - - 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 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); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index 0521a1f59..0b3f722de 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/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; } + /// + /// The asset tags. + /// + public HashSet Tags { get; set; } + /// /// The size of the file in bytes. /// diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs index ce03091f7..61b4e956b 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs +++ b/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) + /// + /// The new asset tags. + /// + [Required] + public HashSet 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 }; + } } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index dbf2e37e1..75320903c 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/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 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 GetContents(string app, string name, [FromQuery] bool archived = false, [FromQuery] string ids = null) { - List idsList = null; + var context = Context().WithArchived(archived).WithSchemaName(name); - if (!string.IsNullOrWhiteSpace(ids)) - { - idsList = new List(); - - 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")); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 0d3efa9a7..97093cdc2 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/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; diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 140a66cc6..973b592c5 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/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() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -104,6 +108,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs>() .As(); diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index e95273ecc..d9388abb1 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -6,7 +6,7 @@ - @@ -14,7 +14,7 @@ -
+
diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index cdde0ccfa..9118b9986 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -1,12 +1,12 @@ - + Assets - @@ -21,4 +21,29 @@ + + + + + + diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.scss b/src/Squidex/app/features/assets/pages/assets-page.component.scss index fbb752506..550096d63 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.scss +++ b/src/Squidex/app/features/assets/pages/assets-page.component.scss @@ -1,2 +1,26 @@ @import '_vars'; -@import '_mixins'; \ No newline at end of file +@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; +} \ No newline at end of file diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.ts b/src/Squidex/app/features/assets/pages/assets-page.component.ts index 18173f82b..3a3e18f69 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.ts +++ b/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(); } diff --git a/src/Squidex/app/features/content/shared/assets-editor.component.html b/src/Squidex/app/features/content/shared/assets-editor.component.html index 92528dafc..8e8499fbd 100644 --- a/src/Squidex/app/features/content/shared/assets-editor.component.html +++ b/src/Squidex/app/features/content/shared/assets-editor.component.html @@ -1,11 +1,9 @@
-
-
- Drop files here to add them. -
+
+ Drop files here to add them.
- + diff --git a/src/Squidex/app/features/content/shared/assets-editor.component.scss b/src/Squidex/app/features/content/shared/assets-editor.component.scss index b0622dc37..30c6f3189 100644 --- a/src/Squidex/app/features/content/shared/assets-editor.component.scss +++ b/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%); } diff --git a/src/Squidex/app/features/content/shared/assets-editor.component.ts b/src/Squidex/app/features/content/shared/assets-editor.component.ts index 7092cfcf7..9eaa13625 100644 --- a/src/Squidex/app/features/content/shared/assets-editor.component.ts +++ b/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!)); diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index fe785f64d..b6c112f2c 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -6,7 +6,7 @@ - diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html index 722f32f07..0aa4c77be 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html @@ -2,15 +2,15 @@ - {{schema.displayName}} + {{schema.displayName}} - -
+
diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index 9e302bf28..d413f2ba4 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -6,7 +6,7 @@ - diff --git a/src/Squidex/app/features/settings/pages/clients/client.component.html b/src/Squidex/app/features/settings/pages/clients/client.component.html index ac0955aa2..39ce830d4 100644 --- a/src/Squidex/app/features/settings/pages/clients/client.component.html +++ b/src/Squidex/app/features/settings/pages/clients/client.component.html @@ -6,7 +6,7 @@
- +
@@ -17,8 +17,8 @@ -

- {{client.name}} +

+ {{client.name}}

diff --git a/src/Squidex/app/features/settings/pages/clients/client.component.scss b/src/Squidex/app/features/settings/pages/clients/client.component.scss index 6dfdfcfa1..ec20b3cba 100644 --- a/src/Squidex/app/features/settings/pages/clients/client.component.scss +++ b/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 { diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.html b/src/Squidex/app/framework/angular/forms/tag-editor.component.html index 860104663..4320d4627 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.html +++ b/src/Squidex/app/framework/angular/forms/tag-editor.component.html @@ -1,12 +1,15 @@ - + + {{item}} + + + - -
- - {{item}} - -
\ No newline at end of file + placeholder="+Tag"> +
\ No newline at end of file diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.scss b/src/Squidex/app/framework/angular/forms/tag-editor.component.scss index 024cc5ca8..f92d6a67f 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.scss +++ b/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 { diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.ts b/src/Squidex/app/framework/angular/forms/tag-editor.component.ts index 6fd3fff59..7ff036436 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.ts +++ b/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 = ((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 = 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 = 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); } } -} \ No newline at end of file +} + +let canvas: HTMLCanvasElement | null = null; \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/image-source.directive.ts b/src/Squidex/app/framework/angular/image-source.directive.ts index 752584091..2bc972977 100644 --- a/src/Squidex/app/framework/angular/image-source.directive.ts +++ b/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'); diff --git a/src/Squidex/app/framework/angular/panel.component.html b/src/Squidex/app/framework/angular/panel.component.html index bdad29894..26729563d 100644 --- a/src/Squidex/app/framework/angular/panel.component.html +++ b/src/Squidex/app/framework/angular/panel.component.html @@ -30,7 +30,7 @@
-
+
diff --git a/src/Squidex/app/framework/angular/panel.component.ts b/src/Squidex/app/framework/angular/panel.component.ts index f0317de6a..7c70ef8fc 100644 --- a/src/Squidex/app/framework/angular/panel.component.ts +++ b/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; diff --git a/src/Squidex/app/shared/components/asset.component.html b/src/Squidex/app/shared/components/asset.component.html index 1a25cb889..ee2c23aa2 100644 --- a/src/Squidex/app/shared/components/asset.component.html +++ b/src/Squidex/app/shared/components/asset.component.html @@ -1,6 +1,6 @@ -
+
-
+
{{asset.fileType}} @@ -8,82 +8,71 @@
-
- +
+
-
-
+
+
-
- - - - + - + {{asset.fileType}} - - {{asset.lastModifiedBy | sqxUserNameRef}} - - - {{asset.lastModified | sqxFromNow}} - + +
+
+ {{asset.lastModifiedBy | sqxUserNameRef}} +
+
+ {{asset.lastModified | sqxFromNow}} +
+
-
- -
+
@@ -25,6 +25,7 @@ [isDisabled]="isDisabled" [isSelectable]="selectedIds" [isSelected]="isSelected(asset)" + (updated)="update($event)" (selected)="select($event)" (deleting)="delete($event)"> diff --git a/src/Squidex/app/shared/components/assets-list.component.scss b/src/Squidex/app/shared/components/assets-list.component.scss index ccd708293..5c9a8cbca 100644 --- a/src/Squidex/app/shared/components/assets-list.component.scss +++ b/src/Squidex/app/shared/components/assets-list.component.scss @@ -35,6 +35,11 @@ } } +.assets { + margin-left: -8px; + margin-right: -8px; +} + .btn { cursor: default; } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/assets-list.component.ts b/src/Squidex/app/shared/components/assets-list.component.ts index e7dd32ce6..d8fce26de 100644 --- a/src/Squidex/app/shared/components/assets-list.component.ts +++ b/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; } diff --git a/src/Squidex/app/shared/services/assets.service.spec.ts b/src/Squidex/app/shared/services/assets.service.spec.ts index 3021e91f3..f4fbd73da 100644 --- a/src/Squidex/app/shared/services/assets.service.spec.ts +++ b/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(); diff --git a/src/Squidex/app/shared/services/assets.service.ts b/src/Squidex/app/shared/services/assets.service.ts index b3601f6e3..9372aa82d 100644 --- a/src/Squidex/app/shared/services/assets.service.ts +++ b/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 { + 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 => response)); + } + + public getAssets(appName: string, take: number, skip: number, query?: string, tags?: string[], ids?: string[]): Observable { 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(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> { + public putAsset(appName: string, id: string, dto: RenameAssetDto | TagAssetDto, version: Version): Observable> { const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`); return HTTP.putVersioned(this.http, url, dto, version).pipe( diff --git a/src/Squidex/app/shared/state/assets.forms.ts b/src/Squidex/app/shared/state/assets.forms.ts index b6a23c236..3cc5426a2 100644 --- a/src/Squidex/app/shared/state/assets.forms.ts +++ b/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 { constructor(formBuilder: FormBuilder) { super(formBuilder.group({ @@ -19,4 +21,28 @@ export class RenameAssetForm extends Form { ] })); } + + 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 }); + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/assets.state.spec.ts b/src/Squidex/app/shared/state/assets.state.spec.ts index 08fc6fea5..5695f5619 100644 --- a/src/Squidex/app/shared/state/assets.state.spec.ts +++ b/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; @@ -49,9 +49,12 @@ describe('AssetsState', () => { assetsService = Mock.ofType(); - 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)); }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/assets.state.ts b/src/Squidex/app/shared/state/assets.state.ts index f931f944f..70be17cc9 100644 --- a/src/Squidex/app/shared/state/assets.state.ts +++ b/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; assetsPager: Pager; assetsQuery?: string; @@ -30,6 +33,10 @@ interface Snapshot { @Injectable() export class AssetsState extends State { + 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 { 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 { @@ -59,17 +66,24 @@ export class AssetsState extends State { } private loadInternal(isReload = false): Observable { - 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 { public delete(asset: AssetDto): Observable { 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 { 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 { + 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 { + this.next(s => ({ ...s, assetsPager: new Pager(0, 0, 30), tagsSelected: {} })); + + return this.loadInternal(); } public search(query: string): Observable { @@ -123,6 +197,14 @@ export class AssetsState extends State { 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; } diff --git a/src/Squidex/app/theme/_forms.scss b/src/Squidex/app/theme/_forms.scss index e69af6ba2..58443b065 100644 --- a/src/Squidex/app/theme/_forms.scss +++ b/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; + } + } +} \ No newline at end of file diff --git a/src/Squidex/app/theme/_panels.scss b/src/Squidex/app/theme/_panels.scss index 3918a476b..6124ea873 100644 --- a/src/Squidex/app/theme/_panels.scss +++ b/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); diff --git a/src/Squidex/app/theme/_vars.scss b/src/Squidex/app/theme/_vars.scss index c6e621401..500373ff0 100644 --- a/src/Squidex/app/theme/_vars.scss +++ b/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; \ No newline at end of file +$asset-width: 16rem; +$asset-height: 19rem; +$asset-header: 12rem; +$asset-image: 12rem; +$asset-footer: 7rem; \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs index 99dbc94dc..40c1868dc 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs +++ b/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(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(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(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(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(new[] { Guid.NewGuid() })); + var result = sut.CleanReferences(token, HashSet.Of(Guid.NewGuid())); Assert.Same(token, result); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs index 087b6f63f..f640a8831 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs +++ b/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 - { - appId1, - appId2 - }; + var state = HashSet.Of(appId1, appId2); await sut.RebuildAsync(state); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index 5d8148151..098b0d3f1 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/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(); private readonly IAssetStore assetStore = A.Fake(); + private readonly ITagService tagService = A.Fake(); private readonly IGrainFactory grainFactory = A.Fake(); 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()); + asset = new AssetGrain(Store, tagService, A.Dummy()); asset.OnActivateAsync(Id).Wait(); A.CallTo(() => grainFactory.GetGrain(Id, null)) diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs index ae0a44f82..069c8a8dc 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs +++ b/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 { + private readonly ITagService tagService = A.Fake(); 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()); + sut = new AssetGrain(Store, tagService, A.Dummy()); sut.OnActivateAsync(Id).Wait(); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs new file mode 100644 index 000000000..f60022bb3 --- /dev/null +++ b/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(); + private readonly IAssetRepository assetRepository = A.Fake(); + private readonly IAppEntity app = A.Fake(); + 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>.That.IsSameSequenceAs("id1", "id2", "id3"))) + .Returns(new Dictionary + { + ["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>.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(); + + A.CallTo(() => asset.Id).Returns(id); + A.CallTo(() => asset.Tags).Returns(HashSet.Of(tags)); + + return asset; + } + } +} \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/OData/ODataQueryTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/OData/ODataQueryTests.cs index 6a0a6be00..c0d5a195e 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/OData/ODataQueryTests.cs +++ b/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(); private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry; private readonly IBsonSerializer serializer = BsonSerializer.SerializerRegistry.GetSerializer(); 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>.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() + parser.BuildFilter(convertValue: valueConverter) .Filter.Render(serializer, registry).ToString(); return query; diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs index c83a35191..c772d4aaf 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs +++ b/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(); - 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.That.IsSameSequenceAs(status), A.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(() => sut.QueryAsync(context.WithSchemaId(schemaId), "query")); + return Assert.ThrowsAsync(() => sut.QueryAsync(context.WithSchemaId(schemaId), Query.Empty.WithODataQuery("query"))); } public static IEnumerable ManyIdRequestData = new[] @@ -239,9 +239,9 @@ namespace Squidex.Domain.Apps.Entities.Contents .Returns(schema); A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), A>.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); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 287278209..8d4e96402 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/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 { 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.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 { 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.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 { content }; - - A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), "?$top=30&$skip=5")) - .Returns(ResultList.Create(contents, 0)); + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.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 { content }; - - A.CallTo(() => contentQuery.QueryAsync(ContextMatch(), "?$top=30&$skip=5")) - .Returns(ResultList.Create(contents, 10)); + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.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 { 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>.That.IsSameSequenceAs(new[] { contentRefId }))) - .Returns(ResultList.Create(refContents, 0)); + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.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 { 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>.That.Matches(x => x.Contains(assetRefId)))) - .Returns(ResultList.Create(refAssets, 0)); + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A.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.That.Matches(x => x.App == app && x.User == user && !x.Archived); + } + + private ContentQueryContext MatchsContentContext() { - return A.That.Matches(x => x.App == app && x.SchemaIdOrName == schema.Id.ToString() && x.User == user && !x.Archived); + return A.That.Matches(x => x.Base.App == app && x.Base.User == user && !x.Base.Archived && x.SchemaIdOrName == schema.Id.ToString()); } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 174f1f477..022622e98 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/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(); - protected readonly IAssetRepository assetRepository = A.Fake(); + protected readonly IAssetQueryService assetQuery = A.Fake(); protected readonly ISchemaEntity schema = A.Fake(); protected readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); protected readonly IAppProvider appProvider = A.Fake(); @@ -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) diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs index 82a77eaef..aaabf1630 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs +++ b/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 Tags { get; set; } + public long Version { get; set; } public string MimeType { get; set; } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs new file mode 100644 index 000000000..f1aa0fd33 --- /dev/null +++ b/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(); + private readonly ITagGrain grain = A.Fake(); + private readonly Guid appId = Guid.NewGuid(); + private readonly GrainTagService sut; + + public GrainTagServiceTests() + { + A.CallTo(() => grainFactory.GetGrain($"{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(); + + 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(); + + 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(); + var tagNames = new HashSet(); + + await sut.NormalizeTagsAsync(appId, TagGroups.Assets, tagNames, tagIds); + + A.CallTo(() => grain.NormalizeTagsAsync(tagNames, tagIds)) + .MustHaveHappened(); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs new file mode 100644 index 000000000..7e2ab07bd --- /dev/null +++ b/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 store = A.Fake>(); + private readonly IPersistence persistence = A.Fake>(); + private readonly TagGrain sut; + + public TagGrainTests() + { + A.CallTo(() => store.WithSnapshots(A.Ignored, A.Ignored, A>.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 + { + ["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 + { + ["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 + { + ["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); + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs b/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs index 487ae93f5..c74f56ee2 100644 --- a/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs +++ b/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 }); diff --git a/tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs b/tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs index b224cdb82..86de1b367 100644 --- a/tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs +++ b/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 }); diff --git a/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs b/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs index 8c50065a5..e1ba8453f 100644 --- a/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs +++ b/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 });