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..f73d16048a4 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, 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.
+
+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/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/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/payload/src/fields/baseFields/slug/generateSlug.ts b/packages/payload/src/fields/baseFields/slug/generateSlug.ts
new file mode 100644
index 00000000000..c065d6ae234
--- /dev/null
+++ b/packages/payload/src/fields/baseFields/slug/generateSlug.ts
@@ -0,0 +1,79 @@
+import type { FieldHook } from '../../config/types.js'
+
+import { slugify } from '../../../utilities/slugify.js'
+import { countVersions } from './countVersions.js'
+
+/**
+ * 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 { 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
+ if (operation === 'create') {
+ if (data) {
+ data.slug = slugify(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 = 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 userOverride = data?.slug !== originalDoc?.slug
+
+ 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
+ }
+ }
+
+ 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({
+ collectionSlug: collection?.slug,
+ globalSlug: global?.slug,
+ parentID: originalDoc?.id,
+ req: args.req,
+ })
+
+ if (versionCount <= 2) {
+ return true
+ } else {
+ return false
+ }
+ }
+ }
+ }
diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts
new file mode 100644
index 00000000000..562493ad51b
--- /dev/null
+++ b/packages/payload/src/fields/baseFields/slug/index.ts
@@ -0,0 +1,122 @@
+import type { TextFieldClientProps } from '../../../admin/types.js'
+import type { FieldAdmin, RowField } from '../../../fields/config/types.js'
+
+import { generateSlug } from './generateSlug.js'
+
+type SlugFieldArgs = {
+ /**
+ * 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'
+ */
+ fieldToUse?: 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.
+ * @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
+}
+
+type SlugField = (args?: SlugFieldArgs) => RowField
+
+export type SlugFieldClientProps = {} & Pick
+
+export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps
+
+/**
+ * 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:
+ * 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 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 risk.
+ */
+export const slugField: SlugField = ({
+ name: fieldName = 'slug',
+ checkboxName = 'generateSlug',
+ fieldToUse = 'title',
+ overrides,
+ position = 'sidebar',
+ required = true,
+} = {}) => {
+ const baseField: RowField = {
+ type: 'row',
+ admin: {
+ position,
+ },
+ fields: [
+ {
+ name: checkboxName,
+ type: 'checkbox',
+ 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,
+ hooks: {
+ beforeChange: [generateSlug(fieldToUse)],
+ },
+ },
+ {
+ name: fieldName,
+ type: 'text',
+ admin: {
+ components: {
+ Field: {
+ clientProps: {
+ fieldToUse,
+ } satisfies SlugFieldClientProps,
+ path: '@payloadcms/ui#SlugField',
+ },
+ },
+ width: '100%',
+ },
+ index: true,
+ required,
+ unique: true,
+ },
+ ],
+ }
+
+ if (typeof overrides === 'function') {
+ return overrides(baseField)
+ }
+
+ return baseField
+}
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..0017a40cd48 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, type SlugFieldProps } from './fields/baseFields/slug/index.js'
+
export {
createClientField,
createClientFields,
diff --git a/packages/payload/src/utilities/slugify.ts b/packages/payload/src/utilities/slugify.ts
new file mode 100644
index 00000000000..13f1a3a3370
--- /dev/null
+++ b/packages/payload/src/utilities/slugify.ts
@@ -0,0 +1,5 @@
+export const slugify = (val?: string) =>
+ val
+ ?.replace(/ /g, '-')
+ .replace(/[^\w-]+/g, '')
+ .toLowerCase()
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/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/exports/client/index.ts b/packages/ui/src/exports/client/index.ts
index 9f03d4032c3..9f67db6922a 100644
--- a/packages/ui/src/exports/client/index.ts
+++ b/packages/ui/src/exports/client/index.ts
@@ -202,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/fields/Slug/index.scss b/packages/ui/src/fields/Slug/index.scss
new file mode 100644
index 00000000000..a67a31a64b9
--- /dev/null
+++ b/packages/ui/src/fields/Slug/index.scss
@@ -0,0 +1,17 @@
+@layer payload-default {
+ .slug-field-component {
+ width: 100%;
+
+ .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/fields/Slug/index.tsx b/packages/ui/src/fields/Slug/index.tsx
new file mode 100644
index 00000000000..89d9813a930
--- /dev/null
+++ b/packages/ui/src/fields/Slug/index.tsx
@@ -0,0 +1,78 @@
+'use client'
+import type { SlugFieldProps } from 'payload'
+
+import { slugify } from 'payload/shared'
+import React, { useCallback, useState } from 'react'
+
+import { Button } from '../../elements/Button/index.js'
+import { useForm } from '../../forms/Form/index.js'
+import { useField } from '../../forms/useField/index.js'
+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 risk.
+ */
+export const SlugField: React.FC = ({
+ field,
+ fieldToUse,
+ path,
+ readOnly: readOnlyFromProps,
+}) => {
+ const { label } = field
+
+ const { setValue, value } = 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)
+
+ if (targetFieldValue) {
+ const formattedSlug = slugify(targetFieldValue as string)
+
+ 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 (
+