feat: slug field (#14007)

Discussion #8859. Requires #14012.

Exports a new `slugField`. This is a wrapper around the text field that
you can drop into any field schema.

A slug is a unique, indexed, URL-friendly string that identifies a
particular document, often used to construct the URL of a webpage. Slugs
are a fundamental concept for seemingly every project.

Traditionally, you'd build this field from scratch, but there are many
edge cases and nice-to-haves to makes this difficult to maintain by
hand. For example, it needs to automatically generate based on the value
of another field, provide UI to lock and re-generate the slug on-demand,
etc.

Fixes #13938.

When autosave is enabled, the slug is only ever generated once after the
initial create, leading to single character, or incomplete slugs.

For example, it is expected that "My Title" → "my-title, however ends up
as "m".

This PR overhauls the field to feel a lot more natural. Now, we only
generate the slug 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.

This fix, along with all the other features, is now standardized behind
the new `slugField`:

```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'

export const MyCollection: CollectionConfig = {
  // ...
  fields: [
   // ...
   slugField()
  ]
}
```

In the future we could also make this field smart enough to auto
increment itself when its generated slug is not unique.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211513433305005
This commit is contained in:
Jacob Fletcher
2025-10-07 10:15:45 -04:00
committed by GitHub
parent 444ca0f439
commit b09ae6772f
69 changed files with 636 additions and 999 deletions

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -55,8 +55,6 @@ _\* An asterisk denotes that a property is required._
## Example
`collections/ExampleCollection.ts`
```ts
import type { CollectionConfig } from 'payload'

View File

@@ -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'

View File

@@ -43,8 +43,6 @@ _\* An asterisk denotes that a property is required._
## Example
`collections/ExampleCollection.ts`
```ts
import type { CollectionConfig } from 'payload'

View File

@@ -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'

View File

@@ -56,8 +56,6 @@ _\* An asterisk denotes that a property is required._
## Example
`collections/ExampleCollection.ts`
```ts
import type { CollectionConfig } from 'payload'

View File

@@ -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
<Banner type="warning">
The slug field is experimental and may change, or even be removed, in future
releases. Use at your own risk.
</Banner>
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
],
}
```

View File

@@ -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'

View File

@@ -41,8 +41,6 @@ _\* An asterisk denotes that a property is required._
## Example
`collections/ExampleCollection.ts`
```ts
import type { CollectionConfig } from 'payload'

View File

@@ -75,8 +75,6 @@ _\* An asterisk denotes that a property is required._
## Example
`collections/ExampleCollection.ts`
```ts
import type { CollectionConfig } from 'payload'

View File

@@ -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'

View File

@@ -28,7 +28,7 @@ _Comparing an old version to a newer version of a document_
<Banner type="success">
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.
</Banner>
## Options

View File

@@ -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

View File

@@ -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
/**

View File

@@ -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<BusboyConfig>
/**

View File

@@ -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 {

View File

@@ -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<number> => {
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
}

View File

@@ -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
}
}
}
}

View File

@@ -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<SlugFieldArgs, 'fieldToUse'>
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
}

View File

@@ -340,7 +340,7 @@ export type BlocksFilterOptions<TData = any> =
) => BlockSlugOrString | Promise<BlockSlugOrString | true> | true)
| BlockSlugOrString
type Admin = {
export type FieldAdmin = {
className?: string
components?: {
Cell?: PayloadComponent<DefaultServerCellComponentProps, DefaultCellComponentProps>
@@ -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<NumberFieldErrorClientComponent | NumberFieldErrorServerComponent>
Label?: CustomComponent<NumberFieldLabelClientComponent | NumberFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
/** Set this property to define a placeholder string for the field. */
placeholder?: Record<string, string> | 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<TextFieldErrorClientComponent | TextFieldErrorServerComponent>
Label?: CustomComponent<TextFieldLabelClientComponent | TextFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
placeholder?: Record<string, string> | string
rtl?: boolean
} & Admin
} & FieldAdmin
maxLength?: number
minLength?: number
type: 'text'
@@ -668,9 +668,9 @@ export type EmailField = {
beforeInput?: CustomComponent[]
Error?: CustomComponent<EmailFieldErrorClientComponent | EmailFieldErrorServerComponent>
Label?: CustomComponent<EmailFieldLabelClientComponent | EmailFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
placeholder?: Record<string, string> | string
} & Admin
} & FieldAdmin
type: 'email'
validate?: EmailFieldValidation
} & Omit<FieldBase, 'validate'>
@@ -688,11 +688,11 @@ export type TextareaField = {
beforeInput?: CustomComponent[]
Error?: CustomComponent<TextareaFieldErrorClientComponent | TextareaFieldErrorServerComponent>
Label?: CustomComponent<TextareaFieldLabelClientComponent | TextareaFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
placeholder?: Record<string, string> | string
rows?: number
rtl?: boolean
} & Admin
} & FieldAdmin
maxLength?: number
minLength?: number
type: 'textarea'
@@ -712,8 +712,8 @@ export type CheckboxField = {
beforeInput?: CustomComponent[]
Error?: CustomComponent<CheckboxFieldErrorClientComponent | CheckboxFieldErrorServerComponent>
Label?: CustomComponent<CheckboxFieldLabelClientComponent | CheckboxFieldLabelServerComponent>
} & Admin['components']
} & Admin
} & FieldAdmin['components']
} & FieldAdmin
type: 'checkbox'
validate?: CheckboxFieldValidation
} & Omit<FieldBase, 'validate'>
@@ -730,10 +730,10 @@ export type DateField = {
beforeInput?: CustomComponent[]
Error?: CustomComponent<DateFieldErrorClientComponent | DateFieldErrorServerComponent>
Label?: CustomComponent<DateFieldLabelClientComponent | DateFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
date?: ConditionalDateProps
placeholder?: Record<string, string> | string
} & Admin
} & FieldAdmin
/**
* Enable timezone selection in the admin interface.
*/
@@ -754,9 +754,9 @@ export type GroupBase = {
afterInput?: CustomComponent[]
beforeInput?: CustomComponent[]
Label?: CustomComponent<GroupFieldLabelClientComponent | GroupFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
hideGutter?: boolean
} & Admin
} & FieldAdmin
fields: Field[]
type: 'group'
validate?: Validate<unknown, unknown, unknown, GroupField>
@@ -791,7 +791,7 @@ export type NamedGroupFieldClient = Pick<NamedGroupField, 'name'> & UnnamedGroup
export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient
export type RowField = {
admin?: Omit<Admin, 'description'>
admin?: Omit<FieldAdmin, 'description'>
fields: Field[]
type: 'row'
} & Omit<FieldBase, 'admin' | 'hooks' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
@@ -814,9 +814,9 @@ export type CollapsibleField = {
Label: CustomComponent<
CollapsibleFieldLabelClientComponent | CollapsibleFieldLabelServerComponent
>
} & Admin['components']
} & FieldAdmin['components']
initCollapsed?: boolean
} & Admin
} & FieldAdmin
label?: Required<FieldBase['label']>
}
| {
@@ -827,9 +827,9 @@ export type CollapsibleField = {
Label?: CustomComponent<
CollapsibleFieldLabelClientComponent | CollapsibleFieldLabelServerComponent
>
} & Admin['components']
} & FieldAdmin['components']
initCollapsed?: boolean
} & Admin
} & FieldAdmin
label: Required<FieldBase['label']>
}
) &
@@ -884,7 +884,7 @@ export type UnnamedTab = {
export type Tab = NamedTab | UnnamedTab
export type TabsField = {
admin?: Omit<Admin, 'description'>
admin?: Omit<FieldAdmin, 'description'>
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<string, any>
@@ -1014,9 +1014,9 @@ type UploadAdmin = {
Label?: CustomComponent<
RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent
>
} & Admin['components']
} & FieldAdmin['components']
isSortable?: boolean
} & Admin
} & FieldAdmin
type UploadAdminClient = AdminClient & Pick<UploadAdmin, 'allowCreate' | 'isSortable'>
@@ -1059,10 +1059,10 @@ export type CodeField = {
beforeInput?: CustomComponent[]
Error?: CustomComponent<CodeFieldErrorClientComponent | CodeFieldErrorServerComponent>
Label?: CustomComponent<CodeFieldLabelClientComponent | CodeFieldLabelServerComponent>
} & 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<JSONFieldErrorClientComponent | JSONFieldErrorServerComponent>
Label?: CustomComponent<JSONFieldLabelClientComponent | JSONFieldLabelServerComponent>
} & 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<SelectFieldErrorClientComponent | SelectFieldErrorServerComponent>
Label?: CustomComponent<SelectFieldLabelClientComponent | SelectFieldLabelServerComponent>
} & 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<RelationshipAdmin, 'allowCreate' | 'allowEdit' | 'appearance' | 'isSortable' | 'placeholder'>
@@ -1290,8 +1290,8 @@ export type RichTextField<
beforeInput?: CustomComponent[]
Error?: CustomComponent
Label?: CustomComponent
} & Admin['components']
} & Admin
} & FieldAdmin['components']
} & FieldAdmin
editor?:
| RichTextAdapter<TValue, TAdapterProps, TExtraProperties>
| RichTextAdapterProvider<TValue, TAdapterProps, TExtraProperties>
@@ -1321,13 +1321,13 @@ export type ArrayField = {
Error?: CustomComponent<ArrayFieldErrorClientComponent | ArrayFieldErrorServerComponent>
Label?: CustomComponent<ArrayFieldLabelClientComponent | ArrayFieldLabelServerComponent>
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<RadioFieldErrorClientComponent | RadioFieldErrorServerComponent>
Label?: CustomComponent<RadioFieldLabelClientComponent | RadioFieldLabelServerComponent>
} & 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<BlocksFieldErrorClientComponent | BlocksFieldErrorServerComponent>
Label?: CustomComponent<BlocksFieldLabelClientComponent | BlocksFieldLabelServerComponent>
} & 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<PointFieldErrorClientComponent | PointFieldErrorServerComponent>
Label?: CustomComponent<PointFieldLabelClientComponent | PointFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
placeholder?: Record<string, string> | string
step?: number
} & Admin
} & FieldAdmin
type: 'point'
validate?: PointFieldValidation
} & Omit<FieldBase, 'validate'>
@@ -1625,12 +1625,12 @@ export type JoinField = {
beforeInput?: CustomComponent[]
Error?: CustomComponent<JoinFieldErrorClientComponent | JoinFieldErrorServerComponent>
Label?: CustomComponent<JoinFieldLabelClientComponent | JoinFieldLabelServerComponent>
} & Admin['components']
} & FieldAdmin['components']
defaultColumns?: string[]
disableBulkEdit?: never
disableRowTypes?: boolean
readOnly?: never
} & Admin
} & FieldAdmin
/**
* The slug of the collection to relate with.
*/

