Voeg leesbare slug toe aan Offering naast het GUID-ID #10
@@ -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",
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user