From 44818cf275b25b1f07de01090df14b198e7b0094 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Thu, 28 Oct 2021 13:43:42 +0200 Subject: [PATCH] Feature/rename tags (#777) * Provide user role in context context. * Also provide user for backwards compatibility. * Show current users on resource. * Fix tests. * Started with backend work. * UI and backend improvements. --- backend/i18n/frontend_en.json | 3 + backend/i18n/frontend_it.json | 3 + backend/i18n/frontend_nl.json | 3 + backend/i18n/frontend_zh.json | 3 + backend/i18n/source/frontend_en.json | 3 + .../Tags/ITagService.cs | 4 +- .../Tags/TagsExport.cs | 19 ++- .../Assets/BackupAssets.cs | 21 +++- .../Backup/BackupReader.cs | 10 ++ .../Backup/IBackupReader.cs | 3 + .../Tags/GrainTagService.cs | 28 +++-- .../Tags/ITagGrain.cs | 4 +- .../Tags/TagGrain.cs | 119 +++++++++++++----- .../Controllers/Assets/AssetsController.cs | 25 +++- .../Controllers/Assets/Models/AssetsDto.cs | 7 ++ .../Areas/Api/Controllers/RenameTagDto.cs | 20 +++ .../Assets/BackupAssetsTests.cs | 67 +++++++++- .../Backup/BackupReaderWriterTests.cs | 36 ++++++ .../Tags/GrainTagServiceTests.cs | 9 ++ .../Tags/TagGrainTests.cs | 81 +++++++++++- frontend/app/features/assets/declarations.ts | 1 + frontend/app/features/assets/module.ts | 3 +- .../pages/asset-tag-dialog.component.html | 29 +++++ .../pages/asset-tag-dialog.component.scss | 0 .../pages/asset-tag-dialog.component.ts | 61 +++++++++ .../assets/pages/asset-tags.component.html | 10 +- .../assets/pages/asset-tags.component.scss | 34 +++++ .../assets/pages/asset-tags.component.ts | 13 +- .../pages/assets-filters-page.component.html | 1 + .../angular/forms/error-validator.ts | 44 +++---- .../search/query-list.component.html | 2 +- .../shared/services/assets.service.spec.ts | 27 ++++ .../app/shared/services/assets.service.ts | 17 ++- frontend/app/shared/state/assets.forms.ts | 14 ++- .../app/shared/state/assets.state.spec.ts | 11 ++ frontend/app/shared/state/assets.state.ts | 19 +++ 36 files changed, 670 insertions(+), 84 deletions(-) create mode 100644 backend/src/Squidex/Areas/Api/Controllers/RenameTagDto.cs create mode 100644 frontend/app/features/assets/pages/asset-tag-dialog.component.html create mode 100644 frontend/app/features/assets/pages/asset-tag-dialog.component.scss create mode 100644 frontend/app/features/assets/pages/asset-tag-dialog.component.ts diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 805ed6543..b7a297806 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -92,6 +92,7 @@ "assets.listPageTitle": "Assets", "assets.loadFailed": "Failed to load assets. Please reload.", "assets.loadFoldersFailed": "Failed to load asset folders. Please reload.", + "assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.metadata": "Metadata", "assets.metadataAdd": "Add Metadata", "assets.moveFailed": "Failed to move asset. Please reload.", @@ -101,6 +102,7 @@ "assets.removeConfirmText": "Do you really want to remove the asset?", "assets.removeConfirmTitle": "Remove asset", "assets.renameFolder": "Rename Folder", + "assets.renameTagFailed": "Failed to rename tag. Please reload.", "assets.replaceConfirmText": "Do you really want to replace the asset with a newer version", "assets.replaceConfirmTitle": "Replace asset?", "assets.replaceFailed": "Failed to replace asset. Please reload.", @@ -336,6 +338,7 @@ "common.refresh": "Refresh", "common.remember": "Don't ask again", "common.rename": "Rename", + "common.renameTag": "Rename Tag", "common.requiredHint": "required", "common.reset": "Reset", "common.restore": "Restore", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 33bb9af2f..c2bae2104 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -92,6 +92,7 @@ "assets.listPageTitle": "Risorse", "assets.loadFailed": "Non è stato possibile caricare le risorse. Per favore ricarica.", "assets.loadFoldersFailed": "Non è stato possibile caricare le cartelle delle risorse. Per favore ricarica.", + "assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.metadata": "Metadati", "assets.metadataAdd": "Aggiungi un metadato", "assets.moveFailed": "Non è stato possibile spostare la risorsa. Per favore ricarica.", @@ -101,6 +102,7 @@ "assets.removeConfirmText": "Sei sicuro di voler cancellare la risorsa?", "assets.removeConfirmTitle": "Risorsa cancellata", "assets.renameFolder": "Rinomina la cartella", + "assets.renameTagFailed": "Failed to rename tag. Please reload.", "assets.replaceConfirmText": "Sei sicuro di voler sostituire la risorsa con una nuova versione?", "assets.replaceConfirmTitle": "Sostituisco la risorsa?", "assets.replaceFailed": "Non è stato possibile sostituire la risorsa. Per favore ricarica.", @@ -336,6 +338,7 @@ "common.refresh": "Aggiorna", "common.remember": "Ricorda la mia decisione", "common.rename": "Rinomina", + "common.renameTag": "Rename Tag", "common.requiredHint": "obbligatorio", "common.reset": "Reimposta", "common.restore": "Ripristina", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index eba9442c9..c5566c3c6 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -92,6 +92,7 @@ "assets.listPageTitle": "Bestanden", "assets.loadFailed": "Laden van bestanden is mislukt. Laad opnieuw.", "assets.loadFoldersFailed": "Laden van mappen is mislukt. Laad opnieuw.", + "assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.metadata": "Metadata", "assets.metadataAdd": "Metadata toevoegen", "assets.moveFailed": "Verplaatsen van item is mislukt. Laad opnieuw.", @@ -101,6 +102,7 @@ "assets.removeConfirmText": "Wil je het bestand echt verwijderen?", "assets.removeConfirmTitle": "Verwijder bestand", "assets.renameFolder": "Naam map wijzigen", + "assets.renameTagFailed": "Failed to rename tag. Please reload.", "assets.replaceConfirmText": "Wilt je de asset echt vervangen door een nieuwere versie", "assets.replaceConfirmTitle": "Asset vervangen?", "assets.replaceFailed": "Kan item niet vervangen. Laad opnieuw.", @@ -336,6 +338,7 @@ "common.refresh": "Vernieuwen", "common.remember": "Onthoud mijn keuze", "common.rename": "Hernoemen", + "common.renameTag": "Rename Tag", "common.requiredHint": "verplicht", "common.reset": "Reset", "common.restore": "Herstellen", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 149d7e192..fc687a926 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -92,6 +92,7 @@ "assets.listPageTitle": "资源", "assets.loadFailed": "资源加载失败,请重新加载。", "assets.loadFoldersFailed": "加载资源文件夹失败。请重新加载。", + "assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.metadata": "元数据", "assets.metadataAdd": "添加元数据", "assets.moveFailed": "资源移动失败。请重新加载。", @@ -101,6 +102,7 @@ "assets.removeConfirmText": "你真的要移除资源吗?", "assets.removeConfirmTitle": "移除资源", "assets.renameFolder": "重命名文件夹", + "assets.renameTagFailed": "Failed to rename tag. Please reload.", "assets.replaceConfirmText": "你真的想用更新的版本替换资源吗", "assets.replaceConfirmTitle": "替换资源?", "assets.replaceFailed": "替换资源失败。请重新加载。", @@ -336,6 +338,7 @@ "common.refresh": "刷新", "common.remember": "不要再问了", "common.rename": "重命名", + "common.renameTag": "Rename Tag", "common.requiredHint": "必需的", "common.reset": "重置", "common.restore": "恢复", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 805ed6543..b7a297806 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -92,6 +92,7 @@ "assets.listPageTitle": "Assets", "assets.loadFailed": "Failed to load assets. Please reload.", "assets.loadFoldersFailed": "Failed to load asset folders. Please reload.", + "assets.loadTagsFailed": "Failed to load tags. Please reload.", "assets.metadata": "Metadata", "assets.metadataAdd": "Add Metadata", "assets.moveFailed": "Failed to move asset. Please reload.", @@ -101,6 +102,7 @@ "assets.removeConfirmText": "Do you really want to remove the asset?", "assets.removeConfirmTitle": "Remove asset", "assets.renameFolder": "Rename Folder", + "assets.renameTagFailed": "Failed to rename tag. Please reload.", "assets.replaceConfirmText": "Do you really want to replace the asset with a newer version", "assets.replaceConfirmTitle": "Replace asset?", "assets.replaceFailed": "Failed to replace asset. Please reload.", @@ -336,6 +338,7 @@ "common.refresh": "Refresh", "common.remember": "Don't ask again", "common.rename": "Rename", + "common.renameTag": "Rename Tag", "common.requiredHint": "required", "common.reset": "Reset", "common.restore": "Restore", diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs index baa9483b1..23ca927d1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs @@ -23,7 +23,9 @@ namespace Squidex.Domain.Apps.Core.Tags Task GetExportableTagsAsync(DomainId appId, string group); - Task RebuildTagsAsync(DomainId appId, string group, TagsExport tags); + Task RenameTagAsync(DomainId appId, string group, string name, string newName); + + Task RebuildTagsAsync(DomainId appId, string group, TagsExport export); Task ClearAsync(DomainId appId, string group); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs index d1f54ecf7..61a53b6cf 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs @@ -9,7 +9,24 @@ using System.Collections.Generic; namespace Squidex.Domain.Apps.Core.Tags { - public sealed class TagsExport : Dictionary + public class TagsExport { + public Dictionary Tags { get; set; } + + public Dictionary? Alias { get; set; } + + public TagsExport Clone() + { + var alias = (Dictionary?)null; + + if (Alias != null) + { + alias = new Dictionary(Alias); + } + + var tags = new Dictionary(Tags); + + return new TagsExport { Alias = alias, Tags = tags }; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs index 82775f5ae..5b7d1882d 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 int BatchSize = 100; private const string TagsFile = "AssetTags.json"; + private const string TagsAliasFile = "AssetTagsAlias.json"; private readonly HashSet assetIds = new HashSet(); private readonly HashSet assetFolderIds = new HashSet(); private readonly Rebuilder rebuilder; @@ -119,9 +120,18 @@ namespace Squidex.Domain.Apps.Entities.Assets private async Task RestoreTagsAsync(RestoreContext context, CancellationToken ct) { - var tags = await context.Reader.ReadJsonAsync(TagsFile, ct); + var tags = await context.Reader.ReadJsonAsync>(TagsFile, ct); - await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, tags); + var alias = (Dictionary?)null; + + if (await context.Reader.HasFileAsync(TagsAliasFile, ct)) + { + alias = await context.Reader.ReadJsonAsync>(TagsAliasFile, ct); + } + + var export = new TagsExport { Tags = tags, Alias = alias }; + + await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, export); } private async Task BackupTagsAsync(BackupContext context, @@ -129,7 +139,12 @@ namespace Squidex.Domain.Apps.Entities.Assets { var tags = await tagService.GetExportableTagsAsync(context.AppId, TagGroups.Assets); - await context.Writer.WriteJsonAsync(TagsFile, tags, ct); + await context.Writer.WriteJsonAsync(TagsFile, tags.Tags, ct); + + if (tags.Alias?.Count > 0) + { + await context.Writer.WriteJsonAsync(TagsAliasFile, tags.Alias, ct); + } } private async Task WriteAssetAsync(DomainId appId, DomainId assetId, long fileVersion, IBackupWriter writer, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs index 0ec3efc9b..bf59ebefb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs @@ -77,6 +77,16 @@ namespace Squidex.Domain.Apps.Entities.Backup } } + public Task HasFileAsync(string name, + CancellationToken ct = default) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); + + return Task.FromResult(attachmentEntry?.Length > 0); + } + private ZipArchiveEntry GetEntry(string name) { var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs index 08fc1b9e8..19b54cb1e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs @@ -27,6 +27,9 @@ namespace Squidex.Domain.Apps.Entities.Backup Task ReadJsonAsync(string name, CancellationToken ct = default); + Task HasFileAsync(string name, + CancellationToken ct = default); + IAsyncEnumerable<(string Stream, Envelope Event)> ReadEventsAsync(IStreamNameResolver streamNameResolver, IEventDataFormatter formatter, CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs b/backend/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs index 53629c5b4..7b0940804 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs @@ -22,21 +22,40 @@ namespace Squidex.Domain.Apps.Entities.Tags this.grainFactory = grainFactory; } - public Task> NormalizeTagsAsync(DomainId appId, string group, HashSet? names, HashSet? ids) + public Task RenameTagAsync(DomainId appId, string group, string name, string newName) { - return GetGrain(appId, group).NormalizeTagsAsync(names, ids); + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNullOrEmpty(newName, nameof(newName)); + + return GetGrain(appId, group).RenameTagAsync(name, newName); + } + + public Task RebuildTagsAsync(DomainId appId, string group, TagsExport export) + { + Guard.NotNull(export, nameof(export)); + + return GetGrain(appId, group).RebuildAsync(export); } public Task> GetTagIdsAsync(DomainId appId, string group, HashSet names) { + Guard.NotNull(names, nameof(names)); + return GetGrain(appId, group).GetTagIdsAsync(names); } public Task> DenormalizeTagsAsync(DomainId appId, string group, HashSet ids) { + Guard.NotNull(ids, nameof(ids)); + return GetGrain(appId, group).DenormalizeTagsAsync(ids); } + public Task> NormalizeTagsAsync(DomainId appId, string group, HashSet? names, HashSet? ids) + { + return GetGrain(appId, group).NormalizeTagsAsync(names, ids); + } + public Task GetTagsAsync(DomainId appId, string group) { return GetGrain(appId, group).GetTagsAsync(); @@ -47,11 +66,6 @@ namespace Squidex.Domain.Apps.Entities.Tags return GetGrain(appId, group).GetExportableTagsAsync(); } - public Task RebuildTagsAsync(DomainId appId, string group, TagsExport tags) - { - return GetGrain(appId, group).RebuildAsync(tags); - } - public Task ClearAsync(DomainId appId, string group) { return GetGrain(appId, group).ClearAsync(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs index a1cd13b9d..61ff4b9ec 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs @@ -26,6 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Tags Task ClearAsync(); - Task RebuildAsync(TagsExport tags); + Task RenameTagAsync(string name, string newName); + + Task RebuildAsync(TagsExport export); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index f89224b5d..6c96afbd5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -21,12 +20,13 @@ namespace Squidex.Domain.Apps.Entities.Tags private readonly IGrainState state; [CollectionName("Index_Tags")] - public sealed class State + public sealed class State : TagsExport { - public TagsExport Tags { get; set; } = new TagsExport(); } - public TagsExport Tags => state.Value.Tags; + private Dictionary Tags => state.Value.Tags ??= new Dictionary(); + + private Dictionary Alias => state.Value.Alias ??= new Dictionary(); public TagGrain(IGrainState state) { @@ -38,9 +38,43 @@ namespace Squidex.Domain.Apps.Entities.Tags return state.ClearAsync(); } - public Task RebuildAsync(TagsExport tags) + public Task RebuildAsync(TagsExport export) { - state.Value.Tags = tags; + state.Value.Tags = export.Tags; + state.Value.Alias = export.Alias; + + return state.WriteAsync(); + } + + public Task RenameTagAsync(string name, string newName) + { + Guard.NotNull(name, nameof(name)); + Guard.NotNull(newName, nameof(newName)); + + name = NormalizeName(name); + + var (_, tag) = FindTag(name); + + if (tag == null) + { + return Task.CompletedTask; + } + + newName = NormalizeName(newName); + + tag.Name = newName; + + foreach (var alias in Alias.Where(x => x.Value == name).ToList()) + { + Alias.Remove(alias.Key); + + if (alias.Key != newName) + { + Alias[alias.Key] = newName; + } + } + + Alias[name] = newName; return state.WriteAsync(); } @@ -53,10 +87,10 @@ namespace Squidex.Domain.Apps.Entities.Tags { foreach (var tag in names) { - if (!string.IsNullOrWhiteSpace(tag)) - { - var name = tag.ToLowerInvariant(); + var name = NormalizeName(tag); + if (!string.IsNullOrWhiteSpace(name)) + { result.Add(name, GetId(name, ids)); } } @@ -86,34 +120,17 @@ namespace Squidex.Domain.Apps.Entities.Tags return result; } - private string GetId(string name, HashSet? ids) - { - var (id, value) = Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)); - - if (value != null) - { - if (ids == null || !ids.Contains(id)) - { - value.Count++; - } - } - else - { - id = DomainId.NewGuid().ToString(); - - Tags.Add(id, new Tag { Name = name }); - } - - return id; - } - public Task> GetTagIdsAsync(HashSet names) { + Guard.NotNull(names, nameof(names)); + var result = new Dictionary(); - foreach (var name in names) + foreach (var tag in names) { - var (id, _) = Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)); + var name = NormalizeName(tag); + + var (id, _) = FindTag(name); if (!string.IsNullOrWhiteSpace(id)) { @@ -148,7 +165,43 @@ namespace Squidex.Domain.Apps.Entities.Tags public Task GetExportableTagsAsync() { - return Task.FromResult(Tags); + return Task.FromResult(state.Value.Clone()); + } + + private string GetId(string name, HashSet? ids) + { + var (id, tag) = FindTag(name); + + if (tag != null) + { + if (ids == null || !ids.Contains(id)) + { + tag.Count++; + } + } + else + { + id = DomainId.NewGuid().ToString(); + + Tags.Add(id, new Tag { Name = name }); + } + + return id; + } + + private static string NormalizeName(string name) + { + return name.Trim().ToLowerInvariant(); + } + + private KeyValuePair FindTag(string name) + { + if (Alias.TryGetValue(name, out var newName)) + { + name = newName; + } + + return Tags.FirstOrDefault(x => x.Value.Name == name); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index f50a2c941..3f91cf05b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -60,7 +61,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// /// The name of the app. /// - /// 200 => Assets returned. + /// 200 => Assets tags returned. /// 404 => App not found. /// /// @@ -80,6 +81,28 @@ namespace Squidex.Areas.Api.Controllers.Assets return Ok(tags); } + /// + /// Rename an asset tag. + /// + /// The name of the app. + /// The tag to return. + /// The required request object. + /// + /// 200 => Asset tag renamed and new tags returned. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/assets/tags/{name}")] + [ProducesResponseType(typeof(Dictionary), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(Permissions.AppAssetsUpdate)] + [ApiCosts(1)] + public async Task PutTag(string app, string name, [FromBody] RenameTagDto request) + { + await tagService.RenameTagAsync(AppId, TagGroups.Assets, Uri.UnescapeDataString(name), request.TagName); + + return await GetTags(app); + } + /// /// Get assets. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs index 3d6ff1373..69aecf96f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs @@ -48,6 +48,13 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models response.AddPostLink("create", resources.Url(x => nameof(x.PostAsset), values)); } + if (resources.CanUpdateAsset) + { + var tagValue = new { values.app, name = "tag" }; + + response.AddPutLink("tags/rename", resources.Url(x => nameof(x.PutTag), tagValue)); + } + response.AddGetLink("tags", resources.Url(x => nameof(x.GetTags), values)); return response; diff --git a/backend/src/Squidex/Areas/Api/Controllers/RenameTagDto.cs b/backend/src/Squidex/Areas/Api/Controllers/RenameTagDto.cs new file mode 100644 index 000000000..0d261f968 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/RenameTagDto.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Validation; + +namespace Squidex.Areas.Api.Controllers +{ + public sealed class RenameTagDto + { + /// + /// The new name for the tag. + /// + [LocalizedRequired] + public string TagName { get; set; } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs index fd3246bf9..ca6a1c0fe 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs @@ -47,9 +47,12 @@ namespace Squidex.Domain.Apps.Entities.Assets } [Fact] - public async Task Should_writer_tags() + public async Task Should_write_tags() { - var tags = new TagsExport(); + var tags = new TagsExport + { + Tags = new Dictionary() + }; var context = CreateBackupContext(); @@ -58,23 +61,75 @@ namespace Squidex.Domain.Apps.Entities.Assets await sut.BackupAsync(context, ct); - A.CallTo(() => context.Writer.WriteJsonAsync(A._, tags, ct)) + A.CallTo(() => context.Writer.WriteJsonAsync(A._, tags.Tags, ct)) + .MustHaveHappened(); + + A.CallTo(() => context.Writer.WriteJsonAsync(A._, tags.Alias!, ct)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_write_tags_with_alias() + { + var tags = new TagsExport + { + Alias = new Dictionary + { + ["tag1"] = "new-name", + }, + Tags = new Dictionary() + }; + + var context = CreateBackupContext(); + + A.CallTo(() => tagService.GetExportableTagsAsync(context.AppId, TagGroups.Assets)) + .Returns(tags); + + await sut.BackupAsync(context, ct); + + A.CallTo(() => context.Writer.WriteJsonAsync(A._, tags.Tags, ct)) + .MustHaveHappened(); + + A.CallTo(() => context.Writer.WriteJsonAsync(A._, tags.Alias, ct)) .MustHaveHappened(); } [Fact] public async Task Should_read_tags() { - var tags = new TagsExport(); + var tags = new Dictionary(); + + var context = CreateRestoreContext(); + + A.CallTo(() => context.Reader.ReadJsonAsync>(A._, ct)) + .Returns(tags); + + await sut.RestoreAsync(context, ct); + + A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A.That.Matches(x => x.Tags == tags))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_read_tags_alias_if_file_exists() + { + var tags = new Dictionary(); + var alias = new Dictionary(); var context = CreateRestoreContext(); - A.CallTo(() => context.Reader.ReadJsonAsync(A._, ct)) + A.CallTo(() => context.Reader.HasFileAsync(A._, ct)) + .Returns(true); + + A.CallTo(() => context.Reader.ReadJsonAsync>(A._, ct)) .Returns(tags); + A.CallTo(() => context.Reader.ReadJsonAsync>(A._, ct)) + .Returns(alias); + await sut.RestoreAsync(context, ct); - A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, tags)) + A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A.That.Matches(x => x.Tags == tags && x.Alias == alias))) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs index df3031d4d..99477e47d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs @@ -65,6 +65,42 @@ namespace Squidex.Domain.Apps.Entities.Backup }); } + [Fact] + public async Task Should_return_true_if_file_exists() + { + var file = "File.json"; + + var value = Guid.NewGuid(); + + await TestReaderWriterAsync(BackupVersion.V1, async writer => + { + await WriteJsonGuidAsync(writer, file, value); + }, async reader => + { + var hasFile = await reader.HasFileAsync(file); + + Assert.True(hasFile); + }); + } + + [Fact] + public async Task Should_return_file_if_file_does_not_exist() + { + var file = "File.json"; + + var value = Guid.NewGuid(); + + await TestReaderWriterAsync(BackupVersion.V1, async writer => + { + await Task.Yield(); + }, async reader => + { + var hasFile = await reader.HasFileAsync(file); + + Assert.False(hasFile); + }); + } + [Fact] public async Task Should_read_and_write_json_async() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs index 9d981d2ce..b9592b6f8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs @@ -39,6 +39,15 @@ namespace Squidex.Domain.Apps.Entities.Tags .MustHaveHappened(); } + [Fact] + public async Task Should_call_grain_if_renaming() + { + await sut.RenameTagAsync(appId, TagGroups.Assets, "name", "newName"); + + A.CallTo(() => grain.RenameTagAsync("name", "newName")) + .MustHaveHappened(); + } + [Fact] public async Task Should_call_grain_if_rebuilding() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs index 9df8a1917..4723679d6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; +using FluentAssertions; using Squidex.Domain.Apps.Core.Tags; using Squidex.Infrastructure; using Squidex.Infrastructure.Orleans; @@ -47,14 +48,83 @@ namespace Squidex.Domain.Apps.Entities.Tags .MustHaveHappened(); } + [Fact] + public async Task Should_rename_tag() + { + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + + await sut.RenameTagAsync("tag1", "tag1_new"); + + // Forward the old name to the new name. + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag1_new"), null); + + var allTags = await sut.GetTagsAsync(); + + Assert.Equal(new Dictionary + { + ["tag1_new"] = 4 + }, allTags); + } + + [Fact] + public async Task Should_rename_tag_twice() + { + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + + await sut.RenameTagAsync("tag1", "tag1_new1"); + + // Rename again. + await sut.RenameTagAsync("tag1_new1", "tag1_new2"); + + // Forward the old name to the new name. + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag1_new1"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag1_new2"), null); + + var allTags = await sut.GetTagsAsync(); + + Assert.Equal(new Dictionary + { + ["tag1_new2"] = 5 + }, allTags); + } + + [Fact] + public async Task Should_rename_tag_back() + { + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + + await sut.RenameTagAsync("tag1", "tag1_new1"); + + // Rename back. + await sut.RenameTagAsync("tag1_new1", "tag1"); + + // Forward the old name to the new name. + await sut.NormalizeTagsAsync(HashSet.Of("tag1"), null); + + var allTags = await sut.GetTagsAsync(); + + Assert.Equal(new Dictionary + { + ["tag1"] = 3 + }, allTags); + } + [Fact] public async Task Should_rebuild_tags() { var tags = new TagsExport { - ["id1"] = new Tag { Name = "name1", Count = 1 }, - ["id2"] = new Tag { Name = "name2", Count = 2 }, - ["id3"] = new Tag { Name = "name3", Count = 6 } + Tags = new Dictionary + { + ["id1"] = new Tag { Name = "name1", Count = 1 }, + ["id2"] = new Tag { Name = "name2", Count = 2 }, + ["id3"] = new Tag { Name = "name3", Count = 6 } + } }; await sut.RebuildAsync(tags); @@ -68,7 +138,9 @@ namespace Squidex.Domain.Apps.Entities.Tags ["name3"] = 6 }, allTags); - Assert.Same(tags, await sut.GetExportableTagsAsync()); + var export = await sut.GetExportableTagsAsync(); + + export.Should().BeEquivalentTo(tags); } [Fact] @@ -124,6 +196,7 @@ namespace Squidex.Domain.Apps.Entities.Tags public async Task Should_resolve_tag_names() { var tagIds = await sut.NormalizeTagsAsync(HashSet.Of("name1", "name2"), null); + var tagNames = await sut.GetTagIdsAsync(HashSet.Of("name1", "name2", "invalid1")); Assert.Equal(tagIds, tagNames); diff --git a/frontend/app/features/assets/declarations.ts b/frontend/app/features/assets/declarations.ts index 3bd37c8d0..fafc30a4a 100644 --- a/frontend/app/features/assets/declarations.ts +++ b/frontend/app/features/assets/declarations.ts @@ -5,6 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +export * from './pages/asset-tag-dialog.component'; export * from './pages/asset-tags.component'; export * from './pages/assets-filters-page.component'; export * from './pages/assets-page.component'; diff --git a/frontend/app/features/assets/module.ts b/frontend/app/features/assets/module.ts index 3c8cffed3..6cb95cafa 100644 --- a/frontend/app/features/assets/module.ts +++ b/frontend/app/features/assets/module.ts @@ -8,7 +8,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; -import { AssetsFiltersPageComponent, AssetsPageComponent, AssetTagsComponent } from './declarations'; +import { AssetsFiltersPageComponent, AssetsPageComponent, AssetTagDialogComponent, AssetTagsComponent } from './declarations'; const routes: Routes = [ { @@ -32,6 +32,7 @@ const routes: Routes = [ declarations: [ AssetsFiltersPageComponent, AssetsPageComponent, + AssetTagDialogComponent, AssetTagsComponent, ], }) diff --git a/frontend/app/features/assets/pages/asset-tag-dialog.component.html b/frontend/app/features/assets/pages/asset-tag-dialog.component.html new file mode 100644 index 000000000..271c3dafb --- /dev/null +++ b/frontend/app/features/assets/pages/asset-tag-dialog.component.html @@ -0,0 +1,29 @@ +
+ + + {{ 'common.renameTag' | sqxTranslate }} + + + + + +
+ + + + + +
+
+ + + + + + +
+
\ No newline at end of file diff --git a/frontend/app/features/assets/pages/asset-tag-dialog.component.scss b/frontend/app/features/assets/pages/asset-tag-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/app/features/assets/pages/asset-tag-dialog.component.ts b/frontend/app/features/assets/pages/asset-tag-dialog.component.ts new file mode 100644 index 000000000..41390c63b --- /dev/null +++ b/frontend/app/features/assets/pages/asset-tag-dialog.component.ts @@ -0,0 +1,61 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { AssetsState, RenameAssetTagForm } from '@app/shared/internal'; + +@Component({ + selector: 'sqx-asset-tag-dialog[tagName]', + styleUrls: ['./asset-tag-dialog.component.scss'], + templateUrl: './asset-tag-dialog.component.html', +}) +export class AssetTagDialogComponent implements OnInit { + @Output() + public complete = new EventEmitter(); + + @Input() + public tagName: string; + + public editForm = new RenameAssetTagForm(this.formBuilder); + + constructor( + private readonly assetsState: AssetsState, + private readonly formBuilder: FormBuilder, + ) { + } + + public ngOnInit() { + this.editForm.load({ tagName: this.tagName }); + } + + public emitComplete() { + this.complete.emit(); + } + + public renameAssetTag() { + const value = this.editForm.submit(); + + if (!value) { + return; + } + + if (value.tagName === this.tagName) { + this.emitComplete(); + } + + this.assetsState.renameTag(this.tagName, value?.tagName) + .subscribe({ + next: () => { + this.emitComplete(); + }, + error: error => { + this.editForm.submitFailed(error); + }, + }); + } +} diff --git a/frontend/app/features/assets/pages/asset-tags.component.html b/frontend/app/features/assets/pages/asset-tags.component.html index ba312eb59..9aac0595e 100644 --- a/frontend/app/features/assets/pages/asset-tags.component.html +++ b/frontend/app/features/assets/pages/asset-tags.component.html @@ -13,8 +13,16 @@
{{tag.count}}
+ + + +
- \ No newline at end of file + + + + + \ No newline at end of file diff --git a/frontend/app/features/assets/pages/asset-tags.component.scss b/frontend/app/features/assets/pages/asset-tags.component.scss index 158da1b45..51cd31922 100644 --- a/frontend/app/features/assets/pages/asset-tags.component.scss +++ b/frontend/app/features/assets/pages/asset-tags.component.scss @@ -1,3 +1,13 @@ +.badge, +.badge-secondary, +.btn-rename { + height: 1.5rem; +} + +.badge { + min-width: 2rem; +} + .active { .badge { background: none; @@ -5,4 +15,28 @@ border-radius: 0; color: $color-theme-brand; } +} + +.btn-rename { + display: none; + + i { + margin: 0; + } +} + +.nav-item { + &:hover { + .btn-rename { + display: block; + } + + .badge { + display: none; + } + } +} + +a { + text-decoration: none; } \ No newline at end of file diff --git a/frontend/app/features/assets/pages/asset-tags.component.ts b/frontend/app/features/assets/pages/asset-tags.component.ts index ea3d7ca4e..69dfc621c 100644 --- a/frontend/app/features/assets/pages/asset-tags.component.ts +++ b/frontend/app/features/assets/pages/asset-tags.component.ts @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { TagItem, TagsSelected } from '@app/shared'; +import { DialogModel, TagItem, TagsSelected } from '@app/shared'; @Component({ selector: 'sqx-asset-tags[tags][tagsSelected]', @@ -29,6 +29,12 @@ export class AssetTagsComponent { @Input() public tagsSelected: TagsSelected; + @Input() + public canRename: boolean; + + public tagRenaming: TagItem; + public tagRenameDialog = new DialogModel(); + public isEmpty() { return Object.keys(this.tagsSelected).length === 0; } @@ -37,6 +43,11 @@ export class AssetTagsComponent { return this.tagsSelected[tag.name] === true; } + public renameTag(tag: TagItem) { + this.tagRenaming = tag; + this.tagRenameDialog.show(); + } + public trackByTag(_index: number, tag: TagItem) { return tag.name; } diff --git a/frontend/app/features/assets/pages/assets-filters-page.component.html b/frontend/app/features/assets/pages/assets-filters-page.component.html index 6e91caa21..2dd8d2f95 100644 --- a/frontend/app/features/assets/pages/assets-filters-page.component.html +++ b/frontend/app/features/assets/pages/assets-filters-page.component.html @@ -2,6 +2,7 @@

{{ 'common.tags' | sqxTranslate }}

diff --git a/frontend/app/framework/angular/forms/error-validator.ts b/frontend/app/framework/angular/forms/error-validator.ts index 1b2948166..444975a77 100644 --- a/frontend/app/framework/angular/forms/error-validator.ts +++ b/frontend/app/framework/angular/forms/error-validator.ts @@ -10,7 +10,7 @@ import { ErrorDto } from '@app/framework/internal'; import { getControlPath } from './forms-helper'; export class ErrorValidator { - private values: { [path: string]: { value: any } } = {}; + private errorsCache: { [path: string]: { value: any } } = {}; private error: ErrorDto | undefined | null; public validator: ValidatorFn = control => { @@ -26,38 +26,40 @@ export class ErrorValidator { const value = control.value; - const current = this.values[path]; + const current = this.errorsCache[path]; if (current && current.value !== value) { - this.values[path] = { value }; + this.errorsCache[path] = { value }; return null; } const errors: string[] = []; - for (const details of this.error.details) { - for (const property of details.properties) { - if (property.startsWith(path)) { - const subProperty = property.substr(path.length); - - const first = subProperty[0]; - - if (!first) { - errors.push(details.message); - break; - } else if (first === '[') { - errors.push(`${subProperty}: ${details.message}`); - break; - } else if (first === '.') { - errors.push(`${subProperty.substr(1)}: ${details.message}`); - break; + if (this.error.details) { + for (const details of this.error.details) { + for (const property of details.properties) { + if (property.startsWith(path)) { + const subProperty = property.substr(path.length); + + const first = subProperty[0]; + + if (!first) { + errors.push(details.message); + break; + } else if (first === '[') { + errors.push(`${subProperty}: ${details.message}`); + break; + } else if (first === '.') { + errors.push(`${subProperty.substr(1)}: ${details.message}`); + break; + } } } } } if (errors.length > 0) { - this.values[path] = { value }; + this.errorsCache[path] = { value }; return { custom: { @@ -70,7 +72,7 @@ export class ErrorValidator { }; public setError(error: ErrorDto | undefined | null) { - this.values = {}; + this.errorsCache = {}; this.error = error; } } diff --git a/frontend/app/shared/components/search/query-list.component.html b/frontend/app/shared/components/search/query-list.component.html index 78dca58fb..e435a63e0 100644 --- a/frontend/app/shared/components/search/query-list.component.html +++ b/frontend/app/shared/components/search/query-list.component.html @@ -10,7 +10,7 @@ - + diff --git a/frontend/app/shared/services/assets.service.spec.ts b/frontend/app/shared/services/assets.service.spec.ts index e05ec2f9a..69c83847f 100644 --- a/frontend/app/shared/services/assets.service.spec.ts +++ b/frontend/app/shared/services/assets.service.spec.ts @@ -53,6 +53,33 @@ describe('AssetsService', () => { }); })); + it('should make put request to rename asset tag', + inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { + const dto = { tagName: 'new-name' }; + + let tags: any; + + assetsService.putTag('my-app', 'old-name', dto).subscribe(result => { + tags = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/tags/old-name'); + + expect(req.request.body).toEqual(dto); + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + tag1: 1, + tag2: 4, + }); + + expect(tags!).toEqual({ + tag1: 1, + tag2: 4, + }); + })); + it('should make get request to get assets', inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { let assets: AssetsDto; diff --git a/frontend/app/shared/services/assets.service.ts b/frontend/app/shared/services/assets.service.ts index 9d7c0d573..6a8e1cd08 100644 --- a/frontend/app/shared/services/assets.service.ts +++ b/frontend/app/shared/services/assets.service.ts @@ -22,6 +22,10 @@ export class AssetsDto extends ResultSet { public get canCreate() { return hasAnyLink(this._links, 'create'); } + + public get canRenameTag() { + return hasAnyLink(this._links, 'tags/rename'); + } } export class AssetDto { @@ -150,6 +154,9 @@ export type CreateAssetFolderDto = export type RenameAssetFolderDto = Readonly<{ folderName: string }>; +export type RenameAssetTagDto = + Readonly<{ tagName: string }>; + export type MoveAssetItemDto = Readonly<{ parentId?: string }>; @@ -165,10 +172,18 @@ export class AssetsService { ) { } + public putTag(appName: string, name: string, dto: RenameAssetTagDto): Observable<{ [name: string]: number }> { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/tags/${encodeURIComponent(name)}`); + + return this.http.put<{ [name: string]: number }>(url, dto).pipe( + pretifyError('i18n:assets.renameTagFailed')); + } + public getTags(appName: string): Observable<{ [name: string]: number }> { const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/tags`); - return this.http.get<{ [name: string]: number }>(url); + return this.http.get<{ [name: string]: number }>(url).pipe( + pretifyError('i18n:assets.loadTagsFailed')); } public getAssets(appName: string, q?: AssetQueryDto): Observable { diff --git a/frontend/app/shared/state/assets.forms.ts b/frontend/app/shared/state/assets.forms.ts index 078991dc8..74ff66d86 100644 --- a/frontend/app/shared/state/assets.forms.ts +++ b/frontend/app/shared/state/assets.forms.ts @@ -8,7 +8,7 @@ import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Form, Mutable, Types } from '@app/framework'; import slugify from 'slugify'; -import { AnnotateAssetDto, AssetDto, AssetFolderDto, RenameAssetFolderDto } from './../services/assets.service'; +import { AnnotateAssetDto, AssetDto, AssetFolderDto, RenameAssetFolderDto, RenameAssetTagDto } from './../services/assets.service'; export class AnnotateAssetForm extends Form { public get metadata() { @@ -217,3 +217,15 @@ export class RenameAssetFolderForm extends Form { + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + tagName: ['', + [ + Validators.required, + ], + ], + })); + } +} diff --git a/frontend/app/shared/state/assets.state.spec.ts b/frontend/app/shared/state/assets.state.spec.ts index bb0610820..2054df367 100644 --- a/frontend/app/shared/state/assets.state.spec.ts +++ b/frontend/app/shared/state/assets.state.spec.ts @@ -373,5 +373,16 @@ describe('AssetsState', () => { expect(assetsState.snapshot.folders.length).toBe(1); }); + + it('should replace tags if renamed', () => { + const newTags = {}; + + assetsService.setup(x => x.putTag(app, 'old-name', { tagName: 'new-name' })) + .returns(() => of(newTags)); + + assetsState.renameTag('old-name', 'new-name').subscribe(); + + expect(assetsState.snapshot.tagsAvailable).toBe(newTags); + }); }); }); diff --git a/frontend/app/shared/state/assets.state.ts b/frontend/app/shared/state/assets.state.ts index 09f4ee417..18212c1af 100644 --- a/frontend/app/shared/state/assets.state.ts +++ b/frontend/app/shared/state/assets.state.ts @@ -50,6 +50,9 @@ interface Snapshot extends ListState { // Indicates if the user can create asset folders. canCreateFolders?: boolean; + + // Indicates if the user can rename asset tags. + canRenameTag?: boolean; } export abstract class AssetsStateBase extends State { @@ -104,6 +107,9 @@ export abstract class AssetsStateBase extends State { public canCreateFolders = this.project(x => x.canCreateFolders === true); + public canRenameTag = + this.project(x => x.canRenameTag === true); + protected constructor(name: string, private readonly appsState: AppsState, private readonly assetsService: AssetsService, @@ -165,6 +171,7 @@ export abstract class AssetsStateBase extends State { folders: foldersResult.items, canCreate: assetsResult.canCreate, canCreateFolders: foldersResult.canCreate, + canRenameTag: assetsResult.canRenameTag, isLoaded: true, isLoadedOnce: true, isLoading: false, @@ -328,6 +335,18 @@ export abstract class AssetsStateBase extends State { shareSubscribed(this.dialogs)); } + public renameTag(name: string, tagName: string): Observable { + return this.assetsService.putTag(this.appName, name, { tagName }).pipe( + tap(tags => { + this.next(s => { + const tagsAvailable = tags; + + return { ...s, tagsAvailable }; + }, 'Tag Renamed'); + }), + shareSubscribed(this.dialogs)); + } + public navigate(parentId: string) { if (!this.next({ parentId, query: undefined, tagsSelected: {} }, 'Loading Navigated')) { return EMPTY;