View File

@@ -1413,6 +1413,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,

View File

@@ -0,0 +1,5 @@
export const slugify = (val?: string) =>
val
?.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()

View File

@@ -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()

View File

@@ -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<string, unknown>
doc: TypeWithID

View File

@@ -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'

View File

@@ -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;
}
}
}

View File

@@ -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<SlugFieldProps> = ({
field,
fieldToUse,
path,
readOnly: readOnlyFromProps,
}) => {
const { label } = field
const { setValue, value } = useField<string>({ path: path || field.name })
const { getDataByPath } = useForm()
const [isLocked, setIsLocked] = useState(true)
const handleGenerate = useCallback(
(e: React.MouseEvent<Element>) => {
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<Element>) => {
e.preventDefault()
setIsLocked((prev) => !prev)
}, [])
return (
<div className="field-type slug-field-component">
<div className="label-wrapper">
<FieldLabel htmlFor={`field-${path}`} label={label} />
{!isLocked && (
<Button buttonStyle="none" className="lock-button" onClick={handleGenerate}>
Generate
</Button>
)}
<Button buttonStyle="none" className="lock-button" onClick={toggleLock}>
{isLocked ? 'Unlock' : 'Lock'}
</Button>
</div>
<TextInput
onChange={setValue}
path={path || field.name}
readOnly={Boolean(readOnlyFromProps || isLocked)}
value={value}
/>
</div>
)
}

