Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/782/head
Sebastian 4 years ago
parent
commit
c70f3db606
  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. 75
      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. 8
      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. 12
      frontend/app/framework/angular/forms/error-validator.ts
  31. 27
      frontend/app/shared/services/assets.service.spec.ts
  32. 17
      frontend/app/shared/services/assets.service.ts
  33. 14
      frontend/app/shared/state/assets.forms.ts
  34. 11
      frontend/app/shared/state/assets.state.spec.ts
  35. 19
      frontend/app/shared/state/assets.state.ts

3
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",

3
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",

3
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",

3
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": "恢复",

3
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",

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

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
{
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 string TagsFile = "AssetTags.json";
private const string TagsAliasFile = "AssetTagsAlias.json";
private readonly HashSet<DomainId> assetIds = new HashSet<DomainId>();
private readonly HashSet<DomainId> assetFolderIds = new HashSet<DomainId>();
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<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,
@ -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,

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)
{
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,
CancellationToken ct = default);
Task<bool> HasFileAsync(string name,
CancellationToken ct = default);
IAsyncEnumerable<(string Stream, Envelope<IEvent> Event)> ReadEventsAsync(IStreamNameResolver streamNameResolver, IEventDataFormatter formatter,
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;
}
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)
{
Guard.NotNull(names, nameof(names));
return GetGrain(appId, group).GetTagIdsAsync(names);
}
public Task<Dictionary<string, string>> DenormalizeTagsAsync(DomainId appId, string group, HashSet<string> ids)
{
Guard.NotNull(ids, nameof(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)
{
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();

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

119
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> 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<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)
{
@ -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<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)
{
Guard.NotNull(names, nameof(names));
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))
{
@ -148,7 +165,43 @@ namespace Squidex.Domain.Apps.Entities.Tags
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.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@ -60,7 +61,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Assets returned.
/// 200 => Assets tags returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
@ -80,6 +81,28 @@ namespace Squidex.Areas.Api.Controllers.Assets
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>
/// Get assets.
/// </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));
}
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));
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,38 @@ 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<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))
.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();
@ -58,23 +87,49 @@ namespace Squidex.Domain.Apps.Entities.Assets
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))
.MustHaveHappened();
}
[Fact]
public async Task Should_read_tags()
{
var tags = new TagsExport();
var tags = new Dictionary<string, Tag>();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadJsonAsync<TagsExport>(A<string>._, ct))
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, tags))
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();
A.CallTo(() => context.Reader.HasFileAsync(A<string>._, ct))
.Returns(true);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(A<string>._, ct))
.Returns(tags);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, string>>(A<string>._, ct))
.Returns(alias);
await sut.RestoreAsync(context, ct);
A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A<TagsExport>.That.Matches(x => x.Tags == tags && x.Alias == alias)))
.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]
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();
}
[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()
{

75
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<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]
public async Task Should_rebuild_tags()
{
var tags = new TagsExport
{
Tags = new Dictionary<string, Tag>
{
["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);

1
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';

3
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,
],
})

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

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

@ -13,8 +13,16 @@
</div>
<div class="col-auto">
<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>
</a>
</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 {
.badge {
background: none;
@ -6,3 +16,27 @@
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 */
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;
}

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

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

12
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,15 +26,16 @@ 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[] = [];
if (this.error.details) {
for (const details of this.error.details) {
for (const property of details.properties) {
if (property.startsWith(path)) {
@ -55,9 +56,10 @@ export class ErrorValidator {
}
}
}
}
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;
}
}

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',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let assets: AssetsDto;

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

@ -22,6 +22,10 @@ export class AssetsDto extends ResultSet<AssetDto> {
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<AssetsDto> {

14
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<FormGroup, AnnotateAssetDto, AssetDto> {
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);
});
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.
canCreateFolders?: boolean;
// Indicates if the user can rename asset tags.
canRenameTag?: boolean;
}
export abstract class AssetsStateBase extends State<Snapshot> {
@ -104,6 +107,9 @@ export abstract class AssetsStateBase extends State<Snapshot> {
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<Snapshot> {
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<Snapshot> {
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) {
if (!this.next({ parentId, query: undefined, tagsSelected: {} }, 'Loading Navigated')) {
return EMPTY;

Loading…
Cancel
Save