Browse Source

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.
pull/778/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
44818cf275
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      backend/i18n/frontend_en.json
  2. 3
      backend/i18n/frontend_it.json
  3. 3
      backend/i18n/frontend_nl.json
  4. 3
      backend/i18n/frontend_zh.json
  5. 3
      backend/i18n/source/frontend_en.json
  6. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs
  7. 19
      backend/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs
  8. 21
      backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs
  9. 10
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs
  10. 3
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs
  11. 28
      backend/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs
  12. 4
      backend/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs
  13. 119
      backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs
  14. 25
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  15. 7
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs
  16. 20
      backend/src/Squidex/Areas/Api/Controllers/RenameTagDto.cs
  17. 67
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs
  18. 36
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs
  19. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs
  20. 81
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs
  21. 1
      frontend/app/features/assets/declarations.ts
  22. 3
      frontend/app/features/assets/module.ts
  23. 29
      frontend/app/features/assets/pages/asset-tag-dialog.component.html
  24. 0
      frontend/app/features/assets/pages/asset-tag-dialog.component.scss
  25. 61
      frontend/app/features/assets/pages/asset-tag-dialog.component.ts
  26. 10
      frontend/app/features/assets/pages/asset-tags.component.html
  27. 34
      frontend/app/features/assets/pages/asset-tags.component.scss
  28. 13
      frontend/app/features/assets/pages/asset-tags.component.ts
  29. 1
      frontend/app/features/assets/pages/assets-filters-page.component.html
  30. 44
      frontend/app/framework/angular/forms/error-validator.ts
  31. 2
      frontend/app/shared/components/search/query-list.component.html
  32. 27
      frontend/app/shared/services/assets.service.spec.ts
  33. 17
      frontend/app/shared/services/assets.service.ts
  34. 14
      frontend/app/shared/state/assets.forms.ts
  35. 11
      frontend/app/shared/state/assets.state.spec.ts
  36. 19
      frontend/app/shared/state/assets.state.ts

3
backend/i18n/frontend_en.json

@ -92,6 +92,7 @@
"assets.listPageTitle": "Assets", "assets.listPageTitle": "Assets",
"assets.loadFailed": "Failed to load assets. Please reload.", "assets.loadFailed": "Failed to load assets. Please reload.",
"assets.loadFoldersFailed": "Failed to load asset folders. Please reload.", "assets.loadFoldersFailed": "Failed to load asset folders. Please reload.",
"assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "Metadata", "assets.metadata": "Metadata",
"assets.metadataAdd": "Add Metadata", "assets.metadataAdd": "Add Metadata",
"assets.moveFailed": "Failed to move asset. Please reload.", "assets.moveFailed": "Failed to move asset. Please reload.",
@ -101,6 +102,7 @@
"assets.removeConfirmText": "Do you really want to remove the asset?", "assets.removeConfirmText": "Do you really want to remove the asset?",
"assets.removeConfirmTitle": "Remove asset", "assets.removeConfirmTitle": "Remove asset",
"assets.renameFolder": "Rename Folder", "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.replaceConfirmText": "Do you really want to replace the asset with a newer version",
"assets.replaceConfirmTitle": "Replace asset?", "assets.replaceConfirmTitle": "Replace asset?",
"assets.replaceFailed": "Failed to replace asset. Please reload.", "assets.replaceFailed": "Failed to replace asset. Please reload.",
@ -336,6 +338,7 @@
"common.refresh": "Refresh", "common.refresh": "Refresh",
"common.remember": "Don't ask again", "common.remember": "Don't ask again",
"common.rename": "Rename", "common.rename": "Rename",
"common.renameTag": "Rename Tag",
"common.requiredHint": "required", "common.requiredHint": "required",
"common.reset": "Reset", "common.reset": "Reset",
"common.restore": "Restore", "common.restore": "Restore",

3
backend/i18n/frontend_it.json