View File

@@ -120,7 +120,7 @@ export type Submit = <T extends Response, C extends Record<string, unknown>>(
) => Promise</**
* Returns the form state and the response from the server.
*
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use at your own discretion.
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use at your own risk.
*/
{ formState?: FormState; res: T } | void>

View File

@@ -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<string>(undefined)
@@ -29,7 +29,7 @@ export const FieldPathContext = React.createContext<string>(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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent'
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'
@@ -26,30 +26,51 @@ 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/ui#SlugField': SlugField_3817bf644402e67bfe6577f60ef982de,
'@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,
}

View File

@@ -1,4 +1,4 @@
import { slugField } from '@/fields/slug'
import { slugField } from 'payload'
import type { CollectionConfig } from 'payload'
export const Categories: CollectionConfig = {
@@ -16,13 +16,8 @@ export const Categories: CollectionConfig = {
type: 'text',
required: true,
},
...slugField('title', {
slugOverrides: {
required: true,
admin: {
position: undefined,
},
},
slugField({
position: undefined,
}),
],
}

View File

@@ -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 'payload'
import { adminOrPublishedStatus } from '@/access/adminOrPublishedStatus'
import {
MetaDescriptionField,
@@ -34,19 +34,16 @@ export const Pages: CollectionConfig = {
group: 'Content',
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, req }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug,
collection: 'pages',
req,
})
return path
},
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
slug: data?.slug as string,
collection: 'pages',
req,
}),
@@ -134,11 +131,7 @@ export const Pages: CollectionConfig = {
},
],
},
...slugField('title', {
slugOverrides: {
required: true,
},
}),
slugField(),
],
hooks: {
afterChange: [revalidatePage],

View File

@@ -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 'payload'
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
import { CollectionOverride } from '@payloadcms/plugin-ecommerce/types'
import {
@@ -26,19 +26,16 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) =>
...defaultCollection?.admin,
defaultColumns: ['title', 'enableVariants', '_status', 'variants.variants'],
livePreview: {
url: ({ data, req }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug,
collection: 'products',
req,
})
return path
},
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
slug: data?.slug as string,
collection: 'products',
req,
}),
@@ -210,10 +207,6 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) =>
hasMany: true,
relationTo: 'categories',
},
...slugField('title', {
slugOverrides: {
required: true,
},
}),
slugField(),
],
})

