From e114b43971b3b12064310c0fea958f791fe4f4ef Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 1 Mar 2026 11:59:11 +0100 Subject: [PATCH] feat: Versioning --- Readme.md | 3 +- src/LinkDotNet.Blog.Domain/BlogPost.cs | 2 +- src/LinkDotNet.Blog.Domain/BlogPostVersion.cs | 55 +++ ...28160341_AddBlogPostVersioning.Designer.cs | 341 ++++++++++++++++++ .../20260228160341_AddBlogPostVersioning.cs | 56 +++ .../Migrations/BlogDbContextModelSnapshot.cs | 65 +++- .../Persistence/Sql/BlogDbContext.cs | 3 + .../Mapping/BlogPostVersionConfiguration.cs | 31 ++ .../Components/CreateNewBlogPost.razor | 152 ++++++++ .../Components/CreateNewModel.cs | 23 ++ .../Admin/BlogPostEditor/CreateBlogPost.razor | 2 + .../BlogPostEditor/UpdateBlogPostPage.razor | 19 + .../CreateNewBlogPostPageTests.cs | 10 + .../BlogPostEditor/UpdateBlogPostPageTests.cs | 18 + .../Domain/BlogPostTests.cs | 32 ++ .../Components/CreateNewBlogPostTests.cs | 58 +++ 16 files changed, 867 insertions(+), 3 deletions(-) create mode 100644 src/LinkDotNet.Blog.Domain/BlogPostVersion.cs create mode 100644 src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.Designer.cs create mode 100644 src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.cs create mode 100644 src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs diff --git a/Readme.md b/Readme.md index a36c69e8..2007c566 100644 --- a/Readme.md +++ b/Readme.md @@ -17,6 +17,7 @@ This also includes source code snippets. Highlighting is done via [highlight.js] - **Bookmarks** - Allow readers to save their favorite articles - **Drafts** - Save work in progress and continue later - **Scheduled Publishing** - Plan ahead and publish automatically +- **Blog Post Versioning** - Restore previous post states and compare changes between versions in the editor - **Similar Blog Posts** - Recommend related content to readers - **Comments** - Enable discussions - **Media Upload** - Easily include images in your posts (Azure Blob Storage and CDN Support) @@ -70,4 +71,4 @@ This repository offers a [GitHub Codespace](https://github.com/features/codespac ## Resources You want a visual walkthrough through the features and details? The awesome [@ncosentino / DevLeader](https://github.com/ncosentino/) has a YouTube video/series: * [*"WordPress Is A DUMPSTER FIRE - Build A Blog In Blazor!"*](https://www.youtube.com/watch?v=RGq2s25xTPE). - * [*"WordPress is HISTORY! Get Your Own Blazor Blog Running TODAY!"*](https://www.youtube.com/watch?v=A2vAO7jxFz4) \ No newline at end of file + * [*"WordPress is HISTORY! Get Your Own Blazor Blog Running TODAY!"*](https://www.youtube.com/watch?v=A2vAO7jxFz4) diff --git a/src/LinkDotNet.Blog.Domain/BlogPost.cs b/src/LinkDotNet.Blog.Domain/BlogPost.cs index 9d0319f1..6b34954a 100644 --- a/src/LinkDotNet.Blog.Domain/BlogPost.cs +++ b/src/LinkDotNet.Blog.Domain/BlogPost.cs @@ -102,7 +102,7 @@ public static BlogPost Create( throw new InvalidOperationException("Can't schedule publish date if the blog post is already published."); } - var blogPostUpdateDate = scheduledPublishDate ?? updatedDate ?? DateTime.UtcNow; + var blogPostUpdateDate = updatedDate ?? scheduledPublishDate ?? DateTime.UtcNow; var blogPost = new BlogPost { diff --git a/src/LinkDotNet.Blog.Domain/BlogPostVersion.cs b/src/LinkDotNet.Blog.Domain/BlogPostVersion.cs new file mode 100644 index 00000000..940f3502 --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/BlogPostVersion.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace LinkDotNet.Blog.Domain; + +public sealed class BlogPostVersion : Entity +{ + public string BlogPostId { get; private set; } = default!; + + public int Version { get; private set; } + + public string Title { get; private set; } = default!; + + public string ShortDescription { get; private set; } = default!; + + public string Content { get; private set; } = default!; + + public string PreviewImageUrl { get; private set; } = default!; + + public string? PreviewImageUrlFallback { get; private set; } + + public DateTime UpdatedDate { get; private set; } + + public IList Tags { get; private set; } = []; + + public bool IsPublished { get; private set; } + + public int ReadingTimeInMinutes { get; private set; } + + public string? AuthorName { get; private set; } + + public static BlogPostVersion Create(BlogPost blogPost, int version) + { + ArgumentNullException.ThrowIfNull(blogPost); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(version); + + return new BlogPostVersion + { + BlogPostId = blogPost.Id, + Version = version, + Title = blogPost.Title, + ShortDescription = blogPost.ShortDescription, + Content = blogPost.Content, + PreviewImageUrl = blogPost.PreviewImageUrl, + PreviewImageUrlFallback = blogPost.PreviewImageUrlFallback, + UpdatedDate = blogPost.UpdatedDate, + Tags = blogPost.Tags.ToImmutableArray(), + IsPublished = blogPost.IsPublished, + ReadingTimeInMinutes = blogPost.ReadingTimeInMinutes, + AuthorName = blogPost.AuthorName + }; + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.Designer.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.Designer.cs new file mode 100644 index 00000000..16a0bd15 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.Designer.cs @@ -0,0 +1,341 @@ +// +using System; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LinkDotNet.Blog.Infrastructure.Migrations +{ + [DbContext(typeof(BlogDbContext))] + [Migration("20260228160341_AddBlogPostVersioning")] + partial class AddBlogPostVersioning + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.13"); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("INTEGER"); + + b.Property("ScheduledPublishDate") + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsPublished", "UpdatedDate") + .IsDescending(false, true) + .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Clicks") + .HasColumnType("INTEGER"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostRecords"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostTemplates"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("INTEGER"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlogPostId", "Version") + .IsUnique(); + + b.ToTable("BlogPostVersions"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ProfileInformationEntries"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ShortCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ShortCodes"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.SimilarBlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("SimilarBlogPostIds") + .IsRequired() + .HasMaxLength(1350) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SimilarBlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ProficiencyLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Talk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Place") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PresentationTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Talks"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.UserRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.Property("UrlClicked") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.cs new file mode 100644 index 00000000..95b6dc92 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260228160341_AddBlogPostVersioning.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LinkDotNet.Blog.Infrastructure.Migrations; + +/// +public partial class AddBlogPostVersioning : Migration +{ + private static readonly string[] BlogPostVersionColumns = ["BlogPostId", "Version"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.CreateTable( + name: "BlogPostVersions", + columns: table => new + { + Id = table.Column(type: "TEXT", unicode: false, nullable: false), + BlogPostId = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Version = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 256, nullable: false), + ShortDescription = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + PreviewImageUrl = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + PreviewImageUrlFallback = table.Column(type: "TEXT", maxLength: 1024, nullable: true), + UpdatedDate = table.Column(type: "TEXT", nullable: false), + Tags = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + IsPublished = table.Column(type: "INTEGER", nullable: false), + ReadingTimeInMinutes = table.Column(type: "INTEGER", nullable: false), + AuthorName = table.Column(type: "TEXT", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BlogPostVersions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BlogPostVersions_BlogPostId_Version", + table: "BlogPostVersions", + columns: BlogPostVersionColumns, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropTable( + name: "BlogPostVersions"); + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs index f0ee127e..89c93dfe 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class BlogDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.13"); modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => { @@ -132,6 +132,69 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlogPostTemplates"); }); + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("INTEGER"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlogPostId", "Version") + .IsUnique(); + + b.ToTable("BlogPostVersions"); + }); + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => { b.Property("Id") diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs index b8b647f6..082aab09 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs @@ -15,6 +15,8 @@ public BlogDbContext(DbContextOptions options) public DbSet BlogPosts { get; set; } + public DbSet BlogPostVersions { get; set; } + public DbSet ProfileInformationEntries { get; set; } public DbSet Skills { get; set; } @@ -36,6 +38,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); + modelBuilder.ApplyConfiguration(new BlogPostVersionConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostRecordConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostTemplateConfiguration()); modelBuilder.ApplyConfiguration(new ProfileInformationEntryConfiguration()); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs new file mode 100644 index 00000000..997b2b7f --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs @@ -0,0 +1,31 @@ +using LinkDotNet.Blog.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping; + +internal sealed class BlogPostVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Id) + .IsUnicode(false) + .ValueGeneratedOnAdd(); + + builder.Property(x => x.BlogPostId).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Version).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(256).IsRequired(); + builder.Property(x => x.PreviewImageUrl).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.PreviewImageUrlFallback).HasMaxLength(1024); + builder.Property(x => x.Content).IsRequired(); + builder.Property(x => x.ShortDescription).IsRequired(); + builder.Property(x => x.IsPublished).IsRequired(); + builder.Property(x => x.ReadingTimeInMinutes).IsRequired(); + builder.Property(x => x.Tags).HasMaxLength(2048); + builder.Property(x => x.AuthorName).HasMaxLength(256).IsRequired(false); + + builder.HasIndex(x => new { x.BlogPostId, x.Version }) + .IsUnique(); + } +} 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 4d42c857..7264953a 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -133,6 +133,72 @@ + + @if (BlogPost is not null && Versions.Count > 0) + { +
+
+
Version History
+
+
+
+ + + @foreach (var version in OrderedVersions) + { + + } + +
+
+ +
+ @if (Versions.Count > 1) + { +
+ +
+
+ + @foreach (var version in OrderedVersions) + { + + } + +
+
+ + @foreach (var version in OrderedVersions) + { + + } + +
+
+ var diffs = GetVersionDiffs(); + @if (diffs.Count == 0) + { + No differences for the selected versions. + } + else + { +
+ @foreach (var diff in diffs) + { +
+
@diff.Field
+
v@(compareVersionLeft): @Truncate(diff.LeftValue)
+
v@(compareVersionRight): @Truncate(diff.RightValue)
+
+ } +
+ } + } +
+
+ }
@@ -282,6 +348,9 @@ [Parameter] public BlogPost? BlogPost { get; set; } + [Parameter] + public IReadOnlyList Versions { get; set; } = []; + [Parameter, EditorRequired] public required string Title { get; set; } @@ -306,8 +375,12 @@ private IPagedList blogPostTemplates = PagedList.Empty; private bool IsScheduled => model.ScheduledPublishDate.HasValue; + private IEnumerable OrderedVersions => Versions.OrderByDescending(v => v.Version); private string? authorName; + private int? selectedVersionToRestore; + private int? compareVersionLeft; + private int? compareVersionRight; protected override async Task OnInitializedAsync() { @@ -328,6 +401,14 @@ } model = CreateNewModel.FromBlogPost(BlogPost); + + if (Versions.Count > 0) + { + var latestVersion = OrderedVersions.First(); + selectedVersionToRestore ??= latestVersion.Version; + compareVersionLeft ??= latestVersion.Version; + compareVersionRight ??= latestVersion.Version; + } } private string GetStatusText() @@ -444,6 +525,77 @@ ToastService.ShowInfo($"Template {template.Name} loaded"); } + private void RestoreVersion() + { + if (!selectedVersionToRestore.HasValue) + { + return; + } + + var selectedVersion = Versions.FirstOrDefault(v => v.Version == selectedVersionToRestore.Value); + if (selectedVersion is null) + { + return; + } + + var scheduledPublishDate = model.ScheduledPublishDate; + model = CreateNewModel.FromBlogPostVersion(selectedVersion, scheduledPublishDate); + model.MarkDirty(); + ToastService.ShowInfo($"Restored version {selectedVersion.Version}"); + } + + private IReadOnlyList GetVersionDiffs() + { + if (!compareVersionLeft.HasValue || !compareVersionRight.HasValue) + { + return []; + } + + var left = Versions.FirstOrDefault(v => v.Version == compareVersionLeft.Value); + var right = Versions.FirstOrDefault(v => v.Version == compareVersionRight.Value); + if (left is null || right is null) + { + return []; + } + + var diffs = new List(); + AddIfChanged("Title", left.Title, right.Title, diffs); + AddIfChanged("Short Description", left.ShortDescription, right.ShortDescription, diffs); + AddIfChanged("Content", left.Content, right.Content, diffs); + AddIfChanged("Preview Image Url", left.PreviewImageUrl, right.PreviewImageUrl, diffs); + AddIfChanged("Preview Fallback Image Url", left.PreviewImageUrlFallback ?? string.Empty, right.PreviewImageUrlFallback ?? string.Empty, diffs); + AddIfChanged("Tags", string.Join(", ", left.Tags), string.Join(", ", right.Tags), diffs); + AddIfChanged("Published", left.IsPublished.ToString(), right.IsPublished.ToString(), diffs); + AddIfChanged("Updated Date", left.UpdatedDate.ToString("u"), right.UpdatedDate.ToString("u"), diffs); + AddIfChanged("Author", left.AuthorName ?? string.Empty, right.AuthorName ?? string.Empty, diffs); + return diffs; + } + + private static void AddIfChanged(string field, string left, string right, ICollection diffs) + { + if (left == right) + { + return; + } + + diffs.Add(new VersionDiff(field, left, right)); + } + + private static string Truncate(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return ""; + } + + const int maxLength = 120; + return input.Length <= maxLength + ? input + : $"{input[..maxLength]}..."; + } + + private sealed record VersionDiff(string Field, string LeftValue, string RightValue); + /// /// Convert content from HTML to Markdown and viceversa /// diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs index cca56134..e6b80b6c 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs @@ -121,6 +121,27 @@ public static CreateNewModel FromBlogPost(BlogPost blogPost) }; } + public static CreateNewModel FromBlogPostVersion(BlogPostVersion blogPostVersion, DateTime? scheduledPublishDate) + { + ArgumentNullException.ThrowIfNull(blogPostVersion); + + return new CreateNewModel + { + id = blogPostVersion.BlogPostId, + Content = blogPostVersion.Content, + Tags = string.Join(",", blogPostVersion.Tags), + Title = blogPostVersion.Title, + ShortDescription = blogPostVersion.ShortDescription, + IsPublished = blogPostVersion.IsPublished, + PreviewImageUrl = blogPostVersion.PreviewImageUrl, + originalUpdatedDate = blogPostVersion.UpdatedDate, + PreviewImageUrlFallback = blogPostVersion.PreviewImageUrlFallback ?? string.Empty, + scheduledPublishDate = scheduledPublishDate?.ToUniversalTime(), + authorName = blogPostVersion.AuthorName, + IsDirty = false, + }; + } + public BlogPost ToBlogPost() { var tagList = string.IsNullOrWhiteSpace(Tags) @@ -145,6 +166,8 @@ public BlogPost ToBlogPost() return blogPost; } + public void MarkDirty() => IsDirty = true; + private void SetProperty(out T backingField, T value) { backingField = value; diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor index 26e190b4..03c67790 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor @@ -4,6 +4,7 @@ @using LinkDotNet.Blog.Infrastructure.Persistence @using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components @inject IRepository BlogPostRepository +@inject IRepository BlogPostVersionRepository @inject IToastService ToastService @@ -12,6 +13,7 @@ private async Task StoreBlogPostAsync(BlogPost blogPost) { await BlogPostRepository.StoreAsync(blogPost); + await BlogPostVersionRepository.StoreAsync(BlogPostVersion.Create(blogPost, 1)); ToastService.ShowInfo($"Created BlogPost {blogPost.Title}"); } } diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor index 46dff63b..aa024674 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor @@ -4,6 +4,7 @@ @using LinkDotNet.Blog.Infrastructure.Persistence @using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components @inject IRepository BlogPostRepository +@inject IRepository BlogPostVersionRepository @inject IToastService ToastService Updating: @blogPostFromDb?.Title @@ -13,6 +14,7 @@ } @@ -26,20 +28,37 @@ else public required string BlogPostId { get; set; } private BlogPost? blogPostFromDb; + private IReadOnlyList versions = []; protected override async Task OnParametersSetAsync() { ArgumentException.ThrowIfNullOrEmpty(BlogPostId); blogPostFromDb = await BlogPostRepository.GetByIdAsync(BlogPostId); + versions = (await BlogPostVersionRepository.GetAllAsync( + v => v.BlogPostId == BlogPostId, + v => v.Version)).ToList(); } private async Task StoreBlogPostAsync(BlogPost blogPost) { ArgumentNullException.ThrowIfNull(blogPostFromDb); + if (versions.Count == 0) + { + var initialVersion = BlogPostVersion.Create(blogPostFromDb, 1); + await BlogPostVersionRepository.StoreAsync(initialVersion); + versions = [initialVersion]; + } + blogPostFromDb.Update(blogPost); await BlogPostRepository.StoreAsync(blogPostFromDb); + + var nextVersion = versions.Max(v => v.Version) + 1; + var version = BlogPostVersion.Create(blogPostFromDb, nextVersion); + await BlogPostVersionRepository.StoreAsync(version); + versions = [version, ..versions]; + ToastService.ShowInfo($"Updated BlogPost {blogPost.Title}"); } } 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 db02007e..6fba1bca 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs @@ -5,6 +5,7 @@ using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure; using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; using LinkDotNet.Blog.TestUtilities.Fakes; using LinkDotNet.Blog.Web; using LinkDotNet.Blog.Web.Features; @@ -15,6 +16,7 @@ using LinkDotNet.Blog.Web.Features.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NCronJob; using TestContext = Xunit.TestContext; @@ -32,7 +34,9 @@ public async Task ShouldSaveBlogPostOnSave() var instantRegistry = Substitute.For(); ctx.JSInterop.SetupVoid("hljs.highlightAll"); ctx.AddAuthorization().SetAuthorized("some username"); + var blogPostVersionRepository = new Repository(DbContextFactory, Substitute.For>>()); ctx.Services.AddScoped(_ => Repository); + ctx.Services.AddScoped>(_ => blogPostVersionRepository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => instantRegistry); @@ -70,6 +74,10 @@ public async Task ShouldSaveBlogPostOnSave() blogPostFromDb.ShouldNotBeNull(); blogPostFromDb.ShortDescription.ShouldBe("My short Description"); blogPostFromDb.AuthorName.ShouldBe("Test Author"); + var versionFromDb = await DbContext.BlogPostVersions.SingleOrDefaultAsync(t => t.BlogPostId == blogPostFromDb.Id, TestContext.Current.CancellationToken); + versionFromDb.ShouldNotBeNull(); + versionFromDb.Version.ShouldBe(1); + versionFromDb.Title.ShouldBe("My Title"); toastService.Received(1).ShowInfo("Created BlogPost My Title", null); instantRegistry.Received(1).RunInstantJob(Arg.Any(), Arg.Any()); @@ -84,7 +92,9 @@ public async Task ShouldSaveAuthorNameAsNullWhenMultiAuthorModeIsDisabled() var instantRegistry = Substitute.For(); ctx.JSInterop.SetupVoid("hljs.highlightAll"); ctx.AddAuthorization().SetAuthorized("some username"); + var blogPostVersionRepository = new Repository(DbContextFactory, Substitute.For>>()); ctx.Services.AddScoped(_ => Repository); + ctx.Services.AddScoped>(_ => blogPostVersionRepository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => instantRegistry); 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 1c86c280..9abc8b11 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs @@ -1,10 +1,12 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Blazored.Toast.Services; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure; using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.TestUtilities.Fakes; using LinkDotNet.Blog.Web; @@ -16,6 +18,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NCronJob; using TestContext = Xunit.TestContext; @@ -35,8 +38,10 @@ public async Task ShouldSaveBlogPostOnSave() var instantRegistry = Substitute.For(); var blogPost = new BlogPostBuilder().WithTitle("Title").WithShortDescription("Sub").Build(); await Repository.StoreAsync(blogPost); + var blogPostVersionRepository = new Repository(DbContextFactory, Substitute.For>>()); ctx.AddAuthorization().SetAuthorized("some username"); ctx.Services.AddScoped(_ => Repository); + ctx.Services.AddScoped>(_ => blogPostVersionRepository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => instantRegistry); var shortCodeRepository = Substitute.For>(); @@ -73,6 +78,15 @@ public async Task ShouldSaveBlogPostOnSave() blogPostFromDb.ShouldNotBeNull(); blogPostFromDb.ShortDescription.ShouldBe("My new Description"); blogPostFromDb.AuthorName.ShouldBe("Test Author"); + var blogPostVersionsFromDb = await DbContext.BlogPostVersions + .Where(t => t.BlogPostId == blogPost.Id) + .OrderBy(t => t.Version) + .ToListAsync(TestContext.Current.CancellationToken); + blogPostVersionsFromDb.Count.ShouldBe(2); + blogPostVersionsFromDb[0].Version.ShouldBe(1); + blogPostVersionsFromDb[0].ShortDescription.ShouldBe("Sub"); + blogPostVersionsFromDb[1].Version.ShouldBe(2); + blogPostVersionsFromDb[1].ShortDescription.ShouldBe("My new Description"); toastService.Received(1).ShowInfo("Updated BlogPost Title", null); instantRegistry.Received(1).RunInstantJob(Arg.Any(), Arg.Any()); @@ -89,8 +103,10 @@ public async Task ShouldSaveAuthorNameAsNullWhenMultiAuthorModeIsDisabled() var instantRegistry = Substitute.For(); var blogPost = new BlogPostBuilder().WithTitle("Title").WithShortDescription("Sub").Build(); await Repository.StoreAsync(blogPost); + var blogPostVersionRepository = new Repository(DbContextFactory, Substitute.For>>()); ctx.AddAuthorization().SetAuthorized("some username"); ctx.Services.AddScoped(_ => Repository); + ctx.Services.AddScoped>(_ => blogPostVersionRepository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => instantRegistry); var shortCodeRepository = Substitute.For>(); @@ -135,6 +151,8 @@ public void ShouldThrowWhenNoIdProvided() ctx.ComponentFactories.Add(); ctx.AddAuthorization().SetAuthorized("some username"); ctx.Services.AddScoped(_ => Repository); + var blogPostVersionRepository = new Repository(DbContextFactory, Substitute.For>>()); + ctx.Services.AddScoped>(_ => blogPostVersionRepository); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); diff --git a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs index eb5e6f97..138df118 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs @@ -177,4 +177,36 @@ public void GivenBlogPostWithNoTags_WhenCreatingStringFromTags_ThenEmptyString() tags.ShouldBeEmpty(); } + + [Fact] + public void ShouldCreateBlogPostVersionFromBlogPost() + { + var updatedDate = new DateTime(1991, 5, 17); + var blogPost = new BlogPostBuilder() + .WithTitle("Title") + .WithShortDescription("Description") + .WithContent("Content") + .WithPreviewImageUrl("Preview") + .WithPreviewImageUrlFallback("Fallback") + .WithUpdatedDate(updatedDate) + .WithTags("tag-1", "tag-2") + .WithAuthorName("Test Author") + .IsPublished(false) + .Build(); + + var version = BlogPostVersion.Create(blogPost, 1); + + version.BlogPostId.ShouldBe(blogPost.Id); + version.Version.ShouldBe(1); + version.Title.ShouldBe("Title"); + version.ShortDescription.ShouldBe("Description"); + version.Content.ShouldBe("Content"); + version.PreviewImageUrl.ShouldBe("Preview"); + version.PreviewImageUrlFallback.ShouldBe("Fallback"); + version.UpdatedDate.ShouldBe(updatedDate); + version.Tags.ShouldContain("tag-1"); + version.Tags.ShouldContain("tag-2"); + version.AuthorName.ShouldBe("Test Author"); + version.IsPublished.ShouldBeFalse(); + } } 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 9a6eb97e..65d353e2 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 @@ -142,6 +142,64 @@ public void ShouldFillGivenBlogPost() blogPostFromComponent.Tags.ShouldContain("tag2"); } + [Fact] + public void ShouldRestoreVersionToEditorAndPreserveScheduleDate() + { + var scheduledDate = new DateTime(2099, 1, 1); + var currentBlogPost = new BlogPostBuilder() + .WithTitle("Current title") + .WithShortDescription("Current description") + .WithContent("Current content") + .WithPreviewImageUrl("current-url") + .IsPublished(false) + .WithScheduledPublishDate(scheduledDate) + .Build(); + var oldDate = new DateTime(1991, 5, 17); + var previousVersionPost = new BlogPostBuilder() + .WithTitle("Version 1 title") + .WithShortDescription("Version 1 description") + .WithContent("Version 1 content") + .WithPreviewImageUrl("version-url") + .IsPublished(false) + .WithUpdatedDate(oldDate) + .Build(); + previousVersionPost.Id = currentBlogPost.Id; + var version = BlogPostVersion.Create(previousVersionPost, 1); + BlogPost? blogPostFromComponent = null; + + var cut = Render( + p => p.Add(c => c.OnBlogPostCreated, bp => blogPostFromComponent = bp) + .Add(c => c.BlogPost, currentBlogPost) + .Add(c => c.Versions, [version])); + + cut.Find("#restore-version").Click(); + cut.Find("form").Submit(); + + blogPostFromComponent.ShouldNotBeNull(); + blogPostFromComponent.Title.ShouldBe("Version 1 title"); + blogPostFromComponent.ShortDescription.ShouldBe("Version 1 description"); + blogPostFromComponent.Content.ShouldBe("Version 1 content"); + blogPostFromComponent.ScheduledPublishDate.ShouldBe(scheduledDate.ToUniversalTime()); + blogPostFromComponent.UpdatedDate.ShouldBe(oldDate); + } + + [Fact] + public void ShouldHideVersionComparerWhenOnlyOneVersionExists() + { + var currentBlogPost = new BlogPostBuilder().Build(); + var versionPost = new BlogPostBuilder().WithTitle("Version 1 title").Build(); + versionPost.Id = currentBlogPost.Id; + var version = BlogPostVersion.Create(versionPost, 1); + + var cut = Render( + p => p.Add(c => c.BlogPost, currentBlogPost) + .Add(c => c.Versions, [version])); + + cut.FindAll("#compare-version-left").ShouldBeEmpty(); + cut.FindAll("#compare-version-right").ShouldBeEmpty(); + cut.FindAll("#restore-version").Count.ShouldBe(1); + } + [Fact] public void ShouldNotDeleteModelWhenSet() {