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
28 changed files with 695 additions and 14 deletions
@@ -1,11 +1,13 @@
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { FieldError } from '@/components/ui/FieldError'; import { FieldError } from '@/components/ui/FieldError';
import { slugifyPreview } from '@/lib/slugify';
import { offeringFormSchema, type OfferingFormData } from '../schemas/offering'; import { offeringFormSchema, type OfferingFormData } from '../schemas/offering';
import type { OfferingAdminDto } from '../services/types'; import type { OfferingAdminDto } from '../services/types';
@@ -23,7 +25,8 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
control, control,
handleSubmit, handleSubmit,
setValue, setValue,
formState: { errors }, resetField,
formState: { errors, dirtyFields },
} = useForm<OfferingFormData>({ } = useForm<OfferingFormData>({
resolver: zodResolver(offeringFormSchema), resolver: zodResolver(offeringFormSchema),
mode: 'onTouched', mode: 'onTouched',
@@ -35,12 +38,41 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
features: [''], features: [''],
ctaLabel: '', ctaLabel: '',
featured: false, featured: false,
slug: '',
}, },
}); });
// useFieldArray requires object-shaped array items — features is a plain string[], // useFieldArray requires object-shaped array items — features is a plain string[],
// so add/remove are handled directly via setValue instead. // so add/remove are handled directly via setValue instead.
const features = useWatch({ control, name: 'features' }); const features = useWatch({ control, name: 'features' });
const title = useWatch({ control, name: 'title' });
const slug = useWatch({ control, name: 'slug' });
const [isEditingSlug, setIsEditingSlug] = useState(false);
// Once the admin has manually set a non-empty slug, it stops tracking the title — mirrors
// OfferingsService: an explicit Slug always wins, an untouched one keeps auto-regenerating.
const isManuallySet = dirtyFields.slug === true && slug !== undefined && slug.length > 0;
const displaySlug = isManuallySet ? slug : slugifyPreview(title ?? '');
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 (!isManuallySet) {
delete payload.slug;
}
onSubmit(payload);
}
function addFeature() { function addFeature() {
setValue('features', [...features, ''], { shouldValidate: true }); setValue('features', [...features, ''], { shouldValidate: true });
@@ -51,13 +83,69 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
} }
return ( return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4"> <form onSubmit={handleSubmit(submitWithSlugPolicy)} noValidate className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label> <Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
<Input id="offering-title" data-testid="offering-title" aria-invalid={errors.title !== undefined} {...register('title')} /> <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" />} {errors.title && <FieldError message={errors.title.message} testId="offering-title-error" />}
</div> </div>
<div className="space-y-2">
{isEditingSlug ? (
<div className="flex items-center gap-2 text-sm">
<Label htmlFor="offering-slug" className="text-muted-foreground">
{t('offerings.form.slugLabel')}:
</Label>
<Input
id="offering-slug"
data-testid="offering-slug"
autoFocus
className="h-8 max-w-xs"
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 text-sm">
<span className="text-muted-foreground">{t('offerings.form.slugLabel')}:</span>
<code data-testid="offering-slug-display" className="font-mono">
{displaySlug.length > 0 ? displaySlug : 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"> <div className="space-y-2">
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label> <Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
<textarea <textarea
@@ -2,9 +2,19 @@ import { http, HttpResponse } from 'msw';
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types'; import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures'; 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[] = [ const seed: OfferingAdminDto[] = [
{ {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
slug: 'starter',
title: 'Starter', title: 'Starter',
description: 'A simple website', description: 'A simple website',
price: '€ 300', price: '€ 300',
@@ -16,6 +26,7 @@ const seed: OfferingAdminDto[] = [
}, },
{ {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
slug: 'pro',
title: 'Pro', title: 'Pro',
description: 'A full website', description: 'A full website',
price: '€ 800', price: '€ 800',
@@ -44,9 +55,18 @@ export const offeringsHandlers = [
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => { http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
const body = (await request.json()) as CreateOfferingRequest; 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 = { const newOffering: OfferingAdminDto = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
...body, ...body,
slug,
displayOrder: mockOfferings.length, displayOrder: mockOfferings.length,
}; };
mockOfferings = [...mockOfferings, newOffering]; mockOfferings = [...mockOfferings, newOffering];
@@ -59,7 +79,20 @@ export const offeringsHandlers = [
const existing = mockOfferings.find((o) => o.id === id); const existing = mockOfferings.find((o) => o.id === id);
if (!existing) return new HttpResponse(null, { status: 404 }); 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)); mockOfferings = mockOfferings.map((o) => (o.id === id ? updated : o));
return HttpResponse.json(updated); return HttpResponse.json(updated);
}), }),
@@ -46,5 +46,77 @@ describe('OfferingFormPage', () => {
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 }); const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
expect(titleInput).toHaveValue('Starter'); 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('live-updates the slug preview as the title is typed, until manually overridden', async () => {
mockAuthenticated();
renderApp('/offerings/new');
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
await userEvent.type(screen.getByTestId('offering-title'), 'Mijn Nieuwe Pakket');
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('mijn-nieuwe-pakket');
await userEvent.type(screen.getByTestId('offering-title'), '!');
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('mijn-nieuwe-pakket');
});
it('stops tracking the title once the slug is manually set, even if the title changes further', 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'), 'manually-set');
await userEvent.click(screen.getByTestId('offering-slug-confirm'));
await userEvent.clear(screen.getByTestId('offering-title'));
await userEvent.type(screen.getByTestId('offering-title'), 'Compleet Andere Titel');
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('manually-set');
});
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 { useOffering } from '../services/useOffering';
import { useCreateOffering } from '../services/useCreateOffering'; import { useCreateOffering } from '../services/useCreateOffering';
import { useUpdateOffering } from '../services/useUpdateOffering'; import { useUpdateOffering } from '../services/useUpdateOffering';
import { NetworkError } from '@/lib/api-client'; import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import type { OfferingFormData } from '../schemas/offering'; import type { OfferingFormData } from '../schemas/offering';
export function OfferingFormPage() { export function OfferingFormPage() {
@@ -34,7 +34,15 @@ export function OfferingFormPage() {
toast.success(t('offerings.form.successToast')); toast.success(t('offerings.form.successToast'));
navigate({ to: '/offerings' }); navigate({ to: '/offerings' });
} catch (err) { } 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'), .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'), ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
featured: z.boolean(), 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>; export type OfferingFormData = z.infer<typeof offeringFormSchema>;
@@ -1,5 +1,6 @@
export interface OfferingAdminDto { export interface OfferingAdminDto {
id: string; id: string;
slug: string;
title: string; title: string;
description: string; description: string;
price: string; price: string;
@@ -18,6 +19,8 @@ export interface CreateOfferingRequest {
features: string[]; features: string[];
ctaLabel: string; ctaLabel: string;
featured: boolean; featured: boolean;
/** Omit to auto-generate from title; set to explicitly override. */
slug?: string;
} }
export type UpdateOfferingRequest = CreateOfferingRequest; export type UpdateOfferingRequest = CreateOfferingRequest;
@@ -263,6 +263,11 @@
"createTitle": "New Offering", "createTitle": "New Offering",
"editTitle": "Edit Offering", "editTitle": "Edit Offering",
"titleLabel": "Title", "titleLabel": "Title",
"slugLabel": "Slug",
"slugAutoHint": "Auto-generated from the title",
"editSlugButton": "Edit",
"confirmSlugButton": "Confirm slug",
"cancelSlugEditButton": "Cancel slug edit",
"descriptionLabel": "Description", "descriptionLabel": "Description",
"priceLabel": "Price", "priceLabel": "Price",
"priceNoteLabel": "Price note", "priceNoteLabel": "Price note",
@@ -263,6 +263,11 @@
"createTitle": "Nieuwe aanbieding", "createTitle": "Nieuwe aanbieding",
"editTitle": "Aanbieding bewerken", "editTitle": "Aanbieding bewerken",
"titleLabel": "Titel", "titleLabel": "Titel",
"slugLabel": "Slug",
"slugAutoHint": "Wordt automatisch gegenereerd uit de titel",
"editSlugButton": "Aanpassen",
"confirmSlugButton": "Slug bevestigen",
"cancelSlugEditButton": "Slug-bewerking annuleren",
"descriptionLabel": "Beschrijving", "descriptionLabel": "Beschrijving",
"priceLabel": "Prijs", "priceLabel": "Prijs",
"priceNoteLabel": "Prijsnotitie", "priceNoteLabel": "Prijsnotitie",
+12
View File
@@ -0,0 +1,12 @@
/**
* Client-side mirror of the backend's Slugifier (SlpModularCms.Modules.Offerings.Services.Slugifier)
* — used only for a live preview while typing. The backend remains the source of truth and
* re-normalizes on save, so drift here is a display nit, not a correctness bug.
*/
export function slugifyPreview(value: string): string {
const withoutDiacritics = value.normalize('NFD').replace(/[̀-ͯ]/g, '');
return withoutDiacritics
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
@@ -3,6 +3,7 @@ using FluentAssertions;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NSubstitute; using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SlpModularCms.Modules.Offerings.Controllers; using SlpModularCms.Modules.Offerings.Controllers;
using SlpModularCms.Modules.Offerings.Models; using SlpModularCms.Modules.Offerings.Models;
using SlpModularCms.Modules.Offerings.Services; using SlpModularCms.Modules.Offerings.Services;
@@ -24,10 +25,10 @@ public class OfferingsControllerTests
} }
private static OfferingDto PublicDto() => new( 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( 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( private static CreateOfferingRequest CreateRequest() => new(
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact"); "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
@@ -71,6 +72,17 @@ public class OfferingsControllerTests
result.Should().BeOfType<CreatedAtActionResult>().Which.StatusCode.Should().Be(201); 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 --- // --- Update ---
[Fact] [Fact]
@@ -94,6 +106,17 @@ public class OfferingsControllerTests
result.Should().BeOfType<NotFoundResult>(); 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 --- // --- Delete ---
[Fact] [Fact]
@@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable
result.Should().BeNull(); 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] [Fact]
public async Task Update_PersistsChanges() public async Task Update_PersistsChanges()
{ {
@@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable
private static Offering Offering(string title, int displayOrder) => new() private static Offering Offering(string title, int displayOrder) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Slug = title.ToLowerInvariant(),
Title = title, Title = title,
Description = "Description", Description = "Description",
Price = "€ 100", Price = "€ 100",
@@ -27,6 +27,7 @@ public class OfferingsServiceTests
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new() private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Slug = "existing",
Title = "Existing", Title = "Existing",
Description = "Description", Description = "Description",
Price = "€ 100", Price = "€ 100",
@@ -91,6 +92,115 @@ public class OfferingsServiceTests
await _repo.DidNotReceive().BeginTransactionAsync(); 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<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 --- // --- UpdateAsync ---
[Fact] [Fact]
@@ -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");
}
}
@@ -1,5 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using SlpModularCms.Modules.Offerings.Models; using SlpModularCms.Modules.Offerings.Models;
@@ -31,15 +32,29 @@ public class OfferingsController(IOfferingsService service) : ControllerBase
[HttpPost("admin")] [HttpPost("admin")]
public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request) public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request)
{ {
var dto = await service.CreateAsync(request, GetCallerId()); try
return CreatedAtAction(nameof(GetAllForAdmin), null, dto); {
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}")] [HttpPut("admin/{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request)
{ {
var dto = await service.UpdateAsync(id, request, GetCallerId()); try
return dto is null ? NotFound() : Ok(dto); {
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}")] [HttpDelete("admin/{id:guid}")]
@@ -3,6 +3,7 @@ namespace SlpModularCms.Modules.Offerings.Data.Entities;
public class Offering public class Offering
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty; public string Price { get; set; } = string.Empty;
@@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions<OfferingsDbContext> options) :
{ {
entity.ToTable("OfferingsOfferings"); entity.ToTable("OfferingsOfferings");
entity.HasKey(e => e.Id); 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.Title).IsRequired().HasMaxLength(100);
entity.Property(e => e.Description).IsRequired().HasMaxLength(500); entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
entity.Property(e => e.Price).IsRequired().HasMaxLength(50); entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
@@ -0,0 +1,93 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetime");
b.Property<string>("CtaLabel")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetime");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<bool>("Featured")
.HasColumnType("tinyint(1)");
b.Property<string>("Features")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<Guid>("LastModifiedByUserId")
.HasColumnType("char(36)");
b.Property<string>("Price")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("PriceNote")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(130)
.HasColumnType("varchar(130)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetime");
b.HasKey("Id");
b.ToTable("OfferingsOfferings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlpModularCms.Modules.Offerings.Migrations
{
/// <inheritdoc />
public partial class AddOfferingSlug : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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 = '';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Slug",
table: "OfferingsOfferings");
}
}
}
@@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("varchar(100)"); .HasColumnType("varchar(100)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(130)
.HasColumnType("varchar(130)");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -9,5 +9,6 @@ public record CreateOfferingRequest(
[Required, MaxLength(100)] string PriceNote, [Required, MaxLength(100)] string PriceNote,
[Required, FeaturesValidation] List<string> Features, [Required, FeaturesValidation] List<string> Features,
[Required, MaxLength(50)] string CtaLabel, [Required, MaxLength(50)] string CtaLabel,
bool Featured = false bool Featured = false,
[MaxLength(130)] string? Slug = null
); );
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public record OfferingAdminDto( public record OfferingAdminDto(
string Id, string Id,
string Slug,
string Title, string Title,
string Description, string Description,
string Price, string Price,
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public record OfferingDto( public record OfferingDto(
string Id, string Id,
string Slug,
string Title, string Title,
string Description, string Description,
string Price, string Price,
@@ -9,5 +9,6 @@ public record UpdateOfferingRequest(
[Required, MaxLength(100)] string PriceNote, [Required, MaxLength(100)] string PriceNote,
[Required, FeaturesValidation] List<string> Features, [Required, FeaturesValidation] List<string> Features,
[Required, MaxLength(50)] string CtaLabel, [Required, MaxLength(50)] string CtaLabel,
bool Featured = false bool Featured = false,
[MaxLength(130)] string? Slug = null
); );
@@ -7,6 +7,7 @@ public interface IOfferingRepository
{ {
Task<IReadOnlyList<Offering>> GetAllAsync(); Task<IReadOnlyList<Offering>> GetAllAsync();
Task<Offering?> GetByIdAsync(Guid id); Task<Offering?> GetByIdAsync(Guid id);
Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null);
Task AddAsync(Offering offering); Task AddAsync(Offering offering);
void Update(Offering offering); void Update(Offering offering);
Task<int> GetMaxDisplayOrderAsync(); Task<int> GetMaxDisplayOrderAsync();
@@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor
public async Task<Offering?> GetByIdAsync(Guid id) public async Task<Offering?> GetByIdAsync(Guid id)
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id); => await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
public async Task<bool> 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) public async Task AddAsync(Offering offering)
=> await context.Offerings.AddAsync(offering); => await context.Offerings.AddAsync(offering);
@@ -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,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
var offering = new Offering var offering = new Offering
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Slug = await ResolveSlugAsync(request.Slug, request.Title),
Title = request.Title, Title = request.Title,
Description = request.Description, Description = request.Description,
Price = request.Price, Price = request.Price,
@@ -69,6 +70,15 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
if (offering is null) if (offering is null)
return null; return null;
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.Title = request.Title;
offering.Description = request.Description; offering.Description = request.Description;
offering.Price = request.Price; offering.Price = request.Price;
@@ -202,8 +212,43 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
repository.Update(current); 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);
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( private static OfferingDto ToPublicDto(Offering o) => new(
o.Id.ToString(), o.Id.ToString(),
o.Slug,
o.Title, o.Title,
o.Description, o.Description,
o.Price, o.Price,
@@ -215,6 +260,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
private static OfferingAdminDto ToAdminDto(Offering o) => new( private static OfferingAdminDto ToAdminDto(Offering o) => new(
o.Id.ToString(), o.Id.ToString(),
o.Slug,
o.Title, o.Title,
o.Description, o.Description,
o.Price, o.Price,
@@ -0,0 +1,40 @@
using System.Text;
namespace SlpModularCms.Modules.Offerings.Services;
/// <summary>
/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics).
/// Pure string transform — uniqueness is handled by the caller.
/// </summary>
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";
}
}