View File

@@ -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...`)

View File

@@ -1,88 +0,0 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import { TextFieldClientProps } from 'payload'
import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui'
import { formatSlug } from './formatSlug'
import './index.scss'
type SlugComponentProps = {
fieldToUse: string
checkboxFieldPath: string
} & TextFieldClientProps
export const SlugComponent: React.FC<SlugComponentProps> = ({
field,
fieldToUse,
checkboxFieldPath: checkboxFieldPathFromProps,
path,
readOnly: readOnlyFromProps,
}) => {
const { label } = field
const checkboxFieldPath = path?.includes('.')
? `${path}.${checkboxFieldPathFromProps}`
: checkboxFieldPathFromProps
const { value, setValue } = useField<string>({ path: path || field.name })
const { dispatchFields } = 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
})
// The value of the field we're listening to for the slug
const targetFieldValue = useFormFields(([fields]) => {
return fields[fieldToUse]?.value as string
})
useEffect(() => {
if (checkboxValue) {
if (targetFieldValue) {
const formattedSlug = formatSlug(targetFieldValue)
if (value !== formattedSlug) setValue(formattedSlug)
} else {
if (value !== '') setValue('')
}
}
}, [targetFieldValue, checkboxValue, setValue, value])
const handleLock = useCallback(
(e: React.MouseEvent<Element>) => {
e.preventDefault()
dispatchFields({
type: 'UPDATE',
path: checkboxFieldPath,
value: !checkboxValue,
})
},
[checkboxValue, checkboxFieldPath, dispatchFields],
)
const readOnly = readOnlyFromProps || checkboxValue
return (
<div className="field-type slug-field-component">
<div className="label-wrapper">
<FieldLabel htmlFor={`field-${path}`} label={label} />
<Button className="lock-button" buttonStyle="none" onClick={handleLock}>
{checkboxValue ? 'Unlock' : 'Lock'}
</Button>
</div>
<TextInput
value={value}
onChange={setValue}
path={path || field.name}
readOnly={Boolean(readOnly)}
/>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import type { FieldHook } from 'payload'
export const formatSlug = (val: string): string =>
val
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
export const formatSlugHook =
(fallback: string): FieldHook =>
({ data, operation, value }) => {
if (typeof value === 'string') {
return formatSlug(value)
}
if (operation === 'create' || !data?.slug) {
const fallbackData = data?.[fallback] || data?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
return formatSlug(fallbackData)
}
}
return value
}

View File

@@ -1,12 +0,0 @@
.slug-field-component {
.label-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
}
.lock-button {
margin: 0;
padding-bottom: 0.3125rem;
}
}

View File

@@ -1,53 +0,0 @@
import type { CheckboxField, TextField } from 'payload'
import { formatSlugHook } from './formatSlug'
type Overrides = {
slugOverrides?: Partial<TextField>
checkboxOverrides?: Partial<CheckboxField>
}
type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField]
export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => {
const { slugOverrides, checkboxOverrides } = overrides
const checkBoxField: CheckboxField = {
name: 'slugLock',
type: 'checkbox',
defaultValue: true,
admin: {
hidden: true,
position: 'sidebar',
},
...checkboxOverrides,
}
// @ts-expect-error - ts mismatch Partial<TextField> 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)],
},
admin: {
position: 'sidebar',
...(slugOverrides?.admin || {}),
components: {
Field: {
path: '@/fields/slug/SlugComponent#SlugComponent',
clientProps: {
fieldToUse,
checkboxFieldPath: checkBoxField.name,
},
},
},
},
}
return [slugField, checkBoxField]
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent'
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,
'@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
'@payloadcms/ui#SlugField': SlugField_3817bf644402e67bfe6577f60ef982de,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':

View File

@@ -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 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
@@ -21,6 +21,8 @@ export const Categories: CollectionConfig = {
type: 'text',
required: true,
},
...slugField(),
slugField({
position: undefined,
}),
],
}

View File

@@ -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 'payload'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
import { revalidateDelete, revalidatePage } from './hooks/revalidatePage'
@@ -39,19 +39,16 @@ export const Pages: CollectionConfig<'pages'> = {
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, req }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug,
collection: 'pages',
req,
})
return path
},
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
slug: data?.slug as string,
collection: 'pages',
req,
}),
@@ -120,7 +117,7 @@ export const Pages: CollectionConfig<'pages'> = {
position: 'sidebar',
},
},
...slugField(),
slugField(),
],
hooks: {
afterChange: [revalidatePage],

View File

@@ -25,7 +25,7 @@ import {
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { slugField } from 'payload'
export const Posts: CollectionConfig<'posts'> = {
slug: 'posts',
@@ -50,19 +50,16 @@ export const Posts: CollectionConfig<'posts'> = {
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, req }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug,
collection: 'posts',
req,
})
return path
},
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
slug: data?.slug as string,
collection: 'posts',
req,
}),
@@ -217,7 +214,7 @@ export const Posts: CollectionConfig<'posts'> = {
},
],
},
...slugField(),
slugField(),
],
hooks: {
afterChange: [revalidatePost],

View File

@@ -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...`)

