Skip to content

Commit 2ced43d

Browse files
authored
fix: allow slugField to accept localized argument and fixed slug generation with custom field names (#14234)
Previously you had to use the overrides to enable localization for the `slugField` however it's a very common request that slugs are localized so we've added it to the args. Now you can do: ```ts slugField({ localized: true }) ``` There was also a bug relating to using custom field names, even though we accept it the hook would still look for `slug`, this has been fixed. Todo: - [x] Add e2e tests for general field and localized field
1 parent fed3bba commit 2ced43d

File tree

11 files changed

+268
-17
lines changed

11 files changed

+268
-17
lines changed

docs/fields/text.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ The slug field exposes a few top-level config options for easy customization:
218218
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](./slug-overrides). |
219219
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
220220
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
221+
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
221222
| `position` | The position of the slug field. [More details](./overview#admin-options). |
222223
| `required` | Require the slug field. Defaults to `true`. |
223224

packages/payload/src/fields/baseFields/slug/generateSlug.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,31 @@ import type { FieldHook } from '../../config/types.js'
33
import { slugify } from '../../../utilities/slugify.js'
44
import { countVersions } from './countVersions.js'
55

6+
type HookArgs = {
7+
/**
8+
* Current field name for the slug. Defaults to `slug`.
9+
*/
10+
fieldName?: string
11+
fieldToUse: string
12+
}
13+
614
/**
715
* This is a `BeforeChange` field hook used to auto-generate the `slug` field.
816
* See `slugField` for more details.
917
*/
1018
export const generateSlug =
11-
(fallback: string): FieldHook =>
19+
({ fieldName = 'slug', fieldToUse }: HookArgs): FieldHook =>
1220
async (args) => {
13-
const { collection, data, global, operation, originalDoc, value: isChecked } = args
21+
const { collection, data, global, operation, originalDoc, req, value: isChecked } = args
1422

1523
// Ensure user-defined slugs are not overwritten during create
1624
// Use a generic falsy check here to include empty strings
1725
if (operation === 'create') {
1826
if (data) {
19-
data.slug = slugify(data?.slug || data?.[fallback])
27+
data[fieldName] = slugify(data?.[fieldName] || data?.[fieldToUse])
2028
}
2129

22-
return Boolean(!data?.slug)
30+
return Boolean(!data?.[fieldName])
2331
}
2432

2533
if (operation === 'update') {
@@ -37,22 +45,22 @@ export const generateSlug =
3745
if (!autosaveEnabled) {
3846
// We can generate the slug at this point
3947
if (data) {
40-
data.slug = slugify(data?.[fallback])
48+
data[fieldName] = slugify(data?.[fieldToUse])
4149
}
4250

43-
return Boolean(!data?.slug)
51+
return Boolean(!data?.[fieldName])
4452
} else {
4553
// If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2)
4654
const isPublishing = data?._status === 'published'
4755

4856
// Ensure the user can take over the generated slug themselves without it ever being overridden back
49-
const userOverride = data?.slug !== originalDoc?.slug
57+
const userOverride = data?.[fieldName] !== originalDoc?.[fieldName]
5058

5159
if (!userOverride) {
5260
if (data) {
5361
// If the fallback is an empty string, we want the slug to return to `null`
5462
// This will ensure that live preview conditions continue to run as expected
55-
data.slug = data?.[fallback] ? slugify(data[fallback]) : null
63+
data[fieldName] = data?.[fieldToUse] ? slugify(data[fieldToUse]) : null
5664
}
5765
}
5866

packages/payload/src/fields/baseFields/slug/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { TextFieldClientProps } from '../../../admin/types.js'
2-
import type { FieldAdmin, RowField } from '../../../fields/config/types.js'
2+
import type { FieldAdmin, RowField, TextField } from '../../../fields/config/types.js'
33

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

@@ -14,6 +14,10 @@ type SlugFieldArgs = {
1414
* @default 'title'
1515
*/
1616
fieldToUse?: string
17+
/**
18+
* Enable localization for the slug field.
19+
*/
20+
localized?: TextField['localized']
1721
/**
1822
* Override for the `slug` field name.
1923
* @default 'slug'
@@ -38,7 +42,7 @@ type SlugFieldArgs = {
3842
* Whether or not the `slug` field is required.
3943
* @default true
4044
*/
41-
required?: boolean
45+
required?: TextField['required']
4246
}
4347

4448
type SlugField = (args?: SlugFieldArgs) => RowField
@@ -66,6 +70,7 @@ export const slugField: SlugField = ({
6670
name: fieldName = 'slug',
6771
checkboxName = 'generateSlug',
6872
fieldToUse = 'title',
73+
localized,
6974
overrides,
7075
position = 'sidebar',
7176
required = true,
@@ -90,8 +95,9 @@ export const slugField: SlugField = ({
9095
},
9196
defaultValue: true,
9297
hooks: {
93-
beforeChange: [generateSlug(fieldToUse)],
98+
beforeChange: [generateSlug({ fieldName, fieldToUse })],
9499
},
100+
localized,
95101
},
96102
{
97103
name: fieldName,
@@ -108,6 +114,7 @@ export const slugField: SlugField = ({
108114
width: '100%',
109115
},
110116
index: true,
117+
localized,
111118
required,
112119
unique: true,
113120
},

test/fields/baseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import RelationshipFields from './collections/Relationship/index.js'
2727
import RowFields from './collections/Row/index.js'
2828
import SelectFields from './collections/Select/index.js'
2929
import SelectVersionsFields from './collections/SelectVersions/index.js'
30+
import SlugField from './collections/SlugField/index.js'
3031
import TabsFields from './collections/Tabs/index.js'
3132
import { TabsFields2 } from './collections/Tabs2/index.js'
3233
import TextFields from './collections/Text/index.js'
@@ -76,6 +77,7 @@ export const collectionSlugs: CollectionConfig[] = [
7677
PointFields,
7778
RelationshipFields,
7879
SelectFields,
80+
SlugField,
7981
TabsFields2,
8082
TabsFields,
8183
TextFields,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { Page } from '@playwright/test'
2+
3+
import { expect, test } from '@playwright/test'
4+
import path from 'path'
5+
import { fileURLToPath } from 'url'
6+
7+
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
8+
import type { Config } from '../../payload-types.js'
9+
10+
import {
11+
changeLocale,
12+
ensureCompilationIsDone,
13+
initPageConsoleErrorCatch,
14+
saveDocAndAssert,
15+
} from '../../../helpers.js'
16+
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
17+
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
18+
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
19+
import { RESTClient } from '../../../helpers/rest.js'
20+
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
21+
import { slugFieldsSlug } from '../../slugs.js'
22+
import { slugFieldDoc } from './shared.js'
23+
24+
const filename = fileURLToPath(import.meta.url)
25+
const currentFolder = path.dirname(filename)
26+
const dirname = path.resolve(currentFolder, '../../')
27+
28+
const { beforeAll, beforeEach, describe } = test
29+
30+
let payload: PayloadTestSDK<Config>
31+
let client: RESTClient
32+
let page: Page
33+
let serverURL: string
34+
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
35+
let url: AdminUrlUtil
36+
37+
describe('SlugField', () => {
38+
beforeAll(async ({ browser }, testInfo) => {
39+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
40+
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
41+
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
42+
dirname,
43+
// prebuild,
44+
}))
45+
url = new AdminUrlUtil(serverURL, slugFieldsSlug)
46+
47+
const context = await browser.newContext()
48+
page = await context.newPage()
49+
initPageConsoleErrorCatch(page)
50+
51+
await ensureCompilationIsDone({ page, serverURL })
52+
})
53+
beforeEach(async () => {
54+
await reInitializeDB({
55+
serverURL,
56+
snapshotKey: 'fieldsTest',
57+
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
58+
})
59+
60+
if (client) {
61+
await client.logout()
62+
}
63+
client = new RESTClient({ defaultSlug: 'users', serverURL })
64+
await client.login()
65+
66+
await ensureCompilationIsDone({ page, serverURL })
67+
})
68+
69+
test('should generate slug for title field', async () => {
70+
await page.goto(url.create)
71+
await page.locator('#field-title').fill('Test title')
72+
73+
await saveDocAndAssert(page)
74+
75+
await expect(page.locator('#field-slug')).toHaveValue('test-title')
76+
})
77+
78+
test('custom values should be kept', async () => {
79+
await page.goto(url.create)
80+
await page.locator('#field-title').fill('Test title with custom slug')
81+
82+
await saveDocAndAssert(page)
83+
84+
const slugField = page.locator('#field-slug')
85+
await expect(slugField).toHaveValue('test-title-with-custom-slug')
86+
await expect(slugField).toBeDisabled()
87+
88+
const unlockButton = page.locator('#field-generateSlug + div .lock-button')
89+
await unlockButton.click()
90+
await expect(slugField).toBeEnabled()
91+
92+
await slugField.fill('custom-slug-value')
93+
94+
await saveDocAndAssert(page)
95+
96+
await expect(slugField).toHaveValue('custom-slug-value')
97+
})
98+
99+
describe('localized slugs', () => {
100+
test('should generate slug for localized fields', async () => {
101+
await page.goto(url.create)
102+
await page.locator('#field-title').fill('Test normal title in default locale')
103+
await page.locator('#field-localizedTitle').fill('Test title in english')
104+
105+
await saveDocAndAssert(page)
106+
107+
await expect(page.locator('#field-slug')).toHaveValue('test-normal-title-in-default-locale')
108+
await expect(page.locator('#field-localizedSlug')).toHaveValue('test-title-in-english')
109+
110+
await changeLocale(page, 'es')
111+
112+
await expect(page.locator('#field-localizedTitle')).toBeEmpty()
113+
await page.locator('#field-localizedTitle').fill('Title in spanish')
114+
115+
await saveDocAndAssert(page)
116+
117+
await expect(page.locator('#field-localizedSlug')).toHaveValue('title-in-spanish')
118+
})
119+
})
120+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { slugField } from 'payload'
4+
5+
import { defaultText, slugFieldSlug } from './shared.js'
6+
7+
const SlugField: CollectionConfig = {
8+
slug: slugFieldSlug,
9+
admin: {
10+
useAsTitle: 'title',
11+
},
12+
fields: [
13+
{
14+
name: 'title',
15+
type: 'text',
16+
required: true,
17+
},
18+
slugField(),
19+
{
20+
name: 'localizedTitle',
21+
type: 'text',
22+
localized: true,
23+
},
24+
slugField({
25+
fieldToUse: 'localizedTitle',
26+
name: 'localizedSlug',
27+
checkboxName: 'generateLocalizedSlug',
28+
localized: true,
29+
required: false,
30+
}),
31+
],
32+
}
33+
34+
export default SlugField
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { RequiredDataFromCollection } from 'payload'
2+
3+
import type { SlugField } from '../../payload-types.js'
4+
5+
export const defaultText = 'default-text'
6+
export const slugFieldSlug = 'slug-fields'
7+
8+
export const slugFieldDoc: RequiredDataFromCollection<SlugField> = {
9+
title: 'Seeded text document',
10+
slug: 'seeded-text-document',
11+
localizedTitle: 'Localized text',
12+
localizedSlug: 'localized-text',
13+
}

0 commit comments

Comments
 (0)