Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/fields/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ The slug field exposes a few top-level config options for easy customization:
| `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`. |
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
| `position` | The position of the slug field. [More details](./overview#admin-options). |
| `required` | Require the slug field. Defaults to `true`. |

Expand Down
24 changes: 16 additions & 8 deletions packages/payload/src/fields/baseFields/slug/generateSlug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,31 @@ import type { FieldHook } from '../../config/types.js'
import { slugify } from '../../../utilities/slugify.js'
import { countVersions } from './countVersions.js'

type HookArgs = {
/**
* Current field name for the slug. Defaults to `slug`.
*/
fieldName?: string
fieldToUse: string
}

/**
* This is a `BeforeChange` field hook used to auto-generate the `slug` field.
* See `slugField` for more details.
*/
export const generateSlug =
(fallback: string): FieldHook =>
({ fieldName = 'slug', fieldToUse }: HookArgs): FieldHook =>
async (args) => {
const { collection, data, global, operation, originalDoc, value: isChecked } = args
const { collection, data, global, operation, originalDoc, req, value: isChecked } = 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 = slugify(data?.slug || data?.[fallback])
data[fieldName] = slugify(data?.[fieldName] || data?.[fieldToUse])
}

return Boolean(!data?.slug)
return Boolean(!data?.[fieldName])
}

if (operation === 'update') {
Expand All @@ -37,22 +45,22 @@ export const generateSlug =
if (!autosaveEnabled) {
// We can generate the slug at this point
if (data) {
data.slug = slugify(data?.[fallback])
data[fieldName] = slugify(data?.[fieldToUse])
}

return Boolean(!data?.slug)
return Boolean(!data?.[fieldName])
} 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
const userOverride = data?.[fieldName] !== originalDoc?.[fieldName]

if (!userOverride) {
if (data) {
// 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
data[fieldName] = data?.[fieldToUse] ? slugify(data[fieldToUse]) : null
}
}

Expand Down
13 changes: 10 additions & 3 deletions packages/payload/src/fields/baseFields/slug/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TextFieldClientProps } from '../../../admin/types.js'
import type { FieldAdmin, RowField } from '../../../fields/config/types.js'
import type { FieldAdmin, RowField, TextField } from '../../../fields/config/types.js'

import { generateSlug } from './generateSlug.js'

Expand All @@ -14,6 +14,10 @@ type SlugFieldArgs = {
* @default 'title'
*/
fieldToUse?: string
/**
* Enable localization for the slug field.
*/
localized?: TextField['localized']
/**
* Override for the `slug` field name.
* @default 'slug'
Expand All @@ -38,7 +42,7 @@ type SlugFieldArgs = {
* Whether or not the `slug` field is required.
* @default true
*/
required?: boolean
required?: TextField['required']
}

type SlugField = (args?: SlugFieldArgs) => RowField
Expand Down Expand Up @@ -66,6 +70,7 @@ export const slugField: SlugField = ({
name: fieldName = 'slug',
checkboxName = 'generateSlug',
fieldToUse = 'title',
localized,
overrides,
position = 'sidebar',
required = true,
Expand All @@ -90,8 +95,9 @@ export const slugField: SlugField = ({
},
defaultValue: true,
hooks: {
beforeChange: [generateSlug(fieldToUse)],
beforeChange: [generateSlug({ fieldName, fieldToUse })],
},
localized,
},
{
name: fieldName,
Expand All @@ -108,6 +114,7 @@ export const slugField: SlugField = ({
width: '100%',
},
index: true,
localized,
required,
unique: true,
},
Expand Down
2 changes: 2 additions & 0 deletions test/fields/baseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import RelationshipFields from './collections/Relationship/index.js'
import RowFields from './collections/Row/index.js'
import SelectFields from './collections/Select/index.js'
import SelectVersionsFields from './collections/SelectVersions/index.js'
import SlugField from './collections/SlugField/index.js'
import TabsFields from './collections/Tabs/index.js'
import { TabsFields2 } from './collections/Tabs2/index.js'
import TextFields from './collections/Text/index.js'
Expand Down Expand Up @@ -76,6 +77,7 @@ export const collectionSlugs: CollectionConfig[] = [
PointFields,
RelationshipFields,
SelectFields,
SlugField,
TabsFields2,
TabsFields,
TextFields,
Expand Down
120 changes: 120 additions & 0 deletions test/fields/collections/SlugField/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Page } from '@playwright/test'

import { expect, test } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'

import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
import type { Config } from '../../payload-types.js'

import {
changeLocale,
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { slugFieldsSlug } from '../../slugs.js'
import { slugFieldDoc } from './shared.js'

const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')

const { beforeAll, beforeEach, describe } = test

let payload: PayloadTestSDK<Config>
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
let url: AdminUrlUtil

describe('SlugField', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname,
// prebuild,
}))
url = new AdminUrlUtil(serverURL, slugFieldsSlug)

const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)

await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()

await ensureCompilationIsDone({ page, serverURL })
})

test('should generate slug for title field', async () => {
await page.goto(url.create)
await page.locator('#field-title').fill('Test title')

await saveDocAndAssert(page)

await expect(page.locator('#field-slug')).toHaveValue('test-title')
})

test('custom values should be kept', async () => {
await page.goto(url.create)
await page.locator('#field-title').fill('Test title with custom slug')

await saveDocAndAssert(page)

const slugField = page.locator('#field-slug')
await expect(slugField).toHaveValue('test-title-with-custom-slug')
await expect(slugField).toBeDisabled()

const unlockButton = page.locator('#field-generateSlug + div .lock-button')
await unlockButton.click()
await expect(slugField).toBeEnabled()

await slugField.fill('custom-slug-value')

await saveDocAndAssert(page)

await expect(slugField).toHaveValue('custom-slug-value')
})

describe('localized slugs', () => {
test('should generate slug for localized fields', async () => {
await page.goto(url.create)
await page.locator('#field-title').fill('Test normal title in default locale')
await page.locator('#field-localizedTitle').fill('Test title in english')

await saveDocAndAssert(page)

await expect(page.locator('#field-slug')).toHaveValue('test-normal-title-in-default-locale')
await expect(page.locator('#field-localizedSlug')).toHaveValue('test-title-in-english')

await changeLocale(page, 'es')

await expect(page.locator('#field-localizedTitle')).toBeEmpty()
await page.locator('#field-localizedTitle').fill('Title in spanish')

await saveDocAndAssert(page)

await expect(page.locator('#field-localizedSlug')).toHaveValue('title-in-spanish')
})
})
})
34 changes: 34 additions & 0 deletions test/fields/collections/SlugField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { CollectionConfig } from 'payload'

import { slugField } from 'payload'

import { defaultText, slugFieldSlug } from './shared.js'

const SlugField: CollectionConfig = {
slug: slugFieldSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
slugField(),
{
name: 'localizedTitle',
type: 'text',
localized: true,
},
slugField({
fieldToUse: 'localizedTitle',
name: 'localizedSlug',
checkboxName: 'generateLocalizedSlug',
localized: true,
required: false,
}),
],
}

export default SlugField
13 changes: 13 additions & 0 deletions test/fields/collections/SlugField/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { RequiredDataFromCollection } from 'payload'

import type { SlugField } from '../../payload-types.js'

export const defaultText = 'default-text'
export const slugFieldSlug = 'slug-fields'

export const slugFieldDoc: RequiredDataFromCollection<SlugField> = {
title: 'Seeded text document',
slug: 'seeded-text-document',
localizedTitle: 'Localized text',
localizedSlug: 'localized-text',
}
Loading