From 8709ae50be25ab2544b3ec86b42d065ca27af0e4 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 26 Jun 2026 09:49:16 +0200 Subject: [PATCH] feat: Tag input shows already existing tags (closes #521) --- .../Components/CreateNewBlogPost.razor | 65 ++++- .../BlogPostEditor/Components/TagInput.razor | 242 ++++++++++++++++++ .../Services/Tags/ITagQueryService.cs | 2 + .../Features/Services/Tags/TagQueryService.cs | 6 +- .../Components/BlogPostAdminActions.razor | 3 + src/LinkDotNet.Blog.Web/wwwroot/css/basic.css | 21 ++ .../CreateNewBlogPostPageTests.cs | 14 +- .../BlogPostEditor/UpdateBlogPostPageTests.cs | 11 + .../ShowBlogPost/ShowBlogPostPageTests.cs | 2 + .../Shared/Admin/BlogPostAdminActionsTests.cs | 19 +- .../Components/CreateNewBlogPostTests.cs | 121 ++++++++- .../Components/TagInputTests.cs | 106 ++++++++ .../Services/Tags/TagQueryServiceTests.cs | 17 ++ .../ShowBlogPost/ShowBlogPostPageTests.cs | 2 + 14 files changed, 608 insertions(+), 23 deletions(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/TagInput.razor create mode 100644 tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/TagInputTests.cs diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor index 9578e40e..d0ac546b 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -13,6 +13,7 @@ @inject IOptions AppConfiguration @inject ICurrentUserService CurrentUserService @inject IBlogPostVersionService BlogPostVersionService +@inject ITagQueryService TagQueryService Creating new Blog Post @@ -154,22 +155,17 @@
Tags
-
- - +
+ + +
Add relevant tags to improve content discoverability and SEO. Use specific, searchable terms that describe your content. - @if (!string.IsNullOrWhiteSpace(model.Tags)) - { -
- @foreach (var tag in model.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries)) - { - @tag.Trim() - } -
- }
@@ -335,6 +331,8 @@ private bool canSubmit = true; private IPagedList shortCodes = PagedList.Empty; private IPagedList blogPostTemplates = PagedList.Empty; + private IReadOnlyList availableTags = []; + private IReadOnlyList originalTags = []; private bool IsScheduled => model.ScheduledPublishDate.HasValue; @@ -344,6 +342,7 @@ { shortCodes = await ShortCodeRepository.GetAllAsync(); blogPostTemplates = await TemplateRepository.GetAllAsync(); + await LoadAvailableTagsAsync(); if (AppConfiguration.Value.UseMultiAuthorMode) { @@ -359,6 +358,7 @@ } model = CreateNewModel.FromBlogPost(BlogPost); + originalTags = BlogPost.Tags.ToList(); if (!string.IsNullOrEmpty(BlogPostId)) { @@ -398,14 +398,23 @@ { canSubmit = false; model.AuthorName = authorName; - await OnBlogPostCreated.InvokeAsync(model.ToBlogPost()); + var blogPost = model.ToBlogPost(); + var tagsChanged = !HaveSameTagSet(originalTags, blogPost.Tags); + await OnBlogPostCreated.InvokeAsync(blogPost); if (model.ShouldInvalidateCache) { await CacheInvalidator.ClearCacheAsync(); } + if (tagsChanged) + { + await TagQueryService.ClearTagCacheAsync(); + await LoadAvailableTagsAsync(); + } + _ = InstantJobRegistry.RunInstantJob(parameter: true); ClearModel(); + originalTags = ClearAfterCreated ? [] : blogPost.Tags.ToList(); canSubmit = true; } @@ -434,7 +443,15 @@ return; } + var tagsChanged = BlogPost is not null && !HaveSameTagSet(BlogPost.Tags, version.Tags); await OnVersionRestored.InvokeAsync(version); + if (tagsChanged) + { + await TagQueryService.ClearTagCacheAsync(); + await LoadAvailableTagsAsync(); + originalTags = version.Tags.ToList(); + } + versionHistory = await BlogPostVersionService.GetVersionHistoryAsync(BlogPostId!); StateHasChanged(); } @@ -519,4 +536,26 @@ model.Content = converter.Convert(model.Content); } } + + private async Task LoadAvailableTagsAsync() + { + availableTags = (await TagQueryService.GetAllOrderedByUsageAsync()) + .Select(tag => tag.Name) + .ToList(); + } + + private static bool HaveSameTagSet(IEnumerable? left, IEnumerable? right) + { + var leftTags = ToTagSet(left); + var rightTags = ToTagSet(right); + return leftTags.SetEquals(rightTags); + } + + private static HashSet ToTagSet(IEnumerable? tags) + { + return (tags ?? []) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/TagInput.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/TagInput.razor new file mode 100644 index 00000000..ee9e16ba --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/TagInput.razor @@ -0,0 +1,242 @@ +@using Microsoft.AspNetCore.Components.Web + +
+
+
+ @foreach (var tag in selectedTags) + { + + @tag + + + } + + +
+
+ + @if (isOpen) + { + var filteredSuggestions = GetFilteredSuggestions(); + + if (filteredSuggestions.Count > 0) + { +
+ @foreach (var (suggestion, index) in filteredSuggestions.Select((tag, i) => (tag, i))) + { + + } +
+ } + } +
+ +@code { + [Parameter] + public string Id { get; set; } = "tags"; + + [Parameter] + public string Placeholder { get; set; } = "Add tags"; + + [Parameter] + public string Value { get; set; } = string.Empty; + + [Parameter] + public EventCallback ValueChanged { get; set; } + + [Parameter] + public IReadOnlyList Suggestions { get; set; } = []; + + private readonly List selectedTags = []; + private string currentInput = string.Empty; + private string? lastValue; + private bool isOpen; + private int selectedIndex = -1; + + private IReadOnlyList GetFilteredSuggestions() + { + var searchTerm = currentInput.Trim(); + return Suggestions + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(tag => selectedTags.All(selected => !selected.Equals(tag, StringComparison.OrdinalIgnoreCase))) + .Where(tag => string.IsNullOrWhiteSpace(searchTerm) || tag.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + protected override void OnParametersSet() + { + if (lastValue == Value) + { + return; + } + + selectedTags.Clear(); + AddTags(ParseTags(Value), notifyChanged: false); + lastValue = Value; + } + + private async Task HandleInputAsync(ChangeEventArgs args) + { + currentInput = args.Value?.ToString() ?? string.Empty; + + if (currentInput.Contains(',')) + { + await AddCurrentInputAsync(); + return; + } + + OpenSuggestions(); + } + + private void HandleFocus() + { + OpenSuggestions(); + } + + private async Task HandleBlurAsync(FocusEventArgs _) + { + await AddCurrentInputAsync(); + isOpen = false; + } + + private async Task HandleKeyDownAsync(KeyboardEventArgs args) + { + switch (args.Key) + { + case "ArrowDown": + MoveSelection(1); + break; + case "ArrowUp": + MoveSelection(-1); + break; + case "Enter": + var suggestions = GetFilteredSuggestions(); + if (isOpen && selectedIndex >= 0 && selectedIndex < suggestions.Count) + { + await SelectSuggestionAsync(suggestions[selectedIndex]); + } + else + { + await AddCurrentInputAsync(); + } + break; + case ",": + await AddCurrentInputAsync(); + break; + case "Escape": + isOpen = false; + selectedIndex = -1; + break; + } + } + + private void MoveSelection(int offset) + { + var suggestions = GetFilteredSuggestions(); + if (suggestions.Count == 0) + { + isOpen = false; + selectedIndex = -1; + return; + } + + isOpen = true; + selectedIndex = selectedIndex < 0 + ? 0 + : Math.Clamp(selectedIndex + offset, 0, suggestions.Count - 1); + } + + private async Task SelectSuggestionAsync(string suggestion) + { + if (AddTags([suggestion], notifyChanged: true)) + { + await NotifyValueChangedAsync(); + } + + currentInput = string.Empty; + isOpen = false; + selectedIndex = -1; + } + + private async Task AddCurrentInputAsync() + { + if (AddTags(ParseTags(currentInput), notifyChanged: true)) + { + await NotifyValueChangedAsync(); + } + + currentInput = string.Empty; + isOpen = false; + selectedIndex = -1; + } + + private async Task RemoveTagAsync(string tag) + { + selectedTags.RemoveAll(selected => selected.Equals(tag, StringComparison.OrdinalIgnoreCase)); + await NotifyValueChangedAsync(); + OpenSuggestions(); + } + + private bool AddTags(IEnumerable tags, bool notifyChanged) + { + var changed = false; + foreach (var tag in tags) + { + if (selectedTags.Any(selected => selected.Equals(tag, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + selectedTags.Add(tag); + changed = true; + } + + if (notifyChanged && changed) + { + lastValue = string.Join(",", selectedTags); + } + + return changed; + } + + private async Task NotifyValueChangedAsync() + { + var value = string.Join(",", selectedTags); + lastValue = value; + await ValueChanged.InvokeAsync(value); + } + + private void OpenSuggestions() + { + var suggestions = GetFilteredSuggestions(); + isOpen = suggestions.Count > 0; + selectedIndex = isOpen ? Math.Clamp(selectedIndex, 0, suggestions.Count - 1) : -1; + } + + private static IEnumerable ParseTags(string? value) + { + return (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(tag => !string.IsNullOrWhiteSpace(tag)); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs index bcacf3ef..31355eb0 100644 --- a/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs @@ -6,4 +6,6 @@ namespace LinkDotNet.Blog.Web.Features.Services.Tags; public interface ITagQueryService { Task> GetAllOrderedByUsageAsync(); + + Task ClearTagCacheAsync(); } diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs index b4eff32c..963bc4b9 100644 --- a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs @@ -28,6 +28,11 @@ public async Task> GetAllOrderedByUsageAsync() }); } + public async Task ClearTagCacheAsync() + { + await fusionCache.RemoveAsync(TagCacheKey); + } + private async Task> LoadTagsAsync() { var tagLists = await blogPostRepository.GetAllByProjectionAsync( @@ -46,5 +51,4 @@ private async Task> LoadTagsAsync() return tagCounts; } - } diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor index 5b166f21..5c4422e3 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor @@ -1,10 +1,12 @@ @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure.Persistence +@using LinkDotNet.Blog.Web.Features.Services.Tags @using NCronJob @inject NavigationManager NavigationManager @inject IToastService ToastService @inject IRepository BlogPostRepository @inject IInstantJobRegistry InstantJobRegistry +@inject ITagQueryService TagQueryService
@@ -30,6 +32,7 @@ private async Task DeleteBlogPostAsync() { await BlogPostRepository.DeleteAsync(BlogPostId); + await TagQueryService.ClearTagCacheAsync(); InstantJobRegistry.RunInstantJob(true); ToastService.ShowSuccess("The Blog Post was successfully deleted"); NavigationManager.NavigateTo("/"); diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css index f867f357..bcc9225d 100644 --- a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css @@ -243,6 +243,27 @@ code { } /*#endregion */ +/*region TagInput.razor */ +.tag-input { + position: relative; +} + +.tag-input-field { + min-width: 8rem; + width: auto; +} + +.tag-input-remove { + font-size: .55rem; +} + +.tag-input-suggestions { + z-index: 1000; + max-height: 14rem; + overflow-y: auto; +} +/*#endregion */ + /*region NavMenu.razor */ .barcode { text-decoration: none; diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs index e9e77cff..fca62887 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs @@ -13,6 +13,7 @@ using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -38,6 +39,7 @@ public async Task ShouldSaveBlogPostOnSave() ctx.Services.AddScoped(_ => instantRegistry); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => EmptyTagQueryService()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -91,6 +93,7 @@ public async Task ShouldSaveAuthorNameAsNullWhenMultiAuthorModeIsDisabled() ctx.Services.AddScoped(_ => instantRegistry); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => EmptyTagQueryService()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -139,6 +142,7 @@ public async Task ShouldLoadTemplate() ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => EmptyTagQueryService()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -180,8 +184,16 @@ private static void TriggerNewBlogPost(IRenderedComponent cut cut.Find("#content").Input("My content"); cut.Find("#preview").Change("My preview url"); cut.Find("#published").Change(false); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("form").Submit(); } + + private static ITagQueryService EmptyTagQueryService() + { + var tagQueryService = Substitute.For(); + tagQueryService.GetAllOrderedByUsageAsync().Returns([]); + tagQueryService.ClearTagCacheAsync().Returns(Task.CompletedTask); + return tagQueryService; + } } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs index e190b4b2..10b1ab62 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs @@ -14,6 +14,7 @@ using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using Microsoft.AspNetCore.Components; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -41,6 +42,7 @@ public async Task ShouldSaveBlogPostOnSave() ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => instantRegistry); ctx.Services.AddScoped(_ => new BlogPostVersionService(DbContextFactory, Repository)); + ctx.Services.AddScoped(_ => EmptyTagQueryService()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -96,6 +98,7 @@ public async Task ShouldSaveAuthorNameAsNullWhenMultiAuthorModeIsDisabled() ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => instantRegistry); ctx.Services.AddScoped(_ => new BlogPostVersionService(DbContextFactory, Repository)); + ctx.Services.AddScoped(_ => EmptyTagQueryService()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -170,4 +173,12 @@ private static void TriggerUpdate(IRenderedComponent cut) cut.Find("form").Submit(); } + + private static ITagQueryService EmptyTagQueryService() + { + var tagQueryService = Substitute.For(); + tagQueryService.GetAllOrderedByUsageAsync().Returns([]); + tagQueryService.ClearTagCacheAsync().Returns(Task.CompletedTask); + return tagQueryService; + } } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs index dd32909f..3c2b1934 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs @@ -7,6 +7,7 @@ using LinkDotNet.Blog.Web.Features.Bookmarks; using LinkDotNet.Blog.Web.Features.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using LinkDotNet.Blog.Web.Features.ShowBlogPost; using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; using Microsoft.AspNetCore.Components.Web; @@ -197,6 +198,7 @@ private void RegisterComponents(BunitContext ctx, ILocalStorageService? localSto ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Options.Create(new ApplicationConfigurationBuilder().WithUseMultiAuthorMode(useMultiAuthorMode).Build())); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => Substitute.For()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs index a049fac0..a31bb615 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs @@ -3,6 +3,7 @@ using Blazored.Toast.Services; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Web.Features.Services.Tags; using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; using Microsoft.Extensions.DependencyInjection; using NCronJob; @@ -11,11 +12,15 @@ namespace LinkDotNet.Blog.IntegrationTests.Web.Shared.Admin; public class BlogPostAdminActionsTests : BunitContext { + private readonly ITagQueryService tagQueryService = Substitute.For(); + public BlogPostAdminActionsTests() { Services.AddSingleton(Substitute.For>()); Services.AddSingleton(Substitute.For()); Services.AddSingleton(Substitute.For()); + tagQueryService.ClearTagCacheAsync().Returns(Task.CompletedTask); + Services.AddSingleton(tagQueryService); AddAuthorization().SetAuthorized("s"); } @@ -34,6 +39,18 @@ public async Task ShouldDeleteBlogPostWhenOkClicked() await repositoryMock.Received(1).DeleteAsync(blogPostId); } + [Fact] + public async Task ShouldClearTagCacheWhenBlogPostIsDeleted() + { + const string blogPostId = "2"; + var cut = Render(s => s.Add(p => p.BlogPostId, blogPostId)); + await cut.Find("#delete-blogpost").ClickAsync(); + + await cut.Find("#ok").ClickAsync(); + + await tagQueryService.Received(1).ClearTagCacheAsync(); + } + [Fact] public async Task ShouldNotDeleteBlogPostWhenCancelClicked() { @@ -60,4 +77,4 @@ public void ShouldGoToEditPageForEdit() anchor.ShouldNotBeNull(); anchor.Href.ShouldEndWith($"update/{blogPostId}"); } -} \ No newline at end of file +} diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs index b637bb8c..2b76f373 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading.Tasks; using AngleSharp.Html.Dom; using Blazored.Toast.Services; using LinkDotNet.Blog.Domain; @@ -12,6 +13,7 @@ using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -22,6 +24,7 @@ namespace LinkDotNet.Blog.UnitTests.Web.Features.Admin.BlogPostEditor.Components public class CreateNewBlogPostTests : BunitContext { private readonly ICacheInvalidator cacheInvalidator = Substitute.For(); + private readonly ITagQueryService tagQueryService = Substitute.For(); private readonly IOptions options; public CreateNewBlogPostTests() @@ -58,6 +61,10 @@ public CreateNewBlogPostTests() var versionService = Substitute.For(); versionService.GetVersionHistoryAsync(Arg.Any()).Returns([]); Services.AddScoped(_ => versionService); + + tagQueryService.GetAllOrderedByUsageAsync().Returns([new TagCount("ExistingTag", 3)]); + tagQueryService.ClearTagCacheAsync().Returns(Task.CompletedTask); + Services.AddScoped(_ => tagQueryService); } [Fact] @@ -72,7 +79,7 @@ public void ShouldCreateNewBlogPostWhenMultiAuthorModeIsEnabled() cut.Find("#preview").Change("My preview url"); cut.Find("#fallback-preview").Change("My fallback preview url"); cut.Find("#published").Change(false); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("form").Submit(); @@ -112,7 +119,7 @@ public void ShouldSetAuthorNameAsNullWhenMultiAuthorModeIsDisable() cut.Find("#preview").Change("My preview url"); cut.Find("#fallback-preview").Change("My fallback preview url"); cut.Find("#published").Change(false); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("form").Submit(); @@ -158,7 +165,7 @@ public void ShouldNotDeleteModelWhenSet() cut.Find("#short").Input("My short Description"); cut.Find("#content").Input("My content"); cut.Find("#preview").Change("My preview url"); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("form").Submit(); blogPost = null; @@ -178,7 +185,7 @@ public void ShouldNotDeleteModelWhenNotSet() cut.Find("#short").Input("My short Description"); cut.Find("#content").Input("My content"); cut.Find("#preview").Change("My preview url"); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("form").Submit(); blogPost = null; @@ -202,7 +209,7 @@ public void ShouldNotUpdateUpdatedDateWhenCheckboxSet() cut.Find("#short").Input("My short Description"); cut.Find("#content").Input("My content"); cut.Find("#preview").Change("My preview url"); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("#updatedate").Change(false); cut.Find("form").Submit(); @@ -231,7 +238,7 @@ public void ShouldAcceptInputWithoutLosingFocusOrEnter() cut.Find("#content").Input("My content"); cut.Find("#preview").Change("My preview url"); cut.Find("#published").Change(false); - cut.Find("#tags").Change("Tag1,Tag2,Tag3"); + cut.Find("#tags").Input("Tag1,Tag2,Tag3"); cut.Find("form").Submit(); @@ -264,7 +271,7 @@ public void ShouldStopInternalNavigationWhenDirty() JSInterop.Setup("confirm", "You have unsaved changes. Are you sure you want to continue?") .SetResult(false); var cut = Render(); - cut.Find("#tags").Change("Hey"); + cut.Find("#tags").Input("Hey,"); var fakeNavigationManager = Services.GetRequiredService(); fakeNavigationManager.NavigateTo("/internal"); @@ -341,6 +348,106 @@ public void GivenBlogPost_WhenCacheInvalidatedOptionIsSet_CacheIsInvalidated() cacheInvalidator.Received(1).ClearCacheAsync(); } + [Fact] + public void ShouldRenderSuggestionsFromTagQueryService() + { + var cut = Render(); + + cut.Find("#tags").Focus(); + + cut.Markup.ShouldContain("ExistingTag"); + } + + [Fact] + public void GivenNewPostWithTags_WhenCacheCheckboxIsOff_TagCacheIsInvalidatedAndSuggestionsReload() + { + var cut = Render(); + cut.Find("#title").Input("My Title"); + cut.Find("#short").Input("My short Description"); + cut.Find("#content").Input("My content"); + cut.Find("#preview").Change("My preview url"); + cut.Find("#published").Change(false); + cut.Find("#tags").Input("Tag1,Tag2"); + + cut.Find("form").Submit(); + + tagQueryService.Received(1).ClearTagCacheAsync(); + cacheInvalidator.DidNotReceive().ClearCacheAsync(); + tagQueryService.Received(2).GetAllOrderedByUsageAsync(); + } + + [Fact] + public void GivenExistingPostWithUnchangedTags_WhenCacheCheckboxIsOff_CacheIsNotInvalidated() + { + var blogPost = new BlogPostBuilder() + .WithTags("Tag1", "Tag2") + .Build(); + var cut = Render( + p => p.Add(c => c.BlogPost, blogPost)); + + cut.Find("form").Submit(); + + cacheInvalidator.DidNotReceive().ClearCacheAsync(); + } + + [Fact] + public void GivenExistingPostWithUnchangedTags_WhenCacheCheckboxIsOn_CacheIsInvalidated() + { + var blogPost = new BlogPostBuilder() + .WithTags("Tag1", "Tag2") + .Build(); + var cut = Render( + p => p.Add(c => c.BlogPost, blogPost)); + + cut.Find("#invalidate-cache").Change(true); + cut.Find("form").Submit(); + + cacheInvalidator.Received(1).ClearCacheAsync(); + } + + [Fact] + public void GivenExistingPostWithChangedTags_WhenCacheCheckboxIsOff_TagCacheIsInvalidated() + { + var blogPost = new BlogPostBuilder() + .WithTags("Tag1") + .Build(); + var cut = Render( + p => p.Add(c => c.BlogPost, blogPost)); + + cut.Find("#tags").Input("Tag2,"); + cut.Find("form").Submit(); + + tagQueryService.Received(1).ClearTagCacheAsync(); + cacheInvalidator.DidNotReceive().ClearCacheAsync(); + } + + [Fact] + public void GivenVersionRestore_WhenTagsDiffer_TagCacheIsInvalidated() + { + var blogPost = new BlogPostBuilder() + .WithTags("Current") + .Build(); + blogPost.Id = "post-1"; + var restoredPost = new BlogPostBuilder() + .WithTags("Restored") + .Build(); + restoredPost.Id = blogPost.Id; + var version = BlogPostVersion.CreateSnapshot(restoredPost, 1); + var versionService = Services.GetRequiredService(); + versionService.GetVersionHistoryAsync(blogPost.Id).Returns([version]); + JSInterop.Setup("confirm", $"Restore version {version.VersionNumber} (\"{version.Title}\")? The current state will be saved as a new version first.") + .SetResult(true); + var cut = Render( + p => p.Add(c => c.BlogPost, blogPost) + .Add(c => c.BlogPostId, blogPost.Id)); + + cut.FindAll("button").Single(button => button.TextContent == "Restore").Click(); + + tagQueryService.Received(1).ClearTagCacheAsync(); + cacheInvalidator.DidNotReceive().ClearCacheAsync(); + tagQueryService.Received(2).GetAllOrderedByUsageAsync(); + } + [Fact] public void ShouldTransformHtmlToMarkdown() { diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/TagInputTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/TagInputTests.cs new file mode 100644 index 00000000..aa5ad658 --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/TagInputTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace LinkDotNet.Blog.UnitTests.Web.Features.Admin.BlogPostEditor.Components; + +public class TagInputTests : BunitContext +{ + [Fact] + public void RendersExistingCommaSeparatedTagsAsPills() + { + var cut = Render(parameters => parameters + .Add(component => component.Value, "dotnet,blazor")); + + cut.Markup.ShouldContain("dotnet"); + cut.Markup.ShouldContain("blazor"); + cut.FindAll(".badge").Count.ShouldBe(2); + } + + [Fact] + public void SelectingSuggestionUpdatesCommaSeparatedValue() + { + var value = "dotnet"; + var cut = Render(parameters => parameters + .Add(component => component.Value, value) + .Add(component => component.ValueChanged, updated => value = updated) + .Add(component => component.Suggestions, new List { "blazor", "csharp" })); + + cut.Find("#tags").Input("bla"); + cut.FindAll(".list-group-item").Single().MouseDown(); + + value.ShouldBe("dotnet,blazor"); + } + + [Fact] + public void FreeFormCommaEntryCreatesPills() + { + var value = string.Empty; + var cut = Render(parameters => parameters + .Add(component => component.Value, value) + .Add(component => component.ValueChanged, updated => value = updated)); + + cut.Find("#tags").Input("dotnet,blazor"); + + value.ShouldBe("dotnet,blazor"); + cut.FindAll(".badge").Count.ShouldBe(2); + } + + [Fact] + public void DuplicateTagsAreIgnoredCaseInsensitively() + { + var value = "DotNet"; + var cut = Render(parameters => parameters + .Add(component => component.Value, value) + .Add(component => component.ValueChanged, updated => value = updated)); + + cut.Find("#tags").Input("dotnet,Blazor"); + + value.ShouldBe("DotNet,Blazor"); + cut.FindAll(".badge").Count.ShouldBe(2); + } + + [Fact] + public void RemovingPillUpdatesValue() + { + var value = "dotnet,blazor"; + var cut = Render(parameters => parameters + .Add(component => component.Value, value) + .Add(component => component.ValueChanged, updated => value = updated)); + + cut.Find("button[aria-label='Remove dotnet']").Click(); + + value.ShouldBe("blazor"); + cut.Markup.ShouldNotContain("dotnet"); + } + + [Fact] + public void PastedCommaSeparatedTagsAreNormalized() + { + var value = string.Empty; + var cut = Render(parameters => parameters + .Add(component => component.Value, value) + .Add(component => component.ValueChanged, updated => value = updated)); + + cut.Find("#tags").Input(" alpha, beta , , Gamma "); + + value.ShouldBe("alpha,beta,Gamma"); + } + + [Fact] + public void KeyboardCanSelectSuggestions() + { + var value = string.Empty; + var cut = Render(parameters => parameters + .Add(component => component.Value, value) + .Add(component => component.ValueChanged, updated => value = updated) + .Add(component => component.Suggestions, new List { "blazor" })); + + var input = cut.Find("#tags"); + input.Input("bla"); + input.KeyDown(new KeyboardEventArgs { Key = "Enter" }); + + value.ShouldBe("blazor"); + } +} diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs index 33660c89..5a66548d 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs @@ -10,6 +10,7 @@ using NSubstitute; using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using ZiggyCreatures.Caching.Fusion; @@ -127,4 +128,20 @@ public async Task ShouldSortAlphabeticallyWhenCountsAreEqual() result[0].Name.ShouldBe("Blazor"); result[1].Name.ShouldBe("CSharp"); } + + [Fact] + public async Task ClearTagCacheAsync_RemovesCachedTagUsageList() + { + repository.GetAllByProjectionAsync(p => p.Tags) + .ReturnsForAnyArgs( + new PagedList>([new() { "Cached" }], 1, 1, 1), + new PagedList>([new() { "Reloaded" }], 1, 1, 1)); + + var cached = await tagQueryService.GetAllOrderedByUsageAsync(); + await tagQueryService.ClearTagCacheAsync(); + var reloaded = await tagQueryService.GetAllOrderedByUsageAsync(); + + cached.Single().Name.ShouldBe("Cached"); + reloaded.Single().Name.ShouldBe("Reloaded"); + } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs index 250c22a7..465cc737 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs @@ -8,6 +8,7 @@ using LinkDotNet.Blog.Web.Features.Bookmarks; using LinkDotNet.Blog.Web.Features.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using LinkDotNet.Blog.Web.Features.ShowBlogPost; using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; using Microsoft.AspNetCore.Components; @@ -31,6 +32,7 @@ public ShowBlogPostPageTests() Services.AddScoped(_ => Substitute.For()); Services.AddScoped(_ => Substitute.For()); Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Substitute.For()); Services.AddScoped(_ => Options.Create(new ApplicationConfigurationBuilder().Build())); Services.AddScoped(_ => Substitute.For()); AddAuthorization();