diff --git a/LinkDotNet.Blog.IntegrationTests/SqlDatabaseTestBase.cs b/LinkDotNet.Blog.IntegrationTests/SqlDatabaseTestBase.cs index 39b13f96..2ab3497f 100644 --- a/LinkDotNet.Blog.IntegrationTests/SqlDatabaseTestBase.cs +++ b/LinkDotNet.Blog.IntegrationTests/SqlDatabaseTestBase.cs @@ -37,7 +37,6 @@ async Task IAsyncLifetime.DisposeAsync() public async ValueTask DisposeAsync() { - await DbContext.Database.EnsureDeletedAsync(); await DbContext.DisposeAsync(); } diff --git a/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/DashboardServiceTests.cs b/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/DashboardServiceTests.cs index de7be1c1..7429ee29 100644 --- a/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/DashboardServiceTests.cs +++ b/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/DashboardServiceTests.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading.Tasks; using FluentAssertions; using LinkDotNet.Blog.Domain; @@ -97,38 +96,5 @@ public async Task ShouldGetAboutMeClicks() data.TotalAboutMeClicks.Should().Be(2); data.AboutMeClicksLast30Days.Should().Be(1); } - - [Fact] - public async Task ShouldGetBlogPostClicks() - { - var record1 = new UserRecord - { - UrlClicked = "blogPost/1", - }; - var record2 = new UserRecord - { - UrlClicked = "blogPost/2", - }; - var record3 = new UserRecord - { - UrlClicked = "blogPost/1", - }; - var record4 = new UserRecord - { - UrlClicked = "unrelated", - }; - await Repository.StoreAsync(record1); - await Repository.StoreAsync(record2); - await Repository.StoreAsync(record3); - await Repository.StoreAsync(record4); - - var data = (await sut.GetDashboardDataAsync()).BlogPostVisitCount.ToList(); - - data.Count.Should().Be(2); - data[0].Key.Should().Be("blogPost/1"); - data[0].Value.Should().Be(2); - data[1].Key.Should().Be("blogPost/2"); - data[1].Value.Should().Be(1); - } } } \ No newline at end of file diff --git a/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/VisitCountPerPageTests.cs b/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/VisitCountPerPageTests.cs index 7239c3a3..77173291 100644 --- a/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/VisitCountPerPageTests.cs +++ b/LinkDotNet.Blog.IntegrationTests/Web/Pages/Admin/Dashboard/VisitCountPerPageTests.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; +using System; using System.Linq; using System.Threading.Tasks; using AngleSharp.Html.Dom; using Bunit; using FluentAssertions; using LinkDotNet.Blog.Domain; -using LinkDotNet.Blog.Infrastructure.Persistence; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web.Shared.Admin.Dashboard; using Microsoft.Extensions.DependencyInjection; @@ -21,12 +20,10 @@ public async Task ShouldShowCounts() var blogPost = new BlogPostBuilder().WithTitle("I was clicked").WithLikes(2).Build(); await Repository.StoreAsync(blogPost); using var ctx = new TestContext(); - ctx.Services.AddScoped>(_ => Repository); - var visits = new List> { new($"blogPost/{blogPost.Id}", 5) }; - var pageVisitCounts = visits.OrderByDescending(s => s.Value); + ctx.Services.AddScoped(_ => DbContext); + await SaveBlogPostArticleClicked(blogPost.Id, 10); - var cut = ctx.RenderComponent(p => p.Add( - s => s.PageVisitCount, pageVisitCounts)); + var cut = ctx.RenderComponent(); cut.WaitForState(() => cut.FindAll("td").Any()); var elements = cut.FindAll("td").ToList(); @@ -35,36 +32,54 @@ public async Task ShouldShowCounts() titleData.Should().NotBeNull(); titleData.InnerHtml.Should().Be(blogPost.Title); titleData.Href.Should().Contain($"blogPost/{blogPost.Id}"); - elements[1].InnerHtml.Should().Be("5"); + elements[1].InnerHtml.Should().Be("10"); elements[2].InnerHtml.Should().Be("2"); } [Fact] - public void ShouldIgnoreNullForBlogPostVisits() + public async Task ShouldFilterStartDate() { + var blogPost1 = new BlogPostBuilder().WithTitle("1").WithLikes(2).Build(); + var blogPost2 = new BlogPostBuilder().WithTitle("2").WithLikes(2).Build(); + await Repository.StoreAsync(blogPost1); + await Repository.StoreAsync(blogPost2); + var urlClicked1New = new UserRecord + { UrlClicked = $"blogPost/{blogPost1.Id}", DateTimeUtcClicked = DateTime.UtcNow }; + var urlClicked1Old = new UserRecord + { UrlClicked = $"blogPost/{blogPost1.Id}", DateTimeUtcClicked = DateTime.MinValue }; + var urlClicked2 = new UserRecord + { UrlClicked = $"blogPost/{blogPost2.Id}", DateTimeUtcClicked = DateTime.MinValue }; + await DbContext.UserRecords.AddRangeAsync(new[] { urlClicked1New, urlClicked1Old, urlClicked2 }); + await DbContext.SaveChangesAsync(); using var ctx = new TestContext(); - ctx.Services.AddScoped>(_ => Repository); + ctx.Services.AddScoped(_ => DbContext); + var cut = ctx.RenderComponent(); - var cut = ctx.RenderComponent(p => p.Add( - s => s.PageVisitCount, null)); + cut.FindComponent().Find("select").Change(DateTime.UtcNow.Date); + cut.WaitForState(() => cut.FindAll("td").Any()); var elements = cut.FindAll("td").ToList(); - elements.Should().BeEmpty(); + elements.Count.Should().Be(3); + var titleData = elements[0].ChildNodes.Single() as IHtmlAnchorElement; + titleData.Should().NotBeNull(); + titleData.InnerHtml.Should().Be(blogPost1.Title); + titleData.Href.Should().Contain($"blogPost/{blogPost1.Id}"); + elements[1].InnerHtml.Should().Be("1"); } - [Fact] - public void ShouldIgnoreNotBlogPosts() + private async Task SaveBlogPostArticleClicked(string blogPostId, int count) { - using var ctx = new TestContext(); - ctx.Services.AddScoped>(_ => Repository); - var visits = new List> { new("notablogpost", 5) }; - var pageVisitCounts = visits.OrderByDescending(s => s.Value); + var urlClicked = $"blogPost/{blogPostId}"; + for (var i = 0; i < count; i++) + { + var data = new UserRecord + { + UrlClicked = urlClicked, + }; + await DbContext.UserRecords.AddAsync(data); + } - var cut = ctx.RenderComponent(p => p.Add( - s => s.PageVisitCount, pageVisitCounts)); - - var elements = cut.FindAll("td").ToList(); - elements.Should().BeEmpty(); + await DbContext.SaveChangesAsync(); } } } \ No newline at end of file diff --git a/LinkDotNet.Blog.UnitTests/Web/Pages/Admin/DashboardTests.cs b/LinkDotNet.Blog.UnitTests/Web/Pages/Admin/DashboardTests.cs index 1053c594..358e15cf 100644 --- a/LinkDotNet.Blog.UnitTests/Web/Pages/Admin/DashboardTests.cs +++ b/LinkDotNet.Blog.UnitTests/Web/Pages/Admin/DashboardTests.cs @@ -1,12 +1,16 @@ -using System.Linq; +using System.Data.Common; +using System.Linq; using Bunit; using Bunit.TestDoubles; using FluentAssertions; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; using LinkDotNet.Blog.Web; using LinkDotNet.Blog.Web.Pages.Admin; using LinkDotNet.Blog.Web.Shared.Admin.Dashboard; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -18,11 +22,15 @@ public class DashboardTests : TestContext [Fact] public void ShouldNotShowAboutMeStatisticsWhenDisabled() { + var options = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryConnection()) + .Options; var dashboardService = new Mock(); this.AddTestAuthorization().SetAuthorized("test"); Services.AddScoped(_ => CreateAppConfiguration(false)); Services.AddScoped(_ => dashboardService.Object); Services.AddScoped(_ => new Mock>().Object); + Services.AddScoped(_ => new BlogDbContext(options)); dashboardService.Setup(d => d.GetDashboardDataAsync()) .ReturnsAsync(new DashboardData()); @@ -41,5 +49,14 @@ private static AppConfiguration CreateAppConfiguration(bool aboutMeEnabled) ProfileInformation = aboutMeEnabled ? new ProfileInformation() : null, }; } + + private static DbConnection CreateInMemoryConnection() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } } } \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Pages/Admin/Dashboard.razor b/LinkDotNet.Blog.Web/Pages/Admin/Dashboard.razor index 422647da..7bb9cd79 100644 --- a/LinkDotNet.Blog.Web/Pages/Admin/Dashboard.razor +++ b/LinkDotNet.Blog.Web/Pages/Admin/Dashboard.razor @@ -22,10 +22,10 @@ }
-
- -
-
+
+ +
+ diff --git a/LinkDotNet.Blog.Web/Pages/Admin/DashboardData.cs b/LinkDotNet.Blog.Web/Pages/Admin/DashboardData.cs index 4d6e86c0..8c7e6d3a 100644 --- a/LinkDotNet.Blog.Web/Pages/Admin/DashboardData.cs +++ b/LinkDotNet.Blog.Web/Pages/Admin/DashboardData.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; - -namespace LinkDotNet.Blog.Web.Pages.Admin +namespace LinkDotNet.Blog.Web.Pages.Admin { public class DashboardData { @@ -13,8 +10,6 @@ public class DashboardData public int PageClicksLast30Days { get; set; } - public IOrderedEnumerable> BlogPostVisitCount { get; set; } - public int TotalAboutMeClicks { get; set; } public int AboutMeClicksLast30Days { get; set; } diff --git a/LinkDotNet.Blog.Web/Pages/Admin/DashboardService.cs b/LinkDotNet.Blog.Web/Pages/Admin/DashboardService.cs index 6bbcac44..d1cc6f00 100644 --- a/LinkDotNet.Blog.Web/Pages/Admin/DashboardService.cs +++ b/LinkDotNet.Blog.Web/Pages/Admin/DashboardService.cs @@ -36,8 +36,6 @@ public async Task GetDashboardDataAsync() var aboutMeClicks = records.Count(r => r.UrlClicked.Contains("AboutMe")); var aboutMeClicksLast30Days = records.Count(r => r.UrlClicked.Contains("AboutMe") && r.DateTimeUtcClicked >= DateTime.UtcNow.AddDays(-30)); - var visitCount = GetPageVisitCount(records); - return new DashboardData { TotalAmountOfUsers = users, @@ -46,17 +44,7 @@ public async Task GetDashboardDataAsync() PageClicksLast30Days = clicks30Days, TotalAboutMeClicks = aboutMeClicks, AboutMeClicksLast30Days = aboutMeClicksLast30Days, - BlogPostVisitCount = visitCount, }; } - - private static IOrderedEnumerable> GetPageVisitCount(IEnumerable records) - { - return records - .Where(u => u.UrlClicked.StartsWith("blogPost/")) - .GroupBy(u => u.UrlClicked) - .ToDictionary(k => k.Key, v => v.Count()) - .OrderByDescending(d => d.Value); - } } } \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Shared/AccessControl.razor b/LinkDotNet.Blog.Web/Shared/AccessControl.razor index 31ad143c..7de1c3aa 100644 --- a/LinkDotNet.Blog.Web/Shared/AccessControl.razor +++ b/LinkDotNet.Blog.Web/Shared/AccessControl.razor @@ -16,7 +16,7 @@
  • Sitemap
  • -
  • Version 2.2
  • +
  • Version 2.3
  • diff --git a/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/DateRangeSelector.razor b/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/DateRangeSelector.razor new file mode 100644 index 00000000..a79a1ef3 --- /dev/null +++ b/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/DateRangeSelector.razor @@ -0,0 +1,30 @@ +
    +

    Since:

    +
    +
    + +
    +@code { + [Parameter] + public EventCallback DateTimeSpanChanged { get; set; } + + private readonly Dictionary options = new() + { + { "Beginning of time", DateTime.MinValue }, + { "Last 90 Days", DateTime.UtcNow.AddDays(-90) }, + { "Last 30 Days", DateTime.UtcNow.AddDays(-30) }, + { "Last 7 Days", DateTime.UtcNow.AddDays(-7) }, + { "Since Today", DateTime.UtcNow.Date }, + }; + + private async Task RaiseDateTimeSpanChanged(ChangeEventArgs args) + { + await DateTimeSpanChanged.InvokeAsync(DateTime.Parse(args.Value!.ToString())); + } + +} \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPageData.cs b/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPageData.cs new file mode 100644 index 00000000..418bc4ef --- /dev/null +++ b/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPageData.cs @@ -0,0 +1,13 @@ +namespace LinkDotNet.Blog.Web.Shared.Admin.Dashboard +{ + public record VisitCountPageData + { + public string Id { get; init; } + + public string Title { get; init; } + + public int Likes { get; init; } + + public int ClickCount { get; init; } + } +} \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPerPage.razor b/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPerPage.razor index ab1048c2..98ceba76 100644 --- a/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPerPage.razor +++ b/LinkDotNet.Blog.Web/Shared/Admin/Dashboard/VisitCountPerPage.razor @@ -1,60 +1,74 @@ @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure.Persistence -@inject IRepository blogPostRepository +@using System.Linq.Expressions +@using LinkDotNet.Blog.Infrastructure.Persistence.Sql +@using Microsoft.EntityFrameworkCore +@inject BlogDbContext blogDbContext
    Blog Post Visit Counts
    - - - - - - - - @if (PageVisitCount != null) - { - @foreach (var (blogPost, visitCount) in blogPostToCountList) +
    + +
    TitleClicksLikes
    + + + + + + + @if (visitData != null) + { + @foreach (var date in visitData) + { + + + + + + } + } + else { - - - - - + } - } - -
    TitleClicksLikes
    @date.Title@date.ClickCount@date.Likes
    @blogPost.Title@visitCount@blogPost.Likes
    + + +
    @code { - [Parameter] - public IOrderedEnumerable> PageVisitCount { get; set; } - private List> blogPostToCountList = new(); + private DateTime startDate = DateTime.MinValue; + private IList visitData; - protected override async Task OnParametersSetAsync() + protected override async Task OnInitializedAsync() { - if (PageVisitCount == null) - { - return; - } - - foreach (var (blogPostUrl, clickCount) in PageVisitCount) - { - const string urlToBlogPost = "blogPost/"; - var possibleBlogPostId = blogPostUrl.IndexOf(urlToBlogPost, StringComparison.InvariantCultureIgnoreCase); + await LoadBlogPostInformationAsync(); + } - if (possibleBlogPostId == -1) + private async Task LoadBlogPostInformationAsync() + { + visitData = await (from ur in blogDbContext.UserRecords + where ur.DateTimeUtcClicked >= startDate + join bp in blogDbContext.BlogPosts + on ur.UrlClicked.Replace("blogPost/", string.Empty) equals bp.Id + group new { ur, bp } by new { ur.UrlClicked } + into gp + orderby gp.Count() descending + select new VisitCountPageData { - continue; - } - - var blogPostId = blogPostUrl[urlToBlogPost.Length..]; - var blogPost = await blogPostRepository.GetByIdAsync(blogPostId); + Id = gp.FirstOrDefault().bp.Id, + Title = gp.FirstOrDefault().bp.Title, + Likes = gp.FirstOrDefault().bp.Likes, + ClickCount = gp.Count() + }).ToListAsync(); + } - blogPostToCountList.Add(new KeyValuePair(blogPost, clickCount)); - } + private async Task RefreshVisitCount(DateTime newBeginning) + { + startDate = newBeginning; + await LoadBlogPostInformationAsync(); } } \ No newline at end of file