@ -92,6 +92,7 @@
"assets.listPageTitle": "Risorse", "assets.listPageTitle": "Risorse",
"assets.loadFailed": "Non è stato possibile caricare le risorse. Per favore ricarica.", "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.loadFoldersFailed": "Non è stato possibile caricare le cartelle delle risorse. Per favore ricarica.",
"assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "Metadati", "assets.metadata": "Metadati",
"assets.metadataAdd": "Aggiungi un metadato", "assets.metadataAdd": "Aggiungi un metadato",
"assets.moveFailed": "Non è stato possibile spostare la risorsa. Per favore ricarica.", "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.removeConfirmText": "Sei sicuro di voler cancellare la risorsa?",
"assets.removeConfirmTitle": "Risorsa cancellata", "assets.removeConfirmTitle": "Risorsa cancellata",
"assets.renameFolder": "Rinomina la cartella", "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.replaceConfirmText": "Sei sicuro di voler sostituire la risorsa con una nuova versione?",
"assets.replaceConfirmTitle": "Sostituisco la risorsa?", "assets.replaceConfirmTitle": "Sostituisco la risorsa?",
"assets.replaceFailed": "Non è stato possibile sostituire la risorsa. Per favore ricarica.", "assets.replaceFailed": "Non è stato possibile sostituire la risorsa. Per favore ricarica.",
@ -336,6 +338,7 @@
"common.refresh": "Aggiorna", "common.refresh": "Aggiorna",
"common.remember": "Ricorda la mia decisione", "common.remember": "Ricorda la mia decisione",
"common.rename": "Rinomina", "common.rename": "Rinomina",
"common.renameTag": "Rename Tag",
"common.requiredHint": "obbligatorio", "common.requiredHint": "obbligatorio",
"common.reset": "Reimposta", "common.reset": "Reimposta",
"common.restore": "Ripristina", "common.restore": "Ripristina",

3
backend/i18n/frontend_nl.json

@ -92,6 +92,7 @@
"assets.listPageTitle": "Bestanden", "assets.listPageTitle": "Bestanden",
"assets.loadFailed": "Laden van bestanden is mislukt. Laad opnieuw.", "assets.loadFailed": "Laden van bestanden is mislukt. Laad opnieuw.",
"assets.loadFoldersFailed": "Laden van mappen 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.metadata": "Metadata",
"assets.metadataAdd": "Metadata toevoegen", "assets.metadataAdd": "Metadata toevoegen",
"assets.moveFailed": "Verplaatsen van item is mislukt. Laad opnieuw.", "assets.moveFailed": "Verplaatsen van item is mislukt. Laad opnieuw.",
@ -101,6 +102,7 @@
"assets.removeConfirmText": "Wil je het bestand echt verwijderen?", "assets.removeConfirmText": "Wil je het bestand echt verwijderen?",
"assets.removeConfirmTitle": "Verwijder bestand", "assets.removeConfirmTitle": "Verwijder bestand",
"assets.renameFolder": "Naam map wijzigen", "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.replaceConfirmText": "Wilt je de asset echt vervangen door een nieuwere versie",
"assets.replaceConfirmTitle": "Asset vervangen?", "assets.replaceConfirmTitle": "Asset vervangen?",
"assets.replaceFailed": "Kan item niet vervangen. Laad opnieuw.", "assets.replaceFailed": "Kan item niet vervangen. Laad opnieuw.",
@ -336,6 +338,7 @@
"common.refresh": "Vernieuwen", "common.refresh": "Vernieuwen",
"common.remember": "Onthoud mijn keuze", "common.remember": "Onthoud mijn keuze",
"common.rename": "Hernoemen", "common.rename": "Hernoemen",
"common.renameTag": "Rename Tag",
"common.requiredHint": "verplicht", "common.requiredHint": "verplicht",
"common.reset": "Reset", "common.reset": "Reset",
"common.restore": "Herstellen", "common.restore": "Herstellen",

3
backend/i18n/frontend_zh.json

@ -92,6 +92,7 @@
"assets.listPageTitle": "资源", "assets.listPageTitle": "资源",
"assets.loadFailed": "资源加载失败,请重新加载。", "assets.loadFailed": "资源加载失败,请重新加载。",
"assets.loadFoldersFailed": "加载资源文件夹失败。请重新加载。", "assets.loadFoldersFailed": "加载资源文件夹失败。请重新加载。",
"assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "元数据", "assets.metadata": "元数据",
"assets.metadataAdd": "添加元数据", "assets.metadataAdd": "添加元数据",
"assets.moveFailed": "资源移动失败。请重新加载。", "assets.moveFailed": "资源移动失败。请重新加载。",
@ -101,6 +102,7 @@
"assets.removeConfirmText": "你真的要移除资源吗?", "assets.removeConfirmText": "你真的要移除资源吗?",
"assets.removeConfirmTitle": "移除资源", "assets.removeConfirmTitle": "移除资源",
"assets.renameFolder": "重命名文件夹", "assets.renameFolder": "重命名文件夹",
"assets.renameTagFailed": "Failed to rename tag. Please reload.",
"assets.replaceConfirmText": "你真的想用更新的版本替换资源吗", "assets.replaceConfirmText": "你真的想用更新的版本替换资源吗",
"assets.replaceConfirmTitle": "替换资源?", "assets.replaceConfirmTitle": "替换资源?",
"assets.replaceFailed": "替换资源失败。请重新加载。", "assets.replaceFailed": "替换资源失败。请重新加载。",
@ -336,6 +338,7 @@
"common.refresh": "刷新", "common.refresh": "刷新",
"common.remember": "不要再问了", "common.remember": "不要再问了",
"common.rename": "重命名", "common.rename": "重命名",
"common.renameTag": "Rename Tag",
"common.requiredHint": "必需的", "common.requiredHint": "必需的",
"common.reset": "重置", "common.reset": "重置",
"common.restore": "恢复", "common.restore": "恢复",

