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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user