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
- {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, '');
+}