3
backend/i18n/source/frontend_en.json

@ -92,6 +92,7 @@
"assets.listPageTitle": "Assets", "assets.listPageTitle": "Assets",
"assets.loadFailed": "Failed to load assets. Please reload.", "assets.loadFailed": "Failed to load assets. Please reload.",
"assets.loadFoldersFailed": "Failed to load asset folders. Please reload.", "assets.loadFoldersFailed": "Failed to load asset folders. Please reload.",
"assets.loadTagsFailed": "Failed to load tags. Please reload.",
"assets.metadata": "Metadata", "assets.metadata": "Metadata",
"assets.metadataAdd": "Add Metadata", "assets.metadataAdd": "Add Metadata",
"assets.moveFailed": "Failed to move asset. Please reload.", "assets.moveFailed": "Failed to move asset. Please reload.",
@ -101,6 +102,7 @@
"assets.removeConfirmText": "Do you really want to remove the asset?", "assets.removeConfirmText": "Do you really want to remove the asset?",
"assets.removeConfirmTitle": "Remove asset", "assets.removeConfirmTitle": "Remove asset",
"assets.renameFolder": "Rename Folder", "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.replaceConfirmText": "Do you really want to replace the asset with a newer version",
"assets.replaceConfirmTitle": "Replace asset?", "assets.replaceConfirmTitle": "Replace asset?",
"assets.replaceFailed": "Failed to replace asset. Please reload.", "assets.replaceFailed": "Failed to replace asset. Please reload.",
@ -336,6 +338,7 @@
"common.refresh": "Refresh", "common.refresh": "Refresh",
"common.remember": "Don't ask again", "common.remember": "Don't ask again",
"common.rename": "Rename", "common.rename": "Rename",
"common.renameTag": "Rename Tag",
"common.requiredHint": "required", "common.requiredHint": "required",
"common.reset": "Reset", "common.reset": "Reset",
"common.restore": "Restore", "common.restore": "Restore",

4
backend/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs

@ -23,7 +23,9 @@ namespace Squidex.Domain.Apps.Core.Tags
Task<TagsExport> GetExportableTagsAsync(DomainId appId, string group); Task<TagsExport> 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); Task ClearAsync(DomainId appId, string group);
} }

19
backend/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs

@ -9,7 +9,24 @@ using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Tags namespace Squidex.Domain.Apps.Core.Tags
{ {
public sealed class TagsExport : Dictionary<string, Tag> public class TagsExport
{ {
public Dictionary<string, Tag> Tags { get; set; }
public Dictionary<string, string>? Alias { get; set; }
public TagsExport Clone()
{
var alias = (Dictionary<string, string>?)null;
if (Alias != null)
{
alias = new Dictionary<string, string>(Alias);
}
var tags = new Dictionary<string, Tag>(Tags);
return new TagsExport { Alias = alias, Tags = tags };
}
} }
} }

21
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 int BatchSize = 100;
private const string TagsFile = "AssetTags.json"; private const string TagsFile = "AssetTags.json";
private const string TagsAliasFile = "AssetTagsAlias.json";
private readonly HashSet<DomainId> assetIds = new HashSet<DomainId>(); private readonly HashSet<DomainId> assetIds = new HashSet<DomainId>();
private readonly HashSet<DomainId> assetFolderIds = new HashSet<DomainId>(); private readonly HashSet<DomainId> assetFolderIds = new HashSet<DomainId>();
private readonly Rebuilder rebuilder; private readonly Rebuilder rebuilder;
@ -119,9 +120,18 @@ namespace Squidex.Domain.Apps.Entities.Assets
private async Task RestoreTagsAsync(RestoreContext context, private async Task RestoreTagsAsync(RestoreContext context,
CancellationToken ct) CancellationToken ct)
{ {
var tags = await context.Reader.ReadJsonAsync<TagsExport>(TagsFile, ct); var tags = await context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(TagsFile, ct);
await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, tags); var alias = (Dictionary<string, string>?)null;
if (await context.Reader.HasFileAsync(TagsAliasFile, ct))
{
alias = await context.Reader.ReadJsonAsync<Dictionary<string, string>>(TagsAliasFile, ct);
}
var export = new TagsExport { Tags = tags, Alias = alias };
await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, export);
} }
private async Task BackupTagsAsync(BackupContext context, 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); 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, private async Task WriteAssetAsync(DomainId appId, DomainId assetId, long fileVersion, IBackupWriter writer,

10
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs

@ -77,6 +77,16 @@ namespace Squidex.Domain.Apps.Entities.Backup
} }
} }
public Task<bool> 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) private ZipArchiveEntry GetEntry(string name)
{ {
var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name));

