From 37a76aaff471ac2394207a41ee5a8523ff6b1208 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 1 Oct 2025 09:56:54 -0400 Subject: [PATCH 01/25] fix(templates): auto gen slug through autosave --- .../ecommerce/src/fields/slug/formatSlug.ts | 45 ++++++++++++++++--- .../website/src/fields/slug/formatSlug.ts | 37 ++++++++++++++- .../src/fields/slug/formatSlug.ts | 37 ++++++++++++++- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/templates/ecommerce/src/fields/slug/formatSlug.ts b/templates/ecommerce/src/fields/slug/formatSlug.ts index 9129de8932d..07d1b69d39d 100644 --- a/templates/ecommerce/src/fields/slug/formatSlug.ts +++ b/templates/ecommerce/src/fields/slug/formatSlug.ts @@ -1,22 +1,55 @@ import type { FieldHook } from 'payload' -export const formatSlug = (val: string): string => +export const formatSlug = (val: string): string | undefined => val - .replace(/ /g, '-') + ?.replace(/ /g, '-') .replace(/[^\w-]+/g, '') .toLowerCase() +/** + * This is a `BeforeValidate` field hook. + * It will auto-generate the slug from a specific fallback field, if necessary. + * For example, slugifying the title field "My First Post" to "my-first-post". + * + * We need to ensure the slug continues to auto-generate through the autosave's initial create. + * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. + * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. + * + * So we only autogenerate the slug if: + * 1. Autosave is not enabled and we're creating a new doc or there is no slug yet + * 2. Autosave is enabled and we're publishing a doc, where we now have 3 versions: + * - The initial create + * - The draft used for autosaves + * - The published version + */ export const formatSlugHook = (fallback: string): FieldHook => - ({ data, operation, value }) => { + (args) => { + const { data, operation, value, collection, global } = args + if (typeof value === 'string') { return formatSlug(value) } - if (operation === 'create' || !data?.slug) { - const fallbackData = data?.[fallback] || data?.[fallback] + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + let autoGenerateSlug = false + + if (!autosaveEnabled && (operation === 'create' || data?.slug === undefined)) { + autoGenerateSlug = true + } else if (autosaveEnabled && operation === 'update') { + if (data?._status === 'published') { + autoGenerateSlug = true + } + } + + if (autoGenerateSlug) { + const fallbackData = data?.[fallback] - if (fallbackData && typeof fallbackData === 'string') { + if (typeof fallbackData === 'string') { return formatSlug(fallbackData) } } diff --git a/templates/website/src/fields/slug/formatSlug.ts b/templates/website/src/fields/slug/formatSlug.ts index 0d4b78239b9..07d1b69d39d 100644 --- a/templates/website/src/fields/slug/formatSlug.ts +++ b/templates/website/src/fields/slug/formatSlug.ts @@ -6,14 +6,47 @@ export const formatSlug = (val: string): string | undefined => .replace(/[^\w-]+/g, '') .toLowerCase() +/** + * This is a `BeforeValidate` field hook. + * It will auto-generate the slug from a specific fallback field, if necessary. + * For example, slugifying the title field "My First Post" to "my-first-post". + * + * We need to ensure the slug continues to auto-generate through the autosave's initial create. + * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. + * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. + * + * So we only autogenerate the slug if: + * 1. Autosave is not enabled and we're creating a new doc or there is no slug yet + * 2. Autosave is enabled and we're publishing a doc, where we now have 3 versions: + * - The initial create + * - The draft used for autosaves + * - The published version + */ export const formatSlugHook = (fallback: string): FieldHook => - ({ data, operation, value }) => { + (args) => { + const { data, operation, value, collection, global } = args + if (typeof value === 'string') { return formatSlug(value) } - if (operation === 'create' || data?.slug === undefined) { + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + let autoGenerateSlug = false + + if (!autosaveEnabled && (operation === 'create' || data?.slug === undefined)) { + autoGenerateSlug = true + } else if (autosaveEnabled && operation === 'update') { + if (data?._status === 'published') { + autoGenerateSlug = true + } + } + + if (autoGenerateSlug) { const fallbackData = data?.[fallback] if (typeof fallbackData === 'string') { diff --git a/templates/with-vercel-website/src/fields/slug/formatSlug.ts b/templates/with-vercel-website/src/fields/slug/formatSlug.ts index 0d4b78239b9..07d1b69d39d 100644 --- a/templates/with-vercel-website/src/fields/slug/formatSlug.ts +++ b/templates/with-vercel-website/src/fields/slug/formatSlug.ts @@ -6,14 +6,47 @@ export const formatSlug = (val: string): string | undefined => .replace(/[^\w-]+/g, '') .toLowerCase() +/** + * This is a `BeforeValidate` field hook. + * It will auto-generate the slug from a specific fallback field, if necessary. + * For example, slugifying the title field "My First Post" to "my-first-post". + * + * We need to ensure the slug continues to auto-generate through the autosave's initial create. + * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. + * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. + * + * So we only autogenerate the slug if: + * 1. Autosave is not enabled and we're creating a new doc or there is no slug yet + * 2. Autosave is enabled and we're publishing a doc, where we now have 3 versions: + * - The initial create + * - The draft used for autosaves + * - The published version + */ export const formatSlugHook = (fallback: string): FieldHook => - ({ data, operation, value }) => { + (args) => { + const { data, operation, value, collection, global } = args + if (typeof value === 'string') { return formatSlug(value) } - if (operation === 'create' || data?.slug === undefined) { + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + let autoGenerateSlug = false + + if (!autosaveEnabled && (operation === 'create' || data?.slug === undefined)) { + autoGenerateSlug = true + } else if (autosaveEnabled && operation === 'update') { + if (data?._status === 'published') { + autoGenerateSlug = true + } + } + + if (autoGenerateSlug) { const fallbackData = data?.[fallback] if (typeof fallbackData === 'string') { From 6c095f1ad1b3218b322fce3ffd97a11d8543e797 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 10:29:40 -0400 Subject: [PATCH 02/25] restructure slug gen --- .../website/src/fields/slug/SlugComponent.tsx | 32 ++++----------- .../website/src/fields/slug/countVersions.ts | 38 ++++++++++++++++++ .../website/src/fields/slug/formatSlug.ts | 40 +++++++++---------- templates/website/src/fields/slug/index.ts | 17 ++++---- 4 files changed, 75 insertions(+), 52 deletions(-) create mode 100644 templates/website/src/fields/slug/countVersions.ts diff --git a/templates/website/src/fields/slug/SlugComponent.tsx b/templates/website/src/fields/slug/SlugComponent.tsx index 8114973e16f..f6e56007d38 100644 --- a/templates/website/src/fields/slug/SlugComponent.tsx +++ b/templates/website/src/fields/slug/SlugComponent.tsx @@ -1,5 +1,5 @@ 'use client' -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' import { TextFieldClientProps } from 'payload' import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' @@ -9,29 +9,21 @@ import './index.scss' type SlugComponentProps = { fieldToUse: string - checkboxFieldPath: string } & TextFieldClientProps export const SlugComponent: React.FC = ({ field, fieldToUse, - checkboxFieldPath: checkboxFieldPathFromProps, path, readOnly: readOnlyFromProps, }) => { const { label } = field - const checkboxFieldPath = path?.includes('.') - ? `${path}.${checkboxFieldPathFromProps}` - : checkboxFieldPathFromProps - const { value, setValue } = useField({ path: path || field.name }) - const { dispatchFields, getDataByPath } = useForm() + const { getDataByPath } = useForm() - const isLocked = useFormFields(([fields]) => { - return fields[checkboxFieldPath]?.value as string - }) + const [isLocked, setIsLocked] = useState(true) const handleGenerate = useCallback( (e: React.MouseEvent) => { @@ -50,18 +42,10 @@ export const SlugComponent: React.FC = ({ [setValue, value, fieldToUse, getDataByPath], ) - const handleLock = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - - dispatchFields({ - type: 'UPDATE', - path: checkboxFieldPath, - value: !isLocked, - }) - }, - [isLocked, checkboxFieldPath, dispatchFields], - ) + const toggleLock = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsLocked((prev) => !prev) + }, []) return (
@@ -72,7 +56,7 @@ export const SlugComponent: React.FC = ({ Generate )} -
diff --git a/templates/website/src/fields/slug/countVersions.ts b/templates/website/src/fields/slug/countVersions.ts new file mode 100644 index 00000000000..c3775176fa1 --- /dev/null +++ b/templates/website/src/fields/slug/countVersions.ts @@ -0,0 +1,38 @@ +import { FieldHook } from 'payload' + +/** + * This is a cross-entity way to count the number of versions for any given document. + * It will work for both collections and globals. + * @returns number of versions + */ +export const countVersions = async (args: Parameters[0]): Promise => { + const { collection, req, global, originalDoc } = args + + let countFn + + let where = { + 'version.parent': { + equals: originalDoc?.id, + }, + } + + if (collection) { + countFn = () => + req.payload.countVersions({ + collection: collection.slug, + where, + depth: 0, + }) + } + + if (global) { + countFn = () => + req.payload.countGlobalVersions({ + global: global.slug, + where, + depth: 0, + }) + } + + return countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 +} diff --git a/templates/website/src/fields/slug/formatSlug.ts b/templates/website/src/fields/slug/formatSlug.ts index 07d1b69d39d..a17e1c215b0 100644 --- a/templates/website/src/fields/slug/formatSlug.ts +++ b/templates/website/src/fields/slug/formatSlug.ts @@ -1,4 +1,5 @@ import type { FieldHook } from 'payload' +import { countVersions } from './countVersions' export const formatSlug = (val: string): string | undefined => val @@ -9,9 +10,9 @@ export const formatSlug = (val: string): string | undefined => /** * This is a `BeforeValidate` field hook. * It will auto-generate the slug from a specific fallback field, if necessary. - * For example, slugifying the title field "My First Post" to "my-first-post". + * For example, generating a slug from the title field: "My First Post" to "my-first-post". * - * We need to ensure the slug continues to auto-generate through the autosave's initial create. + * For autosave, We need to ensure the slug continues to auto-generate through the initial create. * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. * @@ -24,11 +25,14 @@ export const formatSlug = (val: string): string | undefined => */ export const formatSlugHook = (fallback: string): FieldHook => - (args) => { + async (args) => { const { data, operation, value, collection, global } = args - if (typeof value === 'string') { - return formatSlug(value) + let toReturn = value + + // during create, only generate the slug if the user has not provided one + if (data?.generateSlug && (operation === 'update' || (operation === 'create' && !value))) { + toReturn = data?.[fallback] } const autosaveEnabled = Boolean( @@ -36,23 +40,19 @@ export const formatSlugHook = (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), ) - let autoGenerateSlug = false - - if (!autosaveEnabled && (operation === 'create' || data?.slug === undefined)) { - autoGenerateSlug = true - } else if (autosaveEnabled && operation === 'update') { - if (data?._status === 'published') { - autoGenerateSlug = true - } - } - - if (autoGenerateSlug) { - const fallbackData = data?.[fallback] + // Important: ensure `countVersions` is not called unnecessarily often + // To do this, early return if `generateSlug` is already false + const shouldContinueGenerating = Boolean( + data?.generateSlug && + operation === 'update' && + (!autosaveEnabled || (await countVersions(args)) < 3), + ) - if (typeof fallbackData === 'string') { - return formatSlug(fallbackData) + if (!shouldContinueGenerating) { + if (data) { + data.generateSlug = false } } - return value + return formatSlug(toReturn) } diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts index 77feae0e315..ab26f06c1bb 100644 --- a/templates/website/src/fields/slug/index.ts +++ b/templates/website/src/fields/slug/index.ts @@ -4,23 +4,25 @@ import { formatSlugHook } from './formatSlug' type Overrides = { slugOverrides?: Partial - checkboxOverrides?: Partial + generateSlugOverrides?: Partial } type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { - const { slugOverrides, checkboxOverrides } = overrides + const { slugOverrides, generateSlugOverrides } = overrides - const checkBoxField: CheckboxField = { - name: 'slugLock', + const generateSlugField: CheckboxField = { + name: 'generateSlug', type: 'checkbox', + label: 'Auto-generate slug', defaultValue: true, admin: { - hidden: true, + description: + 'When enabled, the slug will auto-generate from the title field on save and autosave.', position: 'sidebar', }, - ...checkboxOverrides, + ...(generateSlugOverrides || {}), } // @ts-expect-error - ts mismatch Partial with TextField @@ -42,12 +44,11 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { path: '@/fields/slug/SlugComponent#SlugComponent', clientProps: { fieldToUse, - checkboxFieldPath: checkBoxField.name, }, }, }, }, } - return [slugField, checkBoxField] + return [slugField, generateSlugField] } From 1aab86f1c9cc775ea4cbb5f6decff3c449a3cfb3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 10:33:56 -0400 Subject: [PATCH 03/25] wire conditional lp --- templates/ecommerce/src/collections/Pages/index.ts | 8 +++++--- templates/ecommerce/src/collections/Products/index.ts | 8 +++++--- templates/website/src/collections/Pages/index.ts | 8 +++++--- templates/website/src/collections/Posts/index.ts | 8 +++++--- .../with-vercel-website/src/collections/Pages/index.ts | 8 +++++--- .../with-vercel-website/src/collections/Posts/index.ts | 8 +++++--- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index 2776459df58..d311c76f3d7 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -35,13 +35,15 @@ export const Pages: CollectionConfig = { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { url: ({ data, req }) => { - const path = generatePreviewPath({ + if (data.slug === undefined || data.slug === null) { + return null + } + + return generatePreviewPath({ slug: typeof data?.slug === 'string' ? data.slug : '', collection: 'pages', req, }) - - return path }, }, preview: (data, { req }) => diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index c8d07f157cf..9dc9cbd7c65 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -27,13 +27,15 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => defaultColumns: ['title', 'enableVariants', '_status', 'variants.variants'], livePreview: { url: ({ data, req }) => { - const path = generatePreviewPath({ + if (data.slug === undefined || data.slug === null) { + return null + } + + return generatePreviewPath({ slug: typeof data?.slug === 'string' ? data.slug : '', collection: 'products', req, }) - - return path }, }, preview: (data, { req }) => diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index c81ffa8815e..4db130c688a 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -40,13 +40,15 @@ export const Pages: CollectionConfig<'pages'> = { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { url: ({ data, req }) => { - const path = generatePreviewPath({ + if (data.slug === undefined || data.slug === null) { + return null + } + + return generatePreviewPath({ slug: typeof data?.slug === 'string' ? data.slug : '', collection: 'pages', req, }) - - return path }, }, preview: (data, { req }) => diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index 3ccaeda82b0..5fe422ecb8c 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -51,13 +51,15 @@ export const Posts: CollectionConfig<'posts'> = { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { url: ({ data, req }) => { - const path = generatePreviewPath({ + if (data.slug === undefined || data.slug === null) { + return null + } + + return generatePreviewPath({ slug: typeof data?.slug === 'string' ? data.slug : '', collection: 'posts', req, }) - - return path }, }, preview: (data, { req }) => diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index c81ffa8815e..4db130c688a 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -40,13 +40,15 @@ export const Pages: CollectionConfig<'pages'> = { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { url: ({ data, req }) => { - const path = generatePreviewPath({ + if (data.slug === undefined || data.slug === null) { + return null + } + + return generatePreviewPath({ slug: typeof data?.slug === 'string' ? data.slug : '', collection: 'pages', req, }) - - return path }, }, preview: (data, { req }) => diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index 3ccaeda82b0..5fe422ecb8c 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -51,13 +51,15 @@ export const Posts: CollectionConfig<'posts'> = { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { url: ({ data, req }) => { - const path = generatePreviewPath({ + if (data.slug === undefined || data.slug === null) { + return null + } + + return generatePreviewPath({ slug: typeof data?.slug === 'string' ? data.slug : '', collection: 'posts', req, }) - - return path }, }, preview: (data, { req }) => From 71f0709898f35ae35af5c0438e44246b5b369191 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 14:40:48 -0400 Subject: [PATCH 04/25] way better --- .../src/fields/slug/SlugComponent.tsx | 63 ++++++---------- .../src/fields/slug/countVersions.ts | 40 ++++++++++ .../ecommerce/src/fields/slug/formatSlug.ts | 58 -------------- .../ecommerce/src/fields/slug/generateSlug.ts | 75 +++++++++++++++++++ .../ecommerce/src/fields/slug/index.scss | 1 + templates/ecommerce/src/fields/slug/index.ts | 72 +++++++++--------- .../ecommerce/src/fields/slug/slugify.ts | 5 ++ .../website/src/fields/slug/SlugComponent.tsx | 4 +- .../website/src/fields/slug/countVersions.ts | 6 +- .../website/src/fields/slug/formatSlug.ts | 58 -------------- .../website/src/fields/slug/generateSlug.ts | 75 +++++++++++++++++++ templates/website/src/fields/slug/index.ts | 69 +++++++++-------- templates/website/src/fields/slug/slugify.ts | 5 ++ .../src/fields/slug/SlugComponent.tsx | 36 +++------ .../src/fields/slug/countVersions.ts | 40 ++++++++++ .../src/fields/slug/formatSlug.ts | 58 -------------- .../src/fields/slug/generateSlug.ts | 75 +++++++++++++++++++ .../src/fields/slug/index.ts | 72 +++++++++--------- .../src/fields/slug/slugify.ts | 5 ++ 19 files changed, 466 insertions(+), 351 deletions(-) create mode 100644 templates/ecommerce/src/fields/slug/countVersions.ts delete mode 100644 templates/ecommerce/src/fields/slug/formatSlug.ts create mode 100644 templates/ecommerce/src/fields/slug/generateSlug.ts create mode 100644 templates/ecommerce/src/fields/slug/slugify.ts delete mode 100644 templates/website/src/fields/slug/formatSlug.ts create mode 100644 templates/website/src/fields/slug/generateSlug.ts create mode 100644 templates/website/src/fields/slug/slugify.ts create mode 100644 templates/with-vercel-website/src/fields/slug/countVersions.ts delete mode 100644 templates/with-vercel-website/src/fields/slug/formatSlug.ts create mode 100644 templates/with-vercel-website/src/fields/slug/generateSlug.ts create mode 100644 templates/with-vercel-website/src/fields/slug/slugify.ts diff --git a/templates/ecommerce/src/fields/slug/SlugComponent.tsx b/templates/ecommerce/src/fields/slug/SlugComponent.tsx index f21ae829d2b..40f26571c96 100644 --- a/templates/ecommerce/src/fields/slug/SlugComponent.tsx +++ b/templates/ecommerce/src/fields/slug/SlugComponent.tsx @@ -1,87 +1,70 @@ 'use client' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useState } from 'react' import { TextFieldClientProps } from 'payload' import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' -import { formatSlug } from './formatSlug' +import { slugify } from './slugify' import './index.scss' type SlugComponentProps = { fieldToUse: string - checkboxFieldPath: string } & TextFieldClientProps export const SlugComponent: React.FC = ({ field, fieldToUse, - checkboxFieldPath: checkboxFieldPathFromProps, path, readOnly: readOnlyFromProps, }) => { const { label } = field - const checkboxFieldPath = path?.includes('.') - ? `${path}.${checkboxFieldPathFromProps}` - : checkboxFieldPathFromProps - const { value, setValue } = useField({ path: path || field.name }) - const { dispatchFields } = useForm() + const { getDataByPath } = useForm() - // The value of the checkbox - // We're using separate useFormFields to minimise re-renders - const checkboxValue = useFormFields(([fields]) => { - return fields[checkboxFieldPath]?.value as string - }) + const [isLocked, setIsLocked] = useState(true) - // The value of the field we're listening to for the slug - const targetFieldValue = useFormFields(([fields]) => { - return fields[fieldToUse]?.value as string - }) + const handleGenerate = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + + const targetFieldValue = getDataByPath(fieldToUse) as string - useEffect(() => { - if (checkboxValue) { if (targetFieldValue) { - const formattedSlug = formatSlug(targetFieldValue) + const formattedSlug = slugify(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], + [setValue, value, fieldToUse, getDataByPath], ) - const readOnly = readOnlyFromProps || checkboxValue + const toggleLock = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsLocked((prev) => !prev) + }, []) return (
- - + )} +
-
) diff --git a/templates/ecommerce/src/fields/slug/countVersions.ts b/templates/ecommerce/src/fields/slug/countVersions.ts new file mode 100644 index 00000000000..95db9335740 --- /dev/null +++ b/templates/ecommerce/src/fields/slug/countVersions.ts @@ -0,0 +1,40 @@ +import { FieldHook } from 'payload' + +/** + * This is a cross-entity way to count the number of versions for any given document. + * It will work for both collections and globals. + * @returns number of versions + */ +export const countVersions = async (args: Parameters[0]): Promise => { + const { collection, req, global, originalDoc } = args + + let countFn + + let where = { + parent: { + equals: originalDoc?.id, + }, + } + + if (collection) { + countFn = () => + req.payload.countVersions({ + collection: collection.slug, + where, + depth: 0, + }) + } + + if (global) { + countFn = () => + req.payload.countGlobalVersions({ + global: global.slug, + where, + depth: 0, + }) + } + + const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 + console.log('counted', res) + return res +} diff --git a/templates/ecommerce/src/fields/slug/formatSlug.ts b/templates/ecommerce/src/fields/slug/formatSlug.ts deleted file mode 100644 index 07d1b69d39d..00000000000 --- a/templates/ecommerce/src/fields/slug/formatSlug.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { FieldHook } from 'payload' - -export const formatSlug = (val: string): string | undefined => - val - ?.replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - -/** - * This is a `BeforeValidate` field hook. - * It will auto-generate the slug from a specific fallback field, if necessary. - * For example, slugifying the title field "My First Post" to "my-first-post". - * - * We need to ensure the slug continues to auto-generate through the autosave's initial create. - * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. - * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. - * - * So we only autogenerate the slug if: - * 1. Autosave is not enabled and we're creating a new doc or there is no slug yet - * 2. Autosave is enabled and we're publishing a doc, where we now have 3 versions: - * - The initial create - * - The draft used for autosaves - * - The published version - */ -export const formatSlugHook = - (fallback: string): FieldHook => - (args) => { - const { data, operation, value, collection, global } = args - - if (typeof value === 'string') { - return formatSlug(value) - } - - const autosaveEnabled = Boolean( - (typeof collection?.versions?.drafts === 'object' && collection?.versions?.drafts.autosave) || - (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), - ) - - let autoGenerateSlug = false - - if (!autosaveEnabled && (operation === 'create' || data?.slug === undefined)) { - autoGenerateSlug = true - } else if (autosaveEnabled && operation === 'update') { - if (data?._status === 'published') { - autoGenerateSlug = true - } - } - - if (autoGenerateSlug) { - const fallbackData = data?.[fallback] - - if (typeof fallbackData === 'string') { - return formatSlug(fallbackData) - } - } - - return value - } diff --git a/templates/ecommerce/src/fields/slug/generateSlug.ts b/templates/ecommerce/src/fields/slug/generateSlug.ts new file mode 100644 index 00000000000..4cec3b5b1fb --- /dev/null +++ b/templates/ecommerce/src/fields/slug/generateSlug.ts @@ -0,0 +1,75 @@ +import type { FieldHook } from 'payload' +import { countVersions } from './countVersions' +import { slugify } from './slugify' + +/** + * This is a `BeforeChange` field hook that generates the `slug` field based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * This should only run on: + * 1. the `create` operation unless the user has provided on themselves + * 2. the `update` operation if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled and there are only 2 versions: the initial create and the autosaved draft + * i. UNLESS the user has modified the slug directly, in this case we want them to take it over without it being overridden + */ +export const generateSlug = + (fallback: string): FieldHook => + async (args) => { + const { operation, value: isChecked, collection, global, data, originalDoc } = args + + // Ensure user-defined slugs are not overwritten during create + // Use a generic falsy check here so that empty strings are still generated + if (operation === 'create') { + return Boolean(!data?.slug) + } + + if (operation === 'update') { + console.log({ data, originalDoc }) + // Early return to avoid additional processing + if (!isChecked) { + return false + } + + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && + collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + if (!autosaveEnabled) { + // Generate the slug here + if (data) { + data.slug = slugify(data?.[fallback]) + } + + return Boolean(!data?.slug) + } else { + // If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2) + const isPublishing = data?._status === 'published' + + // Ensure the user can take over the generated slug themselves without it ever being overridden back + const hasChangedSlugManually = data?.slug !== originalDoc?.slug + + if (!hasChangedSlugManually) { + if (data) { + data.slug = slugify(data?.[fallback]) + } + } + + if (isPublishing || hasChangedSlugManually) { + return false + } + + // Important: ensure `countVersions` is not called unnecessarily often + // That is why this is buried beneath all these conditions + const versionCount = await countVersions(args) + + if (versionCount <= 2) { + return true + } else { + return false + } + } + } + } diff --git a/templates/ecommerce/src/fields/slug/index.scss b/templates/ecommerce/src/fields/slug/index.scss index e3dd2d8369b..514af3d225a 100644 --- a/templates/ecommerce/src/fields/slug/index.scss +++ b/templates/ecommerce/src/fields/slug/index.scss @@ -3,6 +3,7 @@ display: flex; justify-content: space-between; align-items: center; + gap: calc(var(--base) / 2); } .lock-button { diff --git a/templates/ecommerce/src/fields/slug/index.ts b/templates/ecommerce/src/fields/slug/index.ts index 77feae0e315..2ba425309fb 100644 --- a/templates/ecommerce/src/fields/slug/index.ts +++ b/templates/ecommerce/src/fields/slug/index.ts @@ -1,53 +1,53 @@ import type { CheckboxField, TextField } from 'payload' -import { formatSlugHook } from './formatSlug' +import { generateSlug } from './generateSlug' type Overrides = { slugOverrides?: Partial - checkboxOverrides?: Partial + generateSlugOverrides?: Partial } type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] +// @ts-expect-error - ts mismatch Partial with TextField export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { - const { slugOverrides, checkboxOverrides } = overrides + const { slugOverrides, generateSlugOverrides } = overrides - const checkBoxField: CheckboxField = { - name: 'slugLock', - type: 'checkbox', - defaultValue: true, - admin: { - hidden: true, - position: 'sidebar', - }, - ...checkboxOverrides, - } - - // @ts-expect-error - ts mismatch Partial with TextField - const slugField: TextField = { - name: 'slug', - type: 'text', - index: true, - label: 'Slug', - ...(slugOverrides || {}), - hooks: { - // Kept this in for hook or API based updates - beforeValidate: [formatSlugHook(fieldToUse)], + return [ + { + name: 'generateSlug', + type: 'checkbox', + label: 'Auto-generate slug', + defaultValue: true, + admin: { + description: + 'When enabled, the slug will auto-generate from the title field on save and autosave.', + position: 'sidebar', + // hidden: true, + }, + hooks: { + beforeChange: [generateSlug(fieldToUse)], + }, + ...(generateSlugOverrides || {}), }, - admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), - components: { - Field: { - path: '@/fields/slug/SlugComponent#SlugComponent', - clientProps: { - fieldToUse, - checkboxFieldPath: checkBoxField.name, + { + name: 'slug', + type: 'text', + index: true, + label: 'Slug', + ...(slugOverrides || {}), + admin: { + position: 'sidebar', + ...(slugOverrides?.admin || {}), + components: { + Field: { + path: '@/fields/slug/SlugComponent#SlugComponent', + clientProps: { + fieldToUse, + }, }, }, }, }, - } - - return [slugField, checkBoxField] + ] } diff --git a/templates/ecommerce/src/fields/slug/slugify.ts b/templates/ecommerce/src/fields/slug/slugify.ts new file mode 100644 index 00000000000..4c574deef91 --- /dev/null +++ b/templates/ecommerce/src/fields/slug/slugify.ts @@ -0,0 +1,5 @@ +export const slugify = (val: string): string | undefined => + val + ?.replace(/ /g, '-') + .replace(/[^\w-]+/g, '') + .toLowerCase() diff --git a/templates/website/src/fields/slug/SlugComponent.tsx b/templates/website/src/fields/slug/SlugComponent.tsx index f6e56007d38..40f26571c96 100644 --- a/templates/website/src/fields/slug/SlugComponent.tsx +++ b/templates/website/src/fields/slug/SlugComponent.tsx @@ -4,7 +4,7 @@ import { TextFieldClientProps } from 'payload' import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' -import { formatSlug } from './formatSlug' +import { slugify } from './slugify' import './index.scss' type SlugComponentProps = { @@ -32,7 +32,7 @@ export const SlugComponent: React.FC = ({ const targetFieldValue = getDataByPath(fieldToUse) as string if (targetFieldValue) { - const formattedSlug = formatSlug(targetFieldValue) + const formattedSlug = slugify(targetFieldValue) if (value !== formattedSlug) setValue(formattedSlug) } else { diff --git a/templates/website/src/fields/slug/countVersions.ts b/templates/website/src/fields/slug/countVersions.ts index c3775176fa1..95db9335740 100644 --- a/templates/website/src/fields/slug/countVersions.ts +++ b/templates/website/src/fields/slug/countVersions.ts @@ -11,7 +11,7 @@ export const countVersions = async (args: Parameters[0]): Promise[0]): Promise res.totalDocs || 0)) || 0 : 0 + const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 + console.log('counted', res) + return res } diff --git a/templates/website/src/fields/slug/formatSlug.ts b/templates/website/src/fields/slug/formatSlug.ts deleted file mode 100644 index a17e1c215b0..00000000000 --- a/templates/website/src/fields/slug/formatSlug.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { FieldHook } from 'payload' -import { countVersions } from './countVersions' - -export const formatSlug = (val: string): string | undefined => - val - ?.replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - -/** - * This is a `BeforeValidate` field hook. - * It will auto-generate the slug from a specific fallback field, if necessary. - * For example, generating a slug from the title field: "My First Post" to "my-first-post". - * - * For autosave, We need to ensure the slug continues to auto-generate through the initial create. - * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. - * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. - * - * So we only autogenerate the slug if: - * 1. Autosave is not enabled and we're creating a new doc or there is no slug yet - * 2. Autosave is enabled and we're publishing a doc, where we now have 3 versions: - * - The initial create - * - The draft used for autosaves - * - The published version - */ -export const formatSlugHook = - (fallback: string): FieldHook => - async (args) => { - const { data, operation, value, collection, global } = args - - let toReturn = value - - // during create, only generate the slug if the user has not provided one - if (data?.generateSlug && (operation === 'update' || (operation === 'create' && !value))) { - toReturn = data?.[fallback] - } - - const autosaveEnabled = Boolean( - (typeof collection?.versions?.drafts === 'object' && collection?.versions?.drafts.autosave) || - (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), - ) - - // Important: ensure `countVersions` is not called unnecessarily often - // To do this, early return if `generateSlug` is already false - const shouldContinueGenerating = Boolean( - data?.generateSlug && - operation === 'update' && - (!autosaveEnabled || (await countVersions(args)) < 3), - ) - - if (!shouldContinueGenerating) { - if (data) { - data.generateSlug = false - } - } - - return formatSlug(toReturn) - } diff --git a/templates/website/src/fields/slug/generateSlug.ts b/templates/website/src/fields/slug/generateSlug.ts new file mode 100644 index 00000000000..4cec3b5b1fb --- /dev/null +++ b/templates/website/src/fields/slug/generateSlug.ts @@ -0,0 +1,75 @@ +import type { FieldHook } from 'payload' +import { countVersions } from './countVersions' +import { slugify } from './slugify' + +/** + * This is a `BeforeChange` field hook that generates the `slug` field based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * This should only run on: + * 1. the `create` operation unless the user has provided on themselves + * 2. the `update` operation if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled and there are only 2 versions: the initial create and the autosaved draft + * i. UNLESS the user has modified the slug directly, in this case we want them to take it over without it being overridden + */ +export const generateSlug = + (fallback: string): FieldHook => + async (args) => { + const { operation, value: isChecked, collection, global, data, originalDoc } = args + + // Ensure user-defined slugs are not overwritten during create + // Use a generic falsy check here so that empty strings are still generated + if (operation === 'create') { + return Boolean(!data?.slug) + } + + if (operation === 'update') { + console.log({ data, originalDoc }) + // Early return to avoid additional processing + if (!isChecked) { + return false + } + + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && + collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + if (!autosaveEnabled) { + // Generate the slug here + if (data) { + data.slug = slugify(data?.[fallback]) + } + + return Boolean(!data?.slug) + } else { + // If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2) + const isPublishing = data?._status === 'published' + + // Ensure the user can take over the generated slug themselves without it ever being overridden back + const hasChangedSlugManually = data?.slug !== originalDoc?.slug + + if (!hasChangedSlugManually) { + if (data) { + data.slug = slugify(data?.[fallback]) + } + } + + if (isPublishing || hasChangedSlugManually) { + return false + } + + // Important: ensure `countVersions` is not called unnecessarily often + // That is why this is buried beneath all these conditions + const versionCount = await countVersions(args) + + if (versionCount <= 2) { + return true + } else { + return false + } + } + } + } diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts index ab26f06c1bb..2ba425309fb 100644 --- a/templates/website/src/fields/slug/index.ts +++ b/templates/website/src/fields/slug/index.ts @@ -1,6 +1,6 @@ import type { CheckboxField, TextField } from 'payload' -import { formatSlugHook } from './formatSlug' +import { generateSlug } from './generateSlug' type Overrides = { slugOverrides?: Partial @@ -9,46 +9,45 @@ type Overrides = { type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] +// @ts-expect-error - ts mismatch Partial with TextField export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { const { slugOverrides, generateSlugOverrides } = overrides - const generateSlugField: CheckboxField = { - name: 'generateSlug', - type: 'checkbox', - label: 'Auto-generate slug', - defaultValue: true, - admin: { - description: - 'When enabled, the slug will auto-generate from the title field on save and autosave.', - position: 'sidebar', - }, - ...(generateSlugOverrides || {}), - } - - // @ts-expect-error - ts mismatch Partial with TextField - const slugField: TextField = { - name: 'slug', - type: 'text', - index: true, - label: 'Slug', - ...(slugOverrides || {}), - hooks: { - // Kept this in for hook or API based updates - beforeValidate: [formatSlugHook(fieldToUse)], + return [ + { + name: 'generateSlug', + type: 'checkbox', + label: 'Auto-generate slug', + defaultValue: true, + admin: { + description: + 'When enabled, the slug will auto-generate from the title field on save and autosave.', + position: 'sidebar', + // hidden: true, + }, + hooks: { + beforeChange: [generateSlug(fieldToUse)], + }, + ...(generateSlugOverrides || {}), }, - admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), - components: { - Field: { - path: '@/fields/slug/SlugComponent#SlugComponent', - clientProps: { - fieldToUse, + { + name: 'slug', + type: 'text', + index: true, + label: 'Slug', + ...(slugOverrides || {}), + admin: { + position: 'sidebar', + ...(slugOverrides?.admin || {}), + components: { + Field: { + path: '@/fields/slug/SlugComponent#SlugComponent', + clientProps: { + fieldToUse, + }, }, }, }, }, - } - - return [slugField, generateSlugField] + ] } diff --git a/templates/website/src/fields/slug/slugify.ts b/templates/website/src/fields/slug/slugify.ts new file mode 100644 index 00000000000..4c574deef91 --- /dev/null +++ b/templates/website/src/fields/slug/slugify.ts @@ -0,0 +1,5 @@ +export const slugify = (val: string): string | undefined => + val + ?.replace(/ /g, '-') + .replace(/[^\w-]+/g, '') + .toLowerCase() diff --git a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx b/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx index 8114973e16f..40f26571c96 100644 --- a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx +++ b/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx @@ -1,37 +1,29 @@ 'use client' -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' import { TextFieldClientProps } from 'payload' import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' -import { formatSlug } from './formatSlug' +import { slugify } from './slugify' import './index.scss' type SlugComponentProps = { fieldToUse: string - checkboxFieldPath: string } & TextFieldClientProps export const SlugComponent: React.FC = ({ field, fieldToUse, - checkboxFieldPath: checkboxFieldPathFromProps, path, readOnly: readOnlyFromProps, }) => { const { label } = field - const checkboxFieldPath = path?.includes('.') - ? `${path}.${checkboxFieldPathFromProps}` - : checkboxFieldPathFromProps - const { value, setValue } = useField({ path: path || field.name }) - const { dispatchFields, getDataByPath } = useForm() + const { getDataByPath } = useForm() - const isLocked = useFormFields(([fields]) => { - return fields[checkboxFieldPath]?.value as string - }) + const [isLocked, setIsLocked] = useState(true) const handleGenerate = useCallback( (e: React.MouseEvent) => { @@ -40,7 +32,7 @@ export const SlugComponent: React.FC = ({ const targetFieldValue = getDataByPath(fieldToUse) as string if (targetFieldValue) { - const formattedSlug = formatSlug(targetFieldValue) + const formattedSlug = slugify(targetFieldValue) if (value !== formattedSlug) setValue(formattedSlug) } else { @@ -50,18 +42,10 @@ export const SlugComponent: React.FC = ({ [setValue, value, fieldToUse, getDataByPath], ) - const handleLock = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - - dispatchFields({ - type: 'UPDATE', - path: checkboxFieldPath, - value: !isLocked, - }) - }, - [isLocked, checkboxFieldPath, dispatchFields], - ) + const toggleLock = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsLocked((prev) => !prev) + }, []) return (
@@ -72,7 +56,7 @@ export const SlugComponent: React.FC = ({ Generate )} -
diff --git a/templates/with-vercel-website/src/fields/slug/countVersions.ts b/templates/with-vercel-website/src/fields/slug/countVersions.ts new file mode 100644 index 00000000000..95db9335740 --- /dev/null +++ b/templates/with-vercel-website/src/fields/slug/countVersions.ts @@ -0,0 +1,40 @@ +import { FieldHook } from 'payload' + +/** + * This is a cross-entity way to count the number of versions for any given document. + * It will work for both collections and globals. + * @returns number of versions + */ +export const countVersions = async (args: Parameters[0]): Promise => { + const { collection, req, global, originalDoc } = args + + let countFn + + let where = { + parent: { + equals: originalDoc?.id, + }, + } + + if (collection) { + countFn = () => + req.payload.countVersions({ + collection: collection.slug, + where, + depth: 0, + }) + } + + if (global) { + countFn = () => + req.payload.countGlobalVersions({ + global: global.slug, + where, + depth: 0, + }) + } + + const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 + console.log('counted', res) + return res +} diff --git a/templates/with-vercel-website/src/fields/slug/formatSlug.ts b/templates/with-vercel-website/src/fields/slug/formatSlug.ts deleted file mode 100644 index 07d1b69d39d..00000000000 --- a/templates/with-vercel-website/src/fields/slug/formatSlug.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { FieldHook } from 'payload' - -export const formatSlug = (val: string): string | undefined => - val - ?.replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - -/** - * This is a `BeforeValidate` field hook. - * It will auto-generate the slug from a specific fallback field, if necessary. - * For example, slugifying the title field "My First Post" to "my-first-post". - * - * We need to ensure the slug continues to auto-generate through the autosave's initial create. - * This will ensure the user can continue to edit the fallback field without the slug being prematurely generated. - * For example, after creating a new autosave post, then editing the title, we want the slug to continue to update. - * - * So we only autogenerate the slug if: - * 1. Autosave is not enabled and we're creating a new doc or there is no slug yet - * 2. Autosave is enabled and we're publishing a doc, where we now have 3 versions: - * - The initial create - * - The draft used for autosaves - * - The published version - */ -export const formatSlugHook = - (fallback: string): FieldHook => - (args) => { - const { data, operation, value, collection, global } = args - - if (typeof value === 'string') { - return formatSlug(value) - } - - const autosaveEnabled = Boolean( - (typeof collection?.versions?.drafts === 'object' && collection?.versions?.drafts.autosave) || - (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), - ) - - let autoGenerateSlug = false - - if (!autosaveEnabled && (operation === 'create' || data?.slug === undefined)) { - autoGenerateSlug = true - } else if (autosaveEnabled && operation === 'update') { - if (data?._status === 'published') { - autoGenerateSlug = true - } - } - - if (autoGenerateSlug) { - const fallbackData = data?.[fallback] - - if (typeof fallbackData === 'string') { - return formatSlug(fallbackData) - } - } - - return value - } diff --git a/templates/with-vercel-website/src/fields/slug/generateSlug.ts b/templates/with-vercel-website/src/fields/slug/generateSlug.ts new file mode 100644 index 00000000000..4cec3b5b1fb --- /dev/null +++ b/templates/with-vercel-website/src/fields/slug/generateSlug.ts @@ -0,0 +1,75 @@ +import type { FieldHook } from 'payload' +import { countVersions } from './countVersions' +import { slugify } from './slugify' + +/** + * This is a `BeforeChange` field hook that generates the `slug` field based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * This should only run on: + * 1. the `create` operation unless the user has provided on themselves + * 2. the `update` operation if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled and there are only 2 versions: the initial create and the autosaved draft + * i. UNLESS the user has modified the slug directly, in this case we want them to take it over without it being overridden + */ +export const generateSlug = + (fallback: string): FieldHook => + async (args) => { + const { operation, value: isChecked, collection, global, data, originalDoc } = args + + // Ensure user-defined slugs are not overwritten during create + // Use a generic falsy check here so that empty strings are still generated + if (operation === 'create') { + return Boolean(!data?.slug) + } + + if (operation === 'update') { + console.log({ data, originalDoc }) + // Early return to avoid additional processing + if (!isChecked) { + return false + } + + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && + collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + if (!autosaveEnabled) { + // Generate the slug here + if (data) { + data.slug = slugify(data?.[fallback]) + } + + return Boolean(!data?.slug) + } else { + // If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2) + const isPublishing = data?._status === 'published' + + // Ensure the user can take over the generated slug themselves without it ever being overridden back + const hasChangedSlugManually = data?.slug !== originalDoc?.slug + + if (!hasChangedSlugManually) { + if (data) { + data.slug = slugify(data?.[fallback]) + } + } + + if (isPublishing || hasChangedSlugManually) { + return false + } + + // Important: ensure `countVersions` is not called unnecessarily often + // That is why this is buried beneath all these conditions + const versionCount = await countVersions(args) + + if (versionCount <= 2) { + return true + } else { + return false + } + } + } + } diff --git a/templates/with-vercel-website/src/fields/slug/index.ts b/templates/with-vercel-website/src/fields/slug/index.ts index 77feae0e315..2ba425309fb 100644 --- a/templates/with-vercel-website/src/fields/slug/index.ts +++ b/templates/with-vercel-website/src/fields/slug/index.ts @@ -1,53 +1,53 @@ import type { CheckboxField, TextField } from 'payload' -import { formatSlugHook } from './formatSlug' +import { generateSlug } from './generateSlug' type Overrides = { slugOverrides?: Partial - checkboxOverrides?: Partial + generateSlugOverrides?: Partial } type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] +// @ts-expect-error - ts mismatch Partial with TextField export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { - const { slugOverrides, checkboxOverrides } = overrides + const { slugOverrides, generateSlugOverrides } = overrides - const checkBoxField: CheckboxField = { - name: 'slugLock', - type: 'checkbox', - defaultValue: true, - admin: { - hidden: true, - position: 'sidebar', - }, - ...checkboxOverrides, - } - - // @ts-expect-error - ts mismatch Partial with TextField - const slugField: TextField = { - name: 'slug', - type: 'text', - index: true, - label: 'Slug', - ...(slugOverrides || {}), - hooks: { - // Kept this in for hook or API based updates - beforeValidate: [formatSlugHook(fieldToUse)], + return [ + { + name: 'generateSlug', + type: 'checkbox', + label: 'Auto-generate slug', + defaultValue: true, + admin: { + description: + 'When enabled, the slug will auto-generate from the title field on save and autosave.', + position: 'sidebar', + // hidden: true, + }, + hooks: { + beforeChange: [generateSlug(fieldToUse)], + }, + ...(generateSlugOverrides || {}), }, - admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), - components: { - Field: { - path: '@/fields/slug/SlugComponent#SlugComponent', - clientProps: { - fieldToUse, - checkboxFieldPath: checkBoxField.name, + { + name: 'slug', + type: 'text', + index: true, + label: 'Slug', + ...(slugOverrides || {}), + admin: { + position: 'sidebar', + ...(slugOverrides?.admin || {}), + components: { + Field: { + path: '@/fields/slug/SlugComponent#SlugComponent', + clientProps: { + fieldToUse, + }, }, }, }, }, - } - - return [slugField, checkBoxField] + ] } diff --git a/templates/with-vercel-website/src/fields/slug/slugify.ts b/templates/with-vercel-website/src/fields/slug/slugify.ts new file mode 100644 index 00000000000..4c574deef91 --- /dev/null +++ b/templates/with-vercel-website/src/fields/slug/slugify.ts @@ -0,0 +1,5 @@ +export const slugify = (val: string): string | undefined => + val + ?.replace(/ /g, '-') + .replace(/[^\w-]+/g, '') + .toLowerCase() From 11e4fcac695773d185c2f839ac259dbfef4fbbd3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 14:57:44 -0400 Subject: [PATCH 05/25] replace slugify with payload util --- templates/ecommerce/src/fields/slug/SlugComponent.tsx | 8 ++++---- templates/ecommerce/src/fields/slug/generateSlug.ts | 6 +++--- templates/ecommerce/src/fields/slug/slugify.ts | 5 ----- templates/website/src/fields/slug/SlugComponent.tsx | 8 ++++---- templates/website/src/fields/slug/generateSlug.ts | 6 +++--- templates/website/src/fields/slug/slugify.ts | 5 ----- .../with-vercel-website/src/fields/slug/SlugComponent.tsx | 8 ++++---- .../with-vercel-website/src/fields/slug/generateSlug.ts | 6 +++--- templates/with-vercel-website/src/fields/slug/slugify.ts | 5 ----- 9 files changed, 21 insertions(+), 36 deletions(-) delete mode 100644 templates/ecommerce/src/fields/slug/slugify.ts delete mode 100644 templates/website/src/fields/slug/slugify.ts delete mode 100644 templates/with-vercel-website/src/fields/slug/slugify.ts diff --git a/templates/ecommerce/src/fields/slug/SlugComponent.tsx b/templates/ecommerce/src/fields/slug/SlugComponent.tsx index 40f26571c96..28567295872 100644 --- a/templates/ecommerce/src/fields/slug/SlugComponent.tsx +++ b/templates/ecommerce/src/fields/slug/SlugComponent.tsx @@ -1,10 +1,10 @@ 'use client' import React, { useCallback, useState } from 'react' -import { TextFieldClientProps } from 'payload' +import type { TextFieldClientProps } from 'payload' +import { toKebabCase } from 'payload/shared' -import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' +import { useField, Button, TextInput, FieldLabel, useForm } from '@payloadcms/ui' -import { slugify } from './slugify' import './index.scss' type SlugComponentProps = { @@ -32,7 +32,7 @@ export const SlugComponent: React.FC = ({ const targetFieldValue = getDataByPath(fieldToUse) as string if (targetFieldValue) { - const formattedSlug = slugify(targetFieldValue) + const formattedSlug = toKebabCase(targetFieldValue) if (value !== formattedSlug) setValue(formattedSlug) } else { diff --git a/templates/ecommerce/src/fields/slug/generateSlug.ts b/templates/ecommerce/src/fields/slug/generateSlug.ts index 4cec3b5b1fb..b2ebbd10d68 100644 --- a/templates/ecommerce/src/fields/slug/generateSlug.ts +++ b/templates/ecommerce/src/fields/slug/generateSlug.ts @@ -1,6 +1,6 @@ import type { FieldHook } from 'payload' import { countVersions } from './countVersions' -import { slugify } from './slugify' +import { toKebabCase } from 'payload/shared' /** * This is a `BeforeChange` field hook that generates the `slug` field based on another field. @@ -40,7 +40,7 @@ export const generateSlug = if (!autosaveEnabled) { // Generate the slug here if (data) { - data.slug = slugify(data?.[fallback]) + data.slug = toKebabCase(data?.[fallback]) } return Boolean(!data?.slug) @@ -53,7 +53,7 @@ export const generateSlug = if (!hasChangedSlugManually) { if (data) { - data.slug = slugify(data?.[fallback]) + data.slug = toKebabCase(data?.[fallback]) } } diff --git a/templates/ecommerce/src/fields/slug/slugify.ts b/templates/ecommerce/src/fields/slug/slugify.ts deleted file mode 100644 index 4c574deef91..00000000000 --- a/templates/ecommerce/src/fields/slug/slugify.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const slugify = (val: string): string | undefined => - val - ?.replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() diff --git a/templates/website/src/fields/slug/SlugComponent.tsx b/templates/website/src/fields/slug/SlugComponent.tsx index 40f26571c96..28567295872 100644 --- a/templates/website/src/fields/slug/SlugComponent.tsx +++ b/templates/website/src/fields/slug/SlugComponent.tsx @@ -1,10 +1,10 @@ 'use client' import React, { useCallback, useState } from 'react' -import { TextFieldClientProps } from 'payload' +import type { TextFieldClientProps } from 'payload' +import { toKebabCase } from 'payload/shared' -import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' +import { useField, Button, TextInput, FieldLabel, useForm } from '@payloadcms/ui' -import { slugify } from './slugify' import './index.scss' type SlugComponentProps = { @@ -32,7 +32,7 @@ export const SlugComponent: React.FC = ({ const targetFieldValue = getDataByPath(fieldToUse) as string if (targetFieldValue) { - const formattedSlug = slugify(targetFieldValue) + const formattedSlug = toKebabCase(targetFieldValue) if (value !== formattedSlug) setValue(formattedSlug) } else { diff --git a/templates/website/src/fields/slug/generateSlug.ts b/templates/website/src/fields/slug/generateSlug.ts index 4cec3b5b1fb..b2ebbd10d68 100644 --- a/templates/website/src/fields/slug/generateSlug.ts +++ b/templates/website/src/fields/slug/generateSlug.ts @@ -1,6 +1,6 @@ import type { FieldHook } from 'payload' import { countVersions } from './countVersions' -import { slugify } from './slugify' +import { toKebabCase } from 'payload/shared' /** * This is a `BeforeChange` field hook that generates the `slug` field based on another field. @@ -40,7 +40,7 @@ export const generateSlug = if (!autosaveEnabled) { // Generate the slug here if (data) { - data.slug = slugify(data?.[fallback]) + data.slug = toKebabCase(data?.[fallback]) } return Boolean(!data?.slug) @@ -53,7 +53,7 @@ export const generateSlug = if (!hasChangedSlugManually) { if (data) { - data.slug = slugify(data?.[fallback]) + data.slug = toKebabCase(data?.[fallback]) } } diff --git a/templates/website/src/fields/slug/slugify.ts b/templates/website/src/fields/slug/slugify.ts deleted file mode 100644 index 4c574deef91..00000000000 --- a/templates/website/src/fields/slug/slugify.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const slugify = (val: string): string | undefined => - val - ?.replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() diff --git a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx b/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx index 40f26571c96..28567295872 100644 --- a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx +++ b/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx @@ -1,10 +1,10 @@ 'use client' import React, { useCallback, useState } from 'react' -import { TextFieldClientProps } from 'payload' +import type { TextFieldClientProps } from 'payload' +import { toKebabCase } from 'payload/shared' -import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui' +import { useField, Button, TextInput, FieldLabel, useForm } from '@payloadcms/ui' -import { slugify } from './slugify' import './index.scss' type SlugComponentProps = { @@ -32,7 +32,7 @@ export const SlugComponent: React.FC = ({ const targetFieldValue = getDataByPath(fieldToUse) as string if (targetFieldValue) { - const formattedSlug = slugify(targetFieldValue) + const formattedSlug = toKebabCase(targetFieldValue) if (value !== formattedSlug) setValue(formattedSlug) } else { diff --git a/templates/with-vercel-website/src/fields/slug/generateSlug.ts b/templates/with-vercel-website/src/fields/slug/generateSlug.ts index 4cec3b5b1fb..b2ebbd10d68 100644 --- a/templates/with-vercel-website/src/fields/slug/generateSlug.ts +++ b/templates/with-vercel-website/src/fields/slug/generateSlug.ts @@ -1,6 +1,6 @@ import type { FieldHook } from 'payload' import { countVersions } from './countVersions' -import { slugify } from './slugify' +import { toKebabCase } from 'payload/shared' /** * This is a `BeforeChange` field hook that generates the `slug` field based on another field. @@ -40,7 +40,7 @@ export const generateSlug = if (!autosaveEnabled) { // Generate the slug here if (data) { - data.slug = slugify(data?.[fallback]) + data.slug = toKebabCase(data?.[fallback]) } return Boolean(!data?.slug) @@ -53,7 +53,7 @@ export const generateSlug = if (!hasChangedSlugManually) { if (data) { - data.slug = slugify(data?.[fallback]) + data.slug = toKebabCase(data?.[fallback]) } } diff --git a/templates/with-vercel-website/src/fields/slug/slugify.ts b/templates/with-vercel-website/src/fields/slug/slugify.ts deleted file mode 100644 index 4c574deef91..00000000000 --- a/templates/with-vercel-website/src/fields/slug/slugify.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const slugify = (val: string): string | undefined => - val - ?.replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() From 53439fd0e296a58082461e110445f5e6617686ef Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 15:01:12 -0400 Subject: [PATCH 06/25] more cleanup --- templates/ecommerce/src/fields/slug/countVersions.ts | 6 +++--- templates/website/src/fields/slug/countVersions.ts | 6 +++--- templates/website/src/fields/slug/index.ts | 3 +-- .../with-vercel-website/src/fields/slug/countVersions.ts | 6 +++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/templates/ecommerce/src/fields/slug/countVersions.ts b/templates/ecommerce/src/fields/slug/countVersions.ts index 95db9335740..a8b533eedab 100644 --- a/templates/ecommerce/src/fields/slug/countVersions.ts +++ b/templates/ecommerce/src/fields/slug/countVersions.ts @@ -1,4 +1,4 @@ -import { FieldHook } from 'payload' +import { FieldHook, Where } from 'payload' /** * This is a cross-entity way to count the number of versions for any given document. @@ -10,7 +10,7 @@ export const countVersions = async (args: Parameters[0]): Promise[0]): Promise res.totalDocs || 0)) || 0 : 0 - console.log('counted', res) + return res } diff --git a/templates/website/src/fields/slug/countVersions.ts b/templates/website/src/fields/slug/countVersions.ts index 95db9335740..a8b533eedab 100644 --- a/templates/website/src/fields/slug/countVersions.ts +++ b/templates/website/src/fields/slug/countVersions.ts @@ -1,4 +1,4 @@ -import { FieldHook } from 'payload' +import { FieldHook, Where } from 'payload' /** * This is a cross-entity way to count the number of versions for any given document. @@ -10,7 +10,7 @@ export const countVersions = async (args: Parameters[0]): Promise[0]): Promise res.totalDocs || 0)) || 0 : 0 - console.log('counted', res) + return res } diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts index 2ba425309fb..0d24e4fe380 100644 --- a/templates/website/src/fields/slug/index.ts +++ b/templates/website/src/fields/slug/index.ts @@ -17,11 +17,10 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { { name: 'generateSlug', type: 'checkbox', - label: 'Auto-generate slug', defaultValue: true, admin: { description: - 'When enabled, the slug will auto-generate from the title field on save and autosave.', + 'When enabled, the slug will auto-generate from the title field on create and other criteria.', position: 'sidebar', // hidden: true, }, diff --git a/templates/with-vercel-website/src/fields/slug/countVersions.ts b/templates/with-vercel-website/src/fields/slug/countVersions.ts index 95db9335740..a8b533eedab 100644 --- a/templates/with-vercel-website/src/fields/slug/countVersions.ts +++ b/templates/with-vercel-website/src/fields/slug/countVersions.ts @@ -1,4 +1,4 @@ -import { FieldHook } from 'payload' +import { FieldHook, Where } from 'payload' /** * This is a cross-entity way to count the number of versions for any given document. @@ -10,7 +10,7 @@ export const countVersions = async (args: Parameters[0]): Promise[0]): Promise res.totalDocs || 0)) || 0 : 0 - console.log('counted', res) + return res } From 702cf701dda5bf0ad0d369343cb10ebedfdc8def Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 15:11:00 -0400 Subject: [PATCH 07/25] hide gen field --- templates/ecommerce/src/fields/slug/index.ts | 2 +- templates/website/src/fields/slug/index.ts | 2 +- templates/with-vercel-website/src/fields/slug/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/ecommerce/src/fields/slug/index.ts b/templates/ecommerce/src/fields/slug/index.ts index 2ba425309fb..526cc388b2f 100644 --- a/templates/ecommerce/src/fields/slug/index.ts +++ b/templates/ecommerce/src/fields/slug/index.ts @@ -23,7 +23,7 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { description: 'When enabled, the slug will auto-generate from the title field on save and autosave.', position: 'sidebar', - // hidden: true, + hidden: true, }, hooks: { beforeChange: [generateSlug(fieldToUse)], diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts index 0d24e4fe380..b1c0d457c55 100644 --- a/templates/website/src/fields/slug/index.ts +++ b/templates/website/src/fields/slug/index.ts @@ -22,7 +22,7 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { description: 'When enabled, the slug will auto-generate from the title field on create and other criteria.', position: 'sidebar', - // hidden: true, + hidden: true, }, hooks: { beforeChange: [generateSlug(fieldToUse)], diff --git a/templates/with-vercel-website/src/fields/slug/index.ts b/templates/with-vercel-website/src/fields/slug/index.ts index 2ba425309fb..526cc388b2f 100644 --- a/templates/with-vercel-website/src/fields/slug/index.ts +++ b/templates/with-vercel-website/src/fields/slug/index.ts @@ -23,7 +23,7 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { description: 'When enabled, the slug will auto-generate from the title field on save and autosave.', position: 'sidebar', - // hidden: true, + hidden: true, }, hooks: { beforeChange: [generateSlug(fieldToUse)], From 7f636a841497acb602fcbc5d35e48693f8288e22 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 15:23:45 -0400 Subject: [PATCH 08/25] proof jsdocs --- templates/ecommerce/src/fields/slug/generateSlug.ts | 3 +-- templates/website/src/fields/slug/generateSlug.ts | 3 +-- templates/with-vercel-website/src/fields/slug/generateSlug.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/templates/ecommerce/src/fields/slug/generateSlug.ts b/templates/ecommerce/src/fields/slug/generateSlug.ts index b2ebbd10d68..5f61b0a02f7 100644 --- a/templates/ecommerce/src/fields/slug/generateSlug.ts +++ b/templates/ecommerce/src/fields/slug/generateSlug.ts @@ -10,8 +10,7 @@ import { toKebabCase } from 'payload/shared' * 1. the `create` operation unless the user has provided on themselves * 2. the `update` operation if: * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled and there are only 2 versions: the initial create and the autosaved draft - * i. UNLESS the user has modified the slug directly, in this case we want them to take it over without it being overridden + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves */ export const generateSlug = (fallback: string): FieldHook => diff --git a/templates/website/src/fields/slug/generateSlug.ts b/templates/website/src/fields/slug/generateSlug.ts index b2ebbd10d68..5f61b0a02f7 100644 --- a/templates/website/src/fields/slug/generateSlug.ts +++ b/templates/website/src/fields/slug/generateSlug.ts @@ -10,8 +10,7 @@ import { toKebabCase } from 'payload/shared' * 1. the `create` operation unless the user has provided on themselves * 2. the `update` operation if: * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled and there are only 2 versions: the initial create and the autosaved draft - * i. UNLESS the user has modified the slug directly, in this case we want them to take it over without it being overridden + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves */ export const generateSlug = (fallback: string): FieldHook => diff --git a/templates/with-vercel-website/src/fields/slug/generateSlug.ts b/templates/with-vercel-website/src/fields/slug/generateSlug.ts index b2ebbd10d68..5f61b0a02f7 100644 --- a/templates/with-vercel-website/src/fields/slug/generateSlug.ts +++ b/templates/with-vercel-website/src/fields/slug/generateSlug.ts @@ -10,8 +10,7 @@ import { toKebabCase } from 'payload/shared' * 1. the `create` operation unless the user has provided on themselves * 2. the `update` operation if: * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled and there are only 2 versions: the initial create and the autosaved draft - * i. UNLESS the user has modified the slug directly, in this case we want them to take it over without it being overridden + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves */ export const generateSlug = (fallback: string): FieldHook => From 33be9641d445dd7057dff2d34bf1f6119a4b696a Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 15:38:00 -0400 Subject: [PATCH 09/25] move jsdocs and add clarity --- .../ecommerce/src/fields/slug/generateSlug.ts | 10 ++------ templates/ecommerce/src/fields/slug/index.ts | 20 ++++++++++++---- .../website/src/fields/slug/generateSlug.ts | 10 ++------ templates/website/src/fields/slug/index.ts | 23 +++++++++++++++---- .../src/fields/slug/generateSlug.ts | 10 ++------ .../src/fields/slug/index.ts | 20 ++++++++++++---- 6 files changed, 56 insertions(+), 37 deletions(-) diff --git a/templates/ecommerce/src/fields/slug/generateSlug.ts b/templates/ecommerce/src/fields/slug/generateSlug.ts index 5f61b0a02f7..2abe97219a9 100644 --- a/templates/ecommerce/src/fields/slug/generateSlug.ts +++ b/templates/ecommerce/src/fields/slug/generateSlug.ts @@ -3,14 +3,8 @@ import { countVersions } from './countVersions' import { toKebabCase } from 'payload/shared' /** - * This is a `BeforeChange` field hook that generates the `slug` field based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". - * - * This should only run on: - * 1. the `create` operation unless the user has provided on themselves - * 2. the `update` operation if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * This is a `BeforeChange` field hook for the "slug" field. + * See `slugField` for more details. */ export const generateSlug = (fallback: string): FieldHook => diff --git a/templates/ecommerce/src/fields/slug/index.ts b/templates/ecommerce/src/fields/slug/index.ts index 526cc388b2f..e7617eb4a09 100644 --- a/templates/ecommerce/src/fields/slug/index.ts +++ b/templates/ecommerce/src/fields/slug/index.ts @@ -7,10 +7,22 @@ type Overrides = { generateSlugOverrides?: Partial } -type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] +type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, TextField] -// @ts-expect-error - ts mismatch Partial with TextField -export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { +/** + * The slug field is auto-generated based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * The slug should generated through: + * 1. the `create` operation, unless the user has modified the slug manually + * 2. the `update` operation, if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * + * The slug should _stop_ generating after it's been generated, because the URL is typically derived from the slug. + * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. + */ +export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { const { slugOverrides, generateSlugOverrides } = overrides return [ @@ -35,7 +47,7 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { type: 'text', index: true, label: 'Slug', - ...(slugOverrides || {}), + ...((slugOverrides || {}) as any), admin: { position: 'sidebar', ...(slugOverrides?.admin || {}), diff --git a/templates/website/src/fields/slug/generateSlug.ts b/templates/website/src/fields/slug/generateSlug.ts index 5f61b0a02f7..2abe97219a9 100644 --- a/templates/website/src/fields/slug/generateSlug.ts +++ b/templates/website/src/fields/slug/generateSlug.ts @@ -3,14 +3,8 @@ import { countVersions } from './countVersions' import { toKebabCase } from 'payload/shared' /** - * This is a `BeforeChange` field hook that generates the `slug` field based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". - * - * This should only run on: - * 1. the `create` operation unless the user has provided on themselves - * 2. the `update` operation if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * This is a `BeforeChange` field hook for the "slug" field. + * See `slugField` for more details. */ export const generateSlug = (fallback: string): FieldHook => diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts index b1c0d457c55..e7617eb4a09 100644 --- a/templates/website/src/fields/slug/index.ts +++ b/templates/website/src/fields/slug/index.ts @@ -7,20 +7,33 @@ type Overrides = { generateSlugOverrides?: Partial } -type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] +type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, TextField] -// @ts-expect-error - ts mismatch Partial with TextField -export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { +/** + * The slug field is auto-generated based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * The slug should generated through: + * 1. the `create` operation, unless the user has modified the slug manually + * 2. the `update` operation, if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * + * The slug should _stop_ generating after it's been generated, because the URL is typically derived from the slug. + * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. + */ +export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { const { slugOverrides, generateSlugOverrides } = overrides return [ { name: 'generateSlug', type: 'checkbox', + label: 'Auto-generate slug', defaultValue: true, admin: { description: - 'When enabled, the slug will auto-generate from the title field on create and other criteria.', + 'When enabled, the slug will auto-generate from the title field on save and autosave.', position: 'sidebar', hidden: true, }, @@ -34,7 +47,7 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { type: 'text', index: true, label: 'Slug', - ...(slugOverrides || {}), + ...((slugOverrides || {}) as any), admin: { position: 'sidebar', ...(slugOverrides?.admin || {}), diff --git a/templates/with-vercel-website/src/fields/slug/generateSlug.ts b/templates/with-vercel-website/src/fields/slug/generateSlug.ts index 5f61b0a02f7..2abe97219a9 100644 --- a/templates/with-vercel-website/src/fields/slug/generateSlug.ts +++ b/templates/with-vercel-website/src/fields/slug/generateSlug.ts @@ -3,14 +3,8 @@ import { countVersions } from './countVersions' import { toKebabCase } from 'payload/shared' /** - * This is a `BeforeChange` field hook that generates the `slug` field based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". - * - * This should only run on: - * 1. the `create` operation unless the user has provided on themselves - * 2. the `update` operation if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * This is a `BeforeChange` field hook for the "slug" field. + * See `slugField` for more details. */ export const generateSlug = (fallback: string): FieldHook => diff --git a/templates/with-vercel-website/src/fields/slug/index.ts b/templates/with-vercel-website/src/fields/slug/index.ts index 526cc388b2f..e7617eb4a09 100644 --- a/templates/with-vercel-website/src/fields/slug/index.ts +++ b/templates/with-vercel-website/src/fields/slug/index.ts @@ -7,10 +7,22 @@ type Overrides = { generateSlugOverrides?: Partial } -type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField] +type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, TextField] -// @ts-expect-error - ts mismatch Partial with TextField -export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { +/** + * The slug field is auto-generated based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * The slug should generated through: + * 1. the `create` operation, unless the user has modified the slug manually + * 2. the `update` operation, if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * + * The slug should _stop_ generating after it's been generated, because the URL is typically derived from the slug. + * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. + */ +export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { const { slugOverrides, generateSlugOverrides } = overrides return [ @@ -35,7 +47,7 @@ export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { type: 'text', index: true, label: 'Slug', - ...(slugOverrides || {}), + ...((slugOverrides || {}) as any), admin: { position: 'sidebar', ...(slugOverrides?.admin || {}), From 01903cac7c5a2eed9cb4f0b3b0754111350d8d4f Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 2 Oct 2025 16:47:51 -0400 Subject: [PATCH 10/25] fix for non autosave --- packages/payload/src/utilities/toKebabCase.ts | 4 ++-- .../ecommerce/src/fields/slug/generateSlug.ts | 17 ++++++++++------- templates/ecommerce/src/fields/slug/index.ts | 2 +- .../website/src/fields/slug/generateSlug.ts | 17 ++++++++++------- templates/website/src/fields/slug/index.ts | 2 +- .../src/fields/slug/generateSlug.ts | 17 ++++++++++------- .../src/fields/slug/index.ts | 2 +- 7 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/payload/src/utilities/toKebabCase.ts b/packages/payload/src/utilities/toKebabCase.ts index c6333191c98..b058e7c4787 100644 --- a/packages/payload/src/utilities/toKebabCase.ts +++ b/packages/payload/src/utilities/toKebabCase.ts @@ -1,5 +1,5 @@ -export const toKebabCase = (string: string) => +export const toKebabCase = (string?: string) => string - .replace(/([a-z])([A-Z])/g, '$1-$2') + ?.replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/\s+/g, '-') .toLowerCase() diff --git a/templates/ecommerce/src/fields/slug/generateSlug.ts b/templates/ecommerce/src/fields/slug/generateSlug.ts index 2abe97219a9..b770bd1080c 100644 --- a/templates/ecommerce/src/fields/slug/generateSlug.ts +++ b/templates/ecommerce/src/fields/slug/generateSlug.ts @@ -3,7 +3,7 @@ import { countVersions } from './countVersions' import { toKebabCase } from 'payload/shared' /** - * This is a `BeforeChange` field hook for the "slug" field. + * This is a `BeforeChange` field hook used to auto-generate the `slug` field. * See `slugField` for more details. */ export const generateSlug = @@ -12,13 +12,16 @@ export const generateSlug = const { operation, value: isChecked, collection, global, data, originalDoc } = args // Ensure user-defined slugs are not overwritten during create - // Use a generic falsy check here so that empty strings are still generated + // Use a generic falsy check here to include empty strings if (operation === 'create') { + if (data) { + data.slug = toKebabCase(data?.slug || data?.[fallback]) + } + return Boolean(!data?.slug) } if (operation === 'update') { - console.log({ data, originalDoc }) // Early return to avoid additional processing if (!isChecked) { return false @@ -31,7 +34,7 @@ export const generateSlug = ) if (!autosaveEnabled) { - // Generate the slug here + // We can generate the slug at this point if (data) { data.slug = toKebabCase(data?.[fallback]) } @@ -42,15 +45,15 @@ export const generateSlug = const isPublishing = data?._status === 'published' // Ensure the user can take over the generated slug themselves without it ever being overridden back - const hasChangedSlugManually = data?.slug !== originalDoc?.slug + const userOverride = data?.slug !== originalDoc?.slug - if (!hasChangedSlugManually) { + if (!userOverride) { if (data) { data.slug = toKebabCase(data?.[fallback]) } } - if (isPublishing || hasChangedSlugManually) { + if (isPublishing || userOverride) { return false } diff --git a/templates/ecommerce/src/fields/slug/index.ts b/templates/ecommerce/src/fields/slug/index.ts index e7617eb4a09..3aae6e970db 100644 --- a/templates/ecommerce/src/fields/slug/index.ts +++ b/templates/ecommerce/src/fields/slug/index.ts @@ -19,7 +19,7 @@ type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, * a. autosave is _not_ enabled and there is no slug * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves * - * The slug should _stop_ generating after it's been generated, because the URL is typically derived from the slug. + * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. */ export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { diff --git a/templates/website/src/fields/slug/generateSlug.ts b/templates/website/src/fields/slug/generateSlug.ts index 2abe97219a9..b770bd1080c 100644 --- a/templates/website/src/fields/slug/generateSlug.ts +++ b/templates/website/src/fields/slug/generateSlug.ts @@ -3,7 +3,7 @@ import { countVersions } from './countVersions' import { toKebabCase } from 'payload/shared' /** - * This is a `BeforeChange` field hook for the "slug" field. + * This is a `BeforeChange` field hook used to auto-generate the `slug` field. * See `slugField` for more details. */ export const generateSlug = @@ -12,13 +12,16 @@ export const generateSlug = const { operation, value: isChecked, collection, global, data, originalDoc } = args // Ensure user-defined slugs are not overwritten during create - // Use a generic falsy check here so that empty strings are still generated + // Use a generic falsy check here to include empty strings if (operation === 'create') { + if (data) { + data.slug = toKebabCase(data?.slug || data?.[fallback]) + } + return Boolean(!data?.slug) } if (operation === 'update') { - console.log({ data, originalDoc }) // Early return to avoid additional processing if (!isChecked) { return false @@ -31,7 +34,7 @@ export const generateSlug = ) if (!autosaveEnabled) { - // Generate the slug here + // We can generate the slug at this point if (data) { data.slug = toKebabCase(data?.[fallback]) } @@ -42,15 +45,15 @@ export const generateSlug = const isPublishing = data?._status === 'published' // Ensure the user can take over the generated slug themselves without it ever being overridden back - const hasChangedSlugManually = data?.slug !== originalDoc?.slug + const userOverride = data?.slug !== originalDoc?.slug - if (!hasChangedSlugManually) { + if (!userOverride) { if (data) { data.slug = toKebabCase(data?.[fallback]) } } - if (isPublishing || hasChangedSlugManually) { + if (isPublishing || userOverride) { return false } diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts index e7617eb4a09..3aae6e970db 100644 --- a/templates/website/src/fields/slug/index.ts +++ b/templates/website/src/fields/slug/index.ts @@ -19,7 +19,7 @@ type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, * a. autosave is _not_ enabled and there is no slug * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves * - * The slug should _stop_ generating after it's been generated, because the URL is typically derived from the slug. + * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. */ export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { diff --git a/templates/with-vercel-website/src/fields/slug/generateSlug.ts b/templates/with-vercel-website/src/fields/slug/generateSlug.ts index 2abe97219a9..b770bd1080c 100644 --- a/templates/with-vercel-website/src/fields/slug/generateSlug.ts +++ b/templates/with-vercel-website/src/fields/slug/generateSlug.ts @@ -3,7 +3,7 @@ import { countVersions } from './countVersions' import { toKebabCase } from 'payload/shared' /** - * This is a `BeforeChange` field hook for the "slug" field. + * This is a `BeforeChange` field hook used to auto-generate the `slug` field. * See `slugField` for more details. */ export const generateSlug = @@ -12,13 +12,16 @@ export const generateSlug = const { operation, value: isChecked, collection, global, data, originalDoc } = args // Ensure user-defined slugs are not overwritten during create - // Use a generic falsy check here so that empty strings are still generated + // Use a generic falsy check here to include empty strings if (operation === 'create') { + if (data) { + data.slug = toKebabCase(data?.slug || data?.[fallback]) + } + return Boolean(!data?.slug) } if (operation === 'update') { - console.log({ data, originalDoc }) // Early return to avoid additional processing if (!isChecked) { return false @@ -31,7 +34,7 @@ export const generateSlug = ) if (!autosaveEnabled) { - // Generate the slug here + // We can generate the slug at this point if (data) { data.slug = toKebabCase(data?.[fallback]) } @@ -42,15 +45,15 @@ export const generateSlug = const isPublishing = data?._status === 'published' // Ensure the user can take over the generated slug themselves without it ever being overridden back - const hasChangedSlugManually = data?.slug !== originalDoc?.slug + const userOverride = data?.slug !== originalDoc?.slug - if (!hasChangedSlugManually) { + if (!userOverride) { if (data) { data.slug = toKebabCase(data?.[fallback]) } } - if (isPublishing || hasChangedSlugManually) { + if (isPublishing || userOverride) { return false } diff --git a/templates/with-vercel-website/src/fields/slug/index.ts b/templates/with-vercel-website/src/fields/slug/index.ts index e7617eb4a09..3aae6e970db 100644 --- a/templates/with-vercel-website/src/fields/slug/index.ts +++ b/templates/with-vercel-website/src/fields/slug/index.ts @@ -19,7 +19,7 @@ type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, * a. autosave is _not_ enabled and there is no slug * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves * - * The slug should _stop_ generating after it's been generated, because the URL is typically derived from the slug. + * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. */ export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { From a363d2af909e6e786d02e137475fae97d97a85b6 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 09:32:07 -0400 Subject: [PATCH 11/25] export slug field from ui and nest in single unnamed field --- .../ui/src/elements/Slug}/SlugComponent.tsx | 30 +++++--- .../ui/src/elements/Slug}/generateSlug.ts | 13 +++- packages/ui/src/elements/Slug/index.scss | 15 ++++ packages/ui/src/elements/Slug/index.ts | 68 ++++++++++++++++++ packages/ui/src/exports/rsc/index.ts | 1 + .../ui/src/utilities}/countVersions.ts | 31 +++++--- .../ecommerce/src/collections/Categories.ts | 4 +- .../ecommerce/src/collections/Pages/index.ts | 4 +- .../src/collections/Products/index.ts | 4 +- .../src/fields/slug/countVersions.ts | 40 ----------- .../ecommerce/src/fields/slug/generateSlug.ts | 71 ------------------- .../ecommerce/src/fields/slug/index.scss | 13 ---- templates/ecommerce/src/fields/slug/index.ts | 65 ----------------- .../website/src/collections/Categories.ts | 4 +- .../website/src/collections/Pages/index.ts | 4 +- .../website/src/collections/Posts/index.ts | 4 +- .../website/src/fields/slug/SlugComponent.tsx | 71 ------------------- templates/website/src/fields/slug/index.scss | 13 ---- templates/website/src/fields/slug/index.ts | 65 ----------------- .../src/collections/Categories.ts | 4 +- .../src/collections/Pages/index.ts | 4 +- .../src/collections/Posts/index.ts | 4 +- .../collections/ConditionalURL.ts | 20 ++++++ 23 files changed, 172 insertions(+), 380 deletions(-) rename {templates/ecommerce/src/fields/slug => packages/ui/src/elements/Slug}/SlugComponent.tsx (65%) rename {templates/website/src/fields/slug => packages/ui/src/elements/Slug}/generateSlug.ts (84%) create mode 100644 packages/ui/src/elements/Slug/index.scss create mode 100644 packages/ui/src/elements/Slug/index.ts rename {templates/website/src/fields/slug => packages/ui/src/utilities}/countVersions.ts (52%) delete mode 100644 templates/ecommerce/src/fields/slug/countVersions.ts delete mode 100644 templates/ecommerce/src/fields/slug/generateSlug.ts delete mode 100644 templates/ecommerce/src/fields/slug/index.scss delete mode 100644 templates/ecommerce/src/fields/slug/index.ts delete mode 100644 templates/website/src/fields/slug/SlugComponent.tsx delete mode 100644 templates/website/src/fields/slug/index.scss delete mode 100644 templates/website/src/fields/slug/index.ts create mode 100644 test/live-preview/collections/ConditionalURL.ts diff --git a/templates/ecommerce/src/fields/slug/SlugComponent.tsx b/packages/ui/src/elements/Slug/SlugComponent.tsx similarity index 65% rename from templates/ecommerce/src/fields/slug/SlugComponent.tsx rename to packages/ui/src/elements/Slug/SlugComponent.tsx index 28567295872..391d69689e6 100644 --- a/templates/ecommerce/src/fields/slug/SlugComponent.tsx +++ b/packages/ui/src/elements/Slug/SlugComponent.tsx @@ -1,10 +1,14 @@ 'use client' -import React, { useCallback, useState } from 'react' import type { TextFieldClientProps } from 'payload' -import { toKebabCase } from 'payload/shared' -import { useField, Button, TextInput, FieldLabel, useForm } from '@payloadcms/ui' +import { toKebabCase } from 'payload/shared' +import React, { useCallback, useState } from 'react' +import { FieldLabel } from '../../fields/FieldLabel/index.js' +import { TextInput } from '../../fields/Text/index.js' +import { useForm } from '../../forms/Form/index.js' +import { useField } from '../../forms/useField/index.js' +import { Button } from '../Button/index.js' import './index.scss' type SlugComponentProps = { @@ -19,7 +23,7 @@ export const SlugComponent: React.FC = ({ }) => { const { label } = field - const { value, setValue } = useField({ path: path || field.name }) + const { setValue, value } = useField({ path: path || field.name }) const { getDataByPath } = useForm() @@ -29,14 +33,18 @@ export const SlugComponent: React.FC = ({ (e: React.MouseEvent) => { e.preventDefault() - const targetFieldValue = getDataByPath(fieldToUse) as string + const targetFieldValue = getDataByPath(fieldToUse) if (targetFieldValue) { - const formattedSlug = toKebabCase(targetFieldValue) + const formattedSlug = toKebabCase(targetFieldValue as string) - if (value !== formattedSlug) setValue(formattedSlug) + if (value !== formattedSlug) { + setValue(formattedSlug) + } } else { - if (value !== '') setValue('') + if (value !== '') { + setValue('') + } } }, [setValue, value, fieldToUse, getDataByPath], @@ -52,19 +60,19 @@ export const SlugComponent: React.FC = ({
{!isLocked && ( - )} -
) diff --git a/templates/website/src/fields/slug/generateSlug.ts b/packages/ui/src/elements/Slug/generateSlug.ts similarity index 84% rename from templates/website/src/fields/slug/generateSlug.ts rename to packages/ui/src/elements/Slug/generateSlug.ts index b770bd1080c..7da523a5d1c 100644 --- a/templates/website/src/fields/slug/generateSlug.ts +++ b/packages/ui/src/elements/Slug/generateSlug.ts @@ -1,7 +1,9 @@ import type { FieldHook } from 'payload' -import { countVersions } from './countVersions' + import { toKebabCase } from 'payload/shared' +import { countVersions } from '../../utilities/countVersions.js' + /** * This is a `BeforeChange` field hook used to auto-generate the `slug` field. * See `slugField` for more details. @@ -9,7 +11,7 @@ import { toKebabCase } from 'payload/shared' export const generateSlug = (fallback: string): FieldHook => async (args) => { - const { operation, value: isChecked, collection, global, data, originalDoc } = args + const { collection, data, global, operation, originalDoc, value: isChecked } = args // Ensure user-defined slugs are not overwritten during create // Use a generic falsy check here to include empty strings @@ -59,7 +61,12 @@ export const generateSlug = // Important: ensure `countVersions` is not called unnecessarily often // That is why this is buried beneath all these conditions - const versionCount = await countVersions(args) + const versionCount = await countVersions({ + collectionSlug: collection?.slug, + globalSlug: global?.slug, + parentID: originalDoc?.id, + req: args.req, + }) if (versionCount <= 2) { return true diff --git a/packages/ui/src/elements/Slug/index.scss b/packages/ui/src/elements/Slug/index.scss new file mode 100644 index 00000000000..740de581137 --- /dev/null +++ b/packages/ui/src/elements/Slug/index.scss @@ -0,0 +1,15 @@ +@layer payload-default { + .slug-field-component { + .label-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + gap: calc(var(--base) / 2); + } + + .lock-button { + margin: 0; + padding-bottom: 0.3125rem; + } + } +} diff --git a/packages/ui/src/elements/Slug/index.ts b/packages/ui/src/elements/Slug/index.ts new file mode 100644 index 00000000000..2e7b2bc4cb6 --- /dev/null +++ b/packages/ui/src/elements/Slug/index.ts @@ -0,0 +1,68 @@ +import type { CheckboxField, RowField, TextField } from 'payload' + +import { generateSlug } from './slug/generateSlug.js' + +type Overrides = { + generateSlugOverrides?: Partial + slugOverrides?: Partial +} + +type SlugField = (fieldToUse?: string, overrides?: Overrides) => RowField + +/** + * The `slug` field is auto-generated based on another field. + * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * + * The slug should generated through: + * 1. the `create` operation, unless the user has modified the slug manually + * 2. the `update` operation, if: + * a. autosave is _not_ enabled and there is no slug + * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * + * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. + * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. + */ +export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { + const { generateSlugOverrides, slugOverrides } = overrides + + return { + type: 'row', + fields: [ + { + name: 'generateSlug', + type: 'checkbox', + admin: { + description: + 'When enabled, the slug will auto-generate from the title field on save and autosave.', + hidden: true, + position: 'sidebar', + }, + defaultValue: true, + hooks: { + beforeChange: [generateSlug(fieldToUse)], + }, + label: 'Auto-generate slug', + ...(generateSlugOverrides || {}), + }, + { + name: 'slug', + type: 'text', + index: true, + label: 'Slug', + ...((slugOverrides || {}) as any), + admin: { + position: 'sidebar', + ...(slugOverrides?.admin || {}), + components: { + Field: { + clientProps: { + fieldToUse, + }, + path: '@/fields/slug/SlugComponent#SlugComponent', + }, + }, + }, + }, + ], + } +} diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 2e8d5221877..0f0983dd8bf 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -3,6 +3,7 @@ export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js' export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js' export { FolderField } from '../../elements/FolderView/FolderField/index.server.js' export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js' +export { slugField } from '../../elements/Slug/index.js' export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' diff --git a/templates/website/src/fields/slug/countVersions.ts b/packages/ui/src/utilities/countVersions.ts similarity index 52% rename from templates/website/src/fields/slug/countVersions.ts rename to packages/ui/src/utilities/countVersions.ts index a8b533eedab..2db390ead99 100644 --- a/templates/website/src/fields/slug/countVersions.ts +++ b/packages/ui/src/utilities/countVersions.ts @@ -1,36 +1,47 @@ -import { FieldHook, Where } from 'payload' +import type { + CollectionSlug, + DefaultDocumentIDType, + GlobalSlug, + PayloadRequest, + Where, +} from 'payload' /** * This is a cross-entity way to count the number of versions for any given document. * It will work for both collections and globals. * @returns number of versions */ -export const countVersions = async (args: Parameters[0]): Promise => { - const { collection, req, global, originalDoc } = args +export const countVersions = async (args: { + collectionSlug?: CollectionSlug + globalSlug?: GlobalSlug + parentID?: DefaultDocumentIDType + req: PayloadRequest +}): Promise => { + const { collectionSlug, globalSlug, parentID, req } = args let countFn const where: Where = { parent: { - equals: originalDoc?.id, + equals: parentID, }, } - if (collection) { + if (collectionSlug) { countFn = () => req.payload.countVersions({ - collection: collection.slug, - where, + collection: collectionSlug, depth: 0, + where, }) } - if (global) { + if (globalSlug) { countFn = () => req.payload.countGlobalVersions({ - global: global.slug, - where, depth: 0, + global: globalSlug, + where, }) } diff --git a/templates/ecommerce/src/collections/Categories.ts b/templates/ecommerce/src/collections/Categories.ts index 9e9c26a2193..ed8963f312d 100644 --- a/templates/ecommerce/src/collections/Categories.ts +++ b/templates/ecommerce/src/collections/Categories.ts @@ -1,4 +1,4 @@ -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' import type { CollectionConfig } from 'payload' export const Categories: CollectionConfig = { @@ -16,7 +16,7 @@ export const Categories: CollectionConfig = { type: 'text', required: true, }, - ...slugField('title', { + slugField('title', { slugOverrides: { required: true, admin: { diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index d311c76f3d7..6abe03a71b1 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -11,7 +11,7 @@ import { Content } from '@/blocks/Content/config' import { FormBlock } from '@/blocks/Form/config' import { MediaBlock } from '@/blocks/MediaBlock/config' import { hero } from '@/fields/hero' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' import { adminOrPublishedStatus } from '@/access/adminOrPublishedStatus' import { MetaDescriptionField, @@ -136,7 +136,7 @@ export const Pages: CollectionConfig = { }, ], }, - ...slugField('title', { + slugField('title', { slugOverrides: { required: true, }, diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index 9dc9cbd7c65..8d84dabab77 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -1,7 +1,7 @@ import { CallToAction } from '@/blocks/CallToAction/config' import { Content } from '@/blocks/Content/config' import { MediaBlock } from '@/blocks/MediaBlock/config' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { CollectionOverride } from '@payloadcms/plugin-ecommerce/types' import { @@ -212,7 +212,7 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => hasMany: true, relationTo: 'categories', }, - ...slugField('title', { + slugField('title', { slugOverrides: { required: true, }, diff --git a/templates/ecommerce/src/fields/slug/countVersions.ts b/templates/ecommerce/src/fields/slug/countVersions.ts deleted file mode 100644 index a8b533eedab..00000000000 --- a/templates/ecommerce/src/fields/slug/countVersions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FieldHook, Where } from 'payload' - -/** - * This is a cross-entity way to count the number of versions for any given document. - * It will work for both collections and globals. - * @returns number of versions - */ -export const countVersions = async (args: Parameters[0]): Promise => { - const { collection, req, global, originalDoc } = args - - let countFn - - const where: Where = { - parent: { - equals: originalDoc?.id, - }, - } - - if (collection) { - countFn = () => - req.payload.countVersions({ - collection: collection.slug, - where, - depth: 0, - }) - } - - if (global) { - countFn = () => - req.payload.countGlobalVersions({ - global: global.slug, - where, - depth: 0, - }) - } - - const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 - - return res -} diff --git a/templates/ecommerce/src/fields/slug/generateSlug.ts b/templates/ecommerce/src/fields/slug/generateSlug.ts deleted file mode 100644 index b770bd1080c..00000000000 --- a/templates/ecommerce/src/fields/slug/generateSlug.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { FieldHook } from 'payload' -import { countVersions } from './countVersions' -import { toKebabCase } from 'payload/shared' - -/** - * This is a `BeforeChange` field hook used to auto-generate the `slug` field. - * See `slugField` for more details. - */ -export const generateSlug = - (fallback: string): FieldHook => - async (args) => { - const { operation, value: isChecked, collection, global, data, originalDoc } = args - - // Ensure user-defined slugs are not overwritten during create - // Use a generic falsy check here to include empty strings - if (operation === 'create') { - if (data) { - data.slug = toKebabCase(data?.slug || data?.[fallback]) - } - - return Boolean(!data?.slug) - } - - if (operation === 'update') { - // Early return to avoid additional processing - if (!isChecked) { - return false - } - - const autosaveEnabled = Boolean( - (typeof collection?.versions?.drafts === 'object' && - collection?.versions?.drafts.autosave) || - (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), - ) - - if (!autosaveEnabled) { - // We can generate the slug at this point - if (data) { - data.slug = toKebabCase(data?.[fallback]) - } - - return Boolean(!data?.slug) - } else { - // If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2) - const isPublishing = data?._status === 'published' - - // Ensure the user can take over the generated slug themselves without it ever being overridden back - const userOverride = data?.slug !== originalDoc?.slug - - if (!userOverride) { - if (data) { - data.slug = toKebabCase(data?.[fallback]) - } - } - - if (isPublishing || userOverride) { - return false - } - - // Important: ensure `countVersions` is not called unnecessarily often - // That is why this is buried beneath all these conditions - const versionCount = await countVersions(args) - - if (versionCount <= 2) { - return true - } else { - return false - } - } - } - } diff --git a/templates/ecommerce/src/fields/slug/index.scss b/templates/ecommerce/src/fields/slug/index.scss deleted file mode 100644 index 514af3d225a..00000000000 --- a/templates/ecommerce/src/fields/slug/index.scss +++ /dev/null @@ -1,13 +0,0 @@ -.slug-field-component { - .label-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - gap: calc(var(--base) / 2); - } - - .lock-button { - margin: 0; - padding-bottom: 0.3125rem; - } -} diff --git a/templates/ecommerce/src/fields/slug/index.ts b/templates/ecommerce/src/fields/slug/index.ts deleted file mode 100644 index 3aae6e970db..00000000000 --- a/templates/ecommerce/src/fields/slug/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { CheckboxField, TextField } from 'payload' - -import { generateSlug } from './generateSlug' - -type Overrides = { - slugOverrides?: Partial - generateSlugOverrides?: Partial -} - -type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, TextField] - -/** - * The slug field is auto-generated based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". - * - * The slug should generated through: - * 1. the `create` operation, unless the user has modified the slug manually - * 2. the `update` operation, if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves - * - * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. - * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. - */ -export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { - const { slugOverrides, generateSlugOverrides } = overrides - - return [ - { - name: 'generateSlug', - type: 'checkbox', - label: 'Auto-generate slug', - defaultValue: true, - admin: { - description: - 'When enabled, the slug will auto-generate from the title field on save and autosave.', - position: 'sidebar', - hidden: true, - }, - hooks: { - beforeChange: [generateSlug(fieldToUse)], - }, - ...(generateSlugOverrides || {}), - }, - { - name: 'slug', - type: 'text', - index: true, - label: 'Slug', - ...((slugOverrides || {}) as any), - admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), - components: { - Field: { - path: '@/fields/slug/SlugComponent#SlugComponent', - clientProps: { - fieldToUse, - }, - }, - }, - }, - }, - ] -} diff --git a/templates/website/src/collections/Categories.ts b/templates/website/src/collections/Categories.ts index 19d2b0c2065..276ea1a40a7 100644 --- a/templates/website/src/collections/Categories.ts +++ b/templates/website/src/collections/Categories.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' import { anyone } from '../access/anyone' import { authenticated } from '../access/authenticated' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' export const Categories: CollectionConfig = { slug: 'categories', @@ -21,6 +21,6 @@ export const Categories: CollectionConfig = { type: 'text', required: true, }, - ...slugField(), + slugField(), ], } diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index 4db130c688a..aa68df7fafe 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -8,7 +8,7 @@ import { Content } from '../../blocks/Content/config' import { FormBlock } from '../../blocks/Form/config' import { MediaBlock } from '../../blocks/MediaBlock/config' import { hero } from '@/heros/config' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { revalidateDelete, revalidatePage } from './hooks/revalidatePage' @@ -122,7 +122,7 @@ export const Pages: CollectionConfig<'pages'> = { position: 'sidebar', }, }, - ...slugField(), + slugField(), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index 5fe422ecb8c..f83c4de3188 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -25,7 +25,7 @@ import { OverviewField, PreviewField, } from '@payloadcms/plugin-seo/fields' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' export const Posts: CollectionConfig<'posts'> = { slug: 'posts', @@ -219,7 +219,7 @@ export const Posts: CollectionConfig<'posts'> = { }, ], }, - ...slugField(), + slugField(), ], hooks: { afterChange: [revalidatePost], diff --git a/templates/website/src/fields/slug/SlugComponent.tsx b/templates/website/src/fields/slug/SlugComponent.tsx deleted file mode 100644 index 28567295872..00000000000 --- a/templates/website/src/fields/slug/SlugComponent.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client' -import React, { useCallback, useState } from 'react' -import type { TextFieldClientProps } from 'payload' -import { toKebabCase } from 'payload/shared' - -import { useField, Button, TextInput, FieldLabel, useForm } from '@payloadcms/ui' - -import './index.scss' - -type SlugComponentProps = { - fieldToUse: string -} & TextFieldClientProps - -export const SlugComponent: React.FC = ({ - field, - fieldToUse, - path, - readOnly: readOnlyFromProps, -}) => { - const { label } = field - - const { value, setValue } = useField({ path: path || field.name }) - - const { getDataByPath } = useForm() - - const [isLocked, setIsLocked] = useState(true) - - const handleGenerate = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - - const targetFieldValue = getDataByPath(fieldToUse) as string - - if (targetFieldValue) { - const formattedSlug = toKebabCase(targetFieldValue) - - if (value !== formattedSlug) setValue(formattedSlug) - } else { - if (value !== '') setValue('') - } - }, - [setValue, value, fieldToUse, getDataByPath], - ) - - const toggleLock = useCallback((e: React.MouseEvent) => { - e.preventDefault() - setIsLocked((prev) => !prev) - }, []) - - return ( -
-
- - {!isLocked && ( - - )} - -
- -
- ) -} diff --git a/templates/website/src/fields/slug/index.scss b/templates/website/src/fields/slug/index.scss deleted file mode 100644 index 514af3d225a..00000000000 --- a/templates/website/src/fields/slug/index.scss +++ /dev/null @@ -1,13 +0,0 @@ -.slug-field-component { - .label-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - gap: calc(var(--base) / 2); - } - - .lock-button { - margin: 0; - padding-bottom: 0.3125rem; - } -} diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts deleted file mode 100644 index 3aae6e970db..00000000000 --- a/templates/website/src/fields/slug/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { CheckboxField, TextField } from 'payload' - -import { generateSlug } from './generateSlug' - -type Overrides = { - slugOverrides?: Partial - generateSlugOverrides?: Partial -} - -type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, TextField] - -/** - * The slug field is auto-generated based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". - * - * The slug should generated through: - * 1. the `create` operation, unless the user has modified the slug manually - * 2. the `update` operation, if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves - * - * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. - * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. - */ -export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { - const { slugOverrides, generateSlugOverrides } = overrides - - return [ - { - name: 'generateSlug', - type: 'checkbox', - label: 'Auto-generate slug', - defaultValue: true, - admin: { - description: - 'When enabled, the slug will auto-generate from the title field on save and autosave.', - position: 'sidebar', - hidden: true, - }, - hooks: { - beforeChange: [generateSlug(fieldToUse)], - }, - ...(generateSlugOverrides || {}), - }, - { - name: 'slug', - type: 'text', - index: true, - label: 'Slug', - ...((slugOverrides || {}) as any), - admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), - components: { - Field: { - path: '@/fields/slug/SlugComponent#SlugComponent', - clientProps: { - fieldToUse, - }, - }, - }, - }, - }, - ] -} diff --git a/templates/with-vercel-website/src/collections/Categories.ts b/templates/with-vercel-website/src/collections/Categories.ts index 19d2b0c2065..276ea1a40a7 100644 --- a/templates/with-vercel-website/src/collections/Categories.ts +++ b/templates/with-vercel-website/src/collections/Categories.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' import { anyone } from '../access/anyone' import { authenticated } from '../access/authenticated' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' export const Categories: CollectionConfig = { slug: 'categories', @@ -21,6 +21,6 @@ export const Categories: CollectionConfig = { type: 'text', required: true, }, - ...slugField(), + slugField(), ], } diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index 4db130c688a..aa68df7fafe 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -8,7 +8,7 @@ import { Content } from '../../blocks/Content/config' import { FormBlock } from '../../blocks/Form/config' import { MediaBlock } from '../../blocks/MediaBlock/config' import { hero } from '@/heros/config' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { revalidateDelete, revalidatePage } from './hooks/revalidatePage' @@ -122,7 +122,7 @@ export const Pages: CollectionConfig<'pages'> = { position: 'sidebar', }, }, - ...slugField(), + slugField(), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index 5fe422ecb8c..f83c4de3188 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -25,7 +25,7 @@ import { OverviewField, PreviewField, } from '@payloadcms/plugin-seo/fields' -import { slugField } from '@/fields/slug' +import { slugField } from '@payloadcms/ui/rsc' export const Posts: CollectionConfig<'posts'> = { slug: 'posts', @@ -219,7 +219,7 @@ export const Posts: CollectionConfig<'posts'> = { }, ], }, - ...slugField(), + slugField(), ], hooks: { afterChange: [revalidatePost], diff --git a/test/live-preview/collections/ConditionalURL.ts b/test/live-preview/collections/ConditionalURL.ts new file mode 100644 index 00000000000..088fc258c99 --- /dev/null +++ b/test/live-preview/collections/ConditionalURL.ts @@ -0,0 +1,20 @@ +import type { CollectionConfig } from 'payload' + +export const ConditionalURLCollection: CollectionConfig = { + slug: 'conditional-url', + admin: { + livePreview: { + url: ({ data }) => (data?.enabled ? '/live-preview/static' : null), + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'enabled', + type: 'checkbox', + }, + ], +} From fe54fb7d81a3ad6d7d0d78505535dd9060f5fea3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 10:01:51 -0400 Subject: [PATCH 12/25] fix import --- packages/ui/src/elements/Slug/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/elements/Slug/index.ts b/packages/ui/src/elements/Slug/index.ts index 2e7b2bc4cb6..62b73320578 100644 --- a/packages/ui/src/elements/Slug/index.ts +++ b/packages/ui/src/elements/Slug/index.ts @@ -1,6 +1,6 @@ import type { CheckboxField, RowField, TextField } from 'payload' -import { generateSlug } from './slug/generateSlug.js' +import { generateSlug } from './generateSlug.js' type Overrides = { generateSlugOverrides?: Partial From 40916e6caa8da953ba69b43966b9dd1e8d65f7d6 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 11:31:35 -0400 Subject: [PATCH 13/25] better overrides pattern --- packages/ui/src/elements/Slug/index.ts | 77 +++++++++++++------ .../ecommerce/src/collections/Categories.ts | 10 +-- .../ecommerce/src/collections/Pages/index.ts | 6 +- .../src/collections/Products/index.ts | 6 +- .../website/src/collections/Categories.ts | 5 +- .../website/src/collections/Pages/index.ts | 4 +- .../website/src/collections/Posts/index.ts | 4 +- .../src/collections/Categories.ts | 5 +- .../src/collections/Pages/index.ts | 4 +- .../src/collections/Posts/index.ts | 4 +- test/live-preview/collections/NoURL.ts | 2 +- 11 files changed, 82 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/elements/Slug/index.ts b/packages/ui/src/elements/Slug/index.ts index 62b73320578..a9123fe1a79 100644 --- a/packages/ui/src/elements/Slug/index.ts +++ b/packages/ui/src/elements/Slug/index.ts @@ -1,13 +1,34 @@ -import type { CheckboxField, RowField, TextField } from 'payload' +import type { RowField } from 'payload' import { generateSlug } from './generateSlug.js' -type Overrides = { - generateSlugOverrides?: Partial - slugOverrides?: Partial -} - -type SlugField = (fieldToUse?: string, overrides?: Overrides) => RowField +type SlugField = (args?: { + /** + * Override for the `generateSlug` checkbox field name. + * @default 'generateSlug' + */ + checkboxName?: string + /** + * The name of the field to generate the slug from, when applicable. + * @default 'title' + */ + fallback?: string + /** + * Override for the `slug` field name. + * @default 'slug' + */ + name?: string + /** + * A function used to override te fields at a granular level. + * Passes the row field to you to manipulate beyond the exposed options. + */ + overrides?: (field: RowField) => RowField + position?: RowField['admin']['position'] + /** + * Whether or not the `slug` field is required. + */ + required?: boolean +}) => RowField /** * The `slug` field is auto-generated based on another field. @@ -21,48 +42,58 @@ type SlugField = (fieldToUse?: string, overrides?: Overrides) => RowField * * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. + * + * @experimental This property is experimental and may change in the future. Use at your own discretion. */ -export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { - const { generateSlugOverrides, slugOverrides } = overrides - - return { +export const slugField: SlugField = ({ + name: fieldName = 'slug', + checkboxName = 'generateSlug', + fallback = 'title', + overrides, + position = 'sidebar', + required, +}) => { + const baseField: RowField = { type: 'row', + admin: { + position, + }, fields: [ { - name: 'generateSlug', + name: checkboxName, type: 'checkbox', admin: { description: 'When enabled, the slug will auto-generate from the title field on save and autosave.', hidden: true, - position: 'sidebar', }, defaultValue: true, hooks: { - beforeChange: [generateSlug(fieldToUse)], + beforeChange: [generateSlug(fallback)], }, - label: 'Auto-generate slug', - ...(generateSlugOverrides || {}), }, { - name: 'slug', + name: fieldName, type: 'text', - index: true, - label: 'Slug', - ...((slugOverrides || {}) as any), admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), components: { Field: { clientProps: { - fieldToUse, + fallback, }, path: '@/fields/slug/SlugComponent#SlugComponent', }, }, }, + index: true, + required, }, ], } + + if (typeof overrides === 'function') { + return overrides(baseField) + } + + return baseField } diff --git a/templates/ecommerce/src/collections/Categories.ts b/templates/ecommerce/src/collections/Categories.ts index ed8963f312d..a94b854480f 100644 --- a/templates/ecommerce/src/collections/Categories.ts +++ b/templates/ecommerce/src/collections/Categories.ts @@ -16,13 +16,9 @@ export const Categories: CollectionConfig = { type: 'text', required: true, }, - slugField('title', { - slugOverrides: { - required: true, - admin: { - position: undefined, - }, - }, + slugField({ + required: true, + position: undefined, }), ], } diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index 6abe03a71b1..2a4077d4698 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -136,10 +136,8 @@ export const Pages: CollectionConfig = { }, ], }, - slugField('title', { - slugOverrides: { - required: true, - }, + slugField({ + required: true, }), ], hooks: { diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index 8d84dabab77..c233f8dff31 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -212,10 +212,8 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => hasMany: true, relationTo: 'categories', }, - slugField('title', { - slugOverrides: { - required: true, - }, + slugField({ + required: true, }), ], }) diff --git a/templates/website/src/collections/Categories.ts b/templates/website/src/collections/Categories.ts index 276ea1a40a7..f6ece1de4a0 100644 --- a/templates/website/src/collections/Categories.ts +++ b/templates/website/src/collections/Categories.ts @@ -21,6 +21,9 @@ export const Categories: CollectionConfig = { type: 'text', required: true, }, - slugField(), + slugField({ + required: true, + position: undefined, + }), ], } diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index aa68df7fafe..696e1c3c505 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -122,7 +122,9 @@ export const Pages: CollectionConfig<'pages'> = { position: 'sidebar', }, }, - slugField(), + slugField({ + required: true, + }), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index f83c4de3188..123816cbcd9 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -219,7 +219,9 @@ export const Posts: CollectionConfig<'posts'> = { }, ], }, - slugField(), + slugField({ + required: true, + }), ], hooks: { afterChange: [revalidatePost], diff --git a/templates/with-vercel-website/src/collections/Categories.ts b/templates/with-vercel-website/src/collections/Categories.ts index 276ea1a40a7..f6ece1de4a0 100644 --- a/templates/with-vercel-website/src/collections/Categories.ts +++ b/templates/with-vercel-website/src/collections/Categories.ts @@ -21,6 +21,9 @@ export const Categories: CollectionConfig = { type: 'text', required: true, }, - slugField(), + slugField({ + required: true, + position: undefined, + }), ], } diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index aa68df7fafe..696e1c3c505 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -122,7 +122,9 @@ export const Pages: CollectionConfig<'pages'> = { position: 'sidebar', }, }, - slugField(), + slugField({ + required: true, + }), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index f83c4de3188..123816cbcd9 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -219,7 +219,9 @@ export const Posts: CollectionConfig<'posts'> = { }, ], }, - slugField(), + slugField({ + required: true, + }), ], hooks: { afterChange: [revalidatePost], diff --git a/test/live-preview/collections/NoURL.ts b/test/live-preview/collections/NoURL.ts index fde67517f06..8ff8f3d09a9 100644 --- a/test/live-preview/collections/NoURL.ts +++ b/test/live-preview/collections/NoURL.ts @@ -1,7 +1,7 @@ import type { CollectionConfig } from 'payload' export const NoURLCollection: CollectionConfig = { - slug: 'no-url', + slug: 'conditional-url', admin: { livePreview: { url: ({ data }) => (data?.enabled ? '/live-preview/static' : null), From 8a5dcd026f2956386c1079376faca90512f3c683 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 13:02:27 -0400 Subject: [PATCH 14/25] regen import maps --- .../src/app/(payload)/admin/importMap.js | 73 ++++++++++++------- .../src/app/(payload)/admin/importMap.js | 2 - .../src/app/(payload)/admin/importMap.js | 2 - 3 files changed, 46 insertions(+), 31 deletions(-) diff --git a/templates/ecommerce/src/app/(payload)/admin/importMap.js b/templates/ecommerce/src/app/(payload)/admin/importMap.js index ddc9c1f4a07..238f14a2e62 100644 --- a/templates/ecommerce/src/app/(payload)/admin/importMap.js +++ b/templates/ecommerce/src/app/(payload)/admin/importMap.js @@ -17,7 +17,6 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' -import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent' import { VariantOptionsSelector as VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' import { PriceCell as PriceCell_e27bf7b8cc50640dcdd584767b8eac3c } from '@payloadcms/plugin-ecommerce/client' import { PriceInput as PriceInput_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' @@ -26,30 +25,50 @@ import { BeforeDashboard as BeforeDashboard_1a7510af427896d367a49dbf838d2de6 } f import { BeforeLogin as BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' export const importMap = { - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#TableFeatureClient": TableFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, - "@payloadcms/plugin-ecommerce/rsc#VariantOptionsSelector": VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb, - "@payloadcms/plugin-ecommerce/client#PriceCell": PriceCell_e27bf7b8cc50640dcdd584767b8eac3c, - "@payloadcms/plugin-ecommerce/rsc#PriceInput": PriceInput_b91672ccd6e8b071c11142ab941fedfb, - "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@/components/BeforeDashboard#BeforeDashboard": BeforeDashboard_1a7510af427896d367a49dbf838d2de6, - "@/components/BeforeLogin#BeforeLogin": BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': + RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField': + RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent': + LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient': + InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient': + FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#HeadingFeatureClient': + HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnderlineFeatureClient': + UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BoldFeatureClient': + BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ItalicFeatureClient': + ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#OrderedListFeatureClient': + OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnorderedListFeatureClient': + UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#LinkFeatureClient': + LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#IndentFeatureClient': + IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#TableFeatureClient': + TableFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-seo/client#OverviewComponent': + OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaTitleComponent': + MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaImageComponent': + MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaDescriptionComponent': + MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#PreviewComponent': + PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-ecommerce/rsc#VariantOptionsSelector': + VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb, + '@payloadcms/plugin-ecommerce/client#PriceCell': PriceCell_e27bf7b8cc50640dcdd584767b8eac3c, + '@payloadcms/plugin-ecommerce/rsc#PriceInput': PriceInput_b91672ccd6e8b071c11142ab941fedfb, + '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': + HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@/components/BeforeDashboard#BeforeDashboard': BeforeDashboard_1a7510af427896d367a49dbf838d2de6, + '@/components/BeforeLogin#BeforeLogin': BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e, } diff --git a/templates/website/src/app/(payload)/admin/importMap.js b/templates/website/src/app/(payload)/admin/importMap.js index bd78e0fbd3d..3109dabfe02 100644 --- a/templates/website/src/app/(payload)/admin/importMap.js +++ b/templates/website/src/app/(payload)/admin/importMap.js @@ -14,7 +14,6 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' -import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' @@ -57,7 +56,6 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#BlocksFeatureClient': diff --git a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js index 6e009c9a818..54f190b42c6 100644 --- a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js +++ b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js @@ -14,7 +14,6 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' -import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' @@ -58,7 +57,6 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#BlocksFeatureClient': From 59368d3e45d783b629d213af4f4e3297e3722646 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 13:21:36 -0400 Subject: [PATCH 15/25] fix component paths --- packages/ui/src/elements/Slug/index.scss | 2 ++ packages/ui/src/elements/Slug/index.ts | 3 ++- packages/ui/src/exports/client/index.ts | 1 + templates/ecommerce/src/app/(payload)/admin/importMap.js | 2 ++ templates/website/src/app/(payload)/admin/importMap.js | 2 ++ .../with-vercel-website/src/app/(payload)/admin/importMap.js | 5 ++--- 6 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/elements/Slug/index.scss b/packages/ui/src/elements/Slug/index.scss index 740de581137..a67a31a64b9 100644 --- a/packages/ui/src/elements/Slug/index.scss +++ b/packages/ui/src/elements/Slug/index.scss @@ -1,5 +1,7 @@ @layer payload-default { .slug-field-component { + width: 100%; + .label-wrapper { display: flex; justify-content: space-between; diff --git a/packages/ui/src/elements/Slug/index.ts b/packages/ui/src/elements/Slug/index.ts index a9123fe1a79..9febed38466 100644 --- a/packages/ui/src/elements/Slug/index.ts +++ b/packages/ui/src/elements/Slug/index.ts @@ -81,9 +81,10 @@ export const slugField: SlugField = ({ clientProps: { fallback, }, - path: '@/fields/slug/SlugComponent#SlugComponent', + path: '@payloadcms/ui#SlugComponent', }, }, + width: '100%', }, index: true, required, diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 9f03d4032c3..a4c5ef12b42 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -35,6 +35,7 @@ export { QueryPresetsColumnField } from '../../elements/QueryPresets/fields/Colu export { QueryPresetsWhereField } from '../../elements/QueryPresets/fields/WhereField/index.js' // elements +export { SlugComponent } from '../../elements/Slug/SlugComponent.js' export { ConfirmationModal } from '../../elements/ConfirmationModal/index.js' export type { OnCancel } from '../../elements/ConfirmationModal/index.js' export { Link } from '../../elements/Link/index.js' diff --git a/templates/ecommerce/src/app/(payload)/admin/importMap.js b/templates/ecommerce/src/app/(payload)/admin/importMap.js index 238f14a2e62..bd761b032eb 100644 --- a/templates/ecommerce/src/app/(payload)/admin/importMap.js +++ b/templates/ecommerce/src/app/(payload)/admin/importMap.js @@ -17,6 +17,7 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { SlugComponent as SlugComponent_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' import { VariantOptionsSelector as VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' import { PriceCell as PriceCell_e27bf7b8cc50640dcdd584767b8eac3c } from '@payloadcms/plugin-ecommerce/client' import { PriceInput as PriceInput_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' @@ -63,6 +64,7 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/ui#SlugComponent': SlugComponent_3817bf644402e67bfe6577f60ef982de, '@payloadcms/plugin-ecommerce/rsc#VariantOptionsSelector': VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb, '@payloadcms/plugin-ecommerce/client#PriceCell': PriceCell_e27bf7b8cc50640dcdd584767b8eac3c, diff --git a/templates/website/src/app/(payload)/admin/importMap.js b/templates/website/src/app/(payload)/admin/importMap.js index 3109dabfe02..e262479c084 100644 --- a/templates/website/src/app/(payload)/admin/importMap.js +++ b/templates/website/src/app/(payload)/admin/importMap.js @@ -14,6 +14,7 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { SlugComponent as SlugComponent_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' @@ -56,6 +57,7 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/ui#SlugComponent': SlugComponent_3817bf644402e67bfe6577f60ef982de, '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#BlocksFeatureClient': diff --git a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js index 54f190b42c6..e262479c084 100644 --- a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js +++ b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js @@ -14,6 +14,7 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { SlugComponent as SlugComponent_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' @@ -22,7 +23,6 @@ import { RowLabel as RowLabel_ec255a65fa6fa8d1faeb09cf35284224 } from '@/Header/ import { RowLabel as RowLabel_1f6ff6ff633e3695d348f4f3c58f1466 } from '@/Footer/RowLabel' import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard' import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' -import { VercelBlobClientUploadHandler as VercelBlobClientUploadHandler_16c82c5e25f430251a3e3ba57219ff4e } from '@payloadcms/storage-vercel-blob/client' export const importMap = { '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': @@ -57,6 +57,7 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/ui#SlugComponent': SlugComponent_3817bf644402e67bfe6577f60ef982de, '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#BlocksFeatureClient': @@ -67,6 +68,4 @@ export const importMap = { '@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6, '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, - '@payloadcms/storage-vercel-blob/client#VercelBlobClientUploadHandler': - VercelBlobClientUploadHandler_16c82c5e25f430251a3e3ba57219ff4e, } From 900e1081f19fd9ab2ac21d51d6858a50195f8fd3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 13:28:13 -0400 Subject: [PATCH 16/25] simplify generate funcs --- .../ecommerce/src/collections/Pages/index.ts | 15 +++++---------- .../ecommerce/src/collections/Products/index.ts | 15 +++++---------- .../src/utilities/generatePreviewPath.ts | 5 +++++ templates/website/src/collections/Pages/index.ts | 15 +++++---------- templates/website/src/collections/Posts/index.ts | 15 +++++---------- .../website/src/utilities/generatePreviewPath.ts | 5 +++++ .../src/collections/Pages/index.ts | 15 +++++---------- .../src/collections/Posts/index.ts | 15 +++++---------- .../src/utilities/generatePreviewPath.ts | 5 +++++ 9 files changed, 45 insertions(+), 60 deletions(-) diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index 2a4077d4698..bd7c8d5e49b 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -34,21 +34,16 @@ export const Pages: CollectionConfig = { group: 'Content', defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { - url: ({ data, req }) => { - if (data.slug === undefined || data.slug === null) { - return null - } - - return generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + url: ({ data, req }) => + generatePreviewPath({ + slug: data?.slug, collection: 'pages', req, - }) - }, + }), }, preview: (data, { req }) => generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + slug: data?.slug as string, collection: 'pages', req, }), diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index c233f8dff31..1a188b53c83 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -26,21 +26,16 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => ...defaultCollection?.admin, defaultColumns: ['title', 'enableVariants', '_status', 'variants.variants'], livePreview: { - url: ({ data, req }) => { - if (data.slug === undefined || data.slug === null) { - return null - } - - return generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + url: ({ data, req }) => + generatePreviewPath({ + slug: data?.slug, collection: 'products', req, - }) - }, + }), }, preview: (data, { req }) => generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + slug: data?.slug as string, collection: 'products', req, }), diff --git a/templates/ecommerce/src/utilities/generatePreviewPath.ts b/templates/ecommerce/src/utilities/generatePreviewPath.ts index aaee7555014..653e91c1e8d 100644 --- a/templates/ecommerce/src/utilities/generatePreviewPath.ts +++ b/templates/ecommerce/src/utilities/generatePreviewPath.ts @@ -12,6 +12,11 @@ type Props = { } export const generatePreviewPath = ({ collection, slug }: Props) => { + // Allow empty strings, e.g. for the homepage + if (slug === undefined || slug === null) { + return null + } + const encodedParams = new URLSearchParams({ slug, collection, diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index 696e1c3c505..a2e7b653963 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -39,21 +39,16 @@ export const Pages: CollectionConfig<'pages'> = { admin: { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { - url: ({ data, req }) => { - if (data.slug === undefined || data.slug === null) { - return null - } - - return generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + url: ({ data, req }) => + generatePreviewPath({ + slug: data?.slug, collection: 'pages', req, - }) - }, + }), }, preview: (data, { req }) => generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + slug: data?.slug as string, collection: 'pages', req, }), diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index 123816cbcd9..ffd61df4270 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -50,21 +50,16 @@ export const Posts: CollectionConfig<'posts'> = { admin: { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { - url: ({ data, req }) => { - if (data.slug === undefined || data.slug === null) { - return null - } - - return generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + url: ({ data, req }) => + generatePreviewPath({ + slug: data?.slug, collection: 'posts', req, - }) - }, + }), }, preview: (data, { req }) => generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + slug: data?.slug as string, collection: 'posts', req, }), diff --git a/templates/website/src/utilities/generatePreviewPath.ts b/templates/website/src/utilities/generatePreviewPath.ts index 72093dd97e8..df95ecc0d2e 100644 --- a/templates/website/src/utilities/generatePreviewPath.ts +++ b/templates/website/src/utilities/generatePreviewPath.ts @@ -12,6 +12,11 @@ type Props = { } export const generatePreviewPath = ({ collection, slug }: Props) => { + // Allow empty strings, e.g. for the homepage + if (slug === undefined || slug === null) { + return null + } + const encodedParams = new URLSearchParams({ slug, collection, diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index 696e1c3c505..a2e7b653963 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -39,21 +39,16 @@ export const Pages: CollectionConfig<'pages'> = { admin: { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { - url: ({ data, req }) => { - if (data.slug === undefined || data.slug === null) { - return null - } - - return generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + url: ({ data, req }) => + generatePreviewPath({ + slug: data?.slug, collection: 'pages', req, - }) - }, + }), }, preview: (data, { req }) => generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + slug: data?.slug as string, collection: 'pages', req, }), diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index 123816cbcd9..ffd61df4270 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -50,21 +50,16 @@ export const Posts: CollectionConfig<'posts'> = { admin: { defaultColumns: ['title', 'slug', 'updatedAt'], livePreview: { - url: ({ data, req }) => { - if (data.slug === undefined || data.slug === null) { - return null - } - - return generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + url: ({ data, req }) => + generatePreviewPath({ + slug: data?.slug, collection: 'posts', req, - }) - }, + }), }, preview: (data, { req }) => generatePreviewPath({ - slug: typeof data?.slug === 'string' ? data.slug : '', + slug: data?.slug as string, collection: 'posts', req, }), diff --git a/templates/with-vercel-website/src/utilities/generatePreviewPath.ts b/templates/with-vercel-website/src/utilities/generatePreviewPath.ts index 72093dd97e8..df95ecc0d2e 100644 --- a/templates/with-vercel-website/src/utilities/generatePreviewPath.ts +++ b/templates/with-vercel-website/src/utilities/generatePreviewPath.ts @@ -12,6 +12,11 @@ type Props = { } export const generatePreviewPath = ({ collection, slug }: Props) => { + // Allow empty strings, e.g. for the homepage + if (slug === undefined || slug === null) { + return null + } + const encodedParams = new URLSearchParams({ slug, collection, From 62ac2acd896053332d64882fcd8efb4de5bf5dcc Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 14:03:26 -0400 Subject: [PATCH 17/25] move field definitions to payload --- .../fields/baseFields/slug/countVersions.ts | 51 +++++++++++ .../fields/baseFields/slug}/generateSlug.ts | 7 +- .../src/fields/baseFields/slug}/index.ts | 18 ++-- packages/payload/src/fields/config/types.ts | 90 +++++++++---------- packages/payload/src/index.ts | 2 + packages/ui/src/exports/rsc/index.ts | 1 - .../ecommerce/src/collections/Categories.ts | 2 +- .../ecommerce/src/collections/Pages/index.ts | 2 +- .../src/collections/Products/index.ts | 2 +- .../website/src/collections/Categories.ts | 2 +- .../website/src/collections/Pages/index.ts | 2 +- .../website/src/collections/Posts/index.ts | 2 +- .../src/collections/Categories.ts | 2 +- .../src/collections/Pages/index.ts | 2 +- .../src/collections/Posts/index.ts | 2 +- 15 files changed, 119 insertions(+), 68 deletions(-) create mode 100644 packages/payload/src/fields/baseFields/slug/countVersions.ts rename packages/{ui/src/elements/Slug => payload/src/fields/baseFields/slug}/generateSlug.ts (92%) rename packages/{ui/src/elements/Slug => payload/src/fields/baseFields/slug}/index.ts (82%) diff --git a/packages/payload/src/fields/baseFields/slug/countVersions.ts b/packages/payload/src/fields/baseFields/slug/countVersions.ts new file mode 100644 index 00000000000..e122c8b5ccc --- /dev/null +++ b/packages/payload/src/fields/baseFields/slug/countVersions.ts @@ -0,0 +1,51 @@ +import type { + CollectionSlug, + DefaultDocumentIDType, + GlobalSlug, + PayloadRequest, + Where, +} from '../../../index.js' + +/** + * This is a cross-entity way to count the number of versions for any given document. + * It will work for both collections and globals. + * @returns number of versions + */ +export const countVersions = async (args: { + collectionSlug?: CollectionSlug + globalSlug?: GlobalSlug + parentID?: DefaultDocumentIDType + req: PayloadRequest +}): Promise => { + const { collectionSlug, globalSlug, parentID, req } = args + + let countFn + + const where: Where = { + parent: { + equals: parentID, + }, + } + + if (collectionSlug) { + countFn = () => + req.payload.countVersions({ + collection: collectionSlug, + depth: 0, + where, + }) + } + + if (globalSlug) { + countFn = () => + req.payload.countGlobalVersions({ + depth: 0, + global: globalSlug, + where, + }) + } + + const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 + + return res +} diff --git a/packages/ui/src/elements/Slug/generateSlug.ts b/packages/payload/src/fields/baseFields/slug/generateSlug.ts similarity index 92% rename from packages/ui/src/elements/Slug/generateSlug.ts rename to packages/payload/src/fields/baseFields/slug/generateSlug.ts index 7da523a5d1c..fbc5341a7eb 100644 --- a/packages/ui/src/elements/Slug/generateSlug.ts +++ b/packages/payload/src/fields/baseFields/slug/generateSlug.ts @@ -1,8 +1,7 @@ -import type { FieldHook } from 'payload' +import type { FieldHook } from '../../config/types.js' -import { toKebabCase } from 'payload/shared' - -import { countVersions } from '../../utilities/countVersions.js' +import { toKebabCase } from '../../../utilities/toKebabCase.js' +import { countVersions } from './countVersions.js' /** * This is a `BeforeChange` field hook used to auto-generate the `slug` field. diff --git a/packages/ui/src/elements/Slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts similarity index 82% rename from packages/ui/src/elements/Slug/index.ts rename to packages/payload/src/fields/baseFields/slug/index.ts index 9febed38466..3fa446243ce 100644 --- a/packages/ui/src/elements/Slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -1,4 +1,4 @@ -import type { RowField } from 'payload' +import type { FieldAdmin, RowField } from '../../../fields/config/types.js' import { generateSlug } from './generateSlug.js' @@ -23,7 +23,7 @@ type SlugField = (args?: { * Passes the row field to you to manipulate beyond the exposed options. */ overrides?: (field: RowField) => RowField - position?: RowField['admin']['position'] + position?: FieldAdmin['position'] /** * Whether or not the `slug` field is required. */ @@ -35,24 +35,24 @@ type SlugField = (args?: { * For example, it will take a "title" field and transform its value from "My Title" → "my-title". * * The slug should generated through: - * 1. the `create` operation, unless the user has modified the slug manually - * 2. the `update` operation, if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * 1. The `create` operation, unless the user has modified the slug manually + * 2. The `update` operation, if: + * a. Autosave is _not_ enabled and there is no slug + * b. Autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves * * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This field is experimental and may change or be removed in the future. Use at your own discretion. */ export const slugField: SlugField = ({ name: fieldName = 'slug', checkboxName = 'generateSlug', fallback = 'title', overrides, - position = 'sidebar', + position, required, -}) => { +} = {}) => { const baseField: RowField = { type: 'row', admin: { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 38855e637a2..8c4b6835145 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -340,7 +340,7 @@ export type BlocksFilterOptions = ) => BlockSlugOrString | Promise | true) | BlockSlugOrString -type Admin = { +export type FieldAdmin = { className?: string components?: { Cell?: PayloadComponent @@ -504,7 +504,7 @@ export interface FieldBase { read?: FieldAccess update?: FieldAccess } - admin?: Admin + admin?: FieldAdmin /** Extension point to add your custom data. Server only. */ custom?: FieldCustom defaultValue?: DefaultValue @@ -578,12 +578,12 @@ export type NumberField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] /** Set this property to define a placeholder string for the field. */ placeholder?: Record | string /** Set a value for the number field to increment / decrement using browser controls. */ step?: number - } & Admin + } & FieldAdmin /** Maximum value accepted. Used in the default `validate` function. */ max?: number /** Minimum value accepted. Used in the default `validate` function. */ @@ -625,10 +625,10 @@ export type TextField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string rtl?: boolean - } & Admin + } & FieldAdmin maxLength?: number minLength?: number type: 'text' @@ -668,9 +668,9 @@ export type EmailField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string - } & Admin + } & FieldAdmin type: 'email' validate?: EmailFieldValidation } & Omit @@ -688,11 +688,11 @@ export type TextareaField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string rows?: number rtl?: boolean - } & Admin + } & FieldAdmin maxLength?: number minLength?: number type: 'textarea' @@ -712,8 +712,8 @@ export type CheckboxField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] - } & Admin + } & FieldAdmin['components'] + } & FieldAdmin type: 'checkbox' validate?: CheckboxFieldValidation } & Omit @@ -730,10 +730,10 @@ export type DateField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] date?: ConditionalDateProps placeholder?: Record | string - } & Admin + } & FieldAdmin /** * Enable timezone selection in the admin interface. */ @@ -754,9 +754,9 @@ export type GroupBase = { afterInput?: CustomComponent[] beforeInput?: CustomComponent[] Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] hideGutter?: boolean - } & Admin + } & FieldAdmin fields: Field[] type: 'group' validate?: Validate @@ -791,7 +791,7 @@ export type NamedGroupFieldClient = Pick & UnnamedGroup export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient export type RowField = { - admin?: Omit + admin?: Omit fields: Field[] type: 'row' } & Omit @@ -814,9 +814,9 @@ export type CollapsibleField = { Label: CustomComponent< CollapsibleFieldLabelClientComponent | CollapsibleFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean - } & Admin + } & FieldAdmin label?: Required } | { @@ -827,9 +827,9 @@ export type CollapsibleField = { Label?: CustomComponent< CollapsibleFieldLabelClientComponent | CollapsibleFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean - } & Admin + } & FieldAdmin label: Required } ) & @@ -884,7 +884,7 @@ export type UnnamedTab = { export type Tab = NamedTab | UnnamedTab export type TabsField = { - admin?: Omit + admin?: Omit type: 'tabs' } & { tabs: Tab[] @@ -918,7 +918,7 @@ export type UIField = { * The Filter component has to be a client component */ Filter?: PayloadComponent - } & Admin['components'] + } & FieldAdmin['components'] condition?: Condition /** Extension point to add your custom data. Available in server and client. */ custom?: Record @@ -1014,9 +1014,9 @@ type UploadAdmin = { Label?: CustomComponent< RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] isSortable?: boolean -} & Admin +} & FieldAdmin type UploadAdminClient = AdminClient & Pick @@ -1059,10 +1059,10 @@ export type CodeField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] editorOptions?: EditorProps['options'] language?: string - } & Admin + } & FieldAdmin maxLength?: number minLength?: number type: 'code' @@ -1082,10 +1082,10 @@ export type JSONField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] editorOptions?: EditorProps['options'] maxHeight?: number - } & Admin + } & FieldAdmin jsonSchema?: { fileMatch: string[] @@ -1109,11 +1109,11 @@ export type SelectField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] isClearable?: boolean isSortable?: boolean placeholder?: LabelFunction | string - } & Admin + } & FieldAdmin /** * Customize the SQL table name */ @@ -1222,10 +1222,10 @@ type RelationshipAdmin = { Label?: CustomComponent< RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] isSortable?: boolean placeholder?: LabelFunction | string -} & Admin +} & FieldAdmin type RelationshipAdminClient = AdminClient & Pick @@ -1290,8 +1290,8 @@ export type RichTextField< beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] - } & Admin + } & FieldAdmin['components'] + } & FieldAdmin editor?: | RichTextAdapter | RichTextAdapterProvider @@ -1321,13 +1321,13 @@ export type ArrayField = { Error?: CustomComponent Label?: CustomComponent RowLabel?: RowLabelComponent - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean /** * Disable drag and drop sorting */ isSortable?: boolean - } & Admin + } & FieldAdmin /** * Customize the SQL table name */ @@ -1362,9 +1362,9 @@ export type RadioField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] layout?: 'horizontal' | 'vertical' - } & Admin + } & FieldAdmin /** * Customize the SQL table name */ @@ -1522,13 +1522,13 @@ export type BlocksField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean /** * Disable drag and drop sorting */ isSortable?: boolean - } & Admin + } & FieldAdmin /** * Like `blocks`, but allows you to also pass strings that are slugs of blocks defined in `config.blocks`. * @@ -1595,10 +1595,10 @@ export type PointField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string step?: number - } & Admin + } & FieldAdmin type: 'point' validate?: PointFieldValidation } & Omit @@ -1625,12 +1625,12 @@ export type JoinField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] defaultColumns?: string[] disableBulkEdit?: never disableRowTypes?: boolean readOnly?: never - } & Admin + } & FieldAdmin /** * The slug of the collection to relate with. */ diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 1814a92e370..f814626d505 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1410,6 +1410,8 @@ export { baseBlockFields } from './fields/baseFields/baseBlockFields.js' export { baseIDField } from './fields/baseFields/baseIDField.js' +export { slugField } from './fields/baseFields/slug/index.js' + export { createClientField, createClientFields, diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 0f0983dd8bf..2e8d5221877 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -3,7 +3,6 @@ export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js' export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js' export { FolderField } from '../../elements/FolderView/FolderField/index.server.js' export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js' -export { slugField } from '../../elements/Slug/index.js' export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' diff --git a/templates/ecommerce/src/collections/Categories.ts b/templates/ecommerce/src/collections/Categories.ts index a94b854480f..17a0ad14090 100644 --- a/templates/ecommerce/src/collections/Categories.ts +++ b/templates/ecommerce/src/collections/Categories.ts @@ -1,4 +1,4 @@ -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' import type { CollectionConfig } from 'payload' export const Categories: CollectionConfig = { diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index bd7c8d5e49b..dc9226f8aaf 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -11,7 +11,7 @@ import { Content } from '@/blocks/Content/config' import { FormBlock } from '@/blocks/Form/config' import { MediaBlock } from '@/blocks/MediaBlock/config' import { hero } from '@/fields/hero' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' import { adminOrPublishedStatus } from '@/access/adminOrPublishedStatus' import { MetaDescriptionField, diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index 1a188b53c83..eb625735a10 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -1,7 +1,7 @@ import { CallToAction } from '@/blocks/CallToAction/config' import { Content } from '@/blocks/Content/config' import { MediaBlock } from '@/blocks/MediaBlock/config' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { CollectionOverride } from '@payloadcms/plugin-ecommerce/types' import { diff --git a/templates/website/src/collections/Categories.ts b/templates/website/src/collections/Categories.ts index f6ece1de4a0..d04871bb037 100644 --- a/templates/website/src/collections/Categories.ts +++ b/templates/website/src/collections/Categories.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' import { anyone } from '../access/anyone' import { authenticated } from '../access/authenticated' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' export const Categories: CollectionConfig = { slug: 'categories', diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index a2e7b653963..3ab5383409b 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -8,7 +8,7 @@ import { Content } from '../../blocks/Content/config' import { FormBlock } from '../../blocks/Form/config' import { MediaBlock } from '../../blocks/MediaBlock/config' import { hero } from '@/heros/config' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { revalidateDelete, revalidatePage } from './hooks/revalidatePage' diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index ffd61df4270..56432bd6e00 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -25,7 +25,7 @@ import { OverviewField, PreviewField, } from '@payloadcms/plugin-seo/fields' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' export const Posts: CollectionConfig<'posts'> = { slug: 'posts', diff --git a/templates/with-vercel-website/src/collections/Categories.ts b/templates/with-vercel-website/src/collections/Categories.ts index f6ece1de4a0..d04871bb037 100644 --- a/templates/with-vercel-website/src/collections/Categories.ts +++ b/templates/with-vercel-website/src/collections/Categories.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' import { anyone } from '../access/anyone' import { authenticated } from '../access/authenticated' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' export const Categories: CollectionConfig = { slug: 'categories', diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index a2e7b653963..3ab5383409b 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -8,7 +8,7 @@ import { Content } from '../../blocks/Content/config' import { FormBlock } from '../../blocks/Form/config' import { MediaBlock } from '../../blocks/MediaBlock/config' import { hero } from '@/heros/config' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { revalidateDelete, revalidatePage } from './hooks/revalidatePage' diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index ffd61df4270..56432bd6e00 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -25,7 +25,7 @@ import { OverviewField, PreviewField, } from '@payloadcms/plugin-seo/fields' -import { slugField } from '@payloadcms/ui/rsc' +import { slugField } from 'payload' export const Posts: CollectionConfig<'posts'> = { slug: 'posts', From 5c585d2e8f91f84e6d3ddbb6c586d2760f290291 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 14:45:50 -0400 Subject: [PATCH 18/25] fix slugify util, rm unused code, type safety --- packages/payload/src/exports/shared.ts | 5 +- .../fields/baseFields/slug/generateSlug.ts | 10 +-- .../src/fields/baseFields/slug/index.ts | 23 +++--- packages/payload/src/index.ts | 2 +- packages/payload/src/utilities/slugify.ts | 5 ++ packages/ui/src/exports/client/index.ts | 2 +- .../src/{elements => fields}/Slug/index.scss | 0 .../Slug/index.tsx} | 18 ++--- .../src/app/(payload)/admin/importMap.js | 4 +- .../ecommerce/src/utilities/formatSlug.ts | 25 ------- .../src/app/(payload)/admin/importMap.js | 4 +- templates/website/src/hooks/formatSlug.ts | 27 ------- .../src/app/(payload)/admin/importMap.js | 4 +- .../src/fields/slug/SlugComponent.tsx | 71 ------------------- .../src/fields/slug/countVersions.ts | 40 ----------- .../src/fields/slug/generateSlug.ts | 71 ------------------- .../src/fields/slug/index.scss | 13 ---- .../src/fields/slug/index.ts | 65 ----------------- .../src/hooks/formatSlug.ts | 27 ------- 19 files changed, 44 insertions(+), 372 deletions(-) create mode 100644 packages/payload/src/utilities/slugify.ts rename packages/ui/src/{elements => fields}/Slug/index.scss (100%) rename packages/ui/src/{elements/Slug/SlugComponent.tsx => fields/Slug/index.tsx} (78%) delete mode 100644 templates/ecommerce/src/utilities/formatSlug.ts delete mode 100644 templates/website/src/hooks/formatSlug.ts delete mode 100644 templates/with-vercel-website/src/fields/slug/SlugComponent.tsx delete mode 100644 templates/with-vercel-website/src/fields/slug/countVersions.ts delete mode 100644 templates/with-vercel-website/src/fields/slug/generateSlug.ts delete mode 100644 templates/with-vercel-website/src/fields/slug/index.scss delete mode 100644 templates/with-vercel-website/src/fields/slug/index.ts delete mode 100644 templates/with-vercel-website/src/hooks/formatSlug.ts diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index d68a64bf192..89f1e439cd5 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -79,12 +79,11 @@ export { extractID } from '../utilities/extractID.js' export { flattenAllFields } from '../utilities/flattenAllFields.js' export { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js' export { formatAdminURL } from '../utilities/formatAdminURL.js' - export { formatLabels, toWords } from '../utilities/formatLabels.js' + export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js' export { getDataByPath } from '../utilities/getDataByPath.js' export { getFieldPermissions } from '../utilities/getFieldPermissions.js' - export { getSafeRedirect } from '../utilities/getSafeRedirect.js' export { getSelectMode } from '../utilities/getSelectMode.js' @@ -116,6 +115,8 @@ export { sanitizeUserDataForEmail } from '../utilities/sanitizeUserDataForEmail. export { setsAreEqual } from '../utilities/setsAreEqual.js' +export { slugify } from '../utilities/slugify.js' + export { toKebabCase } from '../utilities/toKebabCase.js' export { diff --git a/packages/payload/src/fields/baseFields/slug/generateSlug.ts b/packages/payload/src/fields/baseFields/slug/generateSlug.ts index fbc5341a7eb..c065d6ae234 100644 --- a/packages/payload/src/fields/baseFields/slug/generateSlug.ts +++ b/packages/payload/src/fields/baseFields/slug/generateSlug.ts @@ -1,6 +1,6 @@ import type { FieldHook } from '../../config/types.js' -import { toKebabCase } from '../../../utilities/toKebabCase.js' +import { slugify } from '../../../utilities/slugify.js' import { countVersions } from './countVersions.js' /** @@ -16,7 +16,7 @@ export const generateSlug = // Use a generic falsy check here to include empty strings if (operation === 'create') { if (data) { - data.slug = toKebabCase(data?.slug || data?.[fallback]) + data.slug = slugify(data?.slug || data?.[fallback]) } return Boolean(!data?.slug) @@ -37,7 +37,7 @@ export const generateSlug = if (!autosaveEnabled) { // We can generate the slug at this point if (data) { - data.slug = toKebabCase(data?.[fallback]) + data.slug = slugify(data?.[fallback]) } return Boolean(!data?.slug) @@ -50,7 +50,9 @@ export const generateSlug = if (!userOverride) { if (data) { - data.slug = toKebabCase(data?.[fallback]) + // If the fallback is an empty string, we want the slug to return to `null` + // This will ensure that live preview conditions continue to run as expected + data.slug = data?.[fallback] ? slugify(data[fallback]) : null } } diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts index 3fa446243ce..b6745b55257 100644 --- a/packages/payload/src/fields/baseFields/slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -1,8 +1,9 @@ +import type { TextFieldClientProps } from '../../../admin/types.js' import type { FieldAdmin, RowField } from '../../../fields/config/types.js' import { generateSlug } from './generateSlug.js' -type SlugField = (args?: { +type SlugFieldArgs = { /** * Override for the `generateSlug` checkbox field name. * @default 'generateSlug' @@ -12,7 +13,7 @@ type SlugField = (args?: { * The name of the field to generate the slug from, when applicable. * @default 'title' */ - fallback?: string + fieldToUse?: string /** * Override for the `slug` field name. * @default 'slug' @@ -28,7 +29,13 @@ type SlugField = (args?: { * Whether or not the `slug` field is required. */ required?: boolean -}) => RowField +} + +type SlugField = (args?: SlugFieldArgs) => RowField + +export type SlugFieldClientProps = {} & Pick + +export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps /** * The `slug` field is auto-generated based on another field. @@ -48,7 +55,7 @@ type SlugField = (args?: { export const slugField: SlugField = ({ name: fieldName = 'slug', checkboxName = 'generateSlug', - fallback = 'title', + fieldToUse = 'title', overrides, position, required, @@ -69,7 +76,7 @@ export const slugField: SlugField = ({ }, defaultValue: true, hooks: { - beforeChange: [generateSlug(fallback)], + beforeChange: [generateSlug(fieldToUse)], }, }, { @@ -79,9 +86,9 @@ export const slugField: SlugField = ({ components: { Field: { clientProps: { - fallback, - }, - path: '@payloadcms/ui#SlugComponent', + fieldToUse, + } satisfies SlugFieldClientProps, + path: '@payloadcms/ui#SlugField', }, }, width: '100%', diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f814626d505..0017a40cd48 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1410,7 +1410,7 @@ export { baseBlockFields } from './fields/baseFields/baseBlockFields.js' export { baseIDField } from './fields/baseFields/baseIDField.js' -export { slugField } from './fields/baseFields/slug/index.js' +export { slugField, type SlugFieldProps } from './fields/baseFields/slug/index.js' export { createClientField, diff --git a/packages/payload/src/utilities/slugify.ts b/packages/payload/src/utilities/slugify.ts new file mode 100644 index 00000000000..c600ae941eb --- /dev/null +++ b/packages/payload/src/utilities/slugify.ts @@ -0,0 +1,5 @@ +export const slugify = (val: string): string => + val + .replace(/ /g, '-') + .replace(/[^\w-]+/g, '') + .toLowerCase() diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index a4c5ef12b42..9f67db6922a 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -35,7 +35,6 @@ export { QueryPresetsColumnField } from '../../elements/QueryPresets/fields/Colu export { QueryPresetsWhereField } from '../../elements/QueryPresets/fields/WhereField/index.js' // elements -export { SlugComponent } from '../../elements/Slug/SlugComponent.js' export { ConfirmationModal } from '../../elements/ConfirmationModal/index.js' export type { OnCancel } from '../../elements/ConfirmationModal/index.js' export { Link } from '../../elements/Link/index.js' @@ -203,6 +202,7 @@ export { RowField } from '../../fields/Row/index.js' export { SelectField, SelectInput } from '../../fields/Select/index.js' export { TabsField, TabsProvider } from '../../fields/Tabs/index.js' export { TabComponent } from '../../fields/Tabs/Tab/index.js' +export { SlugField } from '../../fields/Slug/index.js' export { TextField, TextInput } from '../../fields/Text/index.js' export { JoinField } from '../../fields/Join/index.js' diff --git a/packages/ui/src/elements/Slug/index.scss b/packages/ui/src/fields/Slug/index.scss similarity index 100% rename from packages/ui/src/elements/Slug/index.scss rename to packages/ui/src/fields/Slug/index.scss diff --git a/packages/ui/src/elements/Slug/SlugComponent.tsx b/packages/ui/src/fields/Slug/index.tsx similarity index 78% rename from packages/ui/src/elements/Slug/SlugComponent.tsx rename to packages/ui/src/fields/Slug/index.tsx index 391d69689e6..5082e246499 100644 --- a/packages/ui/src/elements/Slug/SlugComponent.tsx +++ b/packages/ui/src/fields/Slug/index.tsx @@ -1,21 +1,17 @@ 'use client' -import type { TextFieldClientProps } from 'payload' +import type { SlugFieldProps } from 'payload' -import { toKebabCase } from 'payload/shared' +import { slugify } from 'payload/shared' import React, { useCallback, useState } from 'react' -import { FieldLabel } from '../../fields/FieldLabel/index.js' -import { TextInput } from '../../fields/Text/index.js' +import { Button } from '../../elements/Button/index.js' import { useForm } from '../../forms/Form/index.js' import { useField } from '../../forms/useField/index.js' -import { Button } from '../Button/index.js' +import { FieldLabel } from '../FieldLabel/index.js' +import { TextInput } from '../Text/index.js' import './index.scss' -type SlugComponentProps = { - fieldToUse: string -} & TextFieldClientProps - -export const SlugComponent: React.FC = ({ +export const SlugField: React.FC = ({ field, fieldToUse, path, @@ -36,7 +32,7 @@ export const SlugComponent: React.FC = ({ const targetFieldValue = getDataByPath(fieldToUse) if (targetFieldValue) { - const formattedSlug = toKebabCase(targetFieldValue as string) + const formattedSlug = slugify(targetFieldValue as string) if (value !== formattedSlug) { setValue(formattedSlug) diff --git a/templates/ecommerce/src/app/(payload)/admin/importMap.js b/templates/ecommerce/src/app/(payload)/admin/importMap.js index bd761b032eb..57d1b2a210b 100644 --- a/templates/ecommerce/src/app/(payload)/admin/importMap.js +++ b/templates/ecommerce/src/app/(payload)/admin/importMap.js @@ -17,7 +17,7 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' -import { SlugComponent as SlugComponent_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' +import { SlugField as SlugField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' import { VariantOptionsSelector as VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' import { PriceCell as PriceCell_e27bf7b8cc50640dcdd584767b8eac3c } from '@payloadcms/plugin-ecommerce/client' import { PriceInput as PriceInput_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' @@ -64,7 +64,7 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/ui#SlugComponent': SlugComponent_3817bf644402e67bfe6577f60ef982de, + '@payloadcms/ui#SlugField': SlugField_3817bf644402e67bfe6577f60ef982de, '@payloadcms/plugin-ecommerce/rsc#VariantOptionsSelector': VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb, '@payloadcms/plugin-ecommerce/client#PriceCell': PriceCell_e27bf7b8cc50640dcdd584767b8eac3c, diff --git a/templates/ecommerce/src/utilities/formatSlug.ts b/templates/ecommerce/src/utilities/formatSlug.ts deleted file mode 100644 index 9d4c53dc8bc..00000000000 --- a/templates/ecommerce/src/utilities/formatSlug.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { FieldHook } from 'payload' - -const format = (val: string): string => - val - .replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - -export const formatSlug = - (fallback: string): FieldHook => - ({ data, operation, originalDoc, value }) => { - if (typeof value === 'string') { - return format(value) - } - - if (operation === 'create') { - const fallbackData = data?.[fallback] || originalDoc?.[fallback] - - if (fallbackData && typeof fallbackData === 'string') { - return format(fallbackData) - } - } - - return value - } diff --git a/templates/website/src/app/(payload)/admin/importMap.js b/templates/website/src/app/(payload)/admin/importMap.js index e262479c084..b7b31112009 100644 --- a/templates/website/src/app/(payload)/admin/importMap.js +++ b/templates/website/src/app/(payload)/admin/importMap.js @@ -14,7 +14,7 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' -import { SlugComponent as SlugComponent_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' +import { SlugField as SlugField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' @@ -57,7 +57,7 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/ui#SlugComponent': SlugComponent_3817bf644402e67bfe6577f60ef982de, + '@payloadcms/ui#SlugField': SlugField_3817bf644402e67bfe6577f60ef982de, '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#BlocksFeatureClient': diff --git a/templates/website/src/hooks/formatSlug.ts b/templates/website/src/hooks/formatSlug.ts deleted file mode 100644 index 43190f88cc2..00000000000 --- a/templates/website/src/hooks/formatSlug.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FieldHook } from 'payload' - -const format = (val: string): string => - val - .replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - -const formatSlug = - (fallback: string): FieldHook => - ({ data, operation, originalDoc, value }) => { - if (typeof value === 'string') { - return format(value) - } - - if (operation === 'create') { - const fallbackData = data?.[fallback] || originalDoc?.[fallback] - - if (fallbackData && typeof fallbackData === 'string') { - return format(fallbackData) - } - } - - return value - } - -export default formatSlug diff --git a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js index e262479c084..b7b31112009 100644 --- a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js +++ b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js @@ -14,7 +14,7 @@ import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c08 import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' -import { SlugComponent as SlugComponent_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' +import { SlugField as SlugField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' @@ -57,7 +57,7 @@ export const importMap = { MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#PreviewComponent': PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/ui#SlugComponent': SlugComponent_3817bf644402e67bfe6577f60ef982de, + '@payloadcms/ui#SlugField': SlugField_3817bf644402e67bfe6577f60ef982de, '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#BlocksFeatureClient': diff --git a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx b/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx deleted file mode 100644 index 28567295872..00000000000 --- a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client' -import React, { useCallback, useState } from 'react' -import type { TextFieldClientProps } from 'payload' -import { toKebabCase } from 'payload/shared' - -import { useField, Button, TextInput, FieldLabel, useForm } from '@payloadcms/ui' - -import './index.scss' - -type SlugComponentProps = { - fieldToUse: string -} & TextFieldClientProps - -export const SlugComponent: React.FC = ({ - field, - fieldToUse, - path, - readOnly: readOnlyFromProps, -}) => { - const { label } = field - - const { value, setValue } = useField({ path: path || field.name }) - - const { getDataByPath } = useForm() - - const [isLocked, setIsLocked] = useState(true) - - const handleGenerate = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - - const targetFieldValue = getDataByPath(fieldToUse) as string - - if (targetFieldValue) { - const formattedSlug = toKebabCase(targetFieldValue) - - if (value !== formattedSlug) setValue(formattedSlug) - } else { - if (value !== '') setValue('') - } - }, - [setValue, value, fieldToUse, getDataByPath], - ) - - const toggleLock = useCallback((e: React.MouseEvent) => { - e.preventDefault() - setIsLocked((prev) => !prev) - }, []) - - return ( -
-
- - {!isLocked && ( - - )} - -
- -
- ) -} diff --git a/templates/with-vercel-website/src/fields/slug/countVersions.ts b/templates/with-vercel-website/src/fields/slug/countVersions.ts deleted file mode 100644 index a8b533eedab..00000000000 --- a/templates/with-vercel-website/src/fields/slug/countVersions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FieldHook, Where } from 'payload' - -/** - * This is a cross-entity way to count the number of versions for any given document. - * It will work for both collections and globals. - * @returns number of versions - */ -export const countVersions = async (args: Parameters[0]): Promise => { - const { collection, req, global, originalDoc } = args - - let countFn - - const where: Where = { - parent: { - equals: originalDoc?.id, - }, - } - - if (collection) { - countFn = () => - req.payload.countVersions({ - collection: collection.slug, - where, - depth: 0, - }) - } - - if (global) { - countFn = () => - req.payload.countGlobalVersions({ - global: global.slug, - where, - depth: 0, - }) - } - - const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 - - return res -} diff --git a/templates/with-vercel-website/src/fields/slug/generateSlug.ts b/templates/with-vercel-website/src/fields/slug/generateSlug.ts deleted file mode 100644 index b770bd1080c..00000000000 --- a/templates/with-vercel-website/src/fields/slug/generateSlug.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { FieldHook } from 'payload' -import { countVersions } from './countVersions' -import { toKebabCase } from 'payload/shared' - -/** - * This is a `BeforeChange` field hook used to auto-generate the `slug` field. - * See `slugField` for more details. - */ -export const generateSlug = - (fallback: string): FieldHook => - async (args) => { - const { operation, value: isChecked, collection, global, data, originalDoc } = args - - // Ensure user-defined slugs are not overwritten during create - // Use a generic falsy check here to include empty strings - if (operation === 'create') { - if (data) { - data.slug = toKebabCase(data?.slug || data?.[fallback]) - } - - return Boolean(!data?.slug) - } - - if (operation === 'update') { - // Early return to avoid additional processing - if (!isChecked) { - return false - } - - const autosaveEnabled = Boolean( - (typeof collection?.versions?.drafts === 'object' && - collection?.versions?.drafts.autosave) || - (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), - ) - - if (!autosaveEnabled) { - // We can generate the slug at this point - if (data) { - data.slug = toKebabCase(data?.[fallback]) - } - - return Boolean(!data?.slug) - } else { - // If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2) - const isPublishing = data?._status === 'published' - - // Ensure the user can take over the generated slug themselves without it ever being overridden back - const userOverride = data?.slug !== originalDoc?.slug - - if (!userOverride) { - if (data) { - data.slug = toKebabCase(data?.[fallback]) - } - } - - if (isPublishing || userOverride) { - return false - } - - // Important: ensure `countVersions` is not called unnecessarily often - // That is why this is buried beneath all these conditions - const versionCount = await countVersions(args) - - if (versionCount <= 2) { - return true - } else { - return false - } - } - } - } diff --git a/templates/with-vercel-website/src/fields/slug/index.scss b/templates/with-vercel-website/src/fields/slug/index.scss deleted file mode 100644 index 514af3d225a..00000000000 --- a/templates/with-vercel-website/src/fields/slug/index.scss +++ /dev/null @@ -1,13 +0,0 @@ -.slug-field-component { - .label-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - gap: calc(var(--base) / 2); - } - - .lock-button { - margin: 0; - padding-bottom: 0.3125rem; - } -} diff --git a/templates/with-vercel-website/src/fields/slug/index.ts b/templates/with-vercel-website/src/fields/slug/index.ts deleted file mode 100644 index 3aae6e970db..00000000000 --- a/templates/with-vercel-website/src/fields/slug/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { CheckboxField, TextField } from 'payload' - -import { generateSlug } from './generateSlug' - -type Overrides = { - slugOverrides?: Partial - generateSlugOverrides?: Partial -} - -type SlugField = (fieldToUse?: string, overrides?: Overrides) => [CheckboxField, TextField] - -/** - * The slug field is auto-generated based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". - * - * The slug should generated through: - * 1. the `create` operation, unless the user has modified the slug manually - * 2. the `update` operation, if: - * a. autosave is _not_ enabled and there is no slug - * b. autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves - * - * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. - * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. - */ -export const slugField: SlugField = (fieldToUse = 'title', overrides = {}) => { - const { slugOverrides, generateSlugOverrides } = overrides - - return [ - { - name: 'generateSlug', - type: 'checkbox', - label: 'Auto-generate slug', - defaultValue: true, - admin: { - description: - 'When enabled, the slug will auto-generate from the title field on save and autosave.', - position: 'sidebar', - hidden: true, - }, - hooks: { - beforeChange: [generateSlug(fieldToUse)], - }, - ...(generateSlugOverrides || {}), - }, - { - name: 'slug', - type: 'text', - index: true, - label: 'Slug', - ...((slugOverrides || {}) as any), - admin: { - position: 'sidebar', - ...(slugOverrides?.admin || {}), - components: { - Field: { - path: '@/fields/slug/SlugComponent#SlugComponent', - clientProps: { - fieldToUse, - }, - }, - }, - }, - }, - ] -} diff --git a/templates/with-vercel-website/src/hooks/formatSlug.ts b/templates/with-vercel-website/src/hooks/formatSlug.ts deleted file mode 100644 index 43190f88cc2..00000000000 --- a/templates/with-vercel-website/src/hooks/formatSlug.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FieldHook } from 'payload' - -const format = (val: string): string => - val - .replace(/ /g, '-') - .replace(/[^\w-]+/g, '') - .toLowerCase() - -const formatSlug = - (fallback: string): FieldHook => - ({ data, operation, originalDoc, value }) => { - if (typeof value === 'string') { - return format(value) - } - - if (operation === 'create') { - const fallbackData = data?.[fallback] || originalDoc?.[fallback] - - if (fallbackData && typeof fallbackData === 'string') { - return format(fallbackData) - } - } - - return value - } - -export default formatSlug From 2b1473e8507799927350db0120e07a52364aaca1 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 15:13:04 -0400 Subject: [PATCH 19/25] add experimental flag to slug field --- packages/ui/src/fields/Slug/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/fields/Slug/index.tsx b/packages/ui/src/fields/Slug/index.tsx index 5082e246499..90a77a3ebb7 100644 --- a/packages/ui/src/fields/Slug/index.tsx +++ b/packages/ui/src/fields/Slug/index.tsx @@ -11,6 +11,9 @@ import { FieldLabel } from '../FieldLabel/index.js' import { TextInput } from '../Text/index.js' import './index.scss' +/** + * @experimental This component is experimental and may change or be removed in the future. Use at your own discretion. + */ export const SlugField: React.FC = ({ field, fieldToUse, From e4d8a1a764d1b853c4052c780f5e86fecf05ebb8 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 22:20:52 -0400 Subject: [PATCH 20/25] disable gen field from list view and fix seed --- .../src/fields/baseFields/slug/index.ts | 4 + .../ecommerce/src/endpoints/seed/index.ts | 35 +++----- templates/website/src/endpoints/seed/index.ts | 89 +++---------------- .../src/endpoints/seed/index.ts | 89 +++---------------- 4 files changed, 39 insertions(+), 178 deletions(-) diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts index b6745b55257..3c543bdad29 100644 --- a/packages/payload/src/fields/baseFields/slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -72,6 +72,10 @@ export const slugField: SlugField = ({ admin: { description: 'When enabled, the slug will auto-generate from the title field on save and autosave.', + disableBulkEdit: true, + disableGroupBy: true, + disableListColumn: true, + disableListFilter: true, hidden: true, }, defaultValue: true, diff --git a/templates/ecommerce/src/endpoints/seed/index.ts b/templates/ecommerce/src/endpoints/seed/index.ts index 5c8e2e3b371..fe71a827638 100644 --- a/templates/ecommerce/src/endpoints/seed/index.ts +++ b/templates/ecommerce/src/endpoints/seed/index.ts @@ -27,6 +27,8 @@ const collections: CollectionSlug[] = [ 'orders', ] +const categories = ['Accessories', 'T-Shirts', 'Hats'] + const sizeVariantOptions = [ { label: 'Small', value: 'small' }, { label: 'Medium', value: 'medium' }, @@ -177,30 +179,15 @@ export const seed = async ({ data: imageHero1Data, file: heroBuffer, }), - - payload.create({ - collection: 'categories', - data: { - title: 'Accessories', - slug: 'accessories', - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'T-Shirts', - slug: 'tshirts', - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Hats', - slug: 'hats', - }, - }), + categories.map((category) => + payload.create({ + collection: 'categories', + data: { + title: category, + slug: category, + }, + }), + ), ]) payload.logger.info(`— Seeding variant types and options...`) diff --git a/templates/website/src/endpoints/seed/index.ts b/templates/website/src/endpoints/seed/index.ts index 323dbd95895..5b8929b4a86 100644 --- a/templates/website/src/endpoints/seed/index.ts +++ b/templates/website/src/endpoints/seed/index.ts @@ -19,8 +19,11 @@ const collections: CollectionSlug[] = [ 'form-submissions', 'search', ] + const globals: GlobalSlug[] = ['header', 'footer'] +const categories = ['Technology', 'News', 'Finance', 'Design', 'Software', 'Engineering'] + // Next.js revalidation errors are normal when seeding the database without a server running // i.e. running `yarn seed` locally instead of using the admin UI within an active app // The app is not running to revalidate the pages and so the API routes are not available @@ -124,83 +127,15 @@ export const seed = async ({ data: imageHero1, file: hero1Buffer, }), - - payload.create({ - collection: 'categories', - data: { - title: 'Technology', - breadcrumbs: [ - { - label: 'Technology', - url: '/technology', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'News', - breadcrumbs: [ - { - label: 'News', - url: '/news', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Finance', - breadcrumbs: [ - { - label: 'Finance', - url: '/finance', - }, - ], - }, - }), - payload.create({ - collection: 'categories', - data: { - title: 'Design', - breadcrumbs: [ - { - label: 'Design', - url: '/design', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Software', - breadcrumbs: [ - { - label: 'Software', - url: '/software', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Engineering', - breadcrumbs: [ - { - label: 'Engineering', - url: '/engineering', - }, - ], - }, - }), + categories.map((category) => + payload.create({ + collection: 'categories', + data: { + title: category, + slug: category, + }, + }), + ), ]) payload.logger.info(`— Seeding posts...`) diff --git a/templates/with-vercel-website/src/endpoints/seed/index.ts b/templates/with-vercel-website/src/endpoints/seed/index.ts index 323dbd95895..5b8929b4a86 100644 --- a/templates/with-vercel-website/src/endpoints/seed/index.ts +++ b/templates/with-vercel-website/src/endpoints/seed/index.ts @@ -19,8 +19,11 @@ const collections: CollectionSlug[] = [ 'form-submissions', 'search', ] + const globals: GlobalSlug[] = ['header', 'footer'] +const categories = ['Technology', 'News', 'Finance', 'Design', 'Software', 'Engineering'] + // Next.js revalidation errors are normal when seeding the database without a server running // i.e. running `yarn seed` locally instead of using the admin UI within an active app // The app is not running to revalidate the pages and so the API routes are not available @@ -124,83 +127,15 @@ export const seed = async ({ data: imageHero1, file: hero1Buffer, }), - - payload.create({ - collection: 'categories', - data: { - title: 'Technology', - breadcrumbs: [ - { - label: 'Technology', - url: '/technology', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'News', - breadcrumbs: [ - { - label: 'News', - url: '/news', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Finance', - breadcrumbs: [ - { - label: 'Finance', - url: '/finance', - }, - ], - }, - }), - payload.create({ - collection: 'categories', - data: { - title: 'Design', - breadcrumbs: [ - { - label: 'Design', - url: '/design', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Software', - breadcrumbs: [ - { - label: 'Software', - url: '/software', - }, - ], - }, - }), - - payload.create({ - collection: 'categories', - data: { - title: 'Engineering', - breadcrumbs: [ - { - label: 'Engineering', - url: '/engineering', - }, - ], - }, - }), + categories.map((category) => + payload.create({ + collection: 'categories', + data: { + title: category, + slug: category, + }, + }), + ), ]) payload.logger.info(`— Seeding posts...`) From c7b8b84a3b3f577ce87c5671775252895c24f7b3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 22:43:59 -0400 Subject: [PATCH 21/25] docs --- docs/fields/collapsible.mdx | 2 - docs/fields/date.mdx | 2 - docs/fields/email.mdx | 2 - docs/fields/group.mdx | 2 - docs/fields/json.mdx | 2 - docs/fields/number.mdx | 2 - docs/fields/point.mdx | 2 - docs/fields/radio.mdx | 2 - docs/fields/row.mdx | 2 - docs/fields/select.mdx | 2 - docs/fields/tabs.mdx | 2 - docs/fields/text.mdx | 65 ++++++++++++++++++- docs/fields/textarea.mdx | 2 - docs/fields/ui.mdx | 2 - docs/fields/upload.mdx | 2 - docs/rich-text/slate.mdx | 2 - docs/versions/overview.mdx | 2 +- packages/payload/src/admin/forms/Form.ts | 4 +- .../payload/src/collections/config/types.ts | 4 +- packages/payload/src/config/types.ts | 4 +- .../src/fields/baseFields/slug/index.ts | 23 +++++-- .../src/elements/DocumentDrawer/Provider.tsx | 2 +- packages/ui/src/fields/Slug/index.tsx | 2 +- packages/ui/src/forms/Form/types.ts | 2 +- packages/ui/src/forms/RenderFields/context.ts | 4 +- packages/ui/src/providers/Config/index.tsx | 2 +- .../ui/src/utilities/handleLivePreview.ts | 2 +- .../ecommerce/src/collections/Categories.ts | 1 - .../ecommerce/src/collections/Pages/index.ts | 4 +- .../src/collections/Products/index.ts | 4 +- .../website/src/collections/Categories.ts | 1 - .../website/src/collections/Pages/index.ts | 4 +- .../website/src/collections/Posts/index.ts | 4 +- .../src/collections/Categories.ts | 1 - .../src/collections/Pages/index.ts | 4 +- .../src/collections/Posts/index.ts | 4 +- 36 files changed, 100 insertions(+), 73 deletions(-) diff --git a/docs/fields/collapsible.mdx b/docs/fields/collapsible.mdx index 0d06e91a6f3..c98b6c06f2e 100644 --- a/docs/fields/collapsible.mdx +++ b/docs/fields/collapsible.mdx @@ -66,8 +66,6 @@ The Collapsible Field inherits all of the default admin options from the base [F ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index 3f575a52d8e..d04d1aba29d 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -96,8 +96,6 @@ When only `pickerAppearance` is set, an equivalent format will be rendered in th ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index 206c74beb13..80feb936e5f 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -74,8 +74,6 @@ The Email Field inherits all of the default admin options from the base [Field A ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index e3803afea2f..1772851f63f 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -77,8 +77,6 @@ The Group Field inherits all of the default admin options from the base [Field A ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/json.mdx b/docs/fields/json.mdx index 4b22f75aff7..2d74dc55264 100644 --- a/docs/fields/json.mdx +++ b/docs/fields/json.mdx @@ -75,8 +75,6 @@ The JSON Field inherits all of the default admin options from the base [Field Ad ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 3cee5a4bde3..3b1355c97a9 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -80,8 +80,6 @@ The Number Field inherits all of the default admin options from the base [Field ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx index 88cabbae74b..c9085e32388 100644 --- a/docs/fields/point.mdx +++ b/docs/fields/point.mdx @@ -55,8 +55,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index 2e11f3715a4..88038346efb 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -90,8 +90,6 @@ The Radio Field inherits all of the default admin options from the base [Field A ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/row.mdx b/docs/fields/row.mdx index 6a2262d4b66..1a3f4a315c8 100644 --- a/docs/fields/row.mdx +++ b/docs/fields/row.mdx @@ -43,8 +43,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index b2fbfca3e7e..1e88a1d15fe 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -149,8 +149,6 @@ The Select Field inherits all of the default admin options from the base [Field ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/tabs.mdx b/docs/fields/tabs.mdx index 723a6d9f139..7670d22dff2 100644 --- a/docs/fields/tabs.mdx +++ b/docs/fields/tabs.mdx @@ -56,8 +56,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index c1cd4e77e1d..70d7b295c66 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -80,8 +80,6 @@ The Text Field inherits all of the default admin options from the base [Field Ad ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' @@ -183,3 +181,66 @@ export const CustomTextFieldLabelClient: TextFieldLabelClientComponent = ({ ) } ``` + +## Slug Field + + + The slug field is experimental and may change, or even be removed, in future + releases. Use at your own risk. + + +One common use case for the Text Field is to create a "slug" for a document. A slug is a unique, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. + +Payload provides a built-in Slug Field so you don't have to built one from scratch. This field automatically generates a slug based on the value of another field, such as a title or name field. It provides UI to lock and unlock the field to protect its value, as well as to re-generate the slug on-demand. + +To add a Slug Field, import the `slugField` into your field schema: + +```ts +import { slugField } from 'payload' +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + // ... + fields: [ + // ... + // highlight-line + slugField(), + // highlight-line + ], +} +``` + +The slug field exposes a few top-level config options for easy customization: + +| Option | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | To be used as the slug field's name. Defaults to `slug`. | +| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](./slug-overrides). | +| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. | +| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. | +| `position` | The position of the slug field. [More details](./overview#admin-options). | +| `required` | Require the slug field. Defaults to `true`. | + +### Slug Overrides + +If the above options aren't sufficient for your use case, you can use the `overrides` function to customize the slug field at a granular level. The `overrides` function receives the default fields that make up the slug field, and you can modify them to any extent you need. + +```ts +import { slugField } from 'payload' +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + // ... + fields: [ + // ... + // highlight-line + slugField({ + overrides: (defaultField) => { + defaultField.fields[1].label = 'Custom Slug Label' + return defaultField + }, + }), + // highlight-line + ], +} +``` diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 6c4aa78f525..bfab2c2b76a 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -78,8 +78,6 @@ The Textarea Field inherits all of the default admin options from the base [Fiel ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/ui.mdx b/docs/fields/ui.mdx index e7d787b88b5..35c4344b1e9 100644 --- a/docs/fields/ui.mdx +++ b/docs/fields/ui.mdx @@ -41,8 +41,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 177bcf3dc82..17e340f2a77 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -75,8 +75,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/rich-text/slate.mdx b/docs/rich-text/slate.mdx index e8b3264c8fb..a8ddf42c281 100644 --- a/docs/rich-text/slate.mdx +++ b/docs/rich-text/slate.mdx @@ -170,8 +170,6 @@ Specifying custom `Type`s let you extend your custom elements by adding addition ### Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index 62b0c6f9c7a..b37670bdd48 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -28,7 +28,7 @@ _Comparing an old version to a newer version of a document_ Versions are extremely performant and totally opt-in. They don't change the shape of your data at all. All versions are stored in a separate Collection - and can be turned on and off easily at your discretion. + and can be turned on and off easily at your risk. ## Options diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index ea89673ef64..d02039204be 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -67,7 +67,7 @@ export type FieldState = { * Every time a field is changed locally, this flag is set to true. Prevents form state from server from overwriting local changes. * After merging server form state, this flag is reset. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This property is experimental and may change in the future. Use at your own risk. */ isModified?: boolean /** @@ -149,7 +149,7 @@ export type BuildFormStateArgs = { * This will retrieve the client config in its entirety, even when unauthenticated. * For example, the create-first-user view needs the entire config, but there is no user yet. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This property is experimental and may change in the future. Use at your own risk. */ skipClientConfigAuth?: boolean skipValidation?: boolean diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 385c75f3091..07711c56aa8 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -391,7 +391,7 @@ export type CollectionAdminOptions = { * If your cells require specific fields that may be unselected, such as within hooks, etc., * use `forceSelect` in conjunction with this property. * - * @experimental This is an experimental feature and may change in the future. Use at your own discretion. + * @experimental This is an experimental feature and may change in the future. Use at your own risk. */ enableListViewSelectAPI?: boolean enableRichTextLink?: boolean @@ -428,7 +428,7 @@ export type CollectionAdminOptions = { * @description Enable grouping by a field in the list view. * Uses `payload.findDistinct` under the hood to populate the group-by options. * - * @experimental This option is currently in beta and may change in future releases. Use at your own discretion. + * @experimental This option is currently in beta and may change in future releases. Use at your own risk. */ groupBy?: boolean /** diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 64d6ec8ea74..51a8a100d37 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -963,7 +963,7 @@ export type Config = { * Configure toast message behavior and appearance in the admin panel. * Currently using [Sonner](https://sonner.emilkowal.ski) for toast notifications. * - * @experimental This property is experimental and may change in future releases. Use at your own discretion. + * @experimental This property is experimental and may change in future releases. Use at your own risk. */ toast?: { /** @@ -1006,7 +1006,7 @@ export type Config = { * For example, you may want to increase the `limits` imposed by the parser. * Currently using @link {https://www.npmjs.com/package/busboy|busboy} under the hood. * - * @experimental This property is experimental and may change in future releases. Use at your own discretion. + * @experimental This property is experimental and may change in future releases. Use at your own risk. */ bodyParser?: Partial /** diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts index 3c543bdad29..ff2f59758f6 100644 --- a/packages/payload/src/fields/baseFields/slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -22,11 +22,21 @@ type SlugFieldArgs = { /** * A function used to override te fields at a granular level. * Passes the row field to you to manipulate beyond the exposed options. + * @example + * ```ts + * slugField({ + * overrides: (field) => { + * field.fields[1].label = 'Custom Slug Label' + * return field + * } + * }) + * ``` */ overrides?: (field: RowField) => RowField position?: FieldAdmin['position'] /** * Whether or not the `slug` field is required. + * @default true */ required?: boolean } @@ -38,19 +48,19 @@ export type SlugFieldClientProps = {} & Pick export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps /** - * The `slug` field is auto-generated based on another field. - * For example, it will take a "title" field and transform its value from "My Title" → "my-title". + * A slug is a unique, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. + * The `slug` field auto-generates its value based on another field, e.g. "My Title" → "my-title". * - * The slug should generated through: + * The slug should continue to be generated through: * 1. The `create` operation, unless the user has modified the slug manually * 2. The `update` operation, if: * a. Autosave is _not_ enabled and there is no slug - * b. Autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug themselves + * b. Autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug manually * * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. * - * @experimental This field is experimental and may change or be removed in the future. Use at your own discretion. + * @experimental This field is experimental and may change or be removed in the future. Use at your own risk. */ export const slugField: SlugField = ({ name: fieldName = 'slug', @@ -58,7 +68,7 @@ export const slugField: SlugField = ({ fieldToUse = 'title', overrides, position, - required, + required = true, } = {}) => { const baseField: RowField = { type: 'row', @@ -99,6 +109,7 @@ export const slugField: SlugField = ({ }, index: true, required, + unique: true, }, ], } diff --git a/packages/ui/src/elements/DocumentDrawer/Provider.tsx b/packages/ui/src/elements/DocumentDrawer/Provider.tsx index d5ba73b985d..83c477658db 100644 --- a/packages/ui/src/elements/DocumentDrawer/Provider.tsx +++ b/packages/ui/src/elements/DocumentDrawer/Provider.tsx @@ -23,7 +23,7 @@ export type DocumentDrawerContextProps = { /** * If you want to pass additional data to the onSuccess callback, you can use this context object. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This property is experimental and may change in the future. Use at your own risk. */ context?: Record doc: TypeWithID diff --git a/packages/ui/src/fields/Slug/index.tsx b/packages/ui/src/fields/Slug/index.tsx index 90a77a3ebb7..89d9813a930 100644 --- a/packages/ui/src/fields/Slug/index.tsx +++ b/packages/ui/src/fields/Slug/index.tsx @@ -12,7 +12,7 @@ import { TextInput } from '../Text/index.js' import './index.scss' /** - * @experimental This component is experimental and may change or be removed in the future. Use at your own discretion. + * @experimental This component is experimental and may change or be removed in the future. Use at your own risk. */ export const SlugField: React.FC = ({ field, diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 5cd7a2f9edb..7e24e418081 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -120,7 +120,7 @@ export type Submit = >( ) => Promise diff --git a/packages/ui/src/forms/RenderFields/context.ts b/packages/ui/src/forms/RenderFields/context.ts index 7863482ca61..af6546e4599 100644 --- a/packages/ui/src/forms/RenderFields/context.ts +++ b/packages/ui/src/forms/RenderFields/context.ts @@ -21,7 +21,7 @@ import React from 'react' * } * ``` * - * @experimental This is an experimental API and may change at any time. Use at your own discretion. + * @experimental This is an experimental API and may change at any time. Use at your own risk. */ export const FieldPathContext = React.createContext(undefined) @@ -29,7 +29,7 @@ export const FieldPathContext = React.createContext(undefined) * Gets the current field path from the nearest `FieldPathContext` provider. * All fields are wrapped in this context by default. * - * @experimental This is an experimental API and may change at any time. Use at your own discretion. + * @experimental This is an experimental API and may change at any time. Use at your own risk. */ export const useFieldPath = () => { const context = React.useContext(FieldPathContext) diff --git a/packages/ui/src/providers/Config/index.tsx b/packages/ui/src/providers/Config/index.tsx index 45769a93ed5..75a437a43b6 100644 --- a/packages/ui/src/providers/Config/index.tsx +++ b/packages/ui/src/providers/Config/index.tsx @@ -108,7 +108,7 @@ export const useConfig = (): ClientConfigContext => use(RootConfigContext) * If the config here has the same reference as the config from the layout, we * simply reuse the context from the layout to avoid unnecessary re-renders. * - * @experimental This component is experimental and may change or be removed in future releases. Use at your own discretion. + * @experimental This component is experimental and may change or be removed in future releases. Use at your own risk. */ export const PageConfigProvider: React.FC<{ readonly children: React.ReactNode diff --git a/packages/ui/src/utilities/handleLivePreview.ts b/packages/ui/src/utilities/handleLivePreview.ts index 5e1e90e28c6..e1912cefdc3 100644 --- a/packages/ui/src/utilities/handleLivePreview.ts +++ b/packages/ui/src/utilities/handleLivePreview.ts @@ -62,7 +62,7 @@ export const isLivePreviewEnabled = ({ * 3. Merges the config with the root config, if necessary. * 4. Executes the `url` function, if necessary. * - * Notice: internal function only. Subject to change at any time. Use at your own discretion. + * Notice: internal function only. Subject to change at any time. Use at your own risk. */ export const handleLivePreview = async ({ collectionSlug, diff --git a/templates/ecommerce/src/collections/Categories.ts b/templates/ecommerce/src/collections/Categories.ts index 17a0ad14090..7cc180c779a 100644 --- a/templates/ecommerce/src/collections/Categories.ts +++ b/templates/ecommerce/src/collections/Categories.ts @@ -17,7 +17,6 @@ export const Categories: CollectionConfig = { required: true, }, slugField({ - required: true, position: undefined, }), ], diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index dc9226f8aaf..c382f1f888b 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -131,9 +131,7 @@ export const Pages: CollectionConfig = { }, ], }, - slugField({ - required: true, - }), + slugField(), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index eb625735a10..9c95e74572d 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -207,8 +207,6 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => hasMany: true, relationTo: 'categories', }, - slugField({ - required: true, - }), + slugField(), ], }) diff --git a/templates/website/src/collections/Categories.ts b/templates/website/src/collections/Categories.ts index d04871bb037..cca3fc13ab9 100644 --- a/templates/website/src/collections/Categories.ts +++ b/templates/website/src/collections/Categories.ts @@ -22,7 +22,6 @@ export const Categories: CollectionConfig = { required: true, }, slugField({ - required: true, position: undefined, }), ], diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index 3ab5383409b..0f380a746b6 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -117,9 +117,7 @@ export const Pages: CollectionConfig<'pages'> = { position: 'sidebar', }, }, - slugField({ - required: true, - }), + slugField(), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index 56432bd6e00..68ee3990cc8 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -214,9 +214,7 @@ export const Posts: CollectionConfig<'posts'> = { }, ], }, - slugField({ - required: true, - }), + slugField(), ], hooks: { afterChange: [revalidatePost], diff --git a/templates/with-vercel-website/src/collections/Categories.ts b/templates/with-vercel-website/src/collections/Categories.ts index d04871bb037..cca3fc13ab9 100644 --- a/templates/with-vercel-website/src/collections/Categories.ts +++ b/templates/with-vercel-website/src/collections/Categories.ts @@ -22,7 +22,6 @@ export const Categories: CollectionConfig = { required: true, }, slugField({ - required: true, position: undefined, }), ], diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index 3ab5383409b..0f380a746b6 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -117,9 +117,7 @@ export const Pages: CollectionConfig<'pages'> = { position: 'sidebar', }, }, - slugField({ - required: true, - }), + slugField(), ], hooks: { afterChange: [revalidatePage], diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index 56432bd6e00..68ee3990cc8 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -214,9 +214,7 @@ export const Posts: CollectionConfig<'posts'> = { }, ], }, - slugField({ - required: true, - }), + slugField(), ], hooks: { afterChange: [revalidatePost], From 22b8c80ef62de8738db41d4a331c537f03c686f6 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 3 Oct 2025 23:03:36 -0400 Subject: [PATCH 22/25] rm unused util --- packages/ui/src/utilities/countVersions.ts | 51 ---------------------- 1 file changed, 51 deletions(-) delete mode 100644 packages/ui/src/utilities/countVersions.ts diff --git a/packages/ui/src/utilities/countVersions.ts b/packages/ui/src/utilities/countVersions.ts deleted file mode 100644 index 2db390ead99..00000000000 --- a/packages/ui/src/utilities/countVersions.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - CollectionSlug, - DefaultDocumentIDType, - GlobalSlug, - PayloadRequest, - Where, -} from 'payload' - -/** - * This is a cross-entity way to count the number of versions for any given document. - * It will work for both collections and globals. - * @returns number of versions - */ -export const countVersions = async (args: { - collectionSlug?: CollectionSlug - globalSlug?: GlobalSlug - parentID?: DefaultDocumentIDType - req: PayloadRequest -}): Promise => { - const { collectionSlug, globalSlug, parentID, req } = args - - let countFn - - const where: Where = { - parent: { - equals: parentID, - }, - } - - if (collectionSlug) { - countFn = () => - req.payload.countVersions({ - collection: collectionSlug, - depth: 0, - where, - }) - } - - if (globalSlug) { - countFn = () => - req.payload.countGlobalVersions({ - depth: 0, - global: globalSlug, - where, - }) - } - - const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 - - return res -} From 8cce07dec61832f51c7a2fe00f82fd881110ab48 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 6 Oct 2025 09:09:00 -0400 Subject: [PATCH 23/25] docs cleanup --- docs/fields/text.mdx | 2 +- packages/payload/src/fields/baseFields/slug/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index 70d7b295c66..f73d16048a4 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -189,7 +189,7 @@ export const CustomTextFieldLabelClient: TextFieldLabelClientComponent = ({ releases. Use at your own risk. -One common use case for the Text Field is to create a "slug" for a document. A slug is a unique, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. +One common use case for the Text Field is to create a "slug" for a document. A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. Payload provides a built-in Slug Field so you don't have to built one from scratch. This field automatically generates a slug based on the value of another field, such as a title or name field. It provides UI to lock and unlock the field to protect its value, as well as to re-generate the slug on-demand. diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts index ff2f59758f6..aa8bebfda7f 100644 --- a/packages/payload/src/fields/baseFields/slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -48,7 +48,7 @@ export type SlugFieldClientProps = {} & Pick export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps /** - * A slug is a unique, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. + * A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. * The `slug` field auto-generates its value based on another field, e.g. "My Title" → "my-title". * * The slug should continue to be generated through: From 6aee9822dfadbe2f80dd44b103adbea8895eece4 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 6 Oct 2025 09:58:30 -0400 Subject: [PATCH 24/25] sidebar --- packages/payload/src/fields/baseFields/slug/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts index aa8bebfda7f..562493ad51b 100644 --- a/packages/payload/src/fields/baseFields/slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -67,7 +67,7 @@ export const slugField: SlugField = ({ checkboxName = 'generateSlug', fieldToUse = 'title', overrides, - position, + position = 'sidebar', required = true, } = {}) => { const baseField: RowField = { From 0fe318f4c346d96e4965be8e402706a4be418d0f Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 6 Oct 2025 13:21:24 -0400 Subject: [PATCH 25/25] safely slugify --- packages/payload/src/utilities/slugify.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/utilities/slugify.ts b/packages/payload/src/utilities/slugify.ts index c600ae941eb..13f1a3a3370 100644 --- a/packages/payload/src/utilities/slugify.ts +++ b/packages/payload/src/utilities/slugify.ts @@ -1,5 +1,5 @@ -export const slugify = (val: string): string => +export const slugify = (val?: string) => val - .replace(/ /g, '-') + ?.replace(/ /g, '-') .replace(/[^\w-]+/g, '') .toLowerCase()