Voeg leesbare slug toe aan Offering naast het GUID-ID #10

Merged
Sluijsens merged 4 commits from feature/offering-slug into master 2026-08-04 16:38:54 +02:00
15 changed files with 332 additions and 14 deletions
Showing only changes of commit 1b974696bf - Show all commits
@@ -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<OfferingFormData>({
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 (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<form onSubmit={handleSubmit(submitWithSlugPolicy)} noValidate className="space-y-4">
<div className="space-y-2">
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
<Input id="offering-title" data-testid="offering-title" aria-invalid={errors.title !== undefined} {...register('title')} />
{errors.title && <FieldError message={errors.title.message} testId="offering-title-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="offering-slug">{t('offerings.form.slugLabel')}</Label>
{isEditingSlug ? (
<div className="flex items-center gap-2">
<Input
id="offering-slug"
data-testid="offering-slug"
autoFocus
aria-invalid={errors.slug !== undefined}
{...register('slug')}
/>
<Button
type="button"
variant="ghost"
size="icon"
data-testid="offering-slug-confirm"
aria-label={t('offerings.form.confirmSlugButton')}
onClick={confirmSlugEdit}
>
<Check className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
data-testid="offering-slug-cancel"
aria-label={t('offerings.form.cancelSlugEditButton')}
onClick={cancelSlugEdit}
>
<X className="size-4" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<code
data-testid="offering-slug-display"
className="font-mono text-sm text-muted-foreground"
>
{slug && slug.length > 0 ? slug : t('offerings.form.slugAutoHint')}
</code>
<Button
type="button"
variant="ghost"
size="icon"
data-testid="offering-slug-edit"
aria-label={t('offerings.form.editSlugButton')}
onClick={() => setIsEditingSlug(true)}
>
<Pencil className="size-4" />
</Button>
</div>
)}
{errors.slug && <FieldError message={errors.slug.message} testId="offering-slug-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
<textarea
@@ -2,9 +2,19 @@ import { http, HttpResponse } from 'msw';
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures';
function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const seed: OfferingAdminDto[] = [
{
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
slug: 'starter',
title: 'Starter',
description: 'A simple website',
price: '€ 300',
@@ -16,6 +26,7 @@ const seed: OfferingAdminDto[] = [
},
{
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
slug: 'pro',
title: 'Pro',
description: 'A full website',
price: '€ 800',
@@ -44,9 +55,18 @@ export const offeringsHandlers = [
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
const body = (await request.json()) as CreateOfferingRequest;
const slug = body.slug && body.slug.length > 0 ? slugify(body.slug) : slugify(body.title);
if (mockOfferings.some((o) => o.slug === slug)) {
return HttpResponse.json(
{ status: 409, title: `Slug '${slug}' is al in gebruik door een ander aanbod.` },
{ status: 409 },
);
}
const newOffering: OfferingAdminDto = {
id: crypto.randomUUID(),
...body,
slug,
displayOrder: mockOfferings.length,
};
mockOfferings = [...mockOfferings, newOffering];
@@ -59,7 +79,20 @@ export const offeringsHandlers = [
const existing = mockOfferings.find((o) => o.id === id);
if (!existing) return new HttpResponse(null, { status: 404 });
const updated: OfferingAdminDto = { ...existing, ...body };
let slug = existing.slug;
if (body.slug && body.slug.length > 0) {
slug = slugify(body.slug);
} else if (body.title !== existing.title) {
slug = slugify(body.title);
}
if (slug !== existing.slug && mockOfferings.some((o) => o.slug === slug)) {
return HttpResponse.json(
{ status: 409, title: `Slug '${slug}' is al in gebruik door een ander aanbod.` },
{ status: 409 },
);
}
const updated: OfferingAdminDto = { ...existing, ...body, slug };
mockOfferings = mockOfferings.map((o) => (o.id === id ? updated : o));
return HttpResponse.json(updated);
}),
@@ -46,5 +46,48 @@ describe('OfferingFormPage', () => {
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
expect(titleInput).toHaveValue('Starter');
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('starter');
});
it('shows an auto-generate hint for a new offering until the slug is edited via the pencil icon', async () => {
mockAuthenticated();
renderApp('/offerings/new');
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent(/automatisch|auto-generated/i);
await userEvent.click(screen.getByTestId('offering-slug-edit'));
await userEvent.type(screen.getByTestId('offering-slug'), 'custom-slug');
await userEvent.click(screen.getByTestId('offering-slug-confirm'));
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('custom-slug');
});
it('reverts the slug when the inline edit is cancelled', async () => {
mockAuthenticated();
renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit');
await screen.findByTestId('offering-title', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('offering-slug-edit'));
await userEvent.clear(screen.getByTestId('offering-slug'));
await userEvent.type(screen.getByTestId('offering-slug'), 'something-else');
await userEvent.click(screen.getByTestId('offering-slug-cancel'));
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('starter');
});
it('shows a conflict error when the chosen slug is already taken', async () => {
mockAuthenticated();
renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit');
await screen.findByTestId('offering-title', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('offering-slug-edit'));
await userEvent.clear(screen.getByTestId('offering-slug'));
await userEvent.type(screen.getByTestId('offering-slug'), 'pro');
await userEvent.click(screen.getByTestId('offering-slug-confirm'));
await userEvent.click(screen.getByTestId('offering-form-submit'));
expect(await screen.findByText(/pro.*al in gebruik|already.*taken/i)).toBeInTheDocument();
});
});
@@ -7,7 +7,7 @@ import { OfferingForm } from '../components/OfferingForm';
import { useOffering } from '../services/useOffering';
import { useCreateOffering } from '../services/useCreateOffering';
import { useUpdateOffering } from '../services/useUpdateOffering';
import { NetworkError } from '@/lib/api-client';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import type { OfferingFormData } from '../schemas/offering';
export function OfferingFormPage() {
@@ -34,7 +34,15 @@ export function OfferingFormPage() {
toast.success(t('offerings.form.successToast'));
navigate({ to: '/offerings' });
} catch (err) {
setServerError(err instanceof NetworkError ? t('errors.network') : t('errors.generic'));
if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else if (err instanceof ProblemDetailsError && err.status === 409) {
// Slug conflict: the backend's message is already a user-facing Dutch/English
// sentence naming the taken slug — more actionable than a generic fallback.
setServerError(err.message);
} else {
setServerError(t('errors.generic'));
}
}
};
@@ -17,6 +17,12 @@ export const offeringFormSchema = z.object({
.max(10, 'At most 10 features are allowed'),
ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
featured: z.boolean(),
slug: z
.string()
.max(130, 'Slug must be 130 characters or fewer')
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Slug may only contain lowercase letters, digits and hyphens')
.optional()
.or(z.literal('')),
});
export type OfferingFormData = z.infer<typeof offeringFormSchema>;
@@ -1,5 +1,6 @@
export interface OfferingAdminDto {
id: string;
slug: string;
title: string;
description: string;
price: string;
@@ -18,6 +19,8 @@ export interface CreateOfferingRequest {
features: string[];
ctaLabel: string;
featured: boolean;
/** Omit to auto-generate from title; set to explicitly override. */
slug?: string;
}
export type UpdateOfferingRequest = CreateOfferingRequest;
@@ -263,6 +263,11 @@
"createTitle": "New Offering",
"editTitle": "Edit Offering",
"titleLabel": "Title",
"slugLabel": "Slug",
"slugAutoHint": "Auto-generated from the title",
"editSlugButton": "Edit slug",
"confirmSlugButton": "Confirm slug",
"cancelSlugEditButton": "Cancel slug edit",
"descriptionLabel": "Description",
"priceLabel": "Price",
"priceNoteLabel": "Price note",
@@ -263,6 +263,11 @@
"createTitle": "Nieuwe aanbieding",
"editTitle": "Aanbieding bewerken",
"titleLabel": "Titel",
"slugLabel": "Slug",
"slugAutoHint": "Wordt automatisch gegenereerd uit de titel",
"editSlugButton": "Slug aanpassen",
"confirmSlugButton": "Slug bevestigen",
"cancelSlugEditButton": "Slug-bewerking annuleren",
"descriptionLabel": "Beschrijving",
"priceLabel": "Prijs",
"priceNoteLabel": "Prijsnotitie",
@@ -3,6 +3,7 @@ using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SlpModularCms.Modules.Offerings.Controllers;
using SlpModularCms.Modules.Offerings.Models;
using SlpModularCms.Modules.Offerings.Services;
@@ -71,6 +72,17 @@ public class OfferingsControllerTests
result.Should().BeOfType<CreatedAtActionResult>().Which.StatusCode.Should().Be(201);
}
[Fact]
public async Task Create_Returns409_WhenSlugTaken()
{
_service.CreateAsync(Arg.Any<CreateOfferingRequest>(), Arg.Any<Guid>())
.ThrowsAsync(new OfferingSlugConflictException("taken"));
var result = await CreateSut().Create(CreateRequest());
result.Should().BeOfType<ConflictObjectResult>().Which.StatusCode.Should().Be(409);
}
// --- Update ---
[Fact]
@@ -94,6 +106,17 @@ public class OfferingsControllerTests
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task Update_Returns409_WhenSlugTaken()
{
_service.UpdateAsync(Arg.Any<Guid>(), Arg.Any<UpdateOfferingRequest>(), Arg.Any<Guid>())
.ThrowsAsync(new OfferingSlugConflictException("taken"));
var result = await CreateSut().Update(Guid.NewGuid(), UpdateRequest());
result.Should().BeOfType<ConflictObjectResult>().Which.StatusCode.Should().Be(409);
}
// --- Delete ---
[Fact]
@@ -143,6 +143,64 @@ public class OfferingsServiceTests
await _repo.DidNotReceive().ExistsBySlugAsync(Arg.Any<string>(), Arg.Any<Guid?>());
}
// --- Explicit slug override (admin-supplied via CMS) ---
[Fact]
public async Task CreateAsync_NormalizesExplicitSlug()
{
_repo.GetMaxDisplayOrderAsync().Returns(-1);
_repo.ExistsBySlugAsync("my-custom-slug", null).Returns(false);
var request = new CreateOfferingRequest(
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: " My Custom Slug!! ");
var dto = await CreateSut().CreateAsync(request, Guid.NewGuid());
dto.Slug.Should().Be("my-custom-slug");
}
[Fact]
public async Task CreateAsync_Throws_WhenExplicitSlugAlreadyTaken()
{
_repo.GetMaxDisplayOrderAsync().Returns(-1);
_repo.ExistsBySlugAsync("taken", null).Returns(true);
var request = new CreateOfferingRequest(
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "taken");
var act = () => CreateSut().CreateAsync(request, Guid.NewGuid());
await act.Should().ThrowAsync<OfferingSlugConflictException>();
}
[Fact]
public async Task UpdateAsync_UsesExplicitSlug_EvenWhenTitleUnchanged()
{
var target = ExistingOffering();
_repo.GetByIdAsync(target.Id).Returns(target);
_repo.ExistsBySlugAsync("manually-set", target.Id).Returns(false);
var request = new UpdateOfferingRequest(
target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "manually-set");
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
dto!.Slug.Should().Be("manually-set");
}
[Fact]
public async Task UpdateAsync_Throws_WhenExplicitSlugTakenByAnotherOffering()
{
var target = ExistingOffering();
_repo.GetByIdAsync(target.Id).Returns(target);
_repo.ExistsBySlugAsync("taken", target.Id).Returns(true);
var request = new UpdateOfferingRequest(
target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "taken");
var act = () => CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
await act.Should().ThrowAsync<OfferingSlugConflictException>();
}
// --- UpdateAsync ---
[Fact]
@@ -1,5 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using SlpModularCms.Modules.Offerings.Models;
@@ -30,17 +31,31 @@ public class OfferingsController(IOfferingsService service) : ControllerBase
[HttpPost("admin")]
public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request)
{
try
{
var dto = await service.CreateAsync(request, GetCallerId());
return CreatedAtAction(nameof(GetAllForAdmin), null, dto);
}
catch (OfferingSlugConflictException ex)
{
return Conflict(new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = ex.Message });
}
}
[HttpPut("admin/{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request)
{
try
{
var dto = await service.UpdateAsync(id, request, GetCallerId());
return dto is null ? NotFound() : Ok(dto);
}
catch (OfferingSlugConflictException ex)
{
return Conflict(new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = ex.Message });
}
}
[HttpDelete("admin/{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
@@ -9,5 +9,6 @@ public record CreateOfferingRequest(
[Required, MaxLength(100)] string PriceNote,
[Required, FeaturesValidation] List<string> Features,
[Required, MaxLength(50)] string CtaLabel,
bool Featured = false
bool Featured = false,
[MaxLength(130)] string? Slug = null
);
@@ -9,5 +9,6 @@ public record UpdateOfferingRequest(
[Required, MaxLength(100)] string PriceNote,
[Required, FeaturesValidation] List<string> Features,
[Required, MaxLength(50)] string CtaLabel,
bool Featured = false
bool Featured = false,
[MaxLength(130)] string? Slug = null
);
@@ -0,0 +1,12 @@
namespace SlpModularCms.Modules.Offerings.Services;
/// <summary>
/// Thrown when an admin explicitly sets a slug that's already in use by another offering.
/// Auto-generated slugs never hit this (they get a numeric suffix instead) — this is only
/// for the CMS's explicit slug override.
/// </summary>
public class OfferingSlugConflictException(string slug)
: InvalidOperationException($"Slug '{slug}' is al in gebruik door een ander aanbod.")
{
public string Slug { get; } = slug;
}
@@ -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);