3
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs

@ -27,6 +27,9 @@ namespace Squidex.Domain.Apps.Entities.Backup
Task<T> ReadJsonAsync<T>(string name, Task<T> ReadJsonAsync<T>(string name,
CancellationToken ct = default); CancellationToken ct = default);
Task<bool> HasFileAsync(string name,
CancellationToken ct = default);
IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(IStreamNameResolver streamNameResolver, IEventDataFormatter formatter, IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(IStreamNameResolver streamNameResolver, IEventDataFormatter formatter,
CancellationToken ct = default); CancellationToken ct = default);
} }

28
backend/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs

@ -22,21 +22,40 @@ namespace Squidex.Domain.Apps.Entities.Tags
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
} }
public Task<Dictionary<string, string>> NormalizeTagsAsync(DomainId appId, string group, HashSet<string>? names, HashSet<string>? 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<Dictionary<string, string>> GetTagIdsAsync(DomainId appId, string group, HashSet<string> names) public Task<Dictionary<string, string>> GetTagIdsAsync(DomainId appId, string group, HashSet<string> names)
{ {
Guard.NotNull(names, nameof(names));
return GetGrain(appId, group).GetTagIdsAsync(names); return GetGrain(appId, group).GetTagIdsAsync(names);
} }
public Task<Dictionary<string, string>> DenormalizeTagsAsync(DomainId appId, string group, HashSet<string> ids) public Task<Dictionary<string, string>> DenormalizeTagsAsync(DomainId appId, string group, HashSet<string> ids)
{ {
Guard.NotNull(ids, nameof(ids));
return GetGrain(appId, group).DenormalizeTagsAsync(ids); return GetGrain(appId, group).DenormalizeTagsAsync(ids);
} }
public Task<Dictionary<string, string>> NormalizeTagsAsync(DomainId appId, string group, HashSet<string>? names, HashSet<string>? ids)
{
return GetGrain(appId, group).NormalizeTagsAsync(names, ids);
}
public Task<TagsSet> GetTagsAsync(DomainId appId, string group) public Task<TagsSet> GetTagsAsync(DomainId appId, string group)
{ {
return GetGrain(appId, group).GetTagsAsync(); return GetGrain(appId, group).GetTagsAsync();
@ -47,11 +66,6 @@ namespace Squidex.Domain.Apps.Entities.Tags
return GetGrain(appId, group).GetExportableTagsAsync(); 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) public Task ClearAsync(DomainId appId, string group)
{ {
return GetGrain(appId, group).ClearAsync(); return GetGrain(appId, group).ClearAsync();

4
backend/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs

@ -26,6 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Tags
Task ClearAsync(); Task ClearAsync();
Task RebuildAsync(TagsExport tags); Task RenameTagAsync(string name, string newName);
Task RebuildAsync(TagsExport export);
} }
} }

119
backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -21,12 +20,13 @@ namespace Squidex.Domain.Apps.Entities.Tags
private readonly IGrainState<State> state; private readonly IGrainState<State> state;
[CollectionName("Index_Tags")] [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<string, Tag> Tags => state.Value.Tags ??= new Dictionary<string, Tag>();
private Dictionary<string, string> Alias => state.Value.Alias ??= new Dictionary<string, string>();
public TagGrain(IGrainState<State> state) public TagGrain(IGrainState<State> state)
{ {
@ -38,9 +38,43 @@ namespace Squidex.Domain.Apps.Entities.Tags
return state.ClearAsync(); 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(); return state.WriteAsync();
} }
@ -53,10 +87,10 @@ namespace Squidex.Domain.Apps.Entities.Tags
{ {
foreach (var tag in names) foreach (var tag in names)
{ {
if (!string.IsNullOrWhiteSpace(tag)) var name = NormalizeName(tag);
{
var name = tag.ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(name))
{
result.Add(name, GetId(name, ids)); result.Add(name, GetId(name, ids));
} }
} }
@ -86,34 +120,17 @@ namespace Squidex.Domain.Apps.Entities.Tags
return result; return result;
} }
private string GetId(string name, HashSet<string>? 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<Dictionary<string, string>> GetTagIdsAsync(HashSet<string> names) public Task<Dictionary<string, string>> GetTagIdsAsync(HashSet<string> names)
{ {
Guard.NotNull(names, nameof(names));
var result = new Dictionary<string, string>(); var result = new Dictionary<string, string>();
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)) if (!string.IsNullOrWhiteSpace(id))
{ {
@ -148,7 +165,43 @@ namespace Squidex.Domain.Apps.Entities.Tags
public Task<TagsExport> GetExportableTagsAsync() public Task<TagsExport> GetExportableTagsAsync()
{ {
return Task.FromResult(Tags); return Task.FromResult(state.Value.Clone());
}
private string GetId(string name, HashSet<string>? 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<string, Tag> FindTag(string name)
{
if (Alias.TryGetValue(name, out var newName))
{
name = newName;
}
return Tags.FirstOrDefault(x => x.Value.Name == name);
} }
} }
} }

