Merge pull request 'Voeg leesbare slug toe aan Offering naast het GUID-ID' (#10) from feature/offering-slug into master
Continuous Integration / config (push) Successful in 11s
Continuous Integration / changes (push) Successful in 22s
Continuous Integration / backend-build (push) Successful in 4m34s
Continuous Integration / vulnerability-scan (push) Successful in 4m3s
Continuous Integration / frontend-prepare (push) Successful in 1m35s
Continuous Integration / backend-test (push) Successful in 6m14s
Continuous Integration / frontend-build (push) Successful in 2m10s
Continuous Integration / frontend-test (push) Successful in 4m52s
Continuous Integration / frontend-lint (push) Successful in 2m5s
Continuous Integration / publish-production (push) Skipped
Continuous Integration / deploy-production (push) Skipped
Continuous Integration / publish-test (push) Successful in 6m7s
Deploy (SCP) / deploy (push) Successful in 1m20s
Continuous Integration / deploy-test (push) Successful in 1m20s
Continuous Integration / config (push) Successful in 11s
Continuous Integration / changes (push) Successful in 22s
Continuous Integration / backend-build (push) Successful in 4m34s
Continuous Integration / vulnerability-scan (push) Successful in 4m3s
Continuous Integration / frontend-prepare (push) Successful in 1m35s
Continuous Integration / backend-test (push) Successful in 6m14s
Continuous Integration / frontend-build (push) Successful in 2m10s
Continuous Integration / frontend-test (push) Successful in 4m52s
Continuous Integration / frontend-lint (push) Successful in 2m5s
Continuous Integration / publish-production (push) Skipped
Continuous Integration / deploy-production (push) Skipped
Continuous Integration / publish-test (push) Successful in 6m7s
Deploy (SCP) / deploy (push) Successful in 1m20s
Continuous Integration / deploy-test (push) Successful in 1m20s
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
|
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 { Plus, Trash2 } from 'lucide-react';
|
import { Check, Pencil, 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';
|
||||||
|
|
||||||
@@ -23,7 +25,8 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
resetField,
|
||||||
|
formState: { errors, dirtyFields },
|
||||||
} = useForm<OfferingFormData>({
|
} = useForm<OfferingFormData>({
|
||||||
resolver: zodResolver(offeringFormSchema),
|
resolver: zodResolver(offeringFormSchema),
|
||||||
mode: 'onTouched',
|
mode: 'onTouched',
|
||||||
@@ -35,12 +38,41 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
|
|||||||
features: [''],
|
features: [''],
|
||||||
ctaLabel: '',
|
ctaLabel: '',
|
||||||
featured: false,
|
featured: false,
|
||||||
|
slug: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 [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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!isManuallySet) {
|
||||||
|
delete payload.slug;
|
||||||
|
}
|
||||||
|
onSubmit(payload);
|
||||||
|
}
|
||||||
|
|
||||||
function addFeature() {
|
function addFeature() {
|
||||||
setValue('features', [...features, ''], { shouldValidate: true });
|
setValue('features', [...features, ''], { shouldValidate: true });
|
||||||
@@ -51,13 +83,69 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit(submitWithSlugPolicy)} noValidate className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
|
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
|
||||||
<Input id="offering-title" data-testid="offering-title" aria-invalid={errors.title !== undefined} {...register('title')} />
|
<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" />}
|
{errors.title && <FieldError message={errors.title.message} testId="offering-title-error" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isEditingSlug ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Label htmlFor="offering-slug" className="text-muted-foreground">
|
||||||
|
{t('offerings.form.slugLabel')}:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="offering-slug"
|
||||||
|
data-testid="offering-slug"
|
||||||
|
autoFocus
|
||||||
|
className="h-8 max-w-xs"
|
||||||
|
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 text-sm">
|
||||||
|
<span className="text-muted-foreground">{t('offerings.form.slugLabel')}:</span>
|
||||||
|
<code data-testid="offering-slug-display" className="font-mono">
|
||||||
|
{displaySlug.length > 0 ? displaySlug : 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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
|
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import { http, HttpResponse } from 'msw';
|
|||||||
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
|
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
|
||||||
import { API_BASE } from '@/features/auth/mocks/fixtures';
|
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[] = [
|
const seed: OfferingAdminDto[] = [
|
||||||
{
|
{
|
||||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
slug: 'starter',
|
||||||
title: 'Starter',
|
title: 'Starter',
|
||||||
description: 'A simple website',
|
description: 'A simple website',
|
||||||
price: '€ 300',
|
price: '€ 300',
|
||||||
@@ -16,6 +26,7 @@ const seed: OfferingAdminDto[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||||
|
slug: 'pro',
|
||||||
title: 'Pro',
|
title: 'Pro',
|
||||||
description: 'A full website',
|
description: 'A full website',
|
||||||
price: '€ 800',
|
price: '€ 800',
|
||||||
@@ -44,9 +55,18 @@ export const offeringsHandlers = [
|
|||||||
|
|
||||||
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
|
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
|
||||||
const body = (await request.json()) as CreateOfferingRequest;
|
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 = {
|
const newOffering: OfferingAdminDto = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
...body,
|
...body,
|
||||||
|
slug,
|
||||||
displayOrder: mockOfferings.length,
|
displayOrder: mockOfferings.length,
|
||||||
};
|
};
|
||||||
mockOfferings = [...mockOfferings, newOffering];
|
mockOfferings = [...mockOfferings, newOffering];
|
||||||
@@ -59,7 +79,20 @@ export const offeringsHandlers = [
|
|||||||
const existing = mockOfferings.find((o) => o.id === id);
|
const existing = mockOfferings.find((o) => o.id === id);
|
||||||
if (!existing) return new HttpResponse(null, { status: 404 });
|
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));
|
mockOfferings = mockOfferings.map((o) => (o.id === id ? updated : o));
|
||||||
return HttpResponse.json(updated);
|
return HttpResponse.json(updated);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -46,5 +46,77 @@ describe('OfferingFormPage', () => {
|
|||||||
|
|
||||||
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
|
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
|
||||||
expect(titleInput).toHaveValue('Starter');
|
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('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');
|
||||||
|
|
||||||
|
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 { useOffering } from '../services/useOffering';
|
||||||
import { useCreateOffering } from '../services/useCreateOffering';
|
import { useCreateOffering } from '../services/useCreateOffering';
|
||||||
import { useUpdateOffering } from '../services/useUpdateOffering';
|
import { useUpdateOffering } from '../services/useUpdateOffering';
|
||||||
import { NetworkError } from '@/lib/api-client';
|
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||||
import type { OfferingFormData } from '../schemas/offering';
|
import type { OfferingFormData } from '../schemas/offering';
|
||||||
|
|
||||||
export function OfferingFormPage() {
|
export function OfferingFormPage() {
|
||||||
@@ -34,7 +34,15 @@ export function OfferingFormPage() {
|
|||||||
toast.success(t('offerings.form.successToast'));
|
toast.success(t('offerings.form.successToast'));
|
||||||
navigate({ to: '/offerings' });
|
navigate({ to: '/offerings' });
|
||||||
} catch (err) {
|
} 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'),
|
.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'),
|
ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
|
||||||
featured: z.boolean(),
|
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>;
|
export type OfferingFormData = z.infer<typeof offeringFormSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface OfferingAdminDto {
|
export interface OfferingAdminDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
price: string;
|
price: string;
|
||||||
@@ -18,6 +19,8 @@ export interface CreateOfferingRequest {
|
|||||||
features: string[];
|
features: string[];
|
||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
|
/** Omit to auto-generate from title; set to explicitly override. */
|
||||||
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateOfferingRequest = CreateOfferingRequest;
|
export type UpdateOfferingRequest = CreateOfferingRequest;
|
||||||
|
|||||||
@@ -263,6 +263,11 @@
|
|||||||
"createTitle": "New Offering",
|
"createTitle": "New Offering",
|
||||||
"editTitle": "Edit Offering",
|
"editTitle": "Edit Offering",
|
||||||
"titleLabel": "Title",
|
"titleLabel": "Title",
|
||||||
|
"slugLabel": "Slug",
|
||||||
|
"slugAutoHint": "Auto-generated from the title",
|
||||||
|
"editSlugButton": "Edit",
|
||||||
|
"confirmSlugButton": "Confirm slug",
|
||||||
|
"cancelSlugEditButton": "Cancel slug edit",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"priceLabel": "Price",
|
"priceLabel": "Price",
|
||||||
"priceNoteLabel": "Price note",
|
"priceNoteLabel": "Price note",
|
||||||
|
|||||||
@@ -263,6 +263,11 @@
|
|||||||
"createTitle": "Nieuwe aanbieding",
|
"createTitle": "Nieuwe aanbieding",
|
||||||
"editTitle": "Aanbieding bewerken",
|
"editTitle": "Aanbieding bewerken",
|
||||||
"titleLabel": "Titel",
|
"titleLabel": "Titel",
|
||||||
|
"slugLabel": "Slug",
|
||||||
|
"slugAutoHint": "Wordt automatisch gegenereerd uit de titel",
|
||||||
|
"editSlugButton": "Aanpassen",
|
||||||
|
"confirmSlugButton": "Slug bevestigen",
|
||||||
|
"cancelSlugEditButton": "Slug-bewerking annuleren",
|
||||||
"descriptionLabel": "Beschrijving",
|
"descriptionLabel": "Beschrijving",
|
||||||
"priceLabel": "Prijs",
|
"priceLabel": "Prijs",
|
||||||
"priceNoteLabel": "Prijsnotitie",
|
"priceNoteLabel": "Prijsnotitie",
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
using SlpModularCms.Modules.Offerings.Controllers;
|
using SlpModularCms.Modules.Offerings.Controllers;
|
||||||
using SlpModularCms.Modules.Offerings.Models;
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
using SlpModularCms.Modules.Offerings.Services;
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
@@ -24,10 +25,10 @@ public class OfferingsControllerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static OfferingDto PublicDto() => new(
|
private static OfferingDto PublicDto() => new(
|
||||||
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
|
Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
|
||||||
|
|
||||||
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
|
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
|
||||||
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
|
Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
|
||||||
|
|
||||||
private static CreateOfferingRequest CreateRequest() => new(
|
private static CreateOfferingRequest CreateRequest() => new(
|
||||||
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
@@ -71,6 +72,17 @@ public class OfferingsControllerTests
|
|||||||
result.Should().BeOfType<CreatedAtActionResult>().Which.StatusCode.Should().Be(201);
|
result.Should().BeOfType<CreatedAtActionResult>().Which.StatusCode.Should().Be(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Returns409_WhenSlugTaken()
|
||||||
|
{
|
||||||
|
_service.CreateAsync(Arg.Any<CreateOfferingRequest>(), Arg.Any<Guid>())
|
||||||
|
.ThrowsAsync(new OfferingSlugConflictException("taken"));
|
||||||
|
|
||||||
|
var result = await CreateSut().Create(CreateRequest());
|
||||||
|
|
||||||
|
result.Should().BeOfType<ConflictObjectResult>().Which.StatusCode.Should().Be(409);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Update ---
|
// --- Update ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -94,6 +106,17 @@ public class OfferingsControllerTests
|
|||||||
result.Should().BeOfType<NotFoundResult>();
|
result.Should().BeOfType<NotFoundResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_Returns409_WhenSlugTaken()
|
||||||
|
{
|
||||||
|
_service.UpdateAsync(Arg.Any<Guid>(), Arg.Any<UpdateOfferingRequest>(), Arg.Any<Guid>())
|
||||||
|
.ThrowsAsync(new OfferingSlugConflictException("taken"));
|
||||||
|
|
||||||
|
var result = await CreateSut().Update(Guid.NewGuid(), UpdateRequest());
|
||||||
|
|
||||||
|
result.Should().BeOfType<ConflictObjectResult>().Which.StatusCode.Should().Be(409);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Delete ---
|
// --- Delete ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable
|
|||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsBySlugAsync_ReturnsTrue_WhenSlugTaken()
|
||||||
|
{
|
||||||
|
await _sut.AddAsync(Offering("Website", displayOrder: 0));
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.ExistsBySlugAsync("website");
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsBySlugAsync_ReturnsFalse_WhenSlugFree()
|
||||||
|
{
|
||||||
|
var result = await _sut.ExistsBySlugAsync("unused-slug");
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsBySlugAsync_ExcludesGivenId()
|
||||||
|
{
|
||||||
|
var offering = Offering("Website", displayOrder: 0);
|
||||||
|
await _sut.AddAsync(offering);
|
||||||
|
await _sut.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.ExistsBySlugAsync("website", excludeId: offering.Id);
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Update_PersistsChanges()
|
public async Task Update_PersistsChanges()
|
||||||
{
|
{
|
||||||
@@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable
|
|||||||
private static Offering Offering(string title, int displayOrder) => new()
|
private static Offering Offering(string title, int displayOrder) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = title.ToLowerInvariant(),
|
||||||
Title = title,
|
Title = title,
|
||||||
Description = "Description",
|
Description = "Description",
|
||||||
Price = "€ 100",
|
Price = "€ 100",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class OfferingsServiceTests
|
|||||||
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = "existing",
|
||||||
Title = "Existing",
|
Title = "Existing",
|
||||||
Description = "Description",
|
Description = "Description",
|
||||||
Price = "€ 100",
|
Price = "€ 100",
|
||||||
@@ -91,6 +92,115 @@ public class OfferingsServiceTests
|
|||||||
await _repo.DidNotReceive().BeginTransactionAsync();
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slug generation ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_GeneratesSlugFromTitle()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
|
||||||
|
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
|
||||||
|
|
||||||
|
dto.Slug.Should().Be("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_AppendsSuffix_WhenSlugAlreadyTaken()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
_repo.ExistsBySlugAsync("title", null).Returns(true);
|
||||||
|
_repo.ExistsBySlugAsync("title-2", null).Returns(true);
|
||||||
|
_repo.ExistsBySlugAsync("title-3", null).Returns(false);
|
||||||
|
|
||||||
|
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
|
||||||
|
|
||||||
|
dto.Slug.Should().Be("title-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_RegeneratesSlug_WhenTitleChanges()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
_repo.ExistsBySlugAsync("new-title", target.Id).Returns(false);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest("New Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
dto!.Slug.Should().Be("new-title");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_KeepsExistingSlug_WhenTitleUnchanged()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest(target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||||
|
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
dto!.Slug.Should().Be("existing");
|
||||||
|
await _repo.DidNotReceive().ExistsBySlugAsync(Arg.Any<string>(), Arg.Any<Guid?>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Explicit slug override (admin-supplied via CMS) ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_NormalizesExplicitSlug()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
_repo.ExistsBySlugAsync("my-custom-slug", null).Returns(false);
|
||||||
|
|
||||||
|
var request = new CreateOfferingRequest(
|
||||||
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: " My Custom Slug!! ");
|
||||||
|
var dto = await CreateSut().CreateAsync(request, Guid.NewGuid());
|
||||||
|
|
||||||
|
dto.Slug.Should().Be("my-custom-slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_WhenExplicitSlugAlreadyTaken()
|
||||||
|
{
|
||||||
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
||||||
|
_repo.ExistsBySlugAsync("taken", null).Returns(true);
|
||||||
|
|
||||||
|
var request = new CreateOfferingRequest(
|
||||||
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "taken");
|
||||||
|
|
||||||
|
var act = () => CreateSut().CreateAsync(request, Guid.NewGuid());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<OfferingSlugConflictException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_UsesExplicitSlug_EvenWhenTitleUnchanged()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
_repo.ExistsBySlugAsync("manually-set", target.Id).Returns(false);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest(
|
||||||
|
target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "manually-set");
|
||||||
|
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
dto!.Slug.Should().Be("manually-set");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_Throws_WhenExplicitSlugTakenByAnotherOffering()
|
||||||
|
{
|
||||||
|
var target = ExistingOffering();
|
||||||
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
||||||
|
_repo.ExistsBySlugAsync("taken", target.Id).Returns(true);
|
||||||
|
|
||||||
|
var request = new UpdateOfferingRequest(
|
||||||
|
target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "taken");
|
||||||
|
|
||||||
|
var act = () => CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<OfferingSlugConflictException>();
|
||||||
|
}
|
||||||
|
|
||||||
// --- UpdateAsync ---
|
// --- UpdateAsync ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Tests.Services;
|
||||||
|
|
||||||
|
public class SlugifierTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Landingspagina", "landingspagina")]
|
||||||
|
[InlineData("Website", "website")]
|
||||||
|
[InlineData("Grote Website & Maatwerk", "grote-website-maatwerk")]
|
||||||
|
[InlineData(" Trimmed Title ", "trimmed-title")]
|
||||||
|
[InlineData("Café Ontwerp", "cafe-ontwerp")]
|
||||||
|
public void Slugify_ProducesReadableSlug(string title, string expected)
|
||||||
|
{
|
||||||
|
Slugifier.Slugify(title).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slugify_FallsBackToPakket_WhenTitleHasNoAlphanumerics()
|
||||||
|
{
|
||||||
|
Slugifier.Slugify("---").Should().Be("pakket");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using SlpModularCms.Modules.Offerings.Models;
|
using SlpModularCms.Modules.Offerings.Models;
|
||||||
@@ -30,17 +31,31 @@ public class OfferingsController(IOfferingsService service) : ControllerBase
|
|||||||
|
|
||||||
[HttpPost("admin")]
|
[HttpPost("admin")]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var dto = await service.CreateAsync(request, GetCallerId());
|
var dto = await service.CreateAsync(request, GetCallerId());
|
||||||
return CreatedAtAction(nameof(GetAllForAdmin), null, dto);
|
return CreatedAtAction(nameof(GetAllForAdmin), null, dto);
|
||||||
}
|
}
|
||||||
|
catch (OfferingSlugConflictException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("admin/{id:guid}")]
|
[HttpPut("admin/{id:guid}")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request)
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var dto = await service.UpdateAsync(id, request, GetCallerId());
|
var dto = await service.UpdateAsync(id, request, GetCallerId());
|
||||||
return dto is null ? NotFound() : Ok(dto);
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
}
|
}
|
||||||
|
catch (OfferingSlugConflictException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("admin/{id:guid}")]
|
[HttpDelete("admin/{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id)
|
public async Task<IActionResult> Delete(Guid id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace SlpModularCms.Modules.Offerings.Data.Entities;
|
|||||||
public class Offering
|
public class Offering
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public string Price { get; set; } = string.Empty;
|
public string Price { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions<OfferingsDbContext> options) :
|
|||||||
{
|
{
|
||||||
entity.ToTable("OfferingsOfferings");
|
entity.ToTable("OfferingsOfferings");
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.Slug).IsRequired().HasMaxLength(130);
|
||||||
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
|
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
|
||||||
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
|
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
|
||||||
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
|
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using SlpModularCms.Modules.Offerings.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(OfferingsDbContext))]
|
||||||
|
[Migration("20260804113000_AddOfferingSlug")]
|
||||||
|
partial class AddOfferingSlug
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("CtaLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("Featured")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Features")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<Guid>("LastModifiedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Price")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("PriceNote")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(130)
|
||||||
|
.HasColumnType("varchar(130)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("OfferingsOfferings", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOfferingSlug : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Slug",
|
||||||
|
table: "OfferingsOfferings",
|
||||||
|
type: "varchar(130)",
|
||||||
|
maxLength: 130,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
// Backfill existing rows with a title-derived slug so the NOT NULL column
|
||||||
|
// never holds an empty string for pre-existing data. New/edited rows get
|
||||||
|
// their slug from OfferingsService.GenerateUniqueSlugAsync instead, which
|
||||||
|
// also handles collisions this best-effort SQL backfill doesn't.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE OfferingsOfferings " +
|
||||||
|
"SET Slug = LOWER(TRIM(BOTH '-' FROM REGEXP_REPLACE(TRIM(Title), '[^a-zA-Z0-9]+', '-'))) " +
|
||||||
|
"WHERE Slug = '';");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Slug",
|
||||||
|
table: "OfferingsOfferings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("varchar(100)");
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(130)
|
||||||
|
.HasColumnType("varchar(130)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ public record CreateOfferingRequest(
|
|||||||
[Required, MaxLength(100)] string PriceNote,
|
[Required, MaxLength(100)] string PriceNote,
|
||||||
[Required, FeaturesValidation] List<string> Features,
|
[Required, FeaturesValidation] List<string> Features,
|
||||||
[Required, MaxLength(50)] string CtaLabel,
|
[Required, MaxLength(50)] string CtaLabel,
|
||||||
bool Featured = false
|
bool Featured = false,
|
||||||
|
[MaxLength(130)] string? Slug = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public record OfferingAdminDto(
|
public record OfferingAdminDto(
|
||||||
string Id,
|
string Id,
|
||||||
|
string Slug,
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
string Price,
|
string Price,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public record OfferingDto(
|
public record OfferingDto(
|
||||||
string Id,
|
string Id,
|
||||||
|
string Slug,
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
string Price,
|
string Price,
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ public record UpdateOfferingRequest(
|
|||||||
[Required, MaxLength(100)] string PriceNote,
|
[Required, MaxLength(100)] string PriceNote,
|
||||||
[Required, FeaturesValidation] List<string> Features,
|
[Required, FeaturesValidation] List<string> Features,
|
||||||
[Required, MaxLength(50)] string CtaLabel,
|
[Required, MaxLength(50)] string CtaLabel,
|
||||||
bool Featured = false
|
bool Featured = false,
|
||||||
|
[MaxLength(130)] string? Slug = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public interface IOfferingRepository
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<Offering>> GetAllAsync();
|
Task<IReadOnlyList<Offering>> GetAllAsync();
|
||||||
Task<Offering?> GetByIdAsync(Guid id);
|
Task<Offering?> GetByIdAsync(Guid id);
|
||||||
|
Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null);
|
||||||
Task AddAsync(Offering offering);
|
Task AddAsync(Offering offering);
|
||||||
void Update(Offering offering);
|
void Update(Offering offering);
|
||||||
Task<int> GetMaxDisplayOrderAsync();
|
Task<int> GetMaxDisplayOrderAsync();
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor
|
|||||||
public async Task<Offering?> GetByIdAsync(Guid id)
|
public async Task<Offering?> GetByIdAsync(Guid id)
|
||||||
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
|
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
|
||||||
|
|
||||||
|
public async Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null)
|
||||||
|
=> await context.Offerings.AnyAsync(o => o.Slug == slug && o.Id != (excludeId ?? Guid.Empty));
|
||||||
|
|
||||||
public async Task AddAsync(Offering offering)
|
public async Task AddAsync(Offering offering)
|
||||||
=> await context.Offerings.AddAsync(offering);
|
=> await context.Offerings.AddAsync(offering);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when an admin explicitly sets a slug that's already in use by another offering.
|
||||||
|
/// Auto-generated slugs never hit this (they get a numeric suffix instead) — this is only
|
||||||
|
/// for the CMS's explicit slug override.
|
||||||
|
/// </summary>
|
||||||
|
public class OfferingSlugConflictException(string slug)
|
||||||
|
: InvalidOperationException($"Slug '{slug}' is al in gebruik door een ander aanbod.")
|
||||||
|
{
|
||||||
|
public string Slug { get; } = slug;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
var offering = new Offering
|
var offering = new Offering
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = await ResolveSlugAsync(request.Slug, request.Title),
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
Price = request.Price,
|
Price = request.Price,
|
||||||
@@ -69,6 +70,15 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
if (offering is null)
|
if (offering is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Slug))
|
||||||
|
{
|
||||||
|
offering.Slug = await ResolveExplicitSlugAsync(request.Slug, excludeId: offering.Id);
|
||||||
|
}
|
||||||
|
else if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
offering.Slug = await GenerateUniqueSlugAsync(request.Title, excludeId: offering.Id);
|
||||||
|
}
|
||||||
|
|
||||||
offering.Title = request.Title;
|
offering.Title = request.Title;
|
||||||
offering.Description = request.Description;
|
offering.Description = request.Description;
|
||||||
offering.Price = request.Price;
|
offering.Price = request.Price;
|
||||||
@@ -202,8 +212,43 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
repository.Update(current);
|
repository.Update(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveSlugAsync(string? explicitSlug, string title)
|
||||||
|
=> !string.IsNullOrWhiteSpace(explicitSlug)
|
||||||
|
? await ResolveExplicitSlugAsync(explicitSlug, excludeId: null)
|
||||||
|
: await GenerateUniqueSlugAsync(title);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes an admin-supplied slug and enforces uniqueness. Unlike auto-generation,
|
||||||
|
/// a taken slug is rejected outright (OfferingSlugConflictException) rather than
|
||||||
|
/// silently suffixed — the admin picked this value on purpose.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ResolveExplicitSlugAsync(string explicitSlug, Guid? excludeId)
|
||||||
|
{
|
||||||
|
var slug = Slugifier.Slugify(explicitSlug);
|
||||||
|
if (await repository.ExistsBySlugAsync(slug, excludeId))
|
||||||
|
throw new OfferingSlugConflictException(slug);
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateUniqueSlugAsync(string title, Guid? excludeId = null)
|
||||||
|
{
|
||||||
|
var baseSlug = Slugifier.Slugify(title);
|
||||||
|
var slug = baseSlug;
|
||||||
|
var suffix = 2;
|
||||||
|
|
||||||
|
while (await repository.ExistsBySlugAsync(slug, excludeId))
|
||||||
|
{
|
||||||
|
slug = $"{baseSlug}-{suffix}";
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
private static OfferingDto ToPublicDto(Offering o) => new(
|
private static OfferingDto ToPublicDto(Offering o) => new(
|
||||||
o.Id.ToString(),
|
o.Id.ToString(),
|
||||||
|
o.Slug,
|
||||||
o.Title,
|
o.Title,
|
||||||
o.Description,
|
o.Description,
|
||||||
o.Price,
|
o.Price,
|
||||||
@@ -215,6 +260,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
|||||||
|
|
||||||
private static OfferingAdminDto ToAdminDto(Offering o) => new(
|
private static OfferingAdminDto ToAdminDto(Offering o) => new(
|
||||||
o.Id.ToString(),
|
o.Id.ToString(),
|
||||||
|
o.Slug,
|
||||||
o.Title,
|
o.Title,
|
||||||
o.Description,
|
o.Description,
|
||||||
o.Price,
|
o.Price,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Modules.Offerings.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics).
|
||||||
|
/// Pure string transform — uniqueness is handled by the caller.
|
||||||
|
/// </summary>
|
||||||
|
public static class Slugifier
|
||||||
|
{
|
||||||
|
public static string Slugify(string value)
|
||||||
|
{
|
||||||
|
var normalized = value.Normalize(NormalizationForm.FormD);
|
||||||
|
var builder = new StringBuilder(normalized.Length);
|
||||||
|
var lastWasHyphen = false;
|
||||||
|
|
||||||
|
foreach (var c in normalized)
|
||||||
|
{
|
||||||
|
var category = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (category == System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (char.IsLetterOrDigit(c))
|
||||||
|
{
|
||||||
|
builder.Append(char.ToLowerInvariant(c));
|
||||||
|
lastWasHyphen = false;
|
||||||
|
}
|
||||||
|
else if (!lastWasHyphen && builder.Length > 0)
|
||||||
|
{
|
||||||
|
builder.Append('-');
|
||||||
|
lastWasHyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastWasHyphen)
|
||||||
|
builder.Length--;
|
||||||
|
|
||||||
|
return builder.Length > 0 ? builder.ToString() : "pakket";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user