diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index abbc6be88..f0d9eaa82 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -10,17 +10,12 @@ using System.Collections.Generic; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using NodaTime; -using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { - public sealed class MongoAssetEntity : - IAssetEntity, - IUpdateableEntityWithVersion, - IUpdateableEntityWithCreatedBy, - IUpdateableEntityWithLastModifiedBy + public sealed class MongoAssetEntity : IAssetEntity { [BsonId] [BsonElement("_id")] @@ -32,6 +27,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets [BsonRepresentation(BsonType.String)] public Guid IndexedAppId { get; set; } + [BsonIgnoreIfDefault] + [BsonElement("pi")] + public Guid ParentId { get; set; } + [BsonRequired] [BsonElement("ct")] public Instant Created { get; set; } @@ -100,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets [BsonElement("dl")] public bool IsDeleted { get; set; } - Guid IAssetInfo.AssetId + public Guid AssetId { get { return Id; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderEntity.cs new file mode 100644 index 000000000..74d5d8ed3 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderEntity.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using NodaTime; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets +{ + public sealed class MongoAssetFolderEntity : IAssetFolderEntity + { + [BsonId] + [BsonElement("_id")] + [BsonRepresentation(BsonType.String)] + public Guid Id { get; set; } + + [BsonRequired] + [BsonElement("_ai")] + [BsonRepresentation(BsonType.String)] + public Guid IndexedAppId { get; set; } + + [BsonRequired] + [BsonElement("pi")] + public Guid ParentId { get; set; } + + [BsonRequired] + [BsonElement("ct")] + public Instant Created { get; set; } + + [BsonRequired] + [BsonElement("mt")] + public Instant LastModified { get; set; } + + [BsonRequired] + [BsonElement("ai")] + public NamedId AppId { get; set; } + + [BsonRequired] + [BsonElement("fn")] + public string FolderName { get; set; } + + [BsonRequired] + [BsonElement("vs")] + public long Version { get; set; } + + [BsonRequired] + [BsonElement("cb")] + public RefToken CreatedBy { get; set; } + + [BsonRequired] + [BsonElement("mb")] + public RefToken LastModifiedBy { get; set; } + + [BsonRequired] + [BsonElement("dl")] + public bool IsDeleted { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs new file mode 100644 index 000000000..b34286e93 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs @@ -0,0 +1,74 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets +{ + public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase, IAssetFolderRepository + { + public MongoAssetFolderRepository(IMongoDatabase database) + : base(database) + { + } + + protected override string CollectionName() + { + return "States_AssetFolders"; + } + + protected override Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) + { + return collection.Indexes.CreateManyAsync(new[] + { + new CreateIndexModel( + Index + .Ascending(x => x.IndexedAppId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.ParentId)) + }, ct); + } + + public async Task> QueryAsync(Guid appId, Guid parentId) + { + using (Profiler.TraceMethod("QueryAsyncByQuery")) + { + var assetFolders = + await Collection + .Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).SortBy(x => x.FolderName) + .ToListAsync(); + + return ResultList.Create(assetFolders.Count, assetFolders); + } + } + + public async Task FindAssetFolderAsync(Guid id) + { + using (Profiler.TraceMethod()) + { + var assetFolderEntity = + await Collection.Find(x => x.Id == id) + .FirstOrDefaultAsync(); + + if (assetFolderEntity?.IsDeleted == true) + { + return null; + } + + return assetFolderEntity; + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs new file mode 100644 index 000000000..629203307 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets +{ + public sealed partial class MongoAssetFolderRepository : ISnapshotStore + { + async Task<(AssetFolderState Value, long Version)> ISnapshotStore.ReadAsync(Guid key) + { + using (Profiler.TraceMethod()) + { + var existing = + await Collection.Find(x => x.Id == key) + .FirstOrDefaultAsync(); + + if (existing != null) + { + return (Map(existing), existing.Version); + } + + return (null!, EtagVersion.NotFound); + } + } + + async Task ISnapshotStore.WriteAsync(Guid key, AssetFolderState value, long oldVersion, long newVersion) + { + using (Profiler.TraceMethod()) + { + var entity = SimpleMapper.Map(value, new MongoAssetFolderEntity()); + + entity.Version = newVersion; + entity.IndexedAppId = value.AppId.Id; + + await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); + } + } + + async Task ISnapshotStore.ReadAllAsync(Func callback, CancellationToken ct) + { + using (Profiler.TraceMethod()) + { + await Collection.Find(new BsonDocument(), options: Batching.Options).ForEachPipelineAsync(x => callback(Map(x), x.Version), ct); + } + } + + async Task ISnapshotStore.RemoveAsync(Guid key) + { + using (Profiler.TraceMethod()) + { + await Collection.DeleteOneAsync(x => x.Id == key); + } + } + + private static AssetFolderState Map(MongoAssetFolderEntity existing) + { + return SimpleMapper.Map(existing, new AssetFolderState()); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 536eaee23..6017edfc7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -41,6 +41,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets Index .Ascending(x => x.IndexedAppId) .Ascending(x => x.IsDeleted) + .Ascending(x => x.ParentId) .Ascending(x => x.Tags) .Descending(x => x.LastModified)), new CreateIndexModel( @@ -51,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets }, ct); } - public async Task> QueryAsync(Guid appId, ClrQuery query) + public async Task> QueryAsync(Guid appId, Guid? parentId, ClrQuery query) { using (Profiler.TraceMethod("QueryAsyncByQuery")) { @@ -59,19 +60,19 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { query = query.AdjustToModel(); - var filter = query.BuildFilter(appId); + var filter = query.BuildFilter(appId, parentId); - var contentCount = Collection.Find(filter).CountDocumentsAsync(); - var contentItems = + var assetCount = Collection.Find(filter).CountDocumentsAsync(); + var assetItems = Collection.Find(filter) .AssetTake(query) .AssetSkip(query) .AssetSort(query) .ToListAsync(); - await Task.WhenAll(contentItems, contentCount); + await Task.WhenAll(assetItems, assetCount); - return ResultList.Create(contentCount.Result, contentItems.Result); + return ResultList.Create(assetCount.Result, assetItems.Result); } catch (MongoQueryException ex) { @@ -123,26 +124,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } - public async Task FindAssetAsync(Guid id, bool allowDeleted = false) + public async Task FindAssetAsync(Guid id) { using (Profiler.TraceMethod()) { var assetEntity = - await Collection.Find(x => x.Id == id) + await Collection.Find(x => x.Id == id && !x.IsDeleted) .FirstOrDefaultAsync(); - if (assetEntity?.IsDeleted == true && !allowDeleted) - { - return null; - } - return assetEntity; } } - - public Task RemoveAsync(Guid key) - { - return Collection.DeleteOneAsync(x => x.Id == key); - } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index c5f057d86..26eca0f75 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -19,6 +19,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors public static class FindExtensions { private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + private static readonly SortDefinitionBuilder Sorting = Builders.Sort; public static ClrQuery AdjustToModel(this ClrQuery query) { @@ -52,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors return cursor.Skip(query); } - public static FilterDefinition BuildFilter(this ClrQuery query, Guid appId) + public static FilterDefinition BuildFilter(this ClrQuery query, Guid appId, Guid? parentId) { var filters = new List> { @@ -60,6 +61,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors Filter.Eq(x => x.IsDeleted, false) }; + if (parentId.HasValue) + { + if (parentId == Guid.Empty) + { + filters.Add( + Filter.Or( + Filter.Exists(x => x.ParentId, false), + Filter.Eq(x => x.ParentId, Guid.Empty))); + } + else + { + filters.Add(Filter.Eq(x => x.ParentId, parentId.Value)); + } + } + var (filter, last) = query.BuildFilter(false); if (filter != null) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs index 7af666c55..c3fec9472 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs @@ -34,6 +34,11 @@ namespace Squidex.Domain.Apps.Entities.Assets protected override async Task CreateEnrichedEventAsync(Envelope @event) { + if (@event.Payload is AssetMoved) + { + return null; + } + var result = new EnrichedAssetEvent(); var asset = await assetLoader.GetAsync(@event.Payload.AssetId, @event.Headers.EventStreamNumber()); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs index 150e53b78..9424d8c57 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Assets public Guid Id { get; set; } - public Guid AssetId { get; set; } + public Guid ParentId { get; set; } public Instant Created { get; set; } @@ -34,12 +34,12 @@ namespace Squidex.Domain.Apps.Entities.Assets public long Version { get; set; } - public string MimeType { get; set; } - public string FileName { get; set; } public string FileHash { get; set; } + public string MimeType { get; set; } + public string Slug { get; set; } public long FileSize { get; set; } @@ -53,5 +53,10 @@ namespace Squidex.Domain.Apps.Entities.Assets public int? PixelWidth { get; set; } public int? PixelHeight { get; set; } + + public Guid AssetId + { + get { return Id; } + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderGrain.cs new file mode 100644 index 000000000..49525def9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderGrain.cs @@ -0,0 +1,130 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +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.Events; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetFolderGrain : DomainObjectGrain, IAssetFolderGrain + { + private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); + private readonly IAssetQueryService assetQuery; + + public AssetFolderGrain(IStore store, IAssetQueryService assetQuery, IActivationLimit limit, ISemanticLog log) + : base(store, log) + { + Guard.NotNull(assetQuery); + + this.assetQuery = assetQuery; + + limit?.SetLimit(5000, Lifetime); + } + + protected override Task OnActivateAsync(Guid key) + { + TryDelayDeactivation(Lifetime); + + return base.OnActivateAsync(key); + } + + protected override Task ExecuteAsync(IAggregateCommand command) + { + VerifyNotDeleted(); + + switch (command) + { + case CreateAssetFolder createAssetFolder: + return CreateReturnAsync(createAssetFolder, async c => + { + await GuardAssetFolder.CanCreate(c, assetQuery); + + Create(c); + + return Snapshot; + }); + case MoveAssetFolder moveAssetFolder: + return UpdateReturnAsync(moveAssetFolder, async c => + { + await GuardAssetFolder.CanMove(c, assetQuery, Snapshot.Id, Snapshot.ParentId); + + Move(c); + + return Snapshot; + }); + case RenameAssetFolder renameAssetFolder: + return UpdateReturn(renameAssetFolder, c => + { + GuardAssetFolder.CanRename(c, Snapshot.FolderName); + + Rename(c); + + return Snapshot; + }); + case DeleteAssetFolder deleteAssetFolder: + return Update(deleteAssetFolder, c => + { + GuardAssetFolder.CanDelete(c); + + Delete(c); + }); + default: + throw new NotSupportedException(); + } + } + + public void Create(CreateAssetFolder command) + { + RaiseEvent(SimpleMapper.Map(command, new AssetFolderCreated())); + } + + public void Move(MoveAssetFolder command) + { + RaiseEvent(SimpleMapper.Map(command, new AssetFolderMoved())); + } + + public void Rename(RenameAssetFolder command) + { + RaiseEvent(SimpleMapper.Map(command, new AssetFolderRenamed())); + } + + public void Delete(DeleteAssetFolder command) + { + RaiseEvent(SimpleMapper.Map(command, new AssetFolderDeleted())); + } + + private void RaiseEvent(AppEvent @event) + { + if (@event.AppId == null) + { + @event.AppId = Snapshot.AppId; + } + + RaiseEvent(Envelope.Create(@event)); + } + + private void VerifyNotDeleted() + { + if (Snapshot.IsDeleted) + { + throw new DomainException("Asset folder has already been deleted"); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs index 7b31dbfcc..6a625423e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs @@ -28,14 +28,18 @@ namespace Squidex.Domain.Apps.Entities.Assets { private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); private readonly ITagService tagService; + private readonly IAssetQueryService assetQuery; - public AssetGrain(IStore store, ITagService tagService, IActivationLimit limit, ISemanticLog log) + public AssetGrain(IStore store, ITagService tagService, IAssetQueryService assetQuery, IActivationLimit limit, ISemanticLog log) : base(store, log) { Guard.NotNull(tagService); + Guard.NotNull(assetQuery); this.tagService = tagService; + this.assetQuery = assetQuery; + limit?.SetLimit(5000, Lifetime); } @@ -55,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Assets case CreateAsset createAsset: return CreateReturnAsync(createAsset, async c => { - GuardAsset.CanCreate(c); + await GuardAsset.CanCreate(c, assetQuery); var tagIds = await NormalizeTagsAsync(c.AppId.Id, c.Tags); @@ -75,12 +79,21 @@ namespace Squidex.Domain.Apps.Entities.Assets case AnnotateAsset annotateAsset: return UpdateReturnAsync(annotateAsset, async c => { - GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug); + GuardAsset.CanAnnotate(c, Snapshot.FileName!, Snapshot.Slug); var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags); Annotate(c, tagIds); + return Snapshot; + }); + case MoveAsset moveAsset: + return UpdateReturnAsync(moveAsset, async c => + { + await GuardAsset.CanMove(c, assetQuery, Snapshot.ParentId); + + Move(c); + return Snapshot; }); case DeleteAsset deleteAsset: @@ -152,6 +165,11 @@ namespace Squidex.Domain.Apps.Entities.Assets RaiseEvent(@event); } + public void Move(MoveAsset command) + { + RaiseEvent(SimpleMapper.Map(command, new AssetMoved())); + } + public void Delete(DeleteAsset command) { RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize })); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs index aa572fa9c..85971681c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs @@ -24,6 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { private const string TagsFile = "AssetTags.json"; private readonly HashSet assetIds = new HashSet(); + private readonly HashSet assetFolderIds = new HashSet(); private readonly IAssetStore assetStore; private readonly ITagService tagService; @@ -62,6 +63,9 @@ namespace Squidex.Domain.Apps.Entities.Assets { switch (@event.Payload) { + case AssetFolderCreated assetFolderCreated: + assetFolderIds.Add(assetFolderCreated.AssetFolderId); + break; case AssetCreated assetCreated: await ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, reader); break; @@ -78,6 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Assets await RestoreTagsAsync(appId, reader); await RebuildManyAsync(assetIds, RebuildAsync); + await RebuildManyAsync(assetFolderIds, RebuildAsync); } private async Task RestoreTagsAsync(Guid appId, BackupReader reader) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs new file mode 100644 index 000000000..8d3999c25 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public abstract class AssetFolderCommand : SquidexCommand, IAggregateCommand + { + public Guid AssetFolderId { get; set; } + + Guid IAggregateCommand.AggregateId + { + get { return AssetFolderId; } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs index 8e869ba40..2dcab921d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs @@ -15,6 +15,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands { public NamedId AppId { get; set; } + public Guid ParentId { get; set; } + public HashSet Tags { get; set; } public CreateAsset() diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAssetFolder.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAssetFolder.cs new file mode 100644 index 000000000..8ca8d412c --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAssetFolder.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// 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.Assets.Commands +{ + public sealed class CreateAssetFolder : AssetFolderCommand, IAppCommand + { + public NamedId AppId { get; set; } + + public string FolderName { get; set; } + + public Guid ParentId { get; set; } + + public CreateAssetFolder() + { + AssetFolderId = Guid.NewGuid(); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAssetFolder.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAssetFolder.cs new file mode 100644 index 000000000..9d5077c6d --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAssetFolder.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class DeleteAssetFolder : AssetFolderCommand + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAsset.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAsset.cs new file mode 100644 index 000000000..05a16b5fb --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAsset.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class MoveAsset : AssetCommand + { + public Guid ParentId { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAssetFolder.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAssetFolder.cs new file mode 100644 index 000000000..45d16672d --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAssetFolder.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class MoveAssetFolder : AssetFolderCommand + { + public Guid ParentId { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAssetFolder.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAssetFolder.cs new file mode 100644 index 000000000..86e289692 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAssetFolder.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.Assets.Commands +{ + public sealed class RenameAssetFolder : AssetFolderCommand + { + public string FolderName { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs index 0a0a7188a..edc0ba2c5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Validation; @@ -28,9 +30,31 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards }); } - public static void CanCreate(CreateAsset command) + public static Task CanCreate(CreateAsset command, IAssetQueryService assetQuery) { Guard.NotNull(command); + + return Validate.It(() => "Cannot upload asset.", async e => + { + await CheckPathAsync(command.ParentId, assetQuery, e); + }); + } + + public static Task CanMove(MoveAsset command, IAssetQueryService assetQuery, Guid oldParentId) + { + Guard.NotNull(command); + + return Validate.It(() => "Cannot move asset.", async e => + { + if (command.ParentId == oldParentId) + { + e("Asset is already part of this folder.", nameof(command.ParentId)); + } + else + { + await CheckPathAsync(command.ParentId, assetQuery, e); + } + }); } public static void CanUpdate(UpdateAsset command) @@ -42,5 +66,18 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards { Guard.NotNull(command); } + + private static async Task CheckPathAsync(Guid parentId, IAssetQueryService assetQuery, AddValidation e) + { + if (parentId != default) + { + var path = await assetQuery.FindAssetFolderAsync(parentId); + + if (path.Count == 0) + { + e("Asset folder does not exist.", nameof(MoveAsset.ParentId)); + } + } + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAssetFolder.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAssetFolder.cs new file mode 100644 index 000000000..111eb1b85 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAssetFolder.cs @@ -0,0 +1,95 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Entities.Assets.Guards +{ + public static class GuardAssetFolder + { + public static Task CanCreate(CreateAssetFolder command, IAssetQueryService assetQuery) + { + Guard.NotNull(command); + + return Validate.It(() => "Cannot upload asset.", async e => + { + if (string.IsNullOrWhiteSpace(command.FolderName)) + { + e(Not.Defined("Folder name"), nameof(command.FolderName)); + } + + await CheckPathAsync(command.ParentId, assetQuery, Guid.Empty, e); + }); + } + + public static void CanRename(RenameAssetFolder command, string olderFolderName) + { + Guard.NotNull(command); + + Validate.It(() => "Cannot rename asset.", e => + { + if (string.IsNullOrWhiteSpace(command.FolderName)) + { + e(Not.Defined("Folder name"), nameof(command.FolderName)); + } + else if (string.Equals(command.FolderName, olderFolderName)) + { + e(Not.New("Asset folder", "name"), nameof(command.FolderName)); + } + }); + } + + public static Task CanMove(MoveAssetFolder command, IAssetQueryService assetQuery, Guid id, Guid oldParentId) + { + Guard.NotNull(command); + + return Validate.It(() => "Cannot move asset.", async e => + { + if (command.ParentId == oldParentId) + { + e("Asset folder is already part of this folder.", nameof(command.ParentId)); + } + else + { + await CheckPathAsync(command.ParentId, assetQuery, id, e); + } + }); + } + + public static void CanDelete(DeleteAssetFolder command) + { + Guard.NotNull(command); + } + + private static async Task CheckPathAsync(Guid parentId, IAssetQueryService assetQuery, Guid id, AddValidation e) + { + if (parentId != default) + { + var path = await assetQuery.FindAssetFolderAsync(parentId); + + if (path.Count == 0) + { + e("Asset folder does not exist.", nameof(MoveAssetFolder.ParentId)); + } + else if (id != default) + { + var indexOfThis = path.IndexOf(x => x.Id == id); + var indexOfParent = path.IndexOf(x => x.Id == parentId); + + if (indexOfThis >= 0 && indexOfParent > indexOfThis) + { + e("Cannot add folder to its own child.", nameof(MoveAssetFolder.ParentId)); + } + } + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs index 02890b87e..df6da0cc5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs @@ -21,6 +21,8 @@ namespace Squidex.Domain.Apps.Entities.Assets { NamedId AppId { get; } + Guid ParentId { get; } + string MimeType { get; } long FileVersion { get; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFolderEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFolderEntity.cs new file mode 100644 index 000000000..83f9cf768 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFolderEntity.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// 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.Assets +{ + public interface IAssetFolderEntity : IEntity, IEntityWithVersion + { + NamedId AppId { get; set; } + + string FolderName { get; set; } + + Guid ParentId { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFolderGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFolderGrain.cs new file mode 100644 index 000000000..924c52ae2 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFolderGrain.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetFolderGrain : IDomainObjectGrain + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index dc94e0293..6db84de4d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -16,7 +16,11 @@ namespace Squidex.Domain.Apps.Entities.Assets { Task> QueryByHashAsync(Context context, Guid appId, string hash); - Task> QueryAsync(Context context, Q query); + Task> QueryAsync(Context context, Guid? parentId, Q query); + + Task> QueryAssetFoldersAsync(Context context, Guid parentId); + + Task> FindAssetFolderAsync(Guid id); Task FindAssetAsync(Context context, Guid id); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs index 098b5a516..09fec1034 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; @@ -17,19 +18,23 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { private readonly IAssetEnricher assetEnricher; private readonly IAssetRepository assetRepository; + private readonly IAssetFolderRepository assetFolderRepository; private readonly AssetQueryParser queryParser; public AssetQueryService( IAssetEnricher assetEnricher, IAssetRepository assetRepository, + IAssetFolderRepository assetFolderRepository, AssetQueryParser queryParser) { Guard.NotNull(assetEnricher); Guard.NotNull(assetRepository); + Guard.NotNull(assetFolderRepository); Guard.NotNull(queryParser); this.assetEnricher = assetEnricher; this.assetRepository = assetRepository; + this.assetFolderRepository = assetFolderRepository; this.queryParser = queryParser; } @@ -45,6 +50,35 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries return null; } + public async Task> FindAssetFolderAsync(Guid id) + { + var result = new List(); + + while (id != default) + { + var folder = await assetFolderRepository.FindAssetFolderAsync(id); + + if (folder == null || result.Any(x => x.Id == folder.Id)) + { + result.Clear(); + break; + } + + result.Insert(0, folder); + + id = folder.ParentId; + } + + return result; + } + + public async Task> QueryAssetFoldersAsync(Context context, Guid parentId) + { + var assetFolders = await assetFolderRepository.QueryAsync(context.App.Id, parentId); + + return assetFolders; + } + public async Task> QueryByHashAsync(Context context, Guid appId, string hash) { Guard.NotNull(hash); @@ -54,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries return await assetEnricher.EnrichAsync(assets, context); } - public async Task> QueryAsync(Context context, Q query) + public async Task> QueryAsync(Context context, Guid? parentId, Q query) { Guard.NotNull(context); Guard.NotNull(query); @@ -67,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries } else { - assets = await QueryByQueryAsync(context, query); + assets = await QueryByQueryAsync(context, parentId, query); } var enriched = await assetEnricher.EnrichAsync(assets, context); @@ -75,11 +109,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries return ResultList.Create(assets.Total, enriched); } - private async Task> QueryByQueryAsync(Context context, Q query) + private async Task> QueryByQueryAsync(Context context, Guid? parentId, Q query) { var parsedQuery = queryParser.ParseQuery(context, query); - return await assetRepository.QueryAsync(context.App.Id, parsedQuery); + return await assetRepository.QueryAsync(context.App.Id, parentId, parsedQuery); } private async Task> QueryByIdsAsync(Context context, Q query) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs new file mode 100644 index 000000000..f1bdac3cc --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets.Repositories +{ + public interface IAssetFolderRepository + { + Task> QueryAsync(Guid appId, Guid parentId); + + Task FindAssetFolderAsync(Guid id); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index 11a45e228..cc6f19bf7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -17,14 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { Task> QueryByHashAsync(Guid appId, string hash); - Task> QueryAsync(Guid appId, ClrQuery query); + Task> QueryAsync(Guid appId, Guid? parentId, ClrQuery query); Task> QueryAsync(Guid appId, HashSet ids); - Task FindAssetAsync(Guid id, bool allowDeleted = false); + Task FindAssetAsync(Guid id); Task FindAssetBySlugAsync(Guid appId, string slug); - - Task RemoveAsync(Guid appId); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetFolderState.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetFolderState.cs new file mode 100644 index 000000000..6d6ce90cc --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetFolderState.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Runtime.Serialization; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; + +#pragma warning disable IDE0060 // Remove unused parameter + +namespace Squidex.Domain.Apps.Entities.Assets.State +{ + public class AssetFolderState : DomainObjectState, IAssetFolderEntity + { + [DataMember] + public NamedId AppId { get; set; } + + [DataMember] + public string FolderName { get; set; } + + [DataMember] + public bool IsDeleted { get; set; } + + [DataMember] + public Guid ParentId { get; set; } + + public void ApplyEvent(IEvent @event) + { + switch (@event) + { + case AssetFolderCreated e: + { + SimpleMapper.Map(e, this); + + break; + } + + case AssetFolderRenamed e: + { + SimpleMapper.Map(e, this); + + break; + } + + case AssetFolderMoved e: + { + ParentId = e.ParentId; + + break; + } + + case AssetFolderDeleted _: + { + IsDeleted = true; + + break; + } + } + } + + public override AssetFolderState Apply(Envelope @event) + { + return Clone().Update(@event, (e, s) => s.ApplyEvent(e)); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs index f3c9834f4..e271c75fc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs @@ -8,7 +8,6 @@ using System; using System.Collections.Generic; using System.Runtime.Serialization; -using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -23,6 +22,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State [DataMember] public NamedId AppId { get; set; } + [DataMember] + public Guid ParentId { get; set; } + [DataMember] public string FileName { get; set; } @@ -59,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.State [DataMember] public HashSet Tags { get; set; } - Guid IAssetInfo.AssetId + public Guid AssetId { get { return Id; } } @@ -117,6 +119,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.State break; } + case AssetMoved e: + { + ParentId = e.ParentId; + + break; + } + case AssetDeleted _: { IsDeleted = true; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs index 6dde0c175..0ef994ec0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = "fileType", ResolvedType = AllTypes.NonNullString, - Resolver = Resolve(x => x.FileName.FileType()), + Resolver = Resolve(x => x.FileName!.FileType()), Description = "The file type." }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs index 356bd8bbc..74d69825c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs @@ -322,7 +322,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries return EmptyAssets; } - var assets = await assetQuery.QueryAsync(context.Clone().WithNoAssetEnrichment(true), Q.Empty.WithIds(ids)); + var assets = await assetQuery.QueryAsync(context.Clone().WithNoAssetEnrichment(true), null, Q.Empty.WithIds(ids)); return assets.ToLookup(x => x.Id); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index 68aa7ec73..22e9398dd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public virtual async Task> QueryAssetsAsync(string query) { - var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(query)); + var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithODataQuery(query)); foreach (var asset in assets) { @@ -105,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries if (notLoadedAssets.Count > 0) { - var assets = await assetQuery.QueryAsync(context, Q.Empty.WithIds(notLoadedAssets)); + var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithIds(notLoadedAssets)); foreach (var asset in assets) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs index 45ec90994..f58de4d60 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs @@ -12,7 +12,7 @@ namespace Squidex.Domain.Apps.Entities { public interface IEntity { - Guid Id { get; } + Guid Id { get; } Instant Created { get; } diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs index ef7173832..7698c12b5 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using Squidex.Infrastructure.EventSourcing; @@ -13,6 +14,8 @@ namespace Squidex.Domain.Apps.Events.Assets [EventType(nameof(AssetCreated))] public sealed class AssetCreated : AssetEvent { + public Guid ParentId { get; set; } + public string FileName { get; set; } public string FileHash { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderCreated.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderCreated.cs new file mode 100644 index 000000000..228dc54dd --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderCreated.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Assets +{ + [EventType(nameof(AssetFolderCreated))] + public sealed class AssetFolderCreated : AssetFolderEvent + { + public Guid ParentId { get; set; } + + public string FolderName { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderDeleted.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderDeleted.cs new file mode 100644 index 000000000..ff6014106 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderDeleted.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Assets +{ + [EventType(nameof(AssetFolderDeleted))] + public sealed class AssetFolderDeleted : AssetFolderEvent + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderEvent.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderEvent.cs new file mode 100644 index 000000000..7fc7c5fa6 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderEvent.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Events.Assets +{ + public abstract class AssetFolderEvent : AppEvent + { + public Guid AssetFolderId { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderMoved.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderMoved.cs new file mode 100644 index 000000000..c225ba6e5 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderMoved.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Assets +{ + [EventType(nameof(AssetFolderMoved))] + public sealed class AssetFolderMoved : AssetFolderEvent + { + public Guid ParentId { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderRenamed.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderRenamed.cs new file mode 100644 index 000000000..8016098b1 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetFolderRenamed.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Assets +{ + [EventType(nameof(AssetFolderRenamed))] + public sealed class AssetFolderRenamed : AssetFolderEvent + { + public string FolderName { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Events/Assets/AssetMoved.cs b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetMoved.cs new file mode 100644 index 000000000..9a1ab2cc9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Assets/AssetMoved.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Assets +{ + [EventType(nameof(AssetMoved))] + public sealed class AssetMoved : AssetEvent + { + public Guid ParentId { get; set; } + } +} diff --git a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs index a714fdb38..5a459b8c5 100644 --- a/backend/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/backend/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -33,6 +33,23 @@ namespace Squidex.Infrastructure return input.GroupBy(x => x).Where(x => x.Count() > 1).Select(x => x.Key); } + public static int IndexOf(this IEnumerable input, Func predicate) + { + var i = 0; + + foreach (var item in input) + { + if (predicate(item)) + { + return i; + } + + i++; + } + + return -1; + } + public static IEnumerable Duplicates(this IEnumerable input, Func selector) { return input.GroupBy(selector).Where(x => x.Count() > 1).Select(x => x.Key); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 4ce50f255..51e24437e 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -89,7 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// /// Create a new app. /// - /// The app object that needs to be added to squidex. + /// The app object that needs to be added to Squidex. /// /// 201 => App created. /// 400 => App request not valid. diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs new file mode 100644 index 000000000..5a2cf28cb --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs @@ -0,0 +1,168 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using Squidex.Areas.Api.Controllers.Assets.Models; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Assets +{ + /// + /// Uploads and retrieves assets. + /// + [ApiExplorerSettings(GroupName = nameof(Assets))] + public sealed class AssetFoldersController : ApiController + { + private readonly IAssetQueryService assetQuery; + + public AssetFoldersController(ICommandBus commandBus, IAssetQueryService assetQuery) + : base(commandBus) + { + this.assetQuery = assetQuery; + } + + /// + /// Get asset folders. + /// + /// The name of the app. + /// The optional parent folder id. + /// + /// 200 => Asset folders returned. + /// 404 => App not found. + /// + /// + /// Get all asset folders for the app. + /// + [HttpGet] + [Route("apps/{app}/assets/folders", Order = -1)] + [ProducesResponseType(typeof(AssetsDto), 200)] + [ApiPermission(Permissions.AppAssetsRead)] + [ApiCosts(1)] + public async Task GetAssetFolders(string app, [FromQuery] Guid parentId) + { + var assetFolders = await assetQuery.QueryAssetFoldersAsync(Context, parentId); + + var response = Deferred.Response(() => + { + return AssetFoldersDto.FromAssets(assetFolders, this, app); + }); + + Response.Headers[HeaderNames.ETag] = assetFolders.ToEtag(); + + return Ok(response); + } + + /// + /// Upload a new asset. + /// + /// The name of the app. + /// The asset folder object that needs to be added to the App. + /// + /// 201 => Asset folder created. + /// 404 => App not found. + /// + [HttpPost] + [Route("apps/{app}/assets/folders", Order = -1)] + [ProducesResponseType(typeof(AssetDto), 201)] + [AssetRequestSizeLimit] + [ApiPermission(Permissions.AppAssetsUpdate)] + [ApiCosts(1)] + public async Task PostAssetFolder(string app, [FromBody] CreateAssetFolderDto request) + { + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(app, command); + + return Ok(response); + } + + /// + /// Updates the asset folder. + /// + /// The name of the app. + /// The id of the asset folder. + /// The asset folder object that needs to updated. + /// + /// 200 => Asset folder updated. + /// 400 => Asset folder name not valid. + /// 404 => Asset or app not found. + /// + [HttpPut] + [Route("apps/{app}/assets/folders/{id}/", Order = -1)] + [ProducesResponseType(typeof(AssetDto), 200)] + [AssetRequestSizeLimit] + [ApiPermission(Permissions.AppAssetsUpdate)] + [ApiCosts(1)] + public async Task PutAssetFolder(string app, Guid id, [FromBody] RenameAssetFolderDto request) + { + var command = request.ToCommand(id); + + var response = await InvokeCommandAsync(app, command); + + return Ok(response); + } + + /// + /// Moves the asset folder. + /// + /// The name of the app. + /// The id of the asset folder. + /// The asset folder object that needs to updated. + /// + /// 200 => Asset folder moved. + /// 404 => Asset or app not found. + /// + [HttpPut] + [Route("apps/{app}/assets/folders/{id}/parent", Order = -1)] + [ProducesResponseType(typeof(AssetDto), 200)] + [AssetRequestSizeLimit] + [ApiPermission(Permissions.AppAssetsUpdate)] + [ApiCosts(1)] + public async Task PutAssetFolderParent(string app, Guid id, [FromBody] MoveAssetItemDto request) + { + var command = request.ToFolderCommand(id); + + var response = await InvokeCommandAsync(app, command); + + return Ok(response); + } + + /// + /// Delete an asset folder. + /// + /// The name of the app. + /// The id of the asset folder to delete. + /// + /// 204 => Asset folder deleted. + /// 404 => Asset or app not found. + /// + [HttpDelete] + [Route("apps/{app}/assets/folders/{id}/", Order = -1)] + [ApiPermission(Permissions.AppAssetsUpdate)] + [ApiCosts(1)] + public async Task DeleteAssetFolder(string app, Guid id) + { + await CommandBus.PublishAsync(new DeleteAssetFolder { AssetFolderId = id }); + + return NoContent(); + } + + private async Task InvokeCommandAsync(string app, ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + return AssetFolderDto.FromAssetFolder(context.Result(), this, app); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 7a7feca6e..4e4254bf9 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -89,6 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// Get assets. /// /// The name of the app. + /// The optional parent folder id. /// The optional asset ids. /// The optional json query. /// @@ -103,9 +104,9 @@ namespace Squidex.Areas.Api.Controllers.Assets [ProducesResponseType(typeof(AssetsDto), 200)] [ApiPermission(Permissions.AppAssetsRead)] [ApiCosts(1)] - public async Task GetAssets(string app, [FromQuery] string? ids = null, [FromQuery] string? q = null) + public async Task GetAssets(string app, [FromQuery] Guid? parentId, [FromQuery] string? ids = null, [FromQuery] string? q = null) { - var assets = await assetQuery.QueryAsync(Context, + var assets = await assetQuery.QueryAsync(Context, parentId, Q.Empty .WithIds(ids) .WithJsonQuery(q) @@ -168,6 +169,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// Upload a new asset. /// /// The name of the app. + /// The optional parent folder id. /// The file to upload. /// /// 201 => Asset created. @@ -183,11 +185,11 @@ namespace Squidex.Areas.Api.Controllers.Assets [AssetRequestSizeLimit] [ApiPermission(Permissions.AppAssetsCreate)] [ApiCosts(1)] - public async Task PostAsset(string app, [OpenApiIgnore] List file) + public async Task PostAsset(string app, [FromQuery] Guid parentId, [OpenApiIgnore] List file) { var assetFile = await CheckAssetFileAsync(file); - var command = new CreateAsset { File = assetFile }; + var command = new CreateAsset { File = assetFile, ParentId = parentId }; var response = await InvokeCommandAsync(app, command); @@ -250,6 +252,31 @@ namespace Squidex.Areas.Api.Controllers.Assets return Ok(response); } + /// + /// Moves the asset. + /// + /// The name of the app. + /// The id of the asset. + /// The asset object that needs to updated. + /// + /// 200 => Asset moved. + /// 404 => Asset or app not found. + /// + [HttpPut] + [Route("apps/{app}/assets/{id}/parent")] + [ProducesResponseType(typeof(AssetDto), 200)] + [AssetRequestSizeLimit] + [ApiPermission(Permissions.AppAssetsUpdate)] + [ApiCosts(1)] + public async Task PutAssetParent(string app, Guid id, [FromBody] MoveAssetItemDto request) + { + var command = request.ToCommand(id); + + var response = await InvokeCommandAsync(app, command); + + return Ok(response); + } + /// /// Delete an asset. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index cb3494226..b93767441 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -25,6 +25,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// public Guid Id { get; set; } + /// + /// The id of the parent folder. Empty for files without parent. + /// + public Guid ParentId { get; set; } + /// /// The file name. /// @@ -141,6 +146,8 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models { response.AddPutLink("update", controller.Url(x => nameof(x.PutAsset), values)); response.AddPutLink("upload", controller.Url(x => nameof(x.PutAssetContent), values)); + + response.AddPutLink("move", controller.Url(x => nameof(x.PutAssetParent), values)); } if (controller.HasPermission(Permissions.AppAssetsDelete)) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs new file mode 100644 index 000000000..810824973 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Assets.Models +{ + public sealed class AssetFolderDto : Resource + { + /// + /// The id of the asset. + /// + public Guid Id { get; set; } + + /// + /// The id of the parent folder. Empty for files without parent. + /// + public Guid ParentId { get; set; } + + /// + /// The folder name. + /// + [Required] + public string FolderName { get; set; } + + /// + /// The version of the asset folder. + /// + public long Version { get; set; } + + public static AssetFolderDto FromAssetFolder(IAssetFolderEntity asset, ApiController controller, string app) + { + var response = SimpleMapper.Map(asset, new AssetFolderDto()); + + return CreateLinks(response, controller, app); + } + + private static AssetFolderDto CreateLinks(AssetFolderDto response, ApiController controller, string app) + { + var values = new { app, id = response.Id }; + + response.AddSelfLink(controller.Url(x => nameof(x.GetAsset), values)); + + if (controller.HasPermission(Permissions.AppAssetsUpdate)) + { + response.AddPutLink("update", controller.Url(x => nameof(x.PutAssetFolder), values)); + + response.AddPutLink("move", controller.Url(x => nameof(x.PutAssetFolderParent), values)); + } + + if (controller.HasPermission(Permissions.AppAssetsUpdate)) + { + response.AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteAssetFolder), values)); + } + + return response; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs new file mode 100644 index 000000000..264296dd4 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Assets.Models +{ + public sealed class AssetFoldersDto : Resource + { + /// + /// The total number of assets. + /// + public long Total { get; set; } + + /// + /// The assets folders. + /// + [Required] + public AssetFolderDto[] Items { get; set; } + + public static AssetFoldersDto FromAssets(IResultList assetFolders, ApiController controller, string app) + { + var response = new AssetFoldersDto + { + Total = assetFolders.Total, + Items = assetFolders.Select(x => AssetFolderDto.FromAssetFolder(x, controller, app)).ToArray() + }; + + return CreateLinks(response, controller, app); + } + + private static AssetFoldersDto CreateLinks(AssetFoldersDto response, ApiController controller, string app) + { + var values = new { app }; + + response.AddSelfLink(controller.Url(x => nameof(x.GetAssetFolders), values)); + + if (controller.HasPermission(Permissions.AppAssetsUpdate)) + { + response.AddPostLink("create", controller.Url(x => nameof(x.PostAssetFolder), values)); + } + + return response; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs new file mode 100644 index 000000000..1b2154b43 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Assets.Models +{ + public sealed class CreateAssetFolderDto + { + /// + /// The name of the folder. + /// + [Required] + public string FolderName { get; set; } + + /// + /// The id of the parent folder. + /// + public Guid ParentId { get; set; } + + public CreateAssetFolder ToCommand() + { + return SimpleMapper.Map(this, new CreateAssetFolder()); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/MoveAssetItemDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/MoveAssetItemDto.cs new file mode 100644 index 000000000..7053d9d53 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/MoveAssetItemDto.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Entities.Assets.Commands; + +namespace Squidex.Areas.Api.Controllers.Assets.Models +{ + public sealed class MoveAssetItemDto + { + /// + /// The parent folder id. + /// + public Guid ParentId { get; set; } + + public MoveAsset ToCommand(Guid id) + { + return new MoveAsset { AssetId = id, ParentId = ParentId }; + } + + public MoveAssetFolder ToFolderCommand(Guid id) + { + return new MoveAssetFolder { AssetFolderId = id, ParentId = ParentId }; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/RenameAssetFolderDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/RenameAssetFolderDto.cs new file mode 100644 index 000000000..c0b4568e9 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/RenameAssetFolderDto.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Assets.Models +{ + public sealed class RenameAssetFolderDto + { + /// + /// The name of the folder. + /// + [Required] + public string FolderName { get; set; } + + public RenameAssetFolder ToCommand(Guid id) + { + return SimpleMapper.Map(this, new RenameAssetFolder { AssetFolderId = id }); + } + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml index 2760c2247..d28c046c3 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml @@ -19,7 +19,7 @@
- + @RenderBody()
diff --git a/backend/src/Squidex/Config/Domain/CommandsServices.cs b/backend/src/Squidex/Config/Domain/CommandsServices.cs index 36fc54e74..e93168e9a 100644 --- a/backend/src/Squidex/Config/Domain/CommandsServices.cs +++ b/backend/src/Squidex/Config/Domain/CommandsServices.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Templates; using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Contents; @@ -83,6 +84,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs>() + .As(); + services.AddSingletonAs>() .As(); diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index c216fd390..783788626 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -100,6 +100,10 @@ namespace Squidex.Config.Domain .As() .As>(); + services.AddSingletonAs() + .As() + .As>(); + services.AddSingletonAs(c => new MongoContentRepository( c.GetRequiredService().GetDatabase(mongoContentDatabaseName), c.GetRequiredService(), diff --git a/backend/src/Squidex/wwwroot/images/add-app.png b/backend/src/Squidex/wwwroot/images/add-app.png deleted file mode 100644 index 605f0a4c4..000000000 Binary files a/backend/src/Squidex/wwwroot/images/add-app.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/add-app.svg b/backend/src/Squidex/wwwroot/images/add-app.svg new file mode 100644 index 000000000..44b50c081 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/add-app.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/add-blog.png b/backend/src/Squidex/wwwroot/images/add-blog.png deleted file mode 100644 index 764e65cf1..000000000 Binary files a/backend/src/Squidex/wwwroot/images/add-blog.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/add-blog.svg b/backend/src/Squidex/wwwroot/images/add-blog.svg new file mode 100644 index 000000000..cecf13fa4 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/add-blog.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/add-identity.png b/backend/src/Squidex/wwwroot/images/add-identity.png deleted file mode 100644 index 910ed1c77..000000000 Binary files a/backend/src/Squidex/wwwroot/images/add-identity.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/add-identity.svg b/backend/src/Squidex/wwwroot/images/add-identity.svg new file mode 100644 index 000000000..30968a735 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/add-identity.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/add-profile.png b/backend/src/Squidex/wwwroot/images/add-profile.png deleted file mode 100644 index 9aec8b7e1..000000000 Binary files a/backend/src/Squidex/wwwroot/images/add-profile.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/add-profile.svg b/backend/src/Squidex/wwwroot/images/add-profile.svg new file mode 100644 index 000000000..e9b78a32b --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/add-profile.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_doc.png b/backend/src/Squidex/wwwroot/images/asset_doc.png deleted file mode 100644 index 08fb7de74..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_doc.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_doc.svg b/backend/src/Squidex/wwwroot/images/asset_doc.svg new file mode 100644 index 000000000..3bbd3a67b --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_doc.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_docx.png b/backend/src/Squidex/wwwroot/images/asset_docx.png deleted file mode 100644 index 5a31c92da..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_docx.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_docx.svg b/backend/src/Squidex/wwwroot/images/asset_docx.svg new file mode 100644 index 000000000..3bbd3a67b --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_docx.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_generic.png b/backend/src/Squidex/wwwroot/images/asset_generic.png deleted file mode 100644 index 576117903..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_generic.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_generic.svg b/backend/src/Squidex/wwwroot/images/asset_generic.svg new file mode 100644 index 000000000..555a98085 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_generic.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_pdf.png b/backend/src/Squidex/wwwroot/images/asset_pdf.png deleted file mode 100644 index 1aa38c9f1..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_pdf.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_pdf.svg b/backend/src/Squidex/wwwroot/images/asset_pdf.svg new file mode 100644 index 000000000..919d5d670 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_pdf.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_ppt.png b/backend/src/Squidex/wwwroot/images/asset_ppt.png deleted file mode 100644 index c2c7b7e62..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_ppt.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_ppt.svg b/backend/src/Squidex/wwwroot/images/asset_ppt.svg new file mode 100644 index 000000000..53ab946db --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_ppt.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_pptx.png b/backend/src/Squidex/wwwroot/images/asset_pptx.png deleted file mode 100644 index 1d4832111..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_pptx.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_pptx.svg b/backend/src/Squidex/wwwroot/images/asset_pptx.svg new file mode 100644 index 000000000..53ab946db --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_pptx.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_video.png b/backend/src/Squidex/wwwroot/images/asset_video.png deleted file mode 100644 index 0889275a0..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_video.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_video.svg b/backend/src/Squidex/wwwroot/images/asset_video.svg new file mode 100644 index 000000000..3c7971ba5 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_video.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_xls.png b/backend/src/Squidex/wwwroot/images/asset_xls.png deleted file mode 100644 index 3e7849d3e..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_xls.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_xls.svg b/backend/src/Squidex/wwwroot/images/asset_xls.svg new file mode 100644 index 000000000..9a08f88b1 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_xls.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/asset_xlsx.png b/backend/src/Squidex/wwwroot/images/asset_xlsx.png deleted file mode 100644 index d28ad0789..000000000 Binary files a/backend/src/Squidex/wwwroot/images/asset_xlsx.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/asset_xlsx.svg b/backend/src/Squidex/wwwroot/images/asset_xlsx.svg new file mode 100644 index 000000000..9a08f88b1 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/asset_xlsx.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/dashboard-api.png b/backend/src/Squidex/wwwroot/images/dashboard-api.png deleted file mode 100644 index a269ef52b..000000000 Binary files a/backend/src/Squidex/wwwroot/images/dashboard-api.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/dashboard-api.svg b/backend/src/Squidex/wwwroot/images/dashboard-api.svg new file mode 100644 index 000000000..379e76891 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/dashboard-api.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/dashboard-feedback.png b/backend/src/Squidex/wwwroot/images/dashboard-feedback.png deleted file mode 100644 index 5665d765d..000000000 Binary files a/backend/src/Squidex/wwwroot/images/dashboard-feedback.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/dashboard-github.png b/backend/src/Squidex/wwwroot/images/dashboard-github.png deleted file mode 100644 index aca7fa679..000000000 Binary files a/backend/src/Squidex/wwwroot/images/dashboard-github.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/dashboard-github.svg b/backend/src/Squidex/wwwroot/images/dashboard-github.svg new file mode 100644 index 000000000..8e0c9e334 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/dashboard-github.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/dashboard-schema.png b/backend/src/Squidex/wwwroot/images/dashboard-schema.png deleted file mode 100644 index 77162cbb4..000000000 Binary files a/backend/src/Squidex/wwwroot/images/dashboard-schema.png and /dev/null differ diff --git a/backend/src/Squidex/wwwroot/images/dashboard-schema.svg b/backend/src/Squidex/wwwroot/images/dashboard-schema.svg new file mode 100644 index 000000000..3bd7aaff7 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/dashboard-schema.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/dashboard_feedback.svg b/backend/src/Squidex/wwwroot/images/dashboard_feedback.svg new file mode 100644 index 000000000..920b7fb2d --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/dashboard_feedback.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/dashboard_schema.svg b/backend/src/Squidex/wwwroot/images/dashboard_schema.svg new file mode 100644 index 000000000..3bd7aaff7 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/dashboard_schema.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/folder.svg b/backend/src/Squidex/wwwroot/images/folder.svg new file mode 100644 index 000000000..3ad7e240d --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/folder.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/logo.svg b/backend/src/Squidex/wwwroot/images/logo.svg new file mode 100644 index 000000000..c5f97d149 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs index ba32701fc..17a119beb 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs @@ -62,6 +62,16 @@ namespace Squidex.Domain.Apps.Entities.Assets Assert.Equal(type, result!.Type); } + [Fact] + public async Task Should_skip_moved_event() + { + var envelope = Envelope.Create(new AssetMoved()); + + var result = await sut.CreateEnrichedEventAsync(envelope); + + Assert.Null(result); + } + [Fact] public void Should_not_trigger_precheck_when_event_type_not_correct() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index efa873db6..38caa4e0e 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs @@ -58,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { file = new AssetFile("my-image.png", "image/png", 1024, () => stream); - asset = new AssetGrain(Store, tagService, A.Fake(), A.Dummy()); + asset = new AssetGrain(Store, tagService, assetQuery, A.Fake(), A.Dummy()); asset.ActivateAsync(Id).Wait(); A.CallTo(() => contextProvider.Context) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderGrainTests.cs new file mode 100644 index 000000000..0d44399d7 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderGrainTests.cs @@ -0,0 +1,171 @@ +// ========================================================================== +// 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.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetFolderGrainTests : HandlerTestBase + { + private readonly IAssetQueryService assetQuery = A.Fake(); + private readonly IActivationLimit limit = A.Fake(); + private readonly Guid parentId = Guid.NewGuid(); + private readonly Guid assetFolderId = Guid.NewGuid(); + private readonly AssetFolderGrain sut; + + protected override Guid Id + { + get { return assetFolderId; } + } + + public AssetFolderGrainTests() + { + A.CallTo(() => assetQuery.FindAssetFolderAsync(parentId)) + .Returns(new List { A.Fake() }); + + sut = new AssetFolderGrain(Store, assetQuery, limit, A.Dummy()); + sut.ActivateAsync(Id).Wait(); + } + + [Fact] + public void Should_set_limit() + { + A.CallTo(() => limit.SetLimit(5000, TimeSpan.FromMinutes(5))) + .MustHaveHappened(); + } + + [Fact] + public async Task Command_should_throw_exception_if_rule_is_deleted() + { + await ExecuteCreateAsync(); + await ExecuteDeleteAsync(); + + await Assert.ThrowsAsync(ExecuteUpdateAsync); + } + + [Fact] + public async Task Create_should_create_events_and_update_state() + { + var command = new CreateAssetFolder { FolderName = "New Name" }; + + var result = await sut.ExecuteAsync(CreateAssetFolderCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(command.FolderName, sut.Snapshot.FolderName); + + LastEvents + .ShouldHaveSameEvents( + CreateAssetFolderEvent(new AssetFolderCreated + { + FolderName = command.FolderName + }) + ); + } + + [Fact] + public async Task Update_should_create_events_and_update_state() + { + var command = new RenameAssetFolder { FolderName = "New Name" }; + + await ExecuteCreateAsync(); + + var result = await sut.ExecuteAsync(CreateAssetFolderCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(command.FolderName, sut.Snapshot.FolderName); + + LastEvents + .ShouldHaveSameEvents( + CreateAssetFolderEvent(new AssetFolderRenamed + { + FolderName = command.FolderName + }) + ); + } + + [Fact] + public async Task Move_should_create_events_and_update_state() + { + var command = new MoveAssetFolder { ParentId = parentId }; + + await ExecuteCreateAsync(); + + var result = await sut.ExecuteAsync(CreateAssetFolderCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(parentId, sut.Snapshot.ParentId); + + LastEvents + .ShouldHaveSameEvents( + CreateAssetFolderEvent(new AssetFolderMoved { ParentId = parentId }) + ); + } + + [Fact] + public async Task Delete_should_create_events_with_total_file_size() + { + var command = new DeleteAssetFolder(); + + await ExecuteCreateAsync(); + + var result = await sut.ExecuteAsync(CreateAssetFolderCommand(command)); + + result.ShouldBeEquivalent(new EntitySavedResult(1)); + + Assert.True(sut.Snapshot.IsDeleted); + + LastEvents + .ShouldHaveSameEvents( + CreateAssetFolderEvent(new AssetFolderDeleted()) + ); + } + + private Task ExecuteCreateAsync() + { + return sut.ExecuteAsync(CreateAssetFolderCommand(new CreateAssetFolder { FolderName = "My Folder" })); + } + + private Task ExecuteUpdateAsync() + { + return sut.ExecuteAsync(CreateAssetFolderCommand(new RenameAssetFolder { FolderName = "My Folder" })); + } + + private Task ExecuteDeleteAsync() + { + return sut.ExecuteAsync(CreateAssetFolderCommand(new DeleteAssetFolder())); + } + + protected T CreateAssetFolderEvent(T @event) where T : AssetFolderEvent + { + @event.AssetFolderId = assetFolderId; + + return CreateEvent(@event); + } + + protected T CreateAssetFolderCommand(T command) where T : AssetFolderCommand + { + command.AssetFolderId = assetFolderId; + + return CreateCommand(command); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs index 301aada09..23dc31f52 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs @@ -27,11 +27,12 @@ namespace Squidex.Domain.Apps.Entities.Assets public class AssetGrainTests : HandlerTestBase { private readonly ITagService tagService = A.Fake(); + private readonly IAssetQueryService assetQuery = A.Fake(); private readonly IActivationLimit limit = A.Fake(); private readonly ImageInfo image = new ImageInfo(2048, 2048); - private readonly AssetFile file = new AssetFile("my-image.png", "image/png", 1024, () => new MemoryStream()); + private readonly Guid parentId = Guid.NewGuid(); private readonly Guid assetId = Guid.NewGuid(); - private readonly string fileHash = Guid.NewGuid().ToString(); + private readonly AssetFile file = new AssetFile("my-image.png", "image/png", 1024, () => new MemoryStream()); private readonly AssetGrain sut; protected override Guid Id @@ -41,10 +42,13 @@ namespace Squidex.Domain.Apps.Entities.Assets public AssetGrainTests() { + A.CallTo(() => assetQuery.FindAssetFolderAsync(parentId)) + .Returns(new List { A.Fake() }); + A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A>.Ignored, A>.Ignored)) .Returns(new Dictionary()); - sut = new AssetGrain(Store, tagService, limit, A.Dummy()); + sut = new AssetGrain(Store, tagService, assetQuery, limit, A.Dummy()); sut.ActivateAsync(Id).Wait(); } @@ -56,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task Command_should_throw_exception_if_rule_is_deleted() + public async Task Command_should_throw_exception_if_asset_is_deleted() { await ExecuteCreateAsync(); await ExecuteDeleteAsync(); @@ -65,16 +69,16 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task Create_should_create_events() + public async Task Create_should_create_events_and_update_state() { - var command = new CreateAsset { File = file, ImageInfo = image, FileHash = fileHash, Tags = new HashSet() }; + var command = new CreateAsset { File = file, ImageInfo = image, FileHash = "NewHash", Tags = new HashSet() }; var result = await sut.ExecuteAsync(CreateAssetCommand(command)); result.ShouldBeEquivalent(sut.Snapshot); Assert.Equal(0, sut.Snapshot.FileVersion); - Assert.Equal(fileHash, sut.Snapshot.FileHash); + Assert.Equal(command.FileHash, sut.Snapshot.FileHash); LastEvents .ShouldHaveSameEvents( @@ -82,8 +86,8 @@ namespace Squidex.Domain.Apps.Entities.Assets { IsImage = true, FileName = file.FileName, - FileHash = fileHash, FileSize = file.FileSize, + FileHash = command.FileHash, FileVersion = 0, MimeType = file.MimeType, PixelWidth = image.PixelWidth, @@ -95,9 +99,9 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task Update_should_create_events() + public async Task Update_should_create_events_and_update_state() { - var command = new UpdateAsset { File = file, ImageInfo = image, FileHash = fileHash }; + var command = new UpdateAsset { File = file, ImageInfo = image, FileHash = "NewHash" }; await ExecuteCreateAsync(); @@ -106,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Assets result.ShouldBeEquivalent(sut.Snapshot); Assert.Equal(1, sut.Snapshot.FileVersion); - Assert.Equal(fileHash, sut.Snapshot.FileHash); + Assert.Equal(command.FileHash, sut.Snapshot.FileHash); LastEvents .ShouldHaveSameEvents( @@ -114,7 +118,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { IsImage = true, FileSize = file.FileSize, - FileHash = fileHash, + FileHash = command.FileHash, FileVersion = 1, MimeType = file.MimeType, PixelWidth = image.PixelWidth, @@ -124,7 +128,7 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task AnnotateName_should_create_events() + public async Task AnnotateName_should_create_events_and_update_state() { var command = new AnnotateAsset { FileName = "My New Image.png" }; @@ -143,7 +147,7 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task AnnotateSlug_should_create_events() + public async Task AnnotateSlug_should_create_events_and_update_state() { var command = new AnnotateAsset { Slug = "my-new-image.png" }; @@ -162,7 +166,7 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task AnnotateTag_should_create_events() + public async Task AnnotateTag_should_create_events_and_update_state() { var command = new AnnotateAsset { Tags = new HashSet() }; @@ -178,6 +182,25 @@ namespace Squidex.Domain.Apps.Entities.Assets ); } + [Fact] + public async Task Move_should_create_events_and_update_state() + { + var command = new MoveAsset { ParentId = parentId }; + + await ExecuteCreateAsync(); + + var result = await sut.ExecuteAsync(CreateAssetCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(parentId, sut.Snapshot.ParentId); + + LastEvents + .ShouldHaveSameEvents( + CreateAssetEvent(new AssetMoved { ParentId = parentId }) + ); + } + [Fact] public async Task Delete_should_create_events_with_total_file_size() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetFolderTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetFolderTests.cs new file mode 100644 index 000000000..37f08f6f5 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetFolderTests.cs @@ -0,0 +1,167 @@ +// ========================================================================== +// 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.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure.Validation; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets.Guards +{ + public class GuardAssetFolderTests + { + private readonly IAssetQueryService assetQuery = A.Fake(); + + [Fact] + public async Task CanCreate_should_throw_exception_when_folder_name_not_defined() + { + var command = new CreateAssetFolder(); + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List()); + + await ValidationAssert.ThrowsAsync(() => GuardAssetFolder.CanCreate(command, assetQuery), + new ValidationError("Folder name is required.", "FolderName")); + } + + [Fact] + public async Task CanCreate_should_throw_exception_when_folder_not_found() + { + var command = new CreateAssetFolder { FolderName = "My Folder", ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List()); + + await ValidationAssert.ThrowsAsync(() => GuardAssetFolder.CanCreate(command, assetQuery), + new ValidationError("Asset folder does not exist.", "ParentId")); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_when_folder_found() + { + var command = new CreateAssetFolder { FolderName = "My Folder", ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List { CreateFolder() }); + + await GuardAssetFolder.CanCreate(command, assetQuery); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_when_added_to_root() + { + var command = new CreateAssetFolder { FolderName = "My Folder" }; + + await GuardAssetFolder.CanCreate(command, assetQuery); + } + + [Fact] + public async Task CanMove_should_throw_exception_when_adding_to_its_own_child() + { + var id = Guid.NewGuid(); + + var command = new MoveAssetFolder { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List + { + CreateFolder(id), + CreateFolder(command.ParentId) + }); + + await ValidationAssert.ThrowsAsync(() => GuardAssetFolder.CanMove(command, assetQuery, id, Guid.NewGuid()), + new ValidationError("Cannot add folder to its own child.", "ParentId")); + } + + [Fact] + public async Task CanMove_should_throw_exception_when_folder_not_found() + { + var command = new MoveAssetFolder { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List()); + + await ValidationAssert.ThrowsAsync(() => GuardAssetFolder.CanMove(command, assetQuery, Guid.NewGuid(), Guid.NewGuid()), + new ValidationError("Asset folder does not exist.", "ParentId")); + } + + [Fact] + public async Task CanMove_should_throw_exception_when_folder_has_not_changed() + { + var command = new MoveAssetFolder { ParentId = Guid.NewGuid() }; + + await ValidationAssert.ThrowsAsync(() => GuardAssetFolder.CanMove(command, assetQuery, Guid.NewGuid(), command.ParentId), + new ValidationError("Asset folder is already part of this folder.", "ParentId")); + } + + [Fact] + public async Task CanMove_should_not_throw_exception_when_folder_found() + { + var command = new MoveAssetFolder { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List { CreateFolder() }); + + await GuardAssetFolder.CanMove(command, assetQuery, Guid.NewGuid(), Guid.NewGuid()); + } + + [Fact] + public async Task CanMove_should_not_throw_exception_when_added_to_root() + { + var command = new MoveAssetFolder(); + + await GuardAssetFolder.CanMove(command, assetQuery, Guid.NewGuid(), Guid.NewGuid()); + } + + [Fact] + public void CanRename_should_throw_exception_if_folder_name_is_empty() + { + var command = new RenameAssetFolder(); + + ValidationAssert.Throws(() => GuardAssetFolder.CanRename(command, "My Folder"), + new ValidationError("Folder name is required.", "FolderName")); + } + + [Fact] + public void CanRename_should_throw_exception_if_names_are_the_same() + { + var command = new RenameAssetFolder { FolderName = "My Folder" }; + + ValidationAssert.Throws(() => GuardAssetFolder.CanRename(command, "My Folder"), + new ValidationError("Asset folder has already this name.", "FolderName")); + } + + [Fact] + public void CanRename_should_not_throw_exception_if_names_are_different() + { + var command = new RenameAssetFolder { FolderName = "New Folder Name" }; + + GuardAssetFolder.CanRename(command, "My Folder"); + } + + [Fact] + public void CanDelete_should_not_throw_exception() + { + var command = new DeleteAssetFolder(); + + GuardAssetFolder.CanDelete(command); + } + + private IAssetFolderEntity CreateFolder(Guid id = default) + { + var assetFolder = A.Fake(); + + A.CallTo(() => assetFolder.Id).Returns(id); + + return assetFolder; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs index 94e23930a..a343212ab 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs @@ -5,6 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure.Validation; @@ -14,6 +18,79 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards { public class GuardAssetTests { + private readonly IAssetQueryService assetQuery = A.Fake(); + + [Fact] + public async Task CanCreate_should_not_throw_exception_when_folder_found() + { + var command = new CreateAsset { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List { CreateFolder() }); + + await GuardAsset.CanCreate(command, assetQuery); + } + + [Fact] + public async Task CanCreate_should_throw_exception_when_folder_not_found() + { + var command = new CreateAsset { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List()); + + await ValidationAssert.ThrowsAsync(() => GuardAsset.CanCreate(command, assetQuery), + new ValidationError("Asset folder does not exist.", "ParentId")); + } + + [Fact] + public async Task CanCreate_should_not_throw_exception_when_added_to_root() + { + var command = new CreateAsset(); + + await GuardAsset.CanCreate(command, assetQuery); + } + + [Fact] + public async Task CanMove_should_throw_exception_when_folder_not_found() + { + var command = new MoveAsset { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List()); + + await ValidationAssert.ThrowsAsync(() => GuardAsset.CanMove(command, assetQuery, Guid.NewGuid()), + new ValidationError("Asset folder does not exist.", "ParentId")); + } + + [Fact] + public async Task CanMove_should_throw_exception_when_folder_has_not_changed() + { + var command = new MoveAsset { ParentId = Guid.NewGuid() }; + + await ValidationAssert.ThrowsAsync(() => GuardAsset.CanMove(command, assetQuery, command.ParentId), + new ValidationError("Asset is already part of this folder.", "ParentId")); + } + + [Fact] + public async Task CanMove_should_not_throw_exception_when_folder_found() + { + var command = new MoveAsset { ParentId = Guid.NewGuid() }; + + A.CallTo(() => assetQuery.FindAssetFolderAsync(command.ParentId)) + .Returns(new List { CreateFolder() }); + + await GuardAsset.CanMove(command, assetQuery, Guid.NewGuid()); + } + + [Fact] + public async Task CanMove_should_not_throw_exception_when_added_to_root() + { + var command = new MoveAsset(); + + await GuardAsset.CanMove(command, assetQuery, Guid.NewGuid()); + } + [Fact] public void CanAnnotate_should_throw_exception_if_nothing_defined() { @@ -31,14 +108,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards GuardAsset.CanAnnotate(command, "asset-name", "asset-slug"); } - [Fact] - public void CanCreate_should_not_throw_exception() - { - var command = new CreateAsset(); - - GuardAsset.CanCreate(command); - } - [Fact] public void CanUpdate_should_not_throw_exception() { @@ -54,5 +123,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards GuardAsset.CanDelete(command); } + + private IAssetFolderEntity CreateFolder(Guid id = default) + { + var assetFolder = A.Fake(); + + A.CallTo(() => assetFolder.Id).Returns(id); + + return assetFolder; + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs index f804851c7..a210ec60b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs @@ -22,6 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { private readonly IAssetEnricher assetEnricher = A.Fake(); private readonly IAssetRepository assetRepository = A.Fake(); + private readonly IAssetFolderRepository assetFolderRepository = A.Fake(); private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly Context requestContext; private readonly AssetQueryParser queryParser = A.Fake(); @@ -34,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries A.CallTo(() => queryParser.ParseQuery(requestContext, A.Ignored)) .Returns(new ClrQuery()); - sut = new AssetQueryService(assetEnricher, assetRepository, queryParser); + sut = new AssetQueryService(assetEnricher, assetRepository, assetFolderRepository, queryParser); } [Fact] @@ -44,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries var enriched = new AssetEntity(); - A.CallTo(() => assetRepository.FindAssetAsync(found.Id, false)) + A.CallTo(() => assetRepository.FindAssetAsync(found.Id)) .Returns(found); A.CallTo(() => assetEnricher.EnrichAsync(found, requestContext)) @@ -90,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries A.CallTo(() => assetEnricher.EnrichAsync(A>.That.IsSameSequenceAs(found1, found2), requestContext)) .Returns(new List { enriched1, enriched2 }); - var result = await sut.QueryAsync(requestContext, Q.Empty.WithIds(ids)); + var result = await sut.QueryAsync(requestContext, null, Q.Empty.WithIds(ids)); Assert.Equal(8, result.Total); @@ -106,17 +107,136 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries var enriched1 = new AssetEntity(); var enriched2 = new AssetEntity(); - A.CallTo(() => assetRepository.QueryAsync(appId.Id, A.Ignored)) + var parentId = Guid.NewGuid(); + + A.CallTo(() => assetRepository.QueryAsync(appId.Id, parentId, A.Ignored)) .Returns(ResultList.CreateFrom(8, found1, found2)); A.CallTo(() => assetEnricher.EnrichAsync(A>.That.IsSameSequenceAs(found1, found2), requestContext)) .Returns(new List { enriched1, enriched2 }); - var result = await sut.QueryAsync(requestContext, Q.Empty); + var result = await sut.QueryAsync(requestContext, parentId, Q.Empty); Assert.Equal(8, result.Total); Assert.Equal(new[] { enriched1, enriched2 }, result.ToArray()); } + + [Fact] + public async Task Should_load_assets_folders_from_repository() + { + var parentId = Guid.NewGuid(); + + var assetFolders = ResultList.CreateFrom(10); + + A.CallTo(() => assetFolderRepository.QueryAsync(appId.Id, parentId)) + .Returns(assetFolders); + + var result = await sut.QueryAssetFoldersAsync(requestContext, parentId); + + Assert.Same(assetFolders, result); + } + + [Fact] + public async Task Should_resolve_folder_path_from_root() + { + var folderId1 = Guid.NewGuid(); + var folder1 = CreateFolder(folderId1); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId1)) + .Returns(folder1); + + var result = await sut.FindAssetFolderAsync(folderId1); + + Assert.Equal(result, new[] { folder1 }); + } + + [Fact] + public async Task Should_resolve_folder_path_from_child() + { + var folderId1 = Guid.NewGuid(); + var folderId2 = Guid.NewGuid(); + var folderId3 = Guid.NewGuid(); + + var folder1 = CreateFolder(folderId1); + var folder2 = CreateFolder(folderId2, folderId1); + var folder3 = CreateFolder(folderId3, folderId2); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId1)) + .Returns(folder1); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId2)) + .Returns(folder2); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId3)) + .Returns(folder3); + + var result = await sut.FindAssetFolderAsync(folderId3); + + Assert.Equal(result, new[] { folder1, folder2, folder3 }); + } + + [Fact] + public async Task Should_not_resolve_folder_path_if_root_not_found() + { + var folderId1 = Guid.NewGuid(); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId1)) + .Returns(Task.FromResult(null)); + + var result = await sut.FindAssetFolderAsync(folderId1); + + Assert.Empty(result); + } + + [Fact] + public async Task Should_not_resolve_folder_path_if_parent_of_child_not_found() + { + var folderId1 = Guid.NewGuid(); + var folderId2 = Guid.NewGuid(); + + var folder1 = CreateFolder(folderId1); + var folder2 = CreateFolder(folderId2, folderId1); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId1)) + .Returns(Task.FromResult(null)); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId2)) + .Returns(folder2); + + var result = await sut.FindAssetFolderAsync(folderId2); + + Assert.Empty(result); + } + + [Fact] + public async Task Should_not_resolve_folder_path_if_recursion_detected() + { + var folderId1 = Guid.NewGuid(); + var folderId2 = Guid.NewGuid(); + + var folder1 = CreateFolder(folderId1, folderId2); + var folder2 = CreateFolder(folderId2, folderId1); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId1)) + .Returns(Task.FromResult(null)); + + A.CallTo(() => assetFolderRepository.FindAssetFolderAsync(folderId2)) + .Returns(folder2); + + var result = await sut.FindAssetFolderAsync(folderId2); + + Assert.Empty(result); + } + + private IAssetFolderEntity CreateFolder(Guid id, Guid parentId = default) + { + var assetFolder = A.Fake(); + + A.CallTo(() => assetFolder.Id).Returns(id); + A.CallTo(() => assetFolder.ParentId).Returns(parentId); + + return assetFolder; + } } } \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 1fba7f791..73efc9fc5 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -155,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var asset = CreateAsset(Guid.NewGuid()); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query"))) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query"))) .Returns(ResultList.CreateFrom(0, asset)); var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); @@ -228,7 +228,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var asset = CreateAsset(Guid.NewGuid()); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query"))) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query"))) .Returns(ResultList.CreateFrom(10, asset)); var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); @@ -284,7 +284,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }".Replace("", assetId.ToString()); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchIdQuery(assetId))) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, MatchIdQuery(assetId))) .Returns(ResultList.CreateFrom(1)); var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); @@ -331,7 +331,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }".Replace("", assetId.ToString()); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchIdQuery(assetId))) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, MatchIdQuery(assetId))) .Returns(ResultList.CreateFrom(1, asset)); var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); @@ -1131,7 +1131,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), MatchId(contentId))) .Returns(ResultList.CreateFrom(1, content)); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), A.Ignored)) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A.Ignored)) .Returns(ResultList.CreateFrom(0, assetRef)); var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); @@ -1184,10 +1184,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }".Replace("", assetId2.ToString()); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchIdQuery(assetId1))) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, MatchIdQuery(assetId1))) .Returns(ResultList.CreateFrom(0, asset1)); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), MatchIdQuery(assetId2))) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, MatchIdQuery(assetId2))) .Returns(ResultList.CreateFrom(0, asset2)); var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query1 }, new GraphQLQuery { Query = query2 }); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs index f51336474..7333491dc 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherAssetsTests.cs @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries new[] { document2.Id, image2.Id }) }; - A.CallTo(() => assetQuery.QueryAsync(A.That.Matches(x => x.IsNoAssetEnrichment()), A.That.Matches(x => x.Ids.Count == 4))) + A.CallTo(() => assetQuery.QueryAsync(A.That.Matches(x => x.IsNoAssetEnrichment()), null, A.That.Matches(x => x.Ids.Count == 4))) .Returns(ResultList.CreateFrom(4, image1, image2, document1, document2)); var enriched = await sut.EnrichAsync(source, requestContext); @@ -122,7 +122,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries new[] { document2.Id, image2.Id }) }; - A.CallTo(() => assetQuery.QueryAsync(A.That.Matches(x => x.IsNoAssetEnrichment()), A.That.Matches(x => x.Ids.Count == 4))) + A.CallTo(() => assetQuery.QueryAsync(A.That.Matches(x => x.IsNoAssetEnrichment()), null, A.That.Matches(x => x.Ids.Count == 4))) .Returns(ResultList.CreateFrom(4, image1, image2, document1, document2)); var enriched = await sut.EnrichAsync(source, requestContext); @@ -160,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.Null(enriched.ElementAt(0).ReferenceData); - A.CallTo(() => assetQuery.QueryAsync(A.Ignored, A.Ignored)) + A.CallTo(() => assetQuery.QueryAsync(A.Ignored, null, A.Ignored)) .MustNotHaveHappened(); } @@ -176,7 +176,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.Null(enriched.ElementAt(0).ReferenceData); - A.CallTo(() => assetQuery.QueryAsync(A.Ignored, A.Ignored)) + A.CallTo(() => assetQuery.QueryAsync(A.Ignored, null, A.Ignored)) .MustNotHaveHappened(); } @@ -192,7 +192,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.NotNull(enriched.ElementAt(0).ReferenceData); - A.CallTo(() => assetQuery.QueryAsync(A.Ignored, A.Ignored)) + A.CallTo(() => assetQuery.QueryAsync(A.Ignored, null, A.Ignored)) .MustNotHaveHappened(); } @@ -203,7 +203,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await sut.EnrichAsync(source, requestContext); - A.CallTo(() => assetQuery.QueryAsync(A.Ignored, A.Ignored)) + A.CallTo(() => assetQuery.QueryAsync(A.Ignored, null, A.Ignored)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs index 8a1fb7211..9c8096d98 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs @@ -15,7 +15,6 @@ using Squidex.Domain.Apps.Entities.Rules.State; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Rules; using Squidex.Infrastructure; -using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Log; @@ -37,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Rules public sealed class TestAction : RuleAction { - public Uri Url { get; set; } + public int Value { get; set; } } public RuleGrainTests() @@ -209,30 +208,16 @@ namespace Squidex.Domain.Apps.Entities.Rules private static CreateRule MakeCreateCommand() { - var newTrigger = new ContentChangedTriggerV2 - { - Schemas = ReadOnlyCollection.Empty() - }; - - var newAction = new TestAction - { - Url = new Uri("https://squidex.io/v2") - }; + var newTrigger = new ManualTrigger(); + var newAction = new TestAction { Value = 123 }; return new CreateRule { Trigger = newTrigger, Action = newAction }; } private static UpdateRule MakeUpdateCommand() { - var newTrigger = new ContentChangedTriggerV2 - { - Schemas = ReadOnlyCollection.Empty() - }; - - var newAction = new TestAction - { - Url = new Uri("https://squidex.io/v2") - }; + var newTrigger = new ManualTrigger(); + var newAction = new TestAction { Value = 123 }; return new UpdateRule { Trigger = newTrigger, Action = newAction, Name = "NewName" }; } diff --git a/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs index 223bb4ffb..7e074b05c 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/CollectionExtensionsTests.cs @@ -15,6 +15,34 @@ namespace Squidex.Infrastructure private readonly Dictionary valueDictionary = new Dictionary(); private readonly Dictionary> listDictionary = new Dictionary>(); + [Fact] + public void IndexOf_should_return_index_when_found() + { + var source = new List<(int Value, int Other)> + { + (5, 5), + (4, 4) + }; + + var index = source.IndexOf(x => x.Other == 4); + + Assert.Equal(1, index); + } + + [Fact] + public void IndexOf_should_return_negative_value_when_not_found() + { + var source = new List<(int Value, int Other)> + { + (5, 5), + (4, 4) + }; + + var index = source.IndexOf(x => x.Other == 2); + + Assert.Equal(-1, index); + } + [Fact] public void GetOrDefault_should_return_value_if_key_exists() { diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index 63c98476b..a38224668 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -63,7 +63,7 @@ export function configAnalyticsId() { } export function configDecimalSeparator() { - return new DecimalSeparatorConfig('.'); + return new DecimalSeparatorConfig('.'); } export function configCurrency() { diff --git a/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.html index f690b1919..2b7033039 100644 --- a/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.html +++ b/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -1,6 +1,6 @@ - + Consumers @@ -14,25 +14,33 @@ - - - - - - - - + + +
- Name - - Position - - Actions -
+ + + + + + + +
+ Name + + Position + + Actions +
+
- - - +
+ + + +
+
+
diff --git a/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.ts b/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.ts index cfee6abc9..0de18d12f 100644 --- a/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.ts +++ b/frontend/app/features/administration/pages/event-consumers/event-consumers-page.component.ts @@ -7,7 +7,7 @@ import { Component, OnInit } from '@angular/core'; import { timer } from 'rxjs'; -import { onErrorResumeNext, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { DialogModel, ResourceOwner } from '@app/shared'; @@ -31,7 +31,7 @@ export class EventConsumersPageComponent extends ResourceOwner implements OnInit public ngOnInit() { this.eventConsumersState.load(); - this.own(timer(5000, 5000).pipe(switchMap(() => this.eventConsumersState.load(true, true)), onErrorResumeNext())); + this.own(timer(5000, 5000).pipe(switchMap(() => this.eventConsumersState.load(false, true)))); } public reload() { diff --git a/frontend/app/features/administration/pages/users/users-page.component.html b/frontend/app/features/administration/pages/users/users-page.component.html index 137d2a271..72feb75c1 100644 --- a/frontend/app/features/administration/pages/users/users-page.component.html +++ b/frontend/app/features/administration/pages/users/users-page.component.html @@ -1,6 +1,6 @@ - + Users @@ -27,40 +27,40 @@ -
- - - - - - - - - -
-   - - Name - - Email - - Actions -
-
- -
-
- + + +
+ + + + + + + + +
+   + + Name + + Email + + Actions +
+ + +
+
-
- - + + + + + diff --git a/frontend/app/features/administration/state/event-consumers.state.spec.ts b/frontend/app/features/administration/state/event-consumers.state.spec.ts index 9849635be..82123f410 100644 --- a/frontend/app/features/administration/state/event-consumers.state.spec.ts +++ b/frontend/app/features/administration/state/event-consumers.state.spec.ts @@ -44,10 +44,20 @@ describe('EventConsumersState', () => { expect(eventConsumersState.snapshot.eventConsumers).toEqual([eventConsumer1, eventConsumer2]); expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); + expect(eventConsumersState.snapshot.isLoading).toBeFalsy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); + it('should reset loading when loading failed', () => { + eventConsumersService.setup(x => x.getEventConsumers()) + .returns(() => throwError('error')); + + eventConsumersState.load().pipe(onErrorResumeNext()).subscribe(); + + expect(eventConsumersState.snapshot.isLoading).toBeFalsy(); + }); + it('should show notification on load when reload is true', () => { eventConsumersService.setup(x => x.getEventConsumers()) .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); diff --git a/frontend/app/features/administration/state/event-consumers.state.ts b/frontend/app/features/administration/state/event-consumers.state.ts index 4d5668fa8..ceefabeb5 100644 --- a/frontend/app/features/administration/state/event-consumers.state.ts +++ b/frontend/app/features/administration/state/event-consumers.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { finalize, tap } from 'rxjs/operators'; import { DialogService, @@ -23,6 +23,9 @@ interface Snapshot { // Indicates if event consumers are loaded. isLoaded?: boolean; + + // Indicates if event consumers are loading. + isLoading?: boolean; } type EventConsumersList = ReadonlyArray; @@ -35,6 +38,9 @@ export class EventConsumersState extends State { public isLoaded = this.project(x => x.isLoaded === true); + public isLoading = + this.project(x => x.isLoading === true); + constructor( private readonly dialogs: DialogService, private readonly eventConsumersService: EventConsumersService @@ -43,20 +49,33 @@ export class EventConsumersState extends State { } public load(isReload = false, silent = false): Observable { - if (!isReload) { + if (isReload && !silent) { this.resetState(); } + return this.loadInternal(isReload, silent); + } + + private loadInternal(isReload: boolean, silent: boolean): Observable { + if (!silent) { + this.next({ isLoading: true }); + } + return this.eventConsumersService.getEventConsumers().pipe( tap(({ items: eventConsumers }) => { if (isReload && !silent) { this.dialogs.notifyInfo('Event Consumers reloaded.'); } - this.next(s => { - return { ...s, eventConsumers, isLoaded: true }; + this.next({ + eventConsumers, + isLoaded: true, + isLoading: false }); }), + finalize(() => { + this.next({ isLoading: false }); + }), shareSubscribed(this.dialogs, { silent })); } diff --git a/frontend/app/features/administration/state/users.state.spec.ts b/frontend/app/features/administration/state/users.state.spec.ts index 4ded26b20..c66ef5e87 100644 --- a/frontend/app/features/administration/state/users.state.spec.ts +++ b/frontend/app/features/administration/state/users.state.spec.ts @@ -6,6 +6,7 @@ */ import { of, throwError } from 'rxjs'; +import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { @@ -57,13 +58,23 @@ describe('UsersState', () => { usersState.load().subscribe(); + expect(usersState.snapshot.isLoaded).toBeTruthy(); + expect(usersState.snapshot.isLoading).toBeFalsy(); expect(usersState.snapshot.users).toEqual([user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); - expect(usersState.snapshot.isLoaded).toBeTruthy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); + it('should reset loading when loading failed', () => { + usersService.setup(x => x.getUsers(10, 0, undefined)) + .returns(() => throwError('error')); + + usersState.load().pipe(onErrorResumeNext()).subscribe(); + + expect(usersState.snapshot.isLoading).toBeFalsy(); + }); + it('should load page size from local store', () => { localStore.setup(x => x.getInt('users.pageSize', 10)) .returns(() => 25); diff --git a/frontend/app/features/administration/state/users.state.ts b/frontend/app/features/administration/state/users.state.ts index 077a39851..224ced34f 100644 --- a/frontend/app/features/administration/state/users.state.ts +++ b/frontend/app/features/administration/state/users.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError, finalize, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; @@ -39,6 +39,9 @@ interface Snapshot { // Indicates if the users are loaded. isLoaded?: boolean; + // Indicates if the users are loading. + isLoading?: boolean; + // The selected user. selectedUser?: UserDto | null; @@ -63,6 +66,9 @@ export class UsersState extends State { public isLoaded = this.project(x => x.isLoaded === true); + public isLoading = + this.project(x => x.isLoading === true); + public canCreate = this.project(x => x.canCreate === true); @@ -105,15 +111,17 @@ export class UsersState extends State { public load(isReload = false): Observable { if (!isReload) { - const selectedUser = this.snapshot.selectedUser; + const usersPager = this.snapshot.usersPager.reset(); - this.resetState({ selectedUser }); + this.resetState({ usersPager, selectedUser: this.snapshot.selectedUser }); } return this.loadInternal(isReload); } - private loadInternal(isReload = false): Observable { + private loadInternal(isReload: boolean): Observable { + this.next({ isLoading: true }); + return this.usersService.getUsers( this.snapshot.usersPager.pageSize, this.snapshot.usersPager.skip, @@ -135,12 +143,16 @@ export class UsersState extends State { return { ...s, canCreate, isLoaded: true, + isLoading: false, selectedUser, users, usersPager }; }); }), + finalize(() => { + this.next({ isLoading: false }); + }), shareSubscribed(this.dialogs)); } @@ -184,13 +196,13 @@ export class UsersState extends State { public search(query: string): Observable { this.next(s => ({ ...s, usersPager: s.usersPager.reset(), usersQuery: query })); - return this.loadInternal(); + return this.loadInternal(false); } public setPager(usersPager: Pager) { - this.next(s => ({ ...s, usersPager })); + this.next({ usersPager }); - return this.loadInternal(); + return this.loadInternal(false); } private replaceUser(user: UserDto) { diff --git a/frontend/app/features/apps/pages/apps-page.component.html b/frontend/app/features/apps/pages/apps-page.component.html index a8f1e9631..2a267d86e 100644 --- a/frontend/app/features/apps/pages/apps-page.component.html +++ b/frontend/app/features/apps/pages/apps-page.component.html @@ -48,7 +48,7 @@
- +

New App

@@ -62,7 +62,7 @@
- +

New Blog Sample

@@ -79,7 +79,7 @@
- +

New Profile Sample

@@ -96,7 +96,7 @@
- +

New Identity App

diff --git a/frontend/app/features/apps/pages/apps-page.component.scss b/frontend/app/features/apps/pages/apps-page.component.scss index 5776d6080..02998e6a5 100644 --- a/frontend/app/features/apps/pages/apps-page.component.scss +++ b/frontend/app/features/apps/pages/apps-page.component.scss @@ -105,6 +105,12 @@ } } +.card-image { + img { + height: 6rem; + } +} + .info { color: $color-border-dark; padding: 2rem; diff --git a/frontend/app/features/assets/pages/assets-page.component.html b/frontend/app/features/assets/pages/assets-page.component.html index 82a1b2527..f9d930107 100644 --- a/frontend/app/features/assets/pages/assets-page.component.html +++ b/frontend/app/features/assets/pages/assets-page.component.html @@ -1,6 +1,6 @@ - + Assets @@ -44,11 +44,32 @@
+
+ + + +
- + + + + + + +
+ +
+ + + + +
@@ -60,4 +81,10 @@ + + + + + diff --git a/frontend/app/features/assets/pages/assets-page.component.scss b/frontend/app/features/assets/pages/assets-page.component.scss index 98574b085..d615d5710 100644 --- a/frontend/app/features/assets/pages/assets-page.component.scss +++ b/frontend/app/features/assets/pages/assets-page.component.scss @@ -22,4 +22,13 @@ } } } +} + +.grid-header { + padding-left: 1rem; +} + +.grid-content { + padding-top: 1rem; + padding-bottom: 1rem; } \ No newline at end of file diff --git a/frontend/app/features/assets/pages/assets-page.component.ts b/frontend/app/features/assets/pages/assets-page.component.ts index eba5a29d4..ee7824fc3 100644 --- a/frontend/app/features/assets/pages/assets-page.component.ts +++ b/frontend/app/features/assets/pages/assets-page.component.ts @@ -10,6 +10,7 @@ import { FormControl } from '@angular/forms'; import { AssetsState, + DialogModel, LocalStoreService, Queries, Query, @@ -27,6 +28,8 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit { public queries = new Queries(this.uiState, 'assets'); + public addAssetFolderDialog = new DialogModel(); + public isListView: boolean; constructor( diff --git a/frontend/app/features/content/pages/contents/contents-page.component.html b/frontend/app/features/content/pages/contents/contents-page.component.html index 26c62f71b..bb6676758 100644 --- a/frontend/app/features/content/pages/contents/contents-page.component.html +++ b/frontend/app/features/content/pages/contents/contents-page.component.html @@ -1,6 +1,6 @@ - + Contents @@ -38,71 +38,75 @@ -
- - - - - - - - -
- - - Actions - - - -
-
- -
- {{selectionCount}} items selected   - - - - -
+ + +
+ {{selectionCount}} items selected   + + + + +
+
-
-
- - - + +
+ + + + + + +
+ + + Actions + + + +
-
-
+
- + +
+ + + +
+
+
+ + + + + diff --git a/frontend/app/features/content/pages/contents/contents-page.component.scss b/frontend/app/features/content/pages/contents/contents-page.component.scss index 37ec92dda..2f1b55277 100644 --- a/frontend/app/features/content/pages/contents/contents-page.component.scss +++ b/frontend/app/features/content/pages/contents/contents-page.component.scss @@ -18,12 +18,6 @@ min-width: 100%; } -.grid-content { - overflow-y: auto; - overflow-x: auto; - padding-right: 0; -} - .icon-plus { font-size: .8rem; } diff --git a/frontend/app/features/content/shared/assets-editor.component.html b/frontend/app/features/content/shared/assets-editor.component.html index 71bc882ea..f4bb6a22f 100644 --- a/frontend/app/features/content/shared/assets-editor.component.html +++ b/frontend/app/features/content/shared/assets-editor.component.html @@ -22,7 +22,7 @@
- @@ -35,7 +35,7 @@
- diff --git a/frontend/app/features/content/shared/content-list-cell.directive.ts b/frontend/app/features/content/shared/content-list-cell.directive.ts index 77f150f12..1f0d46de4 100644 --- a/frontend/app/features/content/shared/content-list-cell.directive.ts +++ b/frontend/app/features/content/shared/content-list-cell.directive.ts @@ -68,7 +68,7 @@ export class ContentListWidthPipe implements PipeTransform { return 0; } - return `${getTableWidth(value.referenceFields) + 100}px`; + return `${getTableWidth(value.listFields) + 100}px`; } } diff --git a/frontend/app/features/content/shared/contents-selector.component.html b/frontend/app/features/content/shared/contents-selector.component.html index 799a9627f..9f36cd230 100644 --- a/frontend/app/features/content/shared/contents-selector.component.html +++ b/frontend/app/features/content/shared/contents-selector.component.html @@ -1,4 +1,4 @@ - +
@@ -38,47 +38,49 @@ -
- - - - - - - - -
- - - - - - -
-
- -
-
- - - + + +
+ + + + + + +
+ + + + + + +
-
-
- - +
+ + +
+ + + +
+
+
+ + + + +
diff --git a/frontend/app/features/dashboard/pages/dashboard-page.component.html b/frontend/app/features/dashboard/pages/dashboard-page.component.html index 64462fa58..a222cf389 100644 --- a/frontend/app/features/dashboard/pages/dashboard-page.component.html +++ b/frontend/app/features/dashboard/pages/dashboard-page.component.html @@ -15,7 +15,7 @@
- +

New Schema

@@ -29,7 +29,7 @@
- +

API Documentation

@@ -43,7 +43,7 @@
- +

Feedback & Support

@@ -57,7 +57,7 @@
- +

Github

diff --git a/frontend/app/features/dashboard/pages/dashboard-page.component.scss b/frontend/app/features/dashboard/pages/dashboard-page.component.scss index 33106b62d..1b5d18fe4 100644 --- a/frontend/app/features/dashboard/pages/dashboard-page.component.scss +++ b/frontend/app/features/dashboard/pages/dashboard-page.component.scss @@ -30,6 +30,12 @@ } } +.card-image { + img { + height: 5rem; + } +} + .card { & { margin-right: 1rem; diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.html b/frontend/app/features/rules/pages/events/rule-events-page.component.html index f40dcd544..0f3a847e1 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.html +++ b/frontend/app/features/rules/pages/events/rule-events-page.component.html @@ -1,6 +1,6 @@ - + Events @@ -14,83 +14,87 @@ - - - - - - - - - - + + +
- Status - - Event - - Description - - Created -
+ + + + + + + + + - - - - - - - - - - + + + + + + + + + - - - -
+ Status + + Event + + Description + + Created +
- {{event.jobResult}} - - {{event.eventName}} - - {{event.description}} - - {{event.created | sqxFromNow}} - - -
-
-

Last Invocation

-
- -
-
- {{event.result}} -
-
- Attempts: {{event.numCalls}} -
-
- Next: {{event.nextAttempt | sqxFromNow}} -
-
-
+ {{event.jobResult}} + + {{event.eventName}} + + {{event.description}} + + {{event.created | sqxFromNow}} + + +
+
+

Last Invocation

+
+ +
+
+ {{event.result}} +
+
+ Attempts: {{event.numCalls}} +
+
+ Next: {{event.nextAttempt | sqxFromNow}} +
+
+ - -
-
-
-
- -
-
-
+ +
+
+
+
+ +
+
+ + + + + - + + + diff --git a/frontend/app/features/rules/pages/rules/rules-page.component.html b/frontend/app/features/rules/pages/rules/rules-page.component.html index f7ab095ad..ef97afaa6 100644 --- a/frontend/app/features/rules/pages/rules/rules-page.component.html +++ b/frontend/app/features/rules/pages/rules/rules-page.component.html @@ -1,6 +1,6 @@ - + Rules @@ -22,38 +22,42 @@ - -
- No rule created yet. - - + +
+ +
+ No rule created yet. + + +
+ + + + +
+ + + + + +
- - - - -
- - - - - - +
diff --git a/frontend/app/features/rules/pages/rules/rules-page.component.ts b/frontend/app/features/rules/pages/rules/rules-page.component.ts index 7418562e5..2f3f66e0d 100644 --- a/frontend/app/features/rules/pages/rules/rules-page.component.ts +++ b/frontend/app/features/rules/pages/rules/rules-page.component.ts @@ -46,7 +46,7 @@ export class RulesPageComponent implements OnInit { this.ruleActions = actions; }); - this.schemasState.load(); + this.schemasState.loadIfNotLoaded(); } public reload() { diff --git a/frontend/app/features/schemas/pages/schema/schema-edit-form.component.ts b/frontend/app/features/schemas/pages/schema/schema-edit-form.component.ts index 65bdfa976..e1cfd177d 100644 --- a/frontend/app/features/schemas/pages/schema/schema-edit-form.component.ts +++ b/frontend/app/features/schemas/pages/schema/schema-edit-form.component.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnChanges } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { @@ -20,7 +20,7 @@ import { styleUrls: ['./schema-edit-form.component.scss'], templateUrl: './schema-edit-form.component.html' }) -export class SchemaEditFormComponent implements OnInit { +export class SchemaEditFormComponent implements OnChanges { public readonly standalone = { standalone: true }; @Input() @@ -37,7 +37,7 @@ export class SchemaEditFormComponent implements OnInit { ) { } - public ngOnInit() { + public ngOnChanges() { this.isEditable = this.schema.canUpdate; this.editForm.load(this.schema.properties); diff --git a/frontend/app/features/schemas/pages/schema/schema-page.component.html b/frontend/app/features/schemas/pages/schema/schema-page.component.html index 888a7863f..dc2def78b 100644 --- a/frontend/app/features/schemas/pages/schema/schema-page.component.html +++ b/frontend/app/features/schemas/pages/schema/schema-page.component.html @@ -1,6 +1,6 @@ - +