Maak slug in CMS zichtbaar en inline aanpasbaar

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:31:10 +02:00
co-authored by Claude Sonnet 5
parent cc09b46cfb
commit 1b974696bf
15 changed files with 332 additions and 14 deletions
@@ -25,7 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
var offering = new Offering
{
Id = Guid.NewGuid(),
Slug = await GenerateUniqueSlugAsync(request.Title),
Slug = await ResolveSlugAsync(request.Slug, request.Title),
Title = request.Title,
Description = request.Description,
Price = request.Price,
@@ -70,8 +70,14 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
if (offering is null)
return null;
if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
if (!string.IsNullOrWhiteSpace(request.Slug))
{
offering.Slug = await ResolveExplicitSlugAsync(request.Slug, excludeId: offering.Id);
}
else if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
{
offering.Slug = await GenerateUniqueSlugAsync(request.Title, excludeId: offering.Id);
}
offering.Title = request.Title;
offering.Description = request.Description;
@@ -206,6 +212,25 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
repository.Update(current);
}
private async Task<string> ResolveSlugAsync(string? explicitSlug, string title)
=> !string.IsNullOrWhiteSpace(explicitSlug)
? await ResolveExplicitSlugAsync(explicitSlug, excludeId: null)
: await GenerateUniqueSlugAsync(title);
/// <summary>
/// Normalizes an admin-supplied slug and enforces uniqueness. Unlike auto-generation,
/// a taken slug is rejected outright (OfferingSlugConflictException) rather than
/// silently suffixed — the admin picked this value on purpose.
/// </summary>
private async Task<string> ResolveExplicitSlugAsync(string explicitSlug, Guid? excludeId)
{
var slug = Slugifier.Slugify(explicitSlug);
if (await repository.ExistsBySlugAsync(slug, excludeId))
throw new OfferingSlugConflictException(slug);
return slug;
}
private async Task<string> GenerateUniqueSlugAsync(string title, Guid? excludeId = null)
{
var baseSlug = Slugifier.Slugify(title);