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:
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -55,8 +55,6 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
## Example
|
||||
|
||||
`collections/ExampleCollection.ts`
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
## Example
|
||||
|
||||
`collections/ExampleCollection.ts`
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
## Example
|
||||
|
||||
`collections/ExampleCollection.ts`
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
## Example
|
||||
|
||||
`collections/ExampleCollection.ts`
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
||||
@@ -75,8 +75,6 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
## Example
|
||||
|
||||
`collections/ExampleCollection.ts`
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
51
packages/payload/src/fields/baseFields/slug/countVersions.ts
Normal file
51
packages/payload/src/fields/baseFields/slug/countVersions.ts
Normal 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
|
||||
}
|
||||
79
packages/payload/src/fields/baseFields/slug/generateSlug.ts
Normal file
79
packages/payload/src/fields/baseFields/slug/generateSlug.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
packages/payload/src/fields/baseFields/slug/index.ts
Normal file
122
packages/payload/src/fields/baseFields/slug/index.ts
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
packages/payload/src/utilities/slugify.ts
Normal file
5
packages/payload/src/utilities/slugify.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const slugify = (val?: string) =>
|
||||
val
|
||||
?.replace(/ /g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.toLowerCase()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
17
packages/ui/src/fields/Slug/index.scss
Normal file
17
packages/ui/src/fields/Slug/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
packages/ui/src/fields/Slug/index.tsx
Normal file
78
packages/ui/src/fields/Slug/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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...`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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...`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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...`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user