fix(templates): only generate slug from title on demand (#12956)

Currently, the slug field is generated from the title field
indefinitely, even after the document has been created and the initial
slug has been assigned. This should only occur on create, however, as it
currently does, or when the user explicitly requests it.

Given that slugs often determine the URL structure of the webpage that
the document corresponds to, they should rarely change after being
published, and when they do, would require HTTP redirects, etc. to do
right in a production environment.

But this is also a problem with Live Preview which relies on a constant
iframe src. If your Live Preview URL includes the slug as a route param,
which is often the case, then changing the slug will result in a broken
connection as the queried document can no longer be found. The current
workaround is to save the document and refresh the page.

Now, the slug is only generated on initial create, or when the user
explicitly clicks the new "generate" button above the slug field. In the
future we can evaluate supporting dynamic Live Preview URLs.

Regenerating this URL on every change would put additional load on the
client as it would have to reestablish connection every time it changes,
but it should be supported still. See #13055.

Discord discussion here:
https://discord.com/channels/967097582721572934/1102950643259424828/1387737976892686346

Related: #10536
This commit is contained in:
Jacob Fletcher
2025-08-11 17:12:43 -04:00
committed by GitHub
parent 1d81b0c6dd
commit 2bc9a2def4
6 changed files with 52 additions and 52 deletions

View File

@@ -1,5 +1,5 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import React, { useCallback } from 'react'
import { TextFieldClientProps } from 'payload'
import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui'
@@ -27,21 +27,18 @@ export const SlugComponent: React.FC<SlugComponentProps> = ({
const { value, setValue } = useField<string>({ path: path || field.name })
const { dispatchFields } = useForm()
const { dispatchFields, getDataByPath } = useForm()
// The value of the checkbox
// We're using separate useFormFields to minimise re-renders
const checkboxValue = useFormFields(([fields]) => {
const isLocked = useFormFields(([fields]) => {
return fields[checkboxFieldPath]?.value as string
})
// 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<Element>) => {
e.preventDefault()
const targetFieldValue = getDataByPath(fieldToUse) as string
useEffect(() => {
if (checkboxValue) {
if (targetFieldValue) {
const formattedSlug = formatSlug(targetFieldValue)
@@ -49,8 +46,9 @@ export const SlugComponent: React.FC<SlugComponentProps> = ({
} else {
if (value !== '') setValue('')
}
}
}, [targetFieldValue, checkboxValue, setValue, value])
},
[setValue, value, fieldToUse, getDataByPath],
)
const handleLock = useCallback(
(e: React.MouseEvent<Element>) => {
@@ -59,29 +57,30 @@ export const SlugComponent: React.FC<SlugComponentProps> = ({
dispatchFields({
type: 'UPDATE',
path: checkboxFieldPath,
value: !checkboxValue,
value: !isLocked,
})
},
[checkboxValue, checkboxFieldPath, dispatchFields],
[isLocked, checkboxFieldPath, dispatchFields],
)
const readOnly = readOnlyFromProps || checkboxValue
return (
<div className="field-type slug-field-component">
<div className="label-wrapper">
<FieldLabel htmlFor={`field-${path}`} label={label} />
{!isLocked && (
<Button className="lock-button" buttonStyle="none" onClick={handleGenerate}>
Generate
</Button>
)}
<Button className="lock-button" buttonStyle="none" onClick={handleLock}>
{checkboxValue ? 'Unlock' : 'Lock'}
{isLocked ? 'Unlock' : 'Lock'}
</Button>
</div>
<TextInput
value={value}
onChange={setValue}
path={path || field.name}
readOnly={Boolean(readOnly)}
readOnly={Boolean(readOnlyFromProps || isLocked)}
/>
</div>
)

View File

@@ -1,8 +1,8 @@
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()
@@ -13,10 +13,10 @@ export const formatSlugHook =
return formatSlug(value)
}
if (operation === 'create' || !data?.slug) {
const fallbackData = data?.[fallback] || data?.[fallback]
if (operation === 'create' || data?.slug === undefined) {
const fallbackData = data?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
if (typeof fallbackData === 'string') {
return formatSlug(fallbackData)
}
}

View File

@@ -3,6 +3,7 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(var(--base) / 2);
}
.lock-button {

View File

@@ -1,5 +1,5 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import React, { useCallback } from 'react'
import { TextFieldClientProps } from 'payload'
import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui'
@@ -27,21 +27,18 @@ export const SlugComponent: React.FC<SlugComponentProps> = ({
const { value, setValue } = useField<string>({ path: path || field.name })
const { dispatchFields } = useForm()
const { dispatchFields, getDataByPath } = useForm()
// The value of the checkbox
// We're using separate useFormFields to minimise re-renders
const checkboxValue = useFormFields(([fields]) => {
const isLocked = useFormFields(([fields]) => {
return fields[checkboxFieldPath]?.value as string
})
// 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<Element>) => {
e.preventDefault()
const targetFieldValue = getDataByPath(fieldToUse) as string
useEffect(() => {
if (checkboxValue) {
if (targetFieldValue) {
const formattedSlug = formatSlug(targetFieldValue)
@@ -49,8 +46,9 @@ export const SlugComponent: React.FC<SlugComponentProps> = ({
} else {
if (value !== '') setValue('')
}
}
}, [targetFieldValue, checkboxValue, setValue, value])
},
[setValue, value, fieldToUse, getDataByPath],
)
const handleLock = useCallback(
(e: React.MouseEvent<Element>) => {
@@ -59,29 +57,30 @@ export const SlugComponent: React.FC<SlugComponentProps> = ({
dispatchFields({
type: 'UPDATE',
path: checkboxFieldPath,
value: !checkboxValue,
value: !isLocked,
})
},
[checkboxValue, checkboxFieldPath, dispatchFields],
[isLocked, checkboxFieldPath, dispatchFields],
)
const readOnly = readOnlyFromProps || checkboxValue
return (
<div className="field-type slug-field-component">
<div className="label-wrapper">
<FieldLabel htmlFor={`field-${path}`} label={label} />
{!isLocked && (
<Button className="lock-button" buttonStyle="none" onClick={handleGenerate}>
Generate
</Button>
)}
<Button className="lock-button" buttonStyle="none" onClick={handleLock}>
{checkboxValue ? 'Unlock' : 'Lock'}
{isLocked ? 'Unlock' : 'Lock'}
</Button>
</div>
<TextInput
value={value}
onChange={setValue}
path={path || field.name}
readOnly={Boolean(readOnly)}
readOnly={Boolean(readOnlyFromProps || isLocked)}
/>
</div>
)

View File

@@ -1,8 +1,8 @@
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()
@@ -13,10 +13,10 @@ export const formatSlugHook =
return formatSlug(value)
}
if (operation === 'create' || !data?.slug) {
const fallbackData = data?.[fallback] || data?.[fallback]
if (operation === 'create' || data?.slug === undefined) {
const fallbackData = data?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
if (typeof fallbackData === 'string') {
return formatSlug(fallbackData)
}
}

View File

@@ -3,6 +3,7 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(var(--base) / 2);
}
.lock-button {