From 7438f397861c9e8649c0dc1c2fd563a73a59ae66 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Mon, 9 Mar 2026 15:15:48 +0100 Subject: [PATCH] feat: auto slug --- src/admin/components/SlugField/SlugField.tsx | 42 ++++++++++++++++++++ src/app/(payload)/admin/importMap.js | 2 + src/collections/Groups.ts | 15 ++++++- src/collections/Pages.ts | 14 +++++++ src/collections/Parish.ts | 15 ++++++- src/payload-types.ts | 6 +++ 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/admin/components/SlugField/SlugField.tsx diff --git a/src/admin/components/SlugField/SlugField.tsx b/src/admin/components/SlugField/SlugField.tsx new file mode 100644 index 0000000..f92a901 --- /dev/null +++ b/src/admin/components/SlugField/SlugField.tsx @@ -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({ path: props.path }) + const prevAutoSlug = useRef('') + const sourceField = + ((props as Record).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 +} + +export default SlugField diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index fb568b3..80fcaa7 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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 { RscEntryLexicalField as RscEntryLexicalField_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' export const importMap = { + "@/admin/components/SlugField/SlugField#SlugField": SlugField_d8007c5f8420db846c29d4e7711b3d75, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, diff --git a/src/collections/Groups.ts b/src/collections/Groups.ts index af65ec6..305615d 100644 --- a/src/collections/Groups.ts +++ b/src/collections/Groups.ts @@ -42,7 +42,20 @@ export const Groups: CollectionConfig = { de: 'URL slug', }, 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', diff --git a/src/collections/Pages.ts b/src/collections/Pages.ts index ea286f0..e9b7bcf 100644 --- a/src/collections/Pages.ts +++ b/src/collections/Pages.ts @@ -56,6 +56,20 @@ export const Pages: CollectionConfig = { }, admin: { 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, + ], }, }, { diff --git a/src/collections/Parish.ts b/src/collections/Parish.ts index 4a5f659..b3a18df 100644 --- a/src/collections/Parish.ts +++ b/src/collections/Parish.ts @@ -31,7 +31,20 @@ export const Parish: CollectionConfig = { de: 'URL slug', }, 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', diff --git a/src/payload-types.ts b/src/payload-types.ts index 1542c58..d7f7018 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -169,6 +169,9 @@ export interface UserAuthOperations { export interface Parish { id: string; name: string; + /** + * Slug der Gemeinde (z.B. "st-clara" → Gemeindeseite ist zu finden unter /gemeinde/st-clara) + */ slug: string; churches: (string | Church)[]; contactPersons?: @@ -546,6 +549,9 @@ export interface Group { id: string; photo?: (string | null) | Media; name: string; + /** + * Slug der Gruppe (z.B. "ministrant" → Gruppenseite ist zu finden unter /gruppe/ministrant) + */ slug: string; shortDescription: string; text?: {