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
5 changed files with 65 additions and 16 deletions
Showing only changes of commit d09b3196d8 - Show all commits
@@ -2,11 +2,12 @@ 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 { Check, Pencil, Plus, Trash2, X } from 'lucide-react'; import { Check, 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';
@@ -44,9 +45,15 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
// 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 slug = useWatch({ control, name: 'slug' });
const [isEditingSlug, setIsEditingSlug] = useState(false); 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() { function confirmSlugEdit() {
setIsEditingSlug(false); setIsEditingSlug(false);
} }
@@ -61,7 +68,7 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
// keeps auto-regenerating when the title changes (see OfferingsService.UpdateAsync). // keeps auto-regenerating when the title changes (see OfferingsService.UpdateAsync).
function submitWithSlugPolicy(values: OfferingFormData) { function submitWithSlugPolicy(values: OfferingFormData) {
const payload = { ...values }; const payload = { ...values };
if (!dirtyFields.slug) { if (!isManuallySet) {
delete payload.slug; delete payload.slug;
} }
onSubmit(payload); onSubmit(payload);
@@ -84,13 +91,16 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="offering-slug">{t('offerings.form.slugLabel')}</Label>
{isEditingSlug ? ( {isEditingSlug ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-sm">
<Label htmlFor="offering-slug" className="text-muted-foreground">
{t('offerings.form.slugLabel')}:
</Label>
<Input <Input
id="offering-slug" id="offering-slug"
data-testid="offering-slug" data-testid="offering-slug"
autoFocus autoFocus
className="h-8 max-w-xs"
aria-invalid={errors.slug !== undefined} aria-invalid={errors.slug !== undefined}
{...register('slug')} {...register('slug')}
/> />
@@ -116,22 +126,20 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-sm">
<code <span className="text-muted-foreground">{t('offerings.form.slugLabel')}:</span>
data-testid="offering-slug-display" <code data-testid="offering-slug-display" className="font-mono">
className="font-mono text-sm text-muted-foreground" {displaySlug.length > 0 ? displaySlug : t('offerings.form.slugAutoHint')}
>
{slug && slug.length > 0 ? slug : t('offerings.form.slugAutoHint')}
</code> </code>
<Button <Button
type="button" type="button"
variant="ghost" variant="link"
size="icon" size="sm"
className="h-auto p-0"
data-testid="offering-slug-edit" data-testid="offering-slug-edit"
aria-label={t('offerings.form.editSlugButton')}
onClick={() => setIsEditingSlug(true)} onClick={() => setIsEditingSlug(true)}
> >
<Pencil className="size-4" /> {t('offerings.form.editSlugButton')}
</Button> </Button>
</div> </div>
)} )}
@@ -63,6 +63,35 @@ describe('OfferingFormPage', () => {
expect(screen.getByTestId('offering-slug-display')).toHaveTextContent('custom-slug'); 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 () => { it('reverts the slug when the inline edit is cancelled', async () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit'); renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit');
@@ -265,7 +265,7 @@
"titleLabel": "Title", "titleLabel": "Title",
"slugLabel": "Slug", "slugLabel": "Slug",
"slugAutoHint": "Auto-generated from the title", "slugAutoHint": "Auto-generated from the title",
"editSlugButton": "Edit slug", "editSlugButton": "Edit",
"confirmSlugButton": "Confirm slug", "confirmSlugButton": "Confirm slug",
"cancelSlugEditButton": "Cancel slug edit", "cancelSlugEditButton": "Cancel slug edit",
"descriptionLabel": "Description", "descriptionLabel": "Description",
@@ -265,7 +265,7 @@
"titleLabel": "Titel", "titleLabel": "Titel",
"slugLabel": "Slug", "slugLabel": "Slug",
"slugAutoHint": "Wordt automatisch gegenereerd uit de titel", "slugAutoHint": "Wordt automatisch gegenereerd uit de titel",
"editSlugButton": "Slug aanpassen", "editSlugButton": "Aanpassen",
"confirmSlugButton": "Slug bevestigen", "confirmSlugButton": "Slug bevestigen",
"cancelSlugEditButton": "Slug-bewerking annuleren", "cancelSlugEditButton": "Slug-bewerking annuleren",
"descriptionLabel": "Beschrijving", "descriptionLabel": "Beschrijving",
+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, '');
}