View File

@@ -1,87 +0,0 @@
'use client'
import React, { useCallback } from 'react'
import { TextFieldClientProps } from 'payload'
import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui'
import { formatSlug } from './formatSlug'
import './index.scss'
type SlugComponentProps = {
fieldToUse: string
checkboxFieldPath: string
} & TextFieldClientProps
export const SlugComponent: React.FC<SlugComponentProps> = ({
field,
fieldToUse,
checkboxFieldPath: checkboxFieldPathFromProps,
path,
readOnly: readOnlyFromProps,
}) => {
const { label } = field
const checkboxFieldPath = path?.includes('.')
? `${path}.${checkboxFieldPathFromProps}`
: checkboxFieldPathFromProps
const { value, setValue } = useField<string>({ path: path || field.name })
const { dispatchFields, getDataByPath } = useForm()
const isLocked = useFormFields(([fields]) => {
return fields[checkboxFieldPath]?.value as string
})
const handleGenerate = useCallback(
(e: React.MouseEvent<Element>) => {
e.preventDefault()
const targetFieldValue = getDataByPath(fieldToUse) as string
if (targetFieldValue) {
const formattedSlug = formatSlug(targetFieldValue)
if (value !== formattedSlug) setValue(formattedSlug)
} else {
if (value !== '') setValue('')
}
},
[setValue, value, fieldToUse, getDataByPath],
)
const handleLock = useCallback(
(e: React.MouseEvent<Element>) => {
e.preventDefault()
dispatchFields({
type: 'UPDATE',
path: checkboxFieldPath,
value: !isLocked,
})
},
[isLocked, checkboxFieldPath, dispatchFields],
)
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}>
{isLocked ? 'Unlock' : 'Lock'}
</Button>
</div>
<TextInput
value={value}
onChange={setValue}
path={path || field.name}
readOnly={Boolean(readOnlyFromProps || isLocked)}
/>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import type { FieldHook } from 'payload'
export const formatSlug = (val: string): string | undefined =>
val
?.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
export const formatSlugHook =
(fallback: string): FieldHook =>
({ data, operation, value }) => {
if (typeof value === 'string') {
return formatSlug(value)
}
if (operation === 'create' || data?.slug === undefined) {
const fallbackData = data?.[fallback]
if (typeof fallbackData === 'string') {
return formatSlug(fallbackData)
}
}
return value
}

