Voeg leesbare slug toe aan Offering naast het GUID-ID #10
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,7 +24,8 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
resetField,
|
||||
formState: { errors, dirtyFields },
|
||||
} = useForm<OfferingFormData>({
|
||||
resolver: zodResolver(offeringFormSchema),
|
||||
mode: 'onTouched',
|
||||
@@ -35,12 +37,35 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
|
||||
features: [''],
|
||||
ctaLabel: '',
|
||||
featured: false,
|
||||
slug: '',
|
||||
},
|
||||
});
|
||||
|
||||
// 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 slug = useWatch({ control, name: 'slug' });
|
||||
const [isEditingSlug, setIsEditingSlug] = useState(false);
|
||||
|
||||
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 (!dirtyFields.slug) {
|
||||
delete payload.slug;
|
||||
}
|
||||
onSubmit(payload);
|
||||
}
|
||||
|
||||
function addFeature() {
|
||||
setValue('features', [...features, ''], { shouldValidate: true });
|
||||
@@ -51,13 +76,68 @@ export function OfferingForm({ initialValues, onSubmit, isSubmitting }: Offering
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<form onSubmit={handleSubmit(submitWithSlugPolicy)} noValidate className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
|
||||
<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" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-slug">{t('offerings.form.slugLabel')}</Label>
|
||||
{isEditingSlug ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="offering-slug"
|
||||
data-testid="offering-slug"
|
||||
autoFocus
|
||||
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">
|
||||
<code
|
||||
data-testid="offering-slug-display"
|
||||
className="font-mono text-sm text-muted-foreground"
|
||||
>
|
||||
{slug && slug.length > 0 ? slug : 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">
|
||||
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
|
||||
<textarea
|
||||
|
||||
@@ -2,9 +2,19 @@ import { http, HttpResponse } from 'msw';
|
||||
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
|
||||
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[] = [
|
||||
{
|
||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
slug: 'starter',
|
||||
title: 'Starter',
|
||||
description: 'A simple website',
|
||||
price: '€ 300',
|
||||
@@ -16,6 +26,7 @@ const seed: OfferingAdminDto[] = [
|
||||
},
|
||||
{
|
||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
slug: 'pro',
|
||||
title: 'Pro',
|
||||
description: 'A full website',
|
||||
price: '€ 800',
|
||||
@@ -44,9 +55,18 @@ export const offeringsHandlers = [
|
||||
|
||||
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
|
||||
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 = {
|
||||
id: crypto.randomUUID(),
|
||||
...body,
|
||||
slug,
|
||||
displayOrder: mockOfferings.length,
|
||||
};
|
||||
mockOfferings = [...mockOfferings, newOffering];
|
||||
@@ -59,7 +79,20 @@ export const offeringsHandlers = [
|
||||
const existing = mockOfferings.find((o) => o.id === id);
|
||||
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));
|
||||
return HttpResponse.json(updated);
|
||||
}),
|
||||
|
||||
@@ -46,5 +46,48 @@ describe('OfferingFormPage', () => {
|
||||
|
||||
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
|
||||
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('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 { useCreateOffering } from '../services/useCreateOffering';
|
||||
import { useUpdateOffering } from '../services/useUpdateOffering';
|
||||
import { NetworkError } from '@/lib/api-client';
|
||||
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||
import type { OfferingFormData } from '../schemas/offering';
|
||||
|
||||
export function OfferingFormPage() {
|
||||
@@ -34,7 +34,15 @@ export function OfferingFormPage() {
|
||||
toast.success(t('offerings.form.successToast'));
|
||||
navigate({ to: '/offerings' });
|
||||
} 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'),
|
||||
ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
|
||||
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>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface OfferingAdminDto {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
@@ -18,6 +19,8 @@ export interface CreateOfferingRequest {
|
||||
features: string[];
|
||||
ctaLabel: string;
|
||||
featured: boolean;
|
||||
/** Omit to auto-generate from title; set to explicitly override. */
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export type UpdateOfferingRequest = CreateOfferingRequest;
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"createTitle": "New Offering",
|
||||
"editTitle": "Edit Offering",
|
||||
"titleLabel": "Title",
|
||||
"slugLabel": "Slug",
|
||||
"slugAutoHint": "Auto-generated from the title",
|
||||
"editSlugButton": "Edit slug",
|
||||
"confirmSlugButton": "Confirm slug",
|
||||
"cancelSlugEditButton": "Cancel slug edit",
|
||||
"descriptionLabel": "Description",
|
||||
"priceLabel": "Price",
|
||||
"priceNoteLabel": "Price note",
|
||||
|
||||
@@ -263,6 +263,11 @@
|
||||
"createTitle": "Nieuwe aanbieding",
|
||||
"editTitle": "Aanbieding bewerken",
|
||||
"titleLabel": "Titel",
|
||||
"slugLabel": "Slug",
|
||||
"slugAutoHint": "Wordt automatisch gegenereerd uit de titel",
|
||||
"editSlugButton": "Slug aanpassen",
|
||||
"confirmSlugButton": "Slug bevestigen",
|
||||
"cancelSlugEditButton": "Slug-bewerking annuleren",
|
||||
"descriptionLabel": "Beschrijving",
|
||||
"priceLabel": "Prijs",
|
||||
"priceNoteLabel": "Prijsnotitie",
|
||||
|
||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SlpModularCms.Modules.Offerings.Controllers;
|
||||
using SlpModularCms.Modules.Offerings.Models;
|
||||
using SlpModularCms.Modules.Offerings.Services;
|
||||
@@ -71,6 +72,17 @@ public class OfferingsControllerTests
|
||||
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 ---
|
||||
|
||||
[Fact]
|
||||
@@ -94,6 +106,17 @@ public class OfferingsControllerTests
|
||||
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 ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -143,6 +143,64 @@ public class OfferingsServiceTests
|
||||
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 ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using SlpModularCms.Modules.Offerings.Models;
|
||||
@@ -30,17 +31,31 @@ public class OfferingsController(IOfferingsService service) : ControllerBase
|
||||
|
||||
[HttpPost("admin")]
|
||||
public async Task<IActionResult> Create([FromBody] CreateOfferingRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dto = await service.CreateAsync(request, GetCallerId());
|
||||
return CreatedAtAction(nameof(GetAllForAdmin), null, dto);
|
||||
}
|
||||
catch (OfferingSlugConflictException ex)
|
||||
{
|
||||
return Conflict(new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("admin/{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOfferingRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dto = await service.UpdateAsync(id, request, GetCallerId());
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
catch (OfferingSlugConflictException ex)
|
||||
{
|
||||
return Conflict(new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("admin/{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
|
||||
@@ -9,5 +9,6 @@ public record CreateOfferingRequest(
|
||||
[Required, MaxLength(100)] string PriceNote,
|
||||
[Required, FeaturesValidation] List<string> Features,
|
||||
[Required, MaxLength(50)] string CtaLabel,
|
||||
bool Featured = false
|
||||
bool Featured = false,
|
||||
[MaxLength(130)] string? Slug = null
|
||||
);
|
||||
|
||||
@@ -9,5 +9,6 @@ public record UpdateOfferingRequest(
|
||||
[Required, MaxLength(100)] string PriceNote,
|
||||
[Required, FeaturesValidation] List<string> Features,
|
||||
[Required, MaxLength(50)] string CtaLabel,
|
||||
bool Featured = false
|
||||
bool Featured = false,
|
||||
[MaxLength(130)] string? Slug = null
|
||||
);
|
||||
|
||||
@@ -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,7 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
var offering = new Offering
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = await GenerateUniqueSlugAsync(request.Title),
|
||||
Slug = await ResolveSlugAsync(request.Slug, request.Title),
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Price = request.Price,
|
||||
@@ -70,8 +70,14 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
if (offering is null)
|
||||
return null;
|
||||
|
||||
if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
|
||||
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.Description = request.Description;
|
||||
@@ -206,6 +212,25 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user