From d0965a6894955ab8db9ea8b5699ba98291bb9243 Mon Sep 17 00:00:00 2001 From: Ainsley Clark <34712954+ainsleyclark@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:48:56 +0000 Subject: [PATCH 1/3] feat(payload-helper): add reusable slug field helper --- .changeset/fresh-turkeys-bathe.md | 5 + .../src/fields/Slug/Component.tsx | 99 +++++++++++++++++++ .../src/fields/Slug/formatSlug.ts | 24 +++++ .../payload-helper/src/fields/Slug/index.scss | 12 +++ .../payload-helper/src/fields/Slug/index.ts | 62 ++++++++++++ packages/payload-helper/src/fields/index.ts | 2 + packages/payload-helper/src/index.ts | 4 +- 7 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 .changeset/fresh-turkeys-bathe.md create mode 100644 packages/payload-helper/src/fields/Slug/Component.tsx create mode 100644 packages/payload-helper/src/fields/Slug/formatSlug.ts create mode 100644 packages/payload-helper/src/fields/Slug/index.scss create mode 100644 packages/payload-helper/src/fields/Slug/index.ts diff --git a/.changeset/fresh-turkeys-bathe.md b/.changeset/fresh-turkeys-bathe.md new file mode 100644 index 00000000..90883a45 --- /dev/null +++ b/.changeset/fresh-turkeys-bathe.md @@ -0,0 +1,5 @@ +--- +"@ainsleydev/payload-helper": minor +--- + +Add a reusable `SlugField` helper with a lockable admin component and automatic slug formatting hook. diff --git a/packages/payload-helper/src/fields/Slug/Component.tsx b/packages/payload-helper/src/fields/Slug/Component.tsx new file mode 100644 index 00000000..fbd41715 --- /dev/null +++ b/packages/payload-helper/src/fields/Slug/Component.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { + Button, + FieldDescription, + FieldLabel, + TextInput, + useField, + useForm, + useFormFields, +} from '@payloadcms/ui'; +import type { TextFieldClientProps } from 'payload'; +import React, { useCallback, useEffect } from 'react'; + +import { formatSlug } from './formatSlug'; +import './index.scss'; + +type SlugComponentProps = { + fieldToUse: string; + checkboxFieldPath: string; +} & TextFieldClientProps; + +export const Component: React.FC = ({ + field, + fieldToUse, + checkboxFieldPath: checkboxFieldPathFromProps, + path, + readOnly: readOnlyFromProps, +}) => { + const { admin: { description } = {}, label } = field; + + const checkboxFieldPath = path?.includes('.') + ? `${path}.${checkboxFieldPathFromProps}` + : checkboxFieldPathFromProps; + const resolvedPath = path || field.name; + + const { value, setValue } = useField({ path: resolvedPath }); + + const { dispatchFields } = useForm(); + + const checkboxValue = useFormFields(([fields]) => { + return fields[checkboxFieldPath]?.value as string; + }); + + const targetFieldValue = useFormFields(([fields]) => { + return fields[fieldToUse]?.value as string; + }); + + useEffect(() => { + if (checkboxValue) { + if (targetFieldValue) { + const formattedSlug = formatSlug(targetFieldValue); + + if (value !== formattedSlug) { + setValue(formattedSlug); + } + } else if (value !== '') { + setValue(''); + } + } + }, [targetFieldValue, checkboxValue, setValue, value]); + + const handleLock = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + + dispatchFields({ + type: 'UPDATE', + path: checkboxFieldPath, + value: !checkboxValue, + }); + }, + [checkboxValue, checkboxFieldPath, dispatchFields], + ); + + const readOnly = readOnlyFromProps || checkboxValue; + + return ( +
+
+ + +
+ + +
+ ); +}; diff --git a/packages/payload-helper/src/fields/Slug/formatSlug.ts b/packages/payload-helper/src/fields/Slug/formatSlug.ts new file mode 100644 index 00000000..12ebe190 --- /dev/null +++ b/packages/payload-helper/src/fields/Slug/formatSlug.ts @@ -0,0 +1,24 @@ +import type { FieldHook } from 'payload'; + +export const formatSlug = (val: string): string => + val + .replace(/ /g, '-') + .toLowerCase(); + +export const formatSlugHook = + (fallback: string): FieldHook => + ({ data, operation, value }) => { + if (typeof value === 'string') { + return formatSlug(value); + } + + if (operation === 'create' || !data?.slug) { + const fallbackData = data?.[fallback] || data?.[fallback]; + + if (fallbackData && typeof fallbackData === 'string') { + return formatSlug(fallbackData); + } + } + + return value; + }; diff --git a/packages/payload-helper/src/fields/Slug/index.scss b/packages/payload-helper/src/fields/Slug/index.scss new file mode 100644 index 00000000..67e57efa --- /dev/null +++ b/packages/payload-helper/src/fields/Slug/index.scss @@ -0,0 +1,12 @@ +.slug-field-component { + .label-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + } + + .lock-button { + margin: 0; + padding-bottom: 0.3125rem; + } +} diff --git a/packages/payload-helper/src/fields/Slug/index.ts b/packages/payload-helper/src/fields/Slug/index.ts new file mode 100644 index 00000000..888ef676 --- /dev/null +++ b/packages/payload-helper/src/fields/Slug/index.ts @@ -0,0 +1,62 @@ +import { type CheckboxField, type TextField, deepMerge } from 'payload'; + +import { formatSlugHook } from './formatSlug'; + +type Overrides = { + slugOverrides?: Partial; + checkboxOverrides?: Partial; + description?: string; +}; + +type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField]; + +export const SlugField: Slug = (fieldToUse = 'title', overrides = {}) => { + const { slugOverrides, checkboxOverrides } = overrides; + + const checkBoxField = deepMerge>( + { + name: 'slugLock', + type: 'checkbox', + defaultValue: true, + admin: { + hidden: true, + position: 'sidebar', + }, + }, + checkboxOverrides || {}, + ); + + const slugField = deepMerge>( + { + name: 'slug', + type: 'text', + index: true, + label: 'Slug', + unique: true, + required: true, + hooks: { + beforeValidate: [formatSlugHook(fieldToUse)], + }, + admin: { + position: 'sidebar', + description: + overrides.description || + 'The URL friendly version of the title, users will see this text in the URL bar.', + components: { + Field: { + path: '/fields/Slug/Component#Component', + clientProps: { + fieldToUse, + checkboxFieldPath: checkBoxField.name, + }, + }, + }, + }, + }, + slugOverrides || {}, + ); + + return [slugField, checkBoxField]; +}; + +export type { Overrides, Slug }; diff --git a/packages/payload-helper/src/fields/index.ts b/packages/payload-helper/src/fields/index.ts index 1a9c0bce..2a753215 100644 --- a/packages/payload-helper/src/fields/index.ts +++ b/packages/payload-helper/src/fields/index.ts @@ -1,4 +1,6 @@ export { PublishedAt } from './PublishedAt.js'; export type { PublishedAtArgs } from './PublishedAt.js'; +export { SlugField } from './Slug/index.js'; +export type { Overrides as SlugOverrides, Slug } from './Slug/index.js'; export { URLField } from './URLField.js'; export type { URLFieldArgs } from './URLField.js'; diff --git a/packages/payload-helper/src/index.ts b/packages/payload-helper/src/index.ts index b21631a3..84ee40e0 100644 --- a/packages/payload-helper/src/index.ts +++ b/packages/payload-helper/src/index.ts @@ -47,8 +47,8 @@ export { export { SEOFields } from './common/index.js'; // Fields -export { PublishedAt, URLField } from './fields/index.js'; -export type { PublishedAtArgs, URLFieldArgs } from './fields/index.js'; +export { PublishedAt, SlugField, URLField } from './fields/index.js'; +export type { PublishedAtArgs, Slug, SlugOverrides, URLFieldArgs } from './fields/index.js'; // Email Config Helper export { defineEmailConfig } from './email/defineEmailConfig.js'; From ef59db1044989625b31e3449ce87279244b87cfa Mon Sep 17 00:00:00 2001 From: Ainsley Clark <34712954+ainsleyclark@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:03:48 +0000 Subject: [PATCH 2/3] fix(payload-helper): resolve slug field lint and type errors --- .../src/fields/Slug/Component.tsx | 21 +++++++++++-------- .../src/fields/Slug/formatSlug.ts | 5 +---- .../payload-helper/src/fields/Slug/index.ts | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/payload-helper/src/fields/Slug/Component.tsx b/packages/payload-helper/src/fields/Slug/Component.tsx index fbd41715..7e95064d 100644 --- a/packages/payload-helper/src/fields/Slug/Component.tsx +++ b/packages/payload-helper/src/fields/Slug/Component.tsx @@ -10,9 +10,10 @@ import { useFormFields, } from '@payloadcms/ui'; import type { TextFieldClientProps } from 'payload'; -import React, { useCallback, useEffect } from 'react'; +import type React from 'react'; +import { useCallback, useEffect } from 'react'; -import { formatSlug } from './formatSlug'; +import { formatSlug } from './formatSlug.js'; import './index.scss'; type SlugComponentProps = { @@ -27,7 +28,10 @@ export const Component: React.FC = ({ path, readOnly: readOnlyFromProps, }) => { - const { admin: { description } = {}, label } = field; + const { + admin: { description } = {}, + label, + } = field; const checkboxFieldPath = path?.includes('.') ? `${path}.${checkboxFieldPathFromProps}` @@ -35,7 +39,6 @@ export const Component: React.FC = ({ const resolvedPath = path || field.name; const { value, setValue } = useField({ path: resolvedPath }); - const { dispatchFields } = useForm(); const checkboxValue = useFormFields(([fields]) => { @@ -61,8 +64,8 @@ export const Component: React.FC = ({ }, [targetFieldValue, checkboxValue, setValue, value]); const handleLock = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); + (event: React.MouseEvent) => { + event.preventDefault(); dispatchFields({ type: 'UPDATE', @@ -76,10 +79,10 @@ export const Component: React.FC = ({ const readOnly = readOnlyFromProps || checkboxValue; return ( -
-
+
+
-
diff --git a/packages/payload-helper/src/fields/Slug/formatSlug.ts b/packages/payload-helper/src/fields/Slug/formatSlug.ts index 12ebe190..0a808dd7 100644 --- a/packages/payload-helper/src/fields/Slug/formatSlug.ts +++ b/packages/payload-helper/src/fields/Slug/formatSlug.ts @@ -1,9 +1,6 @@ import type { FieldHook } from 'payload'; -export const formatSlug = (val: string): string => - val - .replace(/ /g, '-') - .toLowerCase(); +export const formatSlug = (val: string): string => val.replace(/ /g, '-').toLowerCase(); export const formatSlugHook = (fallback: string): FieldHook => diff --git a/packages/payload-helper/src/fields/Slug/index.ts b/packages/payload-helper/src/fields/Slug/index.ts index 888ef676..a2f058d9 100644 --- a/packages/payload-helper/src/fields/Slug/index.ts +++ b/packages/payload-helper/src/fields/Slug/index.ts @@ -1,6 +1,6 @@ import { type CheckboxField, type TextField, deepMerge } from 'payload'; -import { formatSlugHook } from './formatSlug'; +import { formatSlugHook } from './formatSlug.js'; type Overrides = { slugOverrides?: Partial; From a2a8b1996af106c6cc9e9ed776ce36a6b18e4bd1 Mon Sep 17 00:00:00 2001 From: Ainsley Clark <34712954+ainsleyclark@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:46:50 +0000 Subject: [PATCH 3/3] refactor(payload-helper): export slug types inline --- packages/payload-helper/src/fields/Slug/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/payload-helper/src/fields/Slug/index.ts b/packages/payload-helper/src/fields/Slug/index.ts index a2f058d9..aa0fe7af 100644 --- a/packages/payload-helper/src/fields/Slug/index.ts +++ b/packages/payload-helper/src/fields/Slug/index.ts @@ -2,13 +2,13 @@ import { type CheckboxField, type TextField, deepMerge } from 'payload'; import { formatSlugHook } from './formatSlug.js'; -type Overrides = { +export type Overrides = { slugOverrides?: Partial; checkboxOverrides?: Partial; description?: string; }; -type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField]; +export type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField]; export const SlugField: Slug = (fieldToUse = 'title', overrides = {}) => { const { slugOverrides, checkboxOverrides } = overrides; @@ -58,5 +58,3 @@ export const SlugField: Slug = (fieldToUse = 'title', overrides = {}) => { return [slugField, checkBoxField]; }; - -export type { Overrides, Slug };