View File

@@ -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;
}
}

View File

@@ -1,53 +0,0 @@
import type { CheckboxField, TextField } from 'payload'
import { formatSlugHook } from './formatSlug'
type Overrides = {
slugOverrides?: Partial<TextField>
checkboxOverrides?: Partial<CheckboxField>
}
type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField]
export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => {
const { slugOverrides, checkboxOverrides } = overrides
const checkBoxField: CheckboxField = {
name: 'slugLock',
type: 'checkbox',
defaultValue: true,
admin: {
hidden: true,
position: 'sidebar',
},
...checkboxOverrides,
}
// @ts-expect-error - ts mismatch Partial<TextField> 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)],
},
admin: {
position: 'sidebar',
...(slugOverrides?.admin || {}),
components: {
Field: {
path: '@/fields/slug/SlugComponent#SlugComponent',
clientProps: {
fieldToUse,
checkboxFieldPath: checkBoxField.name,
},
},
},
},
}
return [slugField, checkBoxField]
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent'
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'
@@ -23,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':
@@ -58,7 +57,7 @@ export const importMap = {
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@payloadcms/plugin-seo/client#PreviewComponent':
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
'@payloadcms/ui#SlugField': SlugField_3817bf644402e67bfe6577f60ef982de,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
@@ -69,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,
}

View File

@@ -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 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
@@ -21,6 +21,8 @@ export const Categories: CollectionConfig = {
type: 'text',
required: true,
},
...slugField(),
slugField({
position: undefined,
}),
],
}

View File

