feat: auto slug

This commit is contained in:
Benno Tielen 2026-03-09 15:15:48 +01:00
parent d6e70824d0
commit 7438f39786
6 changed files with 92 additions and 2 deletions

View file

@ -0,0 +1,42 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { TextField, useField, useFormFields } from '@payloadcms/ui'
import { useEffect, useRef } from 'react'
const toSlug = (value: string): string =>
value
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
export const SlugField: TextFieldClientComponent = (props) => {
const { value, setValue } = useField<string>({ path: props.path })
const prevAutoSlug = useRef<string>('')
const sourceField =
((props as Record<string, unknown>).sourceField as string) || 'name'
const sourceValue = useFormFields(([fields]) => {
const field = fields[sourceField]
return (field?.value as string) ?? ''
})
useEffect(() => {
const newSlug = toSlug(sourceValue)
// Only auto-fill if slug is empty or matches the previous auto-slug
if (!value || value === prevAutoSlug.current) {
setValue(newSlug)
}
prevAutoSlug.current = newSlug
}, [sourceValue]) // eslint-disable-line react-hooks/exhaustive-deps
return <TextField {...props} />
}
export default SlugField

View file

@ -1,3 +1,4 @@
import { SlugField as SlugField_d8007c5f8420db846c29d4e7711b3d75 } from '@/admin/components/SlugField/SlugField'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
@ -17,6 +18,7 @@ import { GcsClientUploadHandler as GcsClientUploadHandler_06e62ca02c7c441053a9b6
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
export const importMap = { export const importMap = {
"@/admin/components/SlugField/SlugField#SlugField": SlugField_d8007c5f8420db846c29d4e7711b3d75,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,

View file

@ -42,7 +42,20 @@ export const Groups: CollectionConfig = {
de: 'URL slug', de: 'URL slug',
}, },
required: true, required: true,
unique: true unique: true,
admin: {
description:
'Slug der Gruppe (z.B. "ministrant" → Gruppenseite ist zu finden unter /gruppe/ministrant)',
components: {
Field: '@/admin/components/SlugField/SlugField#SlugField',
},
},
hooks: {
beforeValidate: [
({ value }) =>
typeof value === 'string' ? value.replace(/^\/+/, '') : value,
],
},
}, },
{ {
name: 'shortDescription', name: 'shortDescription',

View file

@ -56,6 +56,20 @@ export const Pages: CollectionConfig = {
}, },
admin: { admin: {
description: 'URL-Pfad der Seite (z.B. "meine-seite" → /meine-seite)', description: 'URL-Pfad der Seite (z.B. "meine-seite" → /meine-seite)',
components: {
Field: {
path: '@/admin/components/SlugField/SlugField#SlugField',
clientProps: {
sourceField: 'title',
},
},
},
},
hooks: {
beforeValidate: [
({ value }) =>
typeof value === 'string' ? value.replace(/^\/+/, '') : value,
],
}, },
}, },
{ {

View file

@ -31,7 +31,20 @@ export const Parish: CollectionConfig = {
de: 'URL slug', de: 'URL slug',
}, },
type: 'text', type: 'text',
required: true required: true,
admin: {
description:
'Slug der Gemeinde (z.B. "st-clara" → Gemeindeseite ist zu finden unter /gemeinde/st-clara)',
components: {
Field: '@/admin/components/SlugField/SlugField#SlugField',
},
},
hooks: {
beforeValidate: [
({ value }) =>
typeof value === 'string' ? value.replace(/^\/+/, '') : value,
],
},
}, },
{ {
name: 'churches', name: 'churches',

View file

@ -169,6 +169,9 @@ export interface UserAuthOperations {
export interface Parish { export interface Parish {
id: string; id: string;
name: string; name: string;
/**
* Slug der Gemeinde (z.B. "st-clara" Gemeindeseite ist zu finden unter /gemeinde/st-clara)
*/
slug: string; slug: string;
churches: (string | Church)[]; churches: (string | Church)[];
contactPersons?: contactPersons?:
@ -546,6 +549,9 @@ export interface Group {
id: string; id: string;
photo?: (string | null) | Media; photo?: (string | null) | Media;
name: string; name: string;
/**
* Slug der Gruppe (z.B. "ministrant" Gruppenseite ist zu finden unter /gruppe/ministrant)
*/
slug: string; slug: string;
shortDescription: string; shortDescription: string;
text?: { text?: {