diff --git a/frontend/src/features/offerings/components/OfferingForm.tsx b/frontend/src/features/offerings/components/OfferingForm.tsx index d5ebd8b..a017d3b 100644 --- a/frontend/src/features/offerings/components/OfferingForm.tsx +++ b/frontend/src/features/offerings/components/OfferingForm.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslation } from 'react-i18next'; -import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'; +import { Check, Plus, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { FieldError } from '@/components/ui/FieldError'; +import { slugifyPreview } from '@/lib/slugify'; import { offeringFormSchema, type OfferingFormData } from '../schemas/offering'; import type { OfferingAdminDto } from '../services/types'; @@ -44,9 +45,15 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering // 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 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); } @@ -61,7 +68,7 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering // keeps auto-regenerating when the title changes (see OfferingsService.UpdateAsync). function submitWithSlugPolicy(values: OfferingFormData) { const payload = { ...values }; - if (!dirtyFields.slug) { + if (!isManuallySet) { delete payload.slug; } onSubmit(payload); @@ -84,13 +91,16 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
- {isEditingSlug ? ( -
+
+ @@ -116,22 +126,20 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
) : ( -
- - {slug && slug.length > 0 ? slug : t('offerings.form.slugAutoHint')} +
+ {t('offerings.form.slugLabel')}: + + {displaySlug.length > 0 ? displaySlug : t('offerings.form.slugAutoHint')}
)} diff --git a/frontend/src/features/offerings/pages/OfferingFormPage.test.tsx b/frontend/src/features/offerings/pages/OfferingFormPage.test.tsx index 69ca344..29fb522 100644 --- a/frontend/src/features/offerings/pages/OfferingFormPage.test.tsx +++ b/frontend/src/features/offerings/pages/OfferingFormPage.test.tsx @@ -63,6 +63,35 @@ describe('OfferingFormPage', () => { 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'); diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 8b0bb8e..e6165c0 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -265,7 +265,7 @@ "titleLabel": "Title", "slugLabel": "Slug", "slugAutoHint": "Auto-generated from the title", - "editSlugButton": "Edit slug", + "editSlugButton": "Edit", "confirmSlugButton": "Confirm slug", "cancelSlugEditButton": "Cancel slug edit", "descriptionLabel": "Description", diff --git a/frontend/src/i18n/locales/nl/translation.json b/frontend/src/i18n/locales/nl/translation.json index 149a05e..7dc00f6 100644 --- a/frontend/src/i18n/locales/nl/translation.json +++ b/frontend/src/i18n/locales/nl/translation.json @@ -265,7 +265,7 @@ "titleLabel": "Titel", "slugLabel": "Slug", "slugAutoHint": "Wordt automatisch gegenereerd uit de titel", - "editSlugButton": "Slug aanpassen", + "editSlugButton": "Aanpassen", "confirmSlugButton": "Slug bevestigen", "cancelSlugEditButton": "Slug-bewerking annuleren", "descriptionLabel": "Beschrijving", diff --git a/frontend/src/lib/slugify.ts b/frontend/src/lib/slugify.ts new file mode 100644 index 0000000..88a28a8 --- /dev/null +++ b/frontend/src/lib/slugify.ts @@ -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, ''); +}