@@ -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 'payload'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
import { revalidateDelete, revalidatePage } from './hooks/revalidatePage'
@@ -39,19 +39,16 @@ export const Pages: CollectionConfig<'pages'> = {
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, req }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug,
collection: 'pages',
req,
})
return path
},
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
slug: data?.slug as string,
collection: 'pages',
req,
}),
@@ -120,7 +117,7 @@ export const Pages: CollectionConfig<'pages'> = {
position: 'sidebar',
},
},
...slugField(),
slugField(),
],
hooks: {
afterChange: [revalidatePage],

View File

@@ -25,7 +25,7 @@ import {
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { slugField } from 'payload'
export const Posts: CollectionConfig<'posts'> = {
slug: 'posts',
@@ -50,19 +50,16 @@ export const Posts: CollectionConfig<'posts'> = {
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, req }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug,
collection: 'posts',
req,
})
return path
},
}),
},
preview: (data, { req }) =>
generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
slug: data?.slug as string,
collection: 'posts',
req,
}),
@@ -217,7 +214,7 @@ export const Posts: CollectionConfig<'posts'> = {
},
],
},
...slugField(),
slugField(),
],
hooks: {
afterChange: [revalidatePost],

View File

@@ -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...`)

View File

@@ -1,87 +0,0 @@
'use client'
import React, { useCallback } from 'react'
import { TextFieldClientProps } from 'payload'
import { useField, Button, TextInput, FieldLabel, useFormFields, useForm } from '@payloadcms/ui'
import { formatSlug } from './formatSlug'
import './index.scss'
type SlugComponentProps = {
fieldToUse: string
checkboxFieldPath: string
} & TextFieldClientProps
export const SlugComponent: React.FC<SlugComponentProps> = ({
field,
fieldToUse,
checkboxFieldPath: checkboxFieldPathFromProps,
path,
readOnly: readOnlyFromProps,
}) => {
const { label } = field
const checkboxFieldPath = path?.includes('.')
? `${path}.${checkboxFieldPathFromProps}`
: checkboxFieldPathFromProps
const { value, setValue } = useField<string>({ path: path || field.name })
const { dispatchFields, getDataByPath } = useForm()
const isLocked = useFormFields(([fields]) => {
return fields[checkboxFieldPath]?.value as string
})
const handleGenerate = useCallback(
(e: React.MouseEvent<Element>) => {
e.preventDefault()
const targetFieldValue = getDataByPath(fieldToUse) as string
if (targetFieldValue) {
const formattedSlug = formatSlug(targetFieldValue)
if (value !== formattedSlug) setValue(formattedSlug)
} else {
if (value !== '') setValue('')
}
},
[setValue, value, fieldToUse, getDataByPath],
)
const handleLock = useCallback(
(e: React.MouseEvent<Element>) => {
e.preventDefault()
dispatchFields({
type: 'UPDATE',
path: checkboxFieldPath,
value: !isLocked,
})
},
[isLocked, checkboxFieldPath, dispatchFields],
)
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}>
{isLocked ? 'Unlock' : 'Lock'}
</Button>
</div>
<TextInput
value={value}
onChange={setValue}
path={path || field.name}
readOnly={Boolean(readOnlyFromProps || isLocked)}
/>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import type { FieldHook } from 'payload'
export const formatSlug = (val: string): string | undefined =>
val
?.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
export const formatSlugHook =
(fallback: string): FieldHook =>
({ data, operation, value }) => {
if (typeof value === 'string') {
return formatSlug(value)
}
if (operation === 'create' || data?.slug === undefined) {
const fallbackData = data?.[fallback]
if (typeof fallbackData === 'string') {
return formatSlug(fallbackData)
}
}
return value
}

View File

@@ -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;
}
}

View File

@@ -1,53 +0,0 @@
import type { CheckboxField, TextField } from 'payload'
import { formatSlugHook } from './formatSlug'
type Overrides = {
slugOverrides?: Partial<TextField>
checkboxOverrides?: Partial<CheckboxField>
}
type Slug = (fieldToUse?: string, overrides?: Overrides) => [TextField, CheckboxField]
export const slugField: Slug = (fieldToUse = 'title', overrides = {}) => {
const { slugOverrides, checkboxOverrides } = overrides
const checkBoxField: CheckboxField = {
name: 'slugLock',
type: 'checkbox',
defaultValue: true,
admin: {
hidden: true,
position: 'sidebar',
},
...checkboxOverrides,
}
// @ts-expect-error - ts mismatch Partial<TextField> 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)],
},
admin: {
position: 'sidebar',
...(slugOverrides?.admin || {}),
components: {
Field: {
path: '@/fields/slug/SlugComponent#SlugComponent',
clientProps: {
fieldToUse,
checkboxFieldPath: checkBoxField.name,
},
},
},
},
}
return [slugField, checkBoxField]
}

View File

@@ -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

View File

@@ -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,