25
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@ -60,7 +61,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <returns> /// <returns>
/// 200 => Assets returned. /// 200 => Assets tags returned.
/// 404 => App not found. /// 404 => App not found.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
@ -80,6 +81,28 @@ namespace Squidex.Areas.Api.Controllers.Assets
return Ok(tags); return Ok(tags);
} }
/// <summary>
/// Rename an asset tag.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The tag to return.</param>
/// <param name="request">The required request object.</param>
/// <returns>
/// 200 => Asset tag renamed and new tags returned.
/// 404 => App not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/tags/{name}")]
[ProducesResponseType(typeof(Dictionary<string, int>), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutTag(string app, string name, [FromBody] RenameTagDto request)
{
await tagService.RenameTagAsync(AppId, TagGroups.Assets, Uri.UnescapeDataString(name), request.TagName);
return await GetTags(app);
}
/// <summary> /// <summary>
/// Get assets. /// Get assets.
/// </summary> /// </summary>

7
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<AssetsController>(x => nameof(x.PostAsset), values)); response.AddPostLink("create", resources.Url<AssetsController>(x => nameof(x.PostAsset), values));
} }
if (resources.CanUpdateAsset)
{
var tagValue = new { values.app, name = "tag" };
response.AddPutLink("tags/rename", resources.Url<AssetsController>(x => nameof(x.PutTag), tagValue));
}
response.AddGetLink("tags", resources.Url<AssetsController>(x => nameof(x.GetTags), values)); response.AddGetLink("tags", resources.Url<AssetsController>(x => nameof(x.GetTags), values));
return response; return response;

20
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
{
/// <summary>
/// The new name for the tag.
/// </summary>
[LocalizedRequired]
public string TagName { get; set; }
}
}

67
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs

@ -47,9 +47,12 @@ namespace Squidex.Domain.Apps.Entities.Assets
} }
[Fact] [Fact]
public async Task Should_writer_tags() public async Task Should_write_tags()
{ {
var tags = new TagsExport(); var tags = new TagsExport
{
Tags = new Dictionary<string, Tag>()
};
var context = CreateBackupContext(); var context = CreateBackupContext();
@ -58,23 +61,75 @@ namespace Squidex.Domain.Apps.Entities.Assets
await sut.BackupAsync(context, ct); await sut.BackupAsync(context, ct);
A.CallTo(() => context.Writer.WriteJsonAsync(A<string>._, tags, ct)) A.CallTo(() => context.Writer.WriteJsonAsync(A<string>._, tags.Tags, ct))
.MustHaveHappened();
A.CallTo(() => context.Writer.WriteJsonAsync(A<string>._, tags.Alias!, ct))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_write_tags_with_alias()
{
var tags = new TagsExport
{
Alias = new Dictionary<string, string>
{
["tag1"] = "new-name",
},
Tags = new Dictionary<string, Tag>()
};
var context = CreateBackupContext();
A.CallTo(() => tagService.GetExportableTagsAsync(context.AppId, TagGroups.Assets))
.Returns(tags);
await sut.BackupAsync(context, ct);
A.CallTo(() => context.Writer.WriteJsonAsync(A<string>._, tags.Tags, ct))
.MustHaveHappened();
A.CallTo(() => context.Writer.WriteJsonAsync(A<string>._, tags.Alias, ct))
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_read_tags() public async Task Should_read_tags()
{ {
var tags = new TagsExport(); var tags = new Dictionary<string, Tag>();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(A<string>._, ct))
.Returns(tags);
await sut.RestoreAsync(context, ct);
A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A<TagsExport>.That.Matches(x => x.Tags == tags)))
.MustHaveHappened();
}
[Fact]
public async Task Should_read_tags_alias_if_file_exists()
{
var tags = new Dictionary<string, Tag>();
var alias = new Dictionary<string, string>();
var context = CreateRestoreContext(); var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadJsonAsync<TagsExport>(A<string>._, ct)) A.CallTo(() => context.Reader.HasFileAsync(A<string>._, ct))
.Returns(true);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(A<string>._, ct))
.Returns(tags); .Returns(tags);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, string>>(A<string>._, ct))
.Returns(alias);
await sut.RestoreAsync(context, ct); await sut.RestoreAsync(context, ct);
A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, tags)) A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A<TagsExport>.That.Matches(x => x.Tags == tags && x.Alias == alias)))
.MustHaveHappened(); .MustHaveHappened();
} }

