diff --git a/docs/Setup/Configuration.md b/docs/Setup/Configuration.md index 6b96e17a..c3743447 100644 --- a/docs/Setup/Configuration.md +++ b/docs/Setup/Configuration.md @@ -67,7 +67,8 @@ The appsettings.json file has a lot of options to customize the content of the b "ContainerName": "", "CdnEndpoint": "" }, - "UseMultiAuthorMode": false + "UseMultiAuthorMode": false, + "EnableTagDiscoveryPanel": true } ``` @@ -113,3 +114,4 @@ The appsettings.json file has a lot of options to customize the content of the b | ContainerName | string | The container name for the image storage provider | | CdnEndpoint | string | Optional CDN endpoint to use for uploaded images. If set, the blog will return this URL instead of the storage account URL for uploaded assets. | | UseMultiAuthorMode | boolean | The default value is `false`. If set to `true` then author name will be associated with blog posts at the time of creation. This author name will be fetched from the identity provider's `name` or `nickname` or `preferred_username` claim property. | +| EnableTagDiscoveryPanel | boolean | The default value is `true`. Enables the Tag Discovery Panel, which helps users discover topics by browsing popular tags. | diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index 6a866d49..fae0c6bf 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -27,4 +27,6 @@ public sealed record ApplicationConfiguration public bool ShowBuildInformation { get; init; } = true; public bool UseMultiAuthorMode { get; init; } + + public bool EnableTagDiscoveryPanel { get; set; } } diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor index be2bee74..ba2497d2 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor @@ -1,4 +1,5 @@ @using LinkDotNet.Blog.Web.Features.SupportMe.Components +@using LinkDotNet.Blog.Web.Features.TagDiscovery @inject IOptions Configuration @inject IOptions SupportConfiguration @inject NavigationManager NavigationManager @@ -57,6 +58,16 @@ + + @if (Configuration.Value.EnableTagDiscoveryPanel) + { + + } +
  • @@ -64,10 +75,13 @@ + @code { private string currentUri = string.Empty; + private bool _isOpen; + protected override void OnInitialized() { NavigationManager.LocationChanged += UpdateUri; @@ -90,4 +104,14 @@ currentUri = e.Location; StateHasChanged(); } + + private void ToggleTagDiscoveryPanel() + { + _isOpen = !_isOpen; + } + + private void CloseTagDiscoveryPanel() + { + _isOpen = false; + } } diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs new file mode 100644 index 00000000..bcacf3ef --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.Web.Features.Services.Tags; + +public interface ITagQueryService +{ + Task> GetAllOrderedByUsageAsync(); +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs new file mode 100644 index 00000000..ce0bb8c7 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs @@ -0,0 +1,3 @@ +namespace LinkDotNet.Blog.Web.Features.Services.Tags; + +public sealed record TagCount(string Name, int Count); diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs new file mode 100644 index 00000000..fc37333d --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs @@ -0,0 +1,49 @@ +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using Microsoft.Extensions.Options; +using Raven.Client.Documents.Operations.AI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; + +namespace LinkDotNet.Blog.Web.Features.Services.Tags; + +public sealed class TagQueryService( + IRepository blogPostRepository, + IFusionCache fusionCache, + IOptions appConfiguration) : ITagQueryService +{ + private const string TagCacheKey = "TagUsageList"; + + public async Task> GetAllOrderedByUsageAsync() + { + return await fusionCache.GetOrSetAsync( + TagCacheKey, + async _ => await LoadTagsAsync(), + options => + { + options.SetDuration(TimeSpan.FromMinutes( + appConfiguration.Value.FirstPageCacheDurationInMinutes)); + }); + } + + private async Task> LoadTagsAsync() + { + var posts = await blogPostRepository.GetAllAsync(); + + var tagCounts = posts + .SelectMany(p => p.Tags ?? Enumerable.Empty()) + .Where(tag => !string.IsNullOrEmpty(tag)) + .GroupBy(tag => tag.Trim()) + .Select(group => new TagCount( + group.Key, + group.Count())) + .OrderByDescending(tc => tc.Count) + .ThenBy(tc => tc.Name) + .ToList(); + return tagCounts; + } + +} diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor new file mode 100644 index 00000000..67039e8d --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor @@ -0,0 +1,89 @@ +@using Microsoft.AspNetCore.Components.Web; +@inject ITagQueryService TagQueryService +@inject IOptions AppConfiguration +@inject NavigationManager Navigation + + +@if (AppConfiguration.Value.EnableTagDiscoveryPanel && IsOpen) +{ +
    +
    + +
    + +
    + + @if (_tags.Count == 0) + { +
    + No tags available yet. +
    + } + else + { +
    + @foreach (var tag in _tags) + { + + @tag.Name + @tag.Count + + } +
    + } +
    +
    +} + +@code { + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private IReadOnlyList _tags = []; + private ElementReference _panelRef; + + protected override async Task OnParametersSetAsync() + { + if (IsOpen) + { + _tags = await TagQueryService.GetAllOrderedByUsageAsync(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (IsOpen) + { + await _panelRef.FocusAsync(); + } + } + + private async Task Close() + { + await OnClose.InvokeAsync(); + } + + private async Task Navigate(string tag) + { + var encoded = Uri.EscapeDataString(tag); + Navigation.NavigateTo($"/searchByTag/{encoded}"); + await Close(); + } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + await Close(); + } + } +} diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 9ed92a1b..3fedb781 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -6,6 +6,7 @@ using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services; using LinkDotNet.Blog.Web.Features.Bookmarks; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using LinkDotNet.Blog.Web.RegistrationExtensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -26,6 +27,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); diff --git a/src/LinkDotNet.Blog.Web/_Imports.razor b/src/LinkDotNet.Blog.Web/_Imports.razor index fa2e19ea..f5cd8f88 100644 --- a/src/LinkDotNet.Blog.Web/_Imports.razor +++ b/src/LinkDotNet.Blog.Web/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @@ -11,3 +11,4 @@ @using LinkDotNet.Blog.Web @using LinkDotNet.Blog.Web.Features.Components @using Microsoft.Extensions.Options +@using LinkDotNet.Blog.Web.Features.Services.Tags diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index bbddcc7d..28cde6a3 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -39,7 +39,7 @@ "ProfilePictureUrl": "assets/profile-picture.webp" }, "ImageStorageProvider": "", - "ImageStorage" : { + "ImageStorage": { "AuthenticationMode": "Default", "ConnectionString": "", "ServiceUrl": "", @@ -49,5 +49,6 @@ "ShowReadingIndicator": true, "ShowSimilarPosts": true, "ShowBuildInformation": true, - "UseMultiAuthorMode": false + "UseMultiAuthorMode": false, + "EnableTagDiscoveryPanel": true } diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css index 6a1033d3..f867f357 100644 --- a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css @@ -1,4 +1,4 @@ -:root, html[data-bs-theme='light'] { +:root, html[data-bs-theme='light'] { /* Fonts */ --default-font: 'Calibri'; --code-font: 'Lucida Console', 'Courier New'; diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs index 010f11f8..26edc49c 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs @@ -1,11 +1,12 @@ -using System.Linq; using AngleSharp.Html.Dom; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web.Features.Home.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using System.Linq; namespace LinkDotNet.Blog.IntegrationTests.Web.Shared; @@ -14,6 +15,13 @@ public class NavMenuTests : BunitContext public NavMenuTests() { ComponentFactories.Add(); + + var tagQueryService = Substitute.For(); + Services.AddSingleton(tagQueryService); + + Services.AddSingleton( + Options.Create(new ApplicationConfigurationBuilder().Build()) + ); } [Fact] diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs new file mode 100644 index 00000000..67a1dc3a --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs @@ -0,0 +1,136 @@ +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web; +using LinkDotNet.Blog.Web.Features.Services.Tags; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using NSubstitute; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; + + +namespace LinkDotNet.Blog.UnitTests.Web.Features.Services.Tags; +public sealed class TagQueryServiceTests +{ + + private readonly IRepository repository; + private readonly TagQueryService tagQueryService; + private readonly IFusionCache fusionCache; + + public TagQueryServiceTests() + { + repository = Substitute.For>(); + + fusionCache = new FusionCache( + new FusionCacheOptions(), + logger: null, + memoryCache: new MemoryCache(new MemoryCacheOptions()) + ); + + var config = Options.Create(new ApplicationConfigurationBuilder().Build()); + + tagQueryService = new TagQueryService(repository, fusionCache, config); + } + + [Fact] + public async Task ShouldReturnEmptyWhenNoPosts() + { + // Arrange + repository.GetAllAsync() + .Returns(PagedList.Empty); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task AggregatesAndSortsTagsByUsage() + { + // Arrange + var posts = new List + { + new BlogPostBuilder().WithTags("CSharp", "Blazor", "DotNet").Build(), + new BlogPostBuilder().WithTags("CSharp", "Blazor").Build(), + new BlogPostBuilder().WithTags("CSharp").Build(), + }; + + repository.GetAllAsync() + .Returns(CreatePagedList(posts)); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result.Count.ShouldBe(3); + + result[0].Name.ShouldBe("CSharp"); + result[0].Count.ShouldBe(3); + + result[1].Name.ShouldBe("Blazor"); + result[1].Count.ShouldBe(2); + + result[2].Name.ShouldBe("DotNet"); + result[2].Count.ShouldBe(1); + } + + [Fact] + public async Task ShouldIgnoreNullOrWhitespaceTags() + { + // Arrange + var posts = new List + { + new BlogPostBuilder().WithTags("CSharp", " ").Build(), + new BlogPostBuilder().Build(), + }; + + repository.GetAllAsync() + .Returns(CreatePagedList(posts)); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result.Count.ShouldBe(1); + result[0].Name.ShouldBe("CSharp"); + result[0].Count.ShouldBe(1); + } + + [Fact] + public async Task ShouldSortAlphabeticallyWhenCountsAreEqual() + { + // Arrange + var posts = new List + { + new BlogPostBuilder().WithTags("CSharp").Build(), + new BlogPostBuilder().WithTags("Blazor").Build(), + }; + + repository.GetAllAsync() + .Returns(CreatePagedList(posts)); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result[0].Name.ShouldBe("Blazor"); + result[1].Name.ShouldBe("CSharp"); + } + + private static PagedList CreatePagedList(List posts) + { + return new PagedList( + posts, + posts.Count, + 1, + posts.Count == 0 ? 1 : posts.Count); + } +} diff --git a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration12To13Tests.cs b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration12To13Tests.cs new file mode 100644 index 00000000..aeeff1e9 --- /dev/null +++ b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration12To13Tests.cs @@ -0,0 +1,61 @@ +using LinkDotNet.Blog.UpgradeAssistant.Migrations; +using System.Text.Json; + +namespace LinkDotNet.Blog.UpgradeAssistant.Tests; + +public class Migration12To13Tests +{ + [Fact] + public void Should_Add_EnableTagDiscoveryPanel_Setting() + { + // Arrange + var migration = new Migration12To13(); + var json = """ + { + "BlogName": "Test Blog" + } + """; + var document = JsonDocument.Parse(json); + + // Act + var result = migration.Apply(document, ref json); + + // Assert + result.ShouldBeTrue(); + json.ShouldContain("\"EnableTagDiscoveryPanel\": true"); + document.Dispose(); + } + + [Fact] + public void Should_Not_Change_When_Setting_Already_Exists() + { + // Arrange + var migration = new Migration12To13(); + var json = """ + { + "BlogName": "Test Blog", + "EnableTagDiscoveryPanel": false + } + """; + var document = JsonDocument.Parse(json); + + // Act + var result = migration.Apply(document, ref json); + + // Assert + result.ShouldBeFalse(); + document.Dispose(); + } + + [Fact] + public void Should_Have_Correct_Version_Info() + { + // Arrange + var migration = new Migration12To13(); + + // Act & Assert + migration.FromVersion.ShouldBe("12.0"); + migration.ToVersion.ShouldBe("13.0"); + migration.GetDescription().ShouldNotBeNullOrEmpty(); + } +} diff --git a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs index ea483f18..fbbb52ce 100644 --- a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs +++ b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs @@ -13,7 +13,7 @@ public MigrationManagerTests() } [Fact] - public async Task Should_Migrate_From_11_To_12() + public async Task Should_Migrate_Config_To_Latest_Version() { // Arrange var testFile = Path.Combine(testDirectory, "appsettings.Development.json"); @@ -32,8 +32,8 @@ public async Task Should_Migrate_From_11_To_12() // Assert result.ShouldBeTrue(); var content = await File.ReadAllTextAsync(testFile, TestContext.Current.CancellationToken); - content.ShouldContain("\"ConfigVersion\": \"12.0\""); - content.ShouldContain("\"ShowBuildInformation\": true"); + content.ShouldContain("\"ConfigVersion\": \"13.0\""); + content.ShouldContain("\"EnableTagDiscoveryPanel\": true"); // Verify backup was created var backupFiles = Directory.GetFiles(backupDir); @@ -47,7 +47,7 @@ public async Task Should_Not_Modify_Already_Migrated_File() var testFile = Path.Combine(testDirectory, "appsettings.Production.json"); var json = """ { - "ConfigVersion": "12.0", + "ConfigVersion": "13.0", "BlogName": "Test Blog", "ShowBuildInformation": true } diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs index ddc075f4..46e03f7c 100644 --- a/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs @@ -14,7 +14,8 @@ public MigrationManager() { _migrations = new List { - new Migration11To12() + new Migration11To12(), + new Migration12To13() }; _currentVersion = DetermineCurrentVersionFromMigrations(); diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_12_To_13.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_12_To_13.cs new file mode 100644 index 00000000..8b62a4be --- /dev/null +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_12_To_13.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace LinkDotNet.Blog.UpgradeAssistant.Migrations; + +/// +/// Migration from version 12.0 to 13.0. +/// Adds EnableTagDiscoveryPanel setting. +/// +public sealed class Migration12To13 : IMigration +{ + public string FromVersion => "12.0"; + public string ToVersion => "13.0"; + + public bool Apply(JsonDocument document, ref string jsonContent) + { + var jsonNode = JsonNode.Parse(jsonContent); + if (jsonNode is not JsonObject rootObject) + { + return false; + } + + var hasChanges = false; + + if (!rootObject.ContainsKey("EnableTagDiscoveryPanel")) + { + rootObject["EnableTagDiscoveryPanel"] = true; + hasChanges = true; + ConsoleOutput.WriteInfo("Added 'EnableTagDiscoveryPanel' setting. Controls whether the Tag Discovery panel is enabled in the UI."); + } + + if (hasChanges) + { + var options = new JsonSerializerOptions { WriteIndented = true }; + jsonContent = jsonNode.ToJsonString(options); + } + + return hasChanges; + } + + public string GetDescription() + { + return "Adds EnableTagDiscoveryPanel setting that controls whether the Tag Discovery panel is enabled in the UI."; + } +}