From cc09b46cfb3caddb155ddcecf2cd1a2c8687441c Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 4 Aug 2026 11:31:16 +0200 Subject: [PATCH 1/4] Voeg leesbare slug toe aan Offering naast het GUID-ID Het Offering-ID is een GUID en niet geschikt om aan gebruikers te tonen. Voeg een op de titel gebaseerde, unieke slug toe die via de public en admin API wordt meegegeven, zodat de frontend iets leesbaars kan tonen in plaats van het ruwe ID. Het ID blijft ongewijzigd de echte identifier. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/OfferingsControllerTests.cs | 4 +- .../Repositories/OfferingRepositoryTests.cs | 32 +++++++ .../Services/OfferingsServiceTests.cs | 52 +++++++++++ .../Services/SlugifierTests.cs | 24 +++++ .../Data/Entities/Offering.cs | 1 + .../Data/OfferingsDbContext.cs | 1 + ...20260804113000_AddOfferingSlug.Designer.cs | 93 +++++++++++++++++++ .../20260804113000_AddOfferingSlug.cs | 39 ++++++++ .../OfferingsDbContextModelSnapshot.cs | 5 + .../Models/OfferingAdminDto.cs | 1 + .../Models/OfferingDto.cs | 1 + .../Repositories/IOfferingRepository.cs | 1 + .../Repositories/OfferingRepository.cs | 3 + .../Services/OfferingsService.cs | 21 +++++ .../Services/Slugifier.cs | 40 ++++++++ 15 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs create mode 100644 src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs create mode 100644 src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs create mode 100644 src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs index ba4f5de..bac6d9f 100644 --- a/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs +++ b/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs @@ -24,10 +24,10 @@ public class OfferingsControllerTests } private static OfferingDto PublicDto() => new( - Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false); + Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false); private static OfferingAdminDto AdminDto(int displayOrder = 0) => new( - Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder); + Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder); private static CreateOfferingRequest CreateRequest() => new( "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact"); diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs index c332748..6661169 100644 --- a/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs +++ b/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs @@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable result.Should().BeNull(); } + [Fact] + public async Task ExistsBySlugAsync_ReturnsTrue_WhenSlugTaken() + { + await _sut.AddAsync(Offering("Website", displayOrder: 0)); + await _sut.SaveChangesAsync(); + + var result = await _sut.ExistsBySlugAsync("website"); + + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsBySlugAsync_ReturnsFalse_WhenSlugFree() + { + var result = await _sut.ExistsBySlugAsync("unused-slug"); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ExistsBySlugAsync_ExcludesGivenId() + { + var offering = Offering("Website", displayOrder: 0); + await _sut.AddAsync(offering); + await _sut.SaveChangesAsync(); + + var result = await _sut.ExistsBySlugAsync("website", excludeId: offering.Id); + + result.Should().BeFalse(); + } + [Fact] public async Task Update_PersistsChanges() { @@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable private static Offering Offering(string title, int displayOrder) => new() { Id = Guid.NewGuid(), + Slug = title.ToLowerInvariant(), Title = title, Description = "Description", Price = "€ 100", diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs index de235c8..91635c2 100644 --- a/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs +++ b/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs @@ -27,6 +27,7 @@ public class OfferingsServiceTests private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new() { Id = Guid.NewGuid(), + Slug = "existing", Title = "Existing", Description = "Description", Price = "€ 100", @@ -91,6 +92,57 @@ public class OfferingsServiceTests await _repo.DidNotReceive().BeginTransactionAsync(); } + // --- Slug generation --- + + [Fact] + public async Task CreateAsync_GeneratesSlugFromTitle() + { + _repo.GetMaxDisplayOrderAsync().Returns(-1); + + var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid()); + + dto.Slug.Should().Be("title"); + } + + [Fact] + public async Task CreateAsync_AppendsSuffix_WhenSlugAlreadyTaken() + { + _repo.GetMaxDisplayOrderAsync().Returns(-1); + _repo.ExistsBySlugAsync("title", null).Returns(true); + _repo.ExistsBySlugAsync("title-2", null).Returns(true); + _repo.ExistsBySlugAsync("title-3", null).Returns(false); + + var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid()); + + dto.Slug.Should().Be("title-3"); + } + + [Fact] + public async Task UpdateAsync_RegeneratesSlug_WhenTitleChanges() + { + var target = ExistingOffering(); + _repo.GetByIdAsync(target.Id).Returns(target); + _repo.ExistsBySlugAsync("new-title", target.Id).Returns(false); + + var request = new UpdateOfferingRequest("New Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact"); + var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid()); + + dto!.Slug.Should().Be("new-title"); + } + + [Fact] + public async Task UpdateAsync_KeepsExistingSlug_WhenTitleUnchanged() + { + var target = ExistingOffering(); + _repo.GetByIdAsync(target.Id).Returns(target); + + var request = new UpdateOfferingRequest(target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact"); + var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid()); + + dto!.Slug.Should().Be("existing"); + await _repo.DidNotReceive().ExistsBySlugAsync(Arg.Any(), Arg.Any()); + } + // --- UpdateAsync --- [Fact] diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs new file mode 100644 index 0000000..a417db5 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs @@ -0,0 +1,24 @@ +using FluentAssertions; +using SlpModularCms.Modules.Offerings.Services; + +namespace SlpModularCms.Modules.Offerings.Tests.Services; + +public class SlugifierTests +{ + [Theory] + [InlineData("Landingspagina", "landingspagina")] + [InlineData("Website", "website")] + [InlineData("Grote Website & Maatwerk", "grote-website-maatwerk")] + [InlineData(" Trimmed Title ", "trimmed-title")] + [InlineData("Café Ontwerp", "cafe-ontwerp")] + public void Slugify_ProducesReadableSlug(string title, string expected) + { + Slugifier.Slugify(title).Should().Be(expected); + } + + [Fact] + public void Slugify_FallsBackToPakket_WhenTitleHasNoAlphanumerics() + { + Slugifier.Slugify("---").Should().Be("pakket"); + } +} diff --git a/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs b/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs index 1878503..0825584 100644 --- a/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs +++ b/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs @@ -3,6 +3,7 @@ namespace SlpModularCms.Modules.Offerings.Data.Entities; public class Offering { public Guid Id { get; set; } + public string Slug { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public string Price { get; set; } = string.Empty; diff --git a/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs b/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs index edca50d..84444d0 100644 --- a/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs +++ b/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs @@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions options) : { entity.ToTable("OfferingsOfferings"); entity.HasKey(e => e.Id); + entity.Property(e => e.Slug).IsRequired().HasMaxLength(130); entity.Property(e => e.Title).IsRequired().HasMaxLength(100); entity.Property(e => e.Description).IsRequired().HasMaxLength(500); entity.Property(e => e.Price).IsRequired().HasMaxLength(50); diff --git a/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs new file mode 100644 index 0000000..d1a6b65 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs @@ -0,0 +1,93 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SlpModularCms.Modules.Offerings.Data; + +#nullable disable + +namespace SlpModularCms.Modules.Offerings.Migrations +{ + [DbContext(typeof(OfferingsDbContext))] + [Migration("20260804113000_AddOfferingSlug")] + partial class AddOfferingSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime"); + + b.Property("CtaLabel") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("Featured") + .HasColumnType("tinyint(1)"); + + b.Property("Features") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsDeleted") + .HasColumnType("tinyint(1)"); + + b.Property("LastModifiedByUserId") + .HasColumnType("char(36)"); + + b.Property("Price") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PriceNote") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(130) + .HasColumnType("varchar(130)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime"); + + b.HasKey("Id"); + + b.ToTable("OfferingsOfferings", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs new file mode 100644 index 0000000..846dd65 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SlpModularCms.Modules.Offerings.Migrations +{ + /// + public partial class AddOfferingSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Slug", + table: "OfferingsOfferings", + type: "varchar(130)", + maxLength: 130, + nullable: false, + defaultValue: ""); + + // Backfill existing rows with a title-derived slug so the NOT NULL column + // never holds an empty string for pre-existing data. New/edited rows get + // their slug from OfferingsService.GenerateUniqueSlugAsync instead, which + // also handles collisions this best-effort SQL backfill doesn't. + migrationBuilder.Sql( + "UPDATE OfferingsOfferings " + + "SET Slug = LOWER(TRIM(BOTH '-' FROM REGEXP_REPLACE(TRIM(Title), '[^a-zA-Z0-9]+', '-'))) " + + "WHERE Slug = '';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Slug", + table: "OfferingsOfferings"); + } + } +} diff --git a/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs b/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs index 04b3d30..9484838 100644 --- a/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs +++ b/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs @@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations .HasMaxLength(100) .HasColumnType("varchar(100)"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(130) + .HasColumnType("varchar(130)"); + b.Property("Title") .IsRequired() .HasMaxLength(100) diff --git a/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs b/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs index 8640696..dc8bc67 100644 --- a/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs +++ b/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs @@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models; [ExcludeFromCodeCoverage] public record OfferingAdminDto( string Id, + string Slug, string Title, string Description, string Price, diff --git a/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs b/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs index 4613b56..ac3a640 100644 --- a/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs +++ b/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs @@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models; [ExcludeFromCodeCoverage] public record OfferingDto( string Id, + string Slug, string Title, string Description, string Price, diff --git a/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs b/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs index e392caf..1b636cb 100644 --- a/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs +++ b/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs @@ -7,6 +7,7 @@ public interface IOfferingRepository { Task> GetAllAsync(); Task GetByIdAsync(Guid id); + Task ExistsBySlugAsync(string slug, Guid? excludeId = null); Task AddAsync(Offering offering); void Update(Offering offering); Task GetMaxDisplayOrderAsync(); diff --git a/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs b/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs index df58598..7796c2f 100644 --- a/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs +++ b/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs @@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor public async Task GetByIdAsync(Guid id) => await context.Offerings.FirstOrDefaultAsync(o => o.Id == id); + public async Task ExistsBySlugAsync(string slug, Guid? excludeId = null) + => await context.Offerings.AnyAsync(o => o.Slug == slug && o.Id != (excludeId ?? Guid.Empty)); + public async Task AddAsync(Offering offering) => await context.Offerings.AddAsync(offering); diff --git a/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs b/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs index 18920b6..0c77fcd 100644 --- a/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs +++ b/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs @@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger GenerateUniqueSlugAsync(string title, Guid? excludeId = null) + { + var baseSlug = Slugifier.Slugify(title); + var slug = baseSlug; + var suffix = 2; + + while (await repository.ExistsBySlugAsync(slug, excludeId)) + { + slug = $"{baseSlug}-{suffix}"; + suffix++; + } + + return slug; + } + private static OfferingDto ToPublicDto(Offering o) => new( o.Id.ToString(), + o.Slug, o.Title, o.Description, o.Price, @@ -215,6 +235,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger new( o.Id.ToString(), + o.Slug, o.Title, o.Description, o.Price, diff --git a/src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs b/src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs new file mode 100644 index 0000000..0e1c039 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs @@ -0,0 +1,40 @@ +using System.Text; + +namespace SlpModularCms.Modules.Offerings.Services; + +/// +/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics). +/// Pure string transform — uniqueness is handled by the caller. +/// +public static class Slugifier +{ + public static string Slugify(string value) + { + var normalized = value.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(normalized.Length); + var lastWasHyphen = false; + + foreach (var c in normalized) + { + var category = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c); + if (category == System.Globalization.UnicodeCategory.NonSpacingMark) + continue; + + if (char.IsLetterOrDigit(c)) + { + builder.Append(char.ToLowerInvariant(c)); + lastWasHyphen = false; + } + else if (!lastWasHyphen && builder.Length > 0) + { + builder.Append('-'); + lastWasHyphen = true; + } + } + + if (lastWasHyphen) + builder.Length--; + + return builder.Length > 0 ? builder.ToString() : "pakket"; + } +} From 1b974696bf87f281c56d2af2d3eaa370ecbe2a3f Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 4 Aug 2026 15:31:10 +0200 Subject: [PATCH 2/4] Maak slug in CMS zichtbaar en inline aanpasbaar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De CMS-admin toonde het nieuwe Slug-veld nog niet — dat bestond alleen in de API-respons. Voegt een read-only slugweergave onder het titelveld toe met een potlood-icoon om 'm inline te bewerken. Backend: Create/UpdateOfferingRequest accepteren nu een optionele Slug-override. Zonder expliciete waarde blijft het bestaande gedrag (auto-genereren uit titel, regenereren bij titelwijziging). Met een expliciete waarde wordt die genormaliseerd en op uniekheid gecontroleerd; een botsing geeft 409 Conflict (RFC 9457 ProblemDetails, naar het patroon van MasterControlledAvailabilityException). Frontend: de slug wordt alleen meegestuurd als de admin 'm daadwerkelijk bewerkt heeft (dirtyFields.slug), zodat een ongemoeide slug bij een titelwijziging gewoon blijft auto-regenereren. Co-Authored-By: Claude Sonnet 5 --- .../offerings/components/OfferingForm.tsx | 86 ++++++++++++++++++- .../src/features/offerings/mocks/handlers.ts | 35 +++++++- .../offerings/pages/OfferingFormPage.test.tsx | 43 ++++++++++ .../offerings/pages/OfferingFormPage.tsx | 12 ++- .../features/offerings/schemas/offering.ts | 6 ++ .../src/features/offerings/services/types.ts | 3 + frontend/src/i18n/locales/en/translation.json | 5 ++ frontend/src/i18n/locales/nl/translation.json | 5 ++ .../Controllers/OfferingsControllerTests.cs | 23 +++++ .../Services/OfferingsServiceTests.cs | 58 +++++++++++++ .../Controllers/OfferingsController.cs | 23 ++++- .../Models/CreateOfferingRequest.cs | 3 +- .../Models/UpdateOfferingRequest.cs | 3 +- .../Services/OfferingSlugConflictException.cs | 12 +++ .../Services/OfferingsService.cs | 29 ++++++- 15 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 src/SlpModularCms.Modules.Offerings/Services/OfferingSlugConflictException.cs diff --git a/frontend/src/features/offerings/components/OfferingForm.tsx b/frontend/src/features/offerings/components/OfferingForm.tsx index 37df333..d5ebd8b 100644 --- a/frontend/src/features/offerings/components/OfferingForm.tsx +++ b/frontend/src/features/offerings/components/OfferingForm.tsx @@ -1,7 +1,8 @@ +import { useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslation } from 'react-i18next'; -import { Plus, Trash2 } from 'lucide-react'; +import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -23,7 +24,8 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering control, handleSubmit, setValue, - formState: { errors }, + resetField, + formState: { errors, dirtyFields }, } = useForm({ resolver: zodResolver(offeringFormSchema), mode: 'onTouched', @@ -35,12 +37,35 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering features: [''], ctaLabel: '', featured: false, + slug: '', }, }); // useFieldArray requires object-shaped array items — features is a plain string[], // so add/remove are handled directly via setValue instead. const features = useWatch({ control, name: 'features' }); + const slug = useWatch({ control, name: 'slug' }); + const [isEditingSlug, setIsEditingSlug] = useState(false); + + function confirmSlugEdit() { + setIsEditingSlug(false); + } + + function cancelSlugEdit() { + resetField('slug'); + setIsEditingSlug(false); + } + + // Slug is auto-generated from the title server-side unless explicitly overridden here — + // only send it along when the admin actually touched this field, so an untouched slug + // keeps auto-regenerating when the title changes (see OfferingsService.UpdateAsync). + function submitWithSlugPolicy(values: OfferingFormData) { + const payload = { ...values }; + if (!dirtyFields.slug) { + delete payload.slug; + } + onSubmit(payload); + } function addFeature() { setValue('features', [...features, ''], { shouldValidate: true }); @@ -51,13 +76,68 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering } return ( -
+
{errors.title && }
+
+ + {isEditingSlug ? ( +
+ + + +
+ ) : ( +
+ + {slug && slug.length > 0 ? slug : t('offerings.form.slugAutoHint')} + + +
+ )} + {errors.slug && } +
+