36
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] [Fact]
public async Task Should_read_and_write_json_async() public async Task Should_read_and_write_json_async()
{ {

9
backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/GrainTagServiceTests.cs

@ -39,6 +39,15 @@ namespace Squidex.Domain.Apps.Entities.Tags
.MustHaveHappened(); .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] [Fact]
public async Task Should_call_grain_if_rebuilding() public async Task Should_call_grain_if_rebuilding()
{ {

81
backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs

@ -8,6 +8,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -47,14 +48,83 @@ namespace Squidex.Domain.Apps.Entities.Tags
.MustHaveHappened(); .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<string, int>
{
["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<string, int>
{
["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<string, int>
{
["tag1"] = 3
}, allTags);
}
[Fact] [Fact]
public async Task Should_rebuild_tags() public async Task Should_rebuild_tags()
{ {
var tags = new TagsExport var tags = new TagsExport
{ {
["id1"] = new Tag { Name = "name1", Count = 1 }, Tags = new Dictionary<string, Tag>
["id2"] = new Tag { Name = "name2", Count = 2 }, {
["id3"] = new Tag { Name = "name3", Count = 6 } ["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); await sut.RebuildAsync(tags);
@ -68,7 +138,9 @@ namespace Squidex.Domain.Apps.Entities.Tags
["name3"] = 6 ["name3"] = 6
}, allTags); }, allTags);
Assert.Same(tags, await sut.GetExportableTagsAsync()); var export = await sut.GetExportableTagsAsync();
export.Should().BeEquivalentTo(tags);
} }
[Fact] [Fact]
@ -124,6 +196,7 @@ namespace Squidex.Domain.Apps.Entities.Tags
public async Task Should_resolve_tag_names() public async Task Should_resolve_tag_names()
{ {
var tagIds = await sut.NormalizeTagsAsync(HashSet.Of("name1", "name2"), null); var tagIds = await sut.NormalizeTagsAsync(HashSet.Of("name1", "name2"), null);
var tagNames = await sut.GetTagIdsAsync(HashSet.Of("name1", "name2", "invalid1")); var tagNames = await sut.GetTagIdsAsync(HashSet.Of("name1", "name2", "invalid1"));
Assert.Equal(tagIds, tagNames); Assert.Equal(tagIds, tagNames);

1
frontend/app/features/assets/declarations.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * 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/asset-tags.component';
export * from './pages/assets-filters-page.component'; export * from './pages/assets-filters-page.component';
export * from './pages/assets-page.component'; export * from './pages/assets-page.component';

3
frontend/app/features/assets/module.ts

@ -8,7 +8,7 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; import { SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { AssetsFiltersPageComponent, AssetsPageComponent, AssetTagsComponent } from './declarations'; import { AssetsFiltersPageComponent, AssetsPageComponent, AssetTagDialogComponent, AssetTagsComponent } from './declarations';
const routes: Routes = [ const routes: Routes = [
{ {
@ -32,6 +32,7 @@ const routes: Routes = [
declarations: [ declarations: [
AssetsFiltersPageComponent, AssetsFiltersPageComponent,
AssetsPageComponent, AssetsPageComponent,
AssetTagDialogComponent,
AssetTagsComponent, AssetTagsComponent,
], ],
}) })

29
frontend/app/features/assets/pages/asset-tag-dialog.component.html

@ -0,0 +1,29 @@
<form [formGroup]="editForm.form" (ngSubmit)="renameAssetTag()">
<sqx-modal-dialog (close)="emitComplete()">
<ng-container title>
{{ 'common.renameTag' | sqxTranslate }}
</ng-container>
<ng-container content>
<sqx-form-error [error]="editForm.error | async"></sqx-form-error>
<div class="form-group">
<label for="tagName">{{ 'common.name' | sqxTranslate }} <small class="hint">({{ 'common.requiredHint' | sqxTranslate }})</small></label>
<sqx-control-errors for="tagName"></sqx-control-errors>
<input type="text" class="form-control" id="tagName" formControlName="tagName" autocomplete="off" sqxFocusOnInit>
</div>
</ng-container>
<ng-container footer>
<button type="button" class="btn btn-text-secondary" (click)="emitComplete()">
{{ 'common.cancel' | sqxTranslate }}
</button>
<button type="submit" class="btn btn-success">
{{ 'common.rename' | sqxTranslate }}
</button>
</ng-container>
</sqx-modal-dialog>
</form>

0
frontend/app/features/assets/pages/asset-tag-dialog.component.scss

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

10
frontend/app/features/assets/pages/asset-tags.component.html

@ -13,8 +13,16 @@
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="badge badge-secondary rounded-pill">{{tag.count}}</div> <div class="badge badge-secondary rounded-pill">{{tag.count}}</div>
<a class="btn-sm btn-text-secondary btn-rename" (click)="renameTag(tag)" *ngIf="canRename" sqxStopClick>
<i class="icon-pencil"></i>
</a>
</div> </div>
</div> </div>
</a> </a>
</div> </div>
</div> </div>
<ng-container *sqxModal="tagRenameDialog">
<sqx-asset-tag-dialog [tagName]="tagRenaming.name" (complete)="tagRenameDialog.hide()"></sqx-asset-tag-dialog>
</ng-container>

34
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 { .active {
.badge { .badge {
background: none; background: none;
@ -5,4 +15,28 @@
border-radius: 0; border-radius: 0;
color: $color-theme-brand; 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;
} }

13
frontend/app/features/assets/pages/asset-tags.component.ts

@ -8,7 +8,7 @@
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */ /* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TagItem, TagsSelected } from '@app/shared'; import { DialogModel, TagItem, TagsSelected } from '@app/shared';
@Component({ @Component({
selector: 'sqx-asset-tags[tags][tagsSelected]', selector: 'sqx-asset-tags[tags][tagsSelected]',
@ -29,6 +29,12 @@ export class AssetTagsComponent {
@Input() @Input()
public tagsSelected: TagsSelected; public tagsSelected: TagsSelected;
@Input()
public canRename: boolean;
public tagRenaming: TagItem;
public tagRenameDialog = new DialogModel();
public isEmpty() { public isEmpty() {
return Object.keys(this.tagsSelected).length === 0; return Object.keys(this.tagsSelected).length === 0;
} }
@ -37,6 +43,11 @@ export class AssetTagsComponent {
return this.tagsSelected[tag.name] === true; return this.tagsSelected[tag.name] === true;
} }
public renameTag(tag: TagItem) {
this.tagRenaming = tag;
this.tagRenameDialog.show();
}
public trackByTag(_index: number, tag: TagItem) { public trackByTag(_index: number, tag: TagItem) {
return tag.name; return tag.name;
} }

1
frontend/app/features/assets/pages/assets-filters-page.component.html

@ -2,6 +2,7 @@
<h3>{{ 'common.tags' | sqxTranslate }}</h3> <h3>{{ 'common.tags' | sqxTranslate }}</h3>
<sqx-asset-tags (reset)="resetTags()" <sqx-asset-tags (reset)="resetTags()"
[canRename]="(assetsState.canRenameTag | async)!"
[tags]="(assetsState.tags | async)!" [tags]="(assetsState.tags | async)!"
[tagsSelected]="(assetsState.tagsSelected | async)!" [tagsSelected]="(assetsState.tagsSelected | async)!"
(toggle)="toggleTag($event)"> (toggle)="toggleTag($event)">

44
frontend/app/framework/angular/forms/error-validator.ts

@ -10,7 +10,7 @@ import { ErrorDto } from '@app/framework/internal';
import { getControlPath } from './forms-helper'; import { getControlPath } from './forms-helper';
export class ErrorValidator { export class ErrorValidator {
private values: { [path: string]: { value: any } } = {}; private errorsCache: { [path: string]: { value: any } } = {};
private error: ErrorDto | undefined | null; private error: ErrorDto | undefined | null;
public validator: ValidatorFn = control => { public validator: ValidatorFn = control => {
@ -26,38 +26,40 @@ export class ErrorValidator {
const value = control.value; const value = control.value;
const current = this.values[path]; const current = this.errorsCache[path];
if (current && current.value !== value) { if (current && current.value !== value) {
this.values[path] = { value }; this.errorsCache[path] = { value };
return null; return null;
} }
const errors: string[] = []; const errors: string[] = [];
for (const details of this.error.details) { if (this.error.details) {
for (const property of details.properties) { for (const details of this.error.details) {
if (property.startsWith(path)) { for (const property of details.properties) {
const subProperty = property.substr(path.length); if (property.startsWith(path)) {
const subProperty = property.substr(path.length);
const first = subProperty[0];
const first = subProperty[0];
if (!first) {
errors.push(details.message); if (!first) {
break; errors.push(details.message);
} else if (first === '[') { break;
errors.push(`${subProperty}: ${details.message}`); } else if (first === '[') {
break; errors.push(`${subProperty}: ${details.message}`);
} else if (first === '.') { break;
errors.push(`${subProperty.substr(1)}: ${details.message}`); } else if (first === '.') {
break; errors.push(`${subProperty.substr(1)}: ${details.message}`);
break;
}
} }
} }
} }
} }
if (errors.length > 0) { if (errors.length > 0) {
this.values[path] = { value }; this.errorsCache[path] = { value };
return { return {
custom: { custom: {
@ -70,7 +72,7 @@ export class ErrorValidator {
}; };
public setError(error: ErrorDto | undefined | null) { public setError(error: ErrorDto | undefined | null) {
this.values = {}; this.errorsCache = {};
this.error = error; this.error = error;
} }
} }

2
frontend/app/shared/components/search/query-list.component.html

@ -10,7 +10,7 @@
<a class="btn-sm btn-text-danger btn-remove" (click)="remove.emit(saved)" *ngIf="canRemove" sqxStopClick> <a class="btn-sm btn-text-danger btn-remove" (click)="remove.emit(saved)" *ngIf="canRemove" sqxStopClick>
<i class="icon-close"></i> <i class="icon-close"></i>
</a> </a>
</div> </div>
</div> </div>
</a> </a>
</div> </div>

27
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', it('should make get request to get assets',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let assets: AssetsDto; let assets: AssetsDto;

17
frontend/app/shared/services/assets.service.ts

@ -22,6 +22,10 @@ export class AssetsDto extends ResultSet<AssetDto> {
public get canCreate() { public get canCreate() {
return hasAnyLink(this._links, 'create'); return hasAnyLink(this._links, 'create');
} }
public get canRenameTag() {
return hasAnyLink(this._links, 'tags/rename');
}
} }
export class AssetDto { export class AssetDto {
@ -150,6 +154,9 @@ export type CreateAssetFolderDto =
export type RenameAssetFolderDto = export type RenameAssetFolderDto =
Readonly<{ folderName: string }>; Readonly<{ folderName: string }>;
export type RenameAssetTagDto =
Readonly<{ tagName: string }>;
export type MoveAssetItemDto = export type MoveAssetItemDto =
Readonly<{ parentId?: string }>; 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 }> { public getTags(appName: string): Observable<{ [name: string]: number }> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/tags`); 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<AssetsDto> { public getAssets(appName: string, q?: AssetQueryDto): Observable<AssetsDto> {

14
frontend/app/shared/state/assets.forms.ts

@ -8,7 +8,7 @@
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Form, Mutable, Types } from '@app/framework'; import { Form, Mutable, Types } from '@app/framework';
import slugify from 'slugify'; 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<FormGroup, AnnotateAssetDto, AssetDto> { export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDto> {
public get metadata() { public get metadata() {
@ -217,3 +217,15 @@ export class RenameAssetFolderForm extends Form<FormGroup, RenameAssetFolderDto,
})); }));
} }
} }
export class RenameAssetTagForm extends Form<FormGroup, RenameAssetTagDto, RenameAssetTagDto> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
tagName: ['',
[
Validators.required,
],
],
}));
}
}

11
frontend/app/shared/state/assets.state.spec.ts

@ -373,5 +373,16 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.folders.length).toBe(1); 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);
});
}); });
}); });

19
frontend/app/shared/state/assets.state.ts

@ -50,6 +50,9 @@ interface Snapshot extends ListState<Query> {
// Indicates if the user can create asset folders. // Indicates if the user can create asset folders.
canCreateFolders?: boolean; canCreateFolders?: boolean;
// Indicates if the user can rename asset tags.
canRenameTag?: boolean;
} }
export abstract class AssetsStateBase extends State<Snapshot> { export abstract class AssetsStateBase extends State<Snapshot> {
@ -104,6 +107,9 @@ export abstract class AssetsStateBase extends State<Snapshot> {
public canCreateFolders = public canCreateFolders =
this.project(x => x.canCreateFolders === true); this.project(x => x.canCreateFolders === true);
public canRenameTag =
this.project(x => x.canRenameTag === true);
protected constructor(name: string, protected constructor(name: string,
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly assetsService: AssetsService, private readonly assetsService: AssetsService,
@ -165,6 +171,7 @@ export abstract class AssetsStateBase extends State<Snapshot> {
folders: foldersResult.items, folders: foldersResult.items,
canCreate: assetsResult.canCreate, canCreate: assetsResult.canCreate,
canCreateFolders: foldersResult.canCreate, canCreateFolders: foldersResult.canCreate,
canRenameTag: assetsResult.canRenameTag,
isLoaded: true, isLoaded: true,
isLoadedOnce: true, isLoadedOnce: true,
isLoading: false, isLoading: false,
@ -328,6 +335,18 @@ export abstract class AssetsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public renameTag(name: string, tagName: string): Observable<any> {
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) { public navigate(parentId: string) {
if (!this.next({ parentId, query: undefined, tagsSelected: {} }, 'Loading Navigated')) { if (!this.next({ parentId, query: undefined, tagsSelected: {} }, 'Loading Navigated')) {
return EMPTY; return EMPTY;

Loading…
Cancel
Save