diff --git a/docs/fields/collapsible.mdx b/docs/fields/collapsible.mdx index 0d06e91a6f3..c98b6c06f2e 100644 --- a/docs/fields/collapsible.mdx +++ b/docs/fields/collapsible.mdx @@ -66,8 +66,6 @@ The Collapsible Field inherits all of the default admin options from the base [F ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index 3f575a52d8e..d04d1aba29d 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -96,8 +96,6 @@ When only `pickerAppearance` is set, an equivalent format will be rendered in th ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index 206c74beb13..80feb936e5f 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -74,8 +74,6 @@ The Email Field inherits all of the default admin options from the base [Field A ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index e3803afea2f..1772851f63f 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -77,8 +77,6 @@ The Group Field inherits all of the default admin options from the base [Field A ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/json.mdx b/docs/fields/json.mdx index 4b22f75aff7..2d74dc55264 100644 --- a/docs/fields/json.mdx +++ b/docs/fields/json.mdx @@ -75,8 +75,6 @@ The JSON Field inherits all of the default admin options from the base [Field Ad ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 3cee5a4bde3..3b1355c97a9 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -80,8 +80,6 @@ The Number Field inherits all of the default admin options from the base [Field ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx index 88cabbae74b..c9085e32388 100644 --- a/docs/fields/point.mdx +++ b/docs/fields/point.mdx @@ -55,8 +55,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index 2e11f3715a4..88038346efb 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -90,8 +90,6 @@ The Radio Field inherits all of the default admin options from the base [Field A ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/row.mdx b/docs/fields/row.mdx index 6a2262d4b66..1a3f4a315c8 100644 --- a/docs/fields/row.mdx +++ b/docs/fields/row.mdx @@ -43,8 +43,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index b2fbfca3e7e..1e88a1d15fe 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -149,8 +149,6 @@ The Select Field inherits all of the default admin options from the base [Field ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/tabs.mdx b/docs/fields/tabs.mdx index 723a6d9f139..7670d22dff2 100644 --- a/docs/fields/tabs.mdx +++ b/docs/fields/tabs.mdx @@ -56,8 +56,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index c1cd4e77e1d..f73d16048a4 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -80,8 +80,6 @@ The Text Field inherits all of the default admin options from the base [Field Ad ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' @@ -183,3 +181,66 @@ export const CustomTextFieldLabelClient: TextFieldLabelClientComponent = ({ ) } ``` + +## Slug Field + + + The slug field is experimental and may change, or even be removed, in future + releases. Use at your own risk. + + +One common use case for the Text Field is to create a "slug" for a document. A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. + +Payload provides a built-in Slug Field so you don't have to built one from scratch. This field automatically generates a slug based on the value of another field, such as a title or name field. It provides UI to lock and unlock the field to protect its value, as well as to re-generate the slug on-demand. + +To add a Slug Field, import the `slugField` into your field schema: + +```ts +import { slugField } from 'payload' +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + // ... + fields: [ + // ... + // highlight-line + slugField(), + // highlight-line + ], +} +``` + +The slug field exposes a few top-level config options for easy customization: + +| Option | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | To be used as the slug field's name. Defaults to `slug`. | +| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](./slug-overrides). | +| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. | +| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. | +| `position` | The position of the slug field. [More details](./overview#admin-options). | +| `required` | Require the slug field. Defaults to `true`. | + +### Slug Overrides + +If the above options aren't sufficient for your use case, you can use the `overrides` function to customize the slug field at a granular level. The `overrides` function receives the default fields that make up the slug field, and you can modify them to any extent you need. + +```ts +import { slugField } from 'payload' +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + // ... + fields: [ + // ... + // highlight-line + slugField({ + overrides: (defaultField) => { + defaultField.fields[1].label = 'Custom Slug Label' + return defaultField + }, + }), + // highlight-line + ], +} +``` diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 6c4aa78f525..bfab2c2b76a 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -78,8 +78,6 @@ The Textarea Field inherits all of the default admin options from the base [Fiel ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/ui.mdx b/docs/fields/ui.mdx index e7d787b88b5..35c4344b1e9 100644 --- a/docs/fields/ui.mdx +++ b/docs/fields/ui.mdx @@ -41,8 +41,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 177bcf3dc82..17e340f2a77 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -75,8 +75,6 @@ _\* An asterisk denotes that a property is required._ ## Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/rich-text/slate.mdx b/docs/rich-text/slate.mdx index e8b3264c8fb..a8ddf42c281 100644 --- a/docs/rich-text/slate.mdx +++ b/docs/rich-text/slate.mdx @@ -170,8 +170,6 @@ Specifying custom `Type`s let you extend your custom elements by adding addition ### Example -`collections/ExampleCollection.ts` - ```ts import type { CollectionConfig } from 'payload' diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index 62b0c6f9c7a..b37670bdd48 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -28,7 +28,7 @@ _Comparing an old version to a newer version of a document_ Versions are extremely performant and totally opt-in. They don't change the shape of your data at all. All versions are stored in a separate Collection - and can be turned on and off easily at your discretion. + and can be turned on and off easily at your risk. ## Options diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index ea89673ef64..d02039204be 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -67,7 +67,7 @@ export type FieldState = { * Every time a field is changed locally, this flag is set to true. Prevents form state from server from overwriting local changes. * After merging server form state, this flag is reset. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This property is experimental and may change in the future. Use at your own risk. */ isModified?: boolean /** @@ -149,7 +149,7 @@ export type BuildFormStateArgs = { * This will retrieve the client config in its entirety, even when unauthenticated. * For example, the create-first-user view needs the entire config, but there is no user yet. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This property is experimental and may change in the future. Use at your own risk. */ skipClientConfigAuth?: boolean skipValidation?: boolean diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 385c75f3091..07711c56aa8 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -391,7 +391,7 @@ export type CollectionAdminOptions = { * If your cells require specific fields that may be unselected, such as within hooks, etc., * use `forceSelect` in conjunction with this property. * - * @experimental This is an experimental feature and may change in the future. Use at your own discretion. + * @experimental This is an experimental feature and may change in the future. Use at your own risk. */ enableListViewSelectAPI?: boolean enableRichTextLink?: boolean @@ -428,7 +428,7 @@ export type CollectionAdminOptions = { * @description Enable grouping by a field in the list view. * Uses `payload.findDistinct` under the hood to populate the group-by options. * - * @experimental This option is currently in beta and may change in future releases. Use at your own discretion. + * @experimental This option is currently in beta and may change in future releases. Use at your own risk. */ groupBy?: boolean /** diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 64d6ec8ea74..51a8a100d37 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -963,7 +963,7 @@ export type Config = { * Configure toast message behavior and appearance in the admin panel. * Currently using [Sonner](https://sonner.emilkowal.ski) for toast notifications. * - * @experimental This property is experimental and may change in future releases. Use at your own discretion. + * @experimental This property is experimental and may change in future releases. Use at your own risk. */ toast?: { /** @@ -1006,7 +1006,7 @@ export type Config = { * For example, you may want to increase the `limits` imposed by the parser. * Currently using @link {https://www.npmjs.com/package/busboy|busboy} under the hood. * - * @experimental This property is experimental and may change in future releases. Use at your own discretion. + * @experimental This property is experimental and may change in future releases. Use at your own risk. */ bodyParser?: Partial /** diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index d68a64bf192..89f1e439cd5 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -79,12 +79,11 @@ export { extractID } from '../utilities/extractID.js' export { flattenAllFields } from '../utilities/flattenAllFields.js' export { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js' export { formatAdminURL } from '../utilities/formatAdminURL.js' - export { formatLabels, toWords } from '../utilities/formatLabels.js' + export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js' export { getDataByPath } from '../utilities/getDataByPath.js' export { getFieldPermissions } from '../utilities/getFieldPermissions.js' - export { getSafeRedirect } from '../utilities/getSafeRedirect.js' export { getSelectMode } from '../utilities/getSelectMode.js' @@ -116,6 +115,8 @@ export { sanitizeUserDataForEmail } from '../utilities/sanitizeUserDataForEmail. export { setsAreEqual } from '../utilities/setsAreEqual.js' +export { slugify } from '../utilities/slugify.js' + export { toKebabCase } from '../utilities/toKebabCase.js' export { diff --git a/packages/payload/src/fields/baseFields/slug/countVersions.ts b/packages/payload/src/fields/baseFields/slug/countVersions.ts new file mode 100644 index 00000000000..e122c8b5ccc --- /dev/null +++ b/packages/payload/src/fields/baseFields/slug/countVersions.ts @@ -0,0 +1,51 @@ +import type { + CollectionSlug, + DefaultDocumentIDType, + GlobalSlug, + PayloadRequest, + Where, +} from '../../../index.js' + +/** + * This is a cross-entity way to count the number of versions for any given document. + * It will work for both collections and globals. + * @returns number of versions + */ +export const countVersions = async (args: { + collectionSlug?: CollectionSlug + globalSlug?: GlobalSlug + parentID?: DefaultDocumentIDType + req: PayloadRequest +}): Promise => { + const { collectionSlug, globalSlug, parentID, req } = args + + let countFn + + const where: Where = { + parent: { + equals: parentID, + }, + } + + if (collectionSlug) { + countFn = () => + req.payload.countVersions({ + collection: collectionSlug, + depth: 0, + where, + }) + } + + if (globalSlug) { + countFn = () => + req.payload.countGlobalVersions({ + depth: 0, + global: globalSlug, + where, + }) + } + + const res = countFn ? (await countFn()?.then((res) => res.totalDocs || 0)) || 0 : 0 + + return res +} diff --git a/packages/payload/src/fields/baseFields/slug/generateSlug.ts b/packages/payload/src/fields/baseFields/slug/generateSlug.ts new file mode 100644 index 00000000000..c065d6ae234 --- /dev/null +++ b/packages/payload/src/fields/baseFields/slug/generateSlug.ts @@ -0,0 +1,79 @@ +import type { FieldHook } from '../../config/types.js' + +import { slugify } from '../../../utilities/slugify.js' +import { countVersions } from './countVersions.js' + +/** + * This is a `BeforeChange` field hook used to auto-generate the `slug` field. + * See `slugField` for more details. + */ +export const generateSlug = + (fallback: string): FieldHook => + async (args) => { + const { collection, data, global, operation, originalDoc, value: isChecked } = args + + // Ensure user-defined slugs are not overwritten during create + // Use a generic falsy check here to include empty strings + if (operation === 'create') { + if (data) { + data.slug = slugify(data?.slug || data?.[fallback]) + } + + return Boolean(!data?.slug) + } + + if (operation === 'update') { + // Early return to avoid additional processing + if (!isChecked) { + return false + } + + const autosaveEnabled = Boolean( + (typeof collection?.versions?.drafts === 'object' && + collection?.versions?.drafts.autosave) || + (typeof global?.versions?.drafts === 'object' && global?.versions?.drafts.autosave), + ) + + if (!autosaveEnabled) { + // We can generate the slug at this point + if (data) { + data.slug = slugify(data?.[fallback]) + } + + return Boolean(!data?.slug) + } else { + // If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2) + const isPublishing = data?._status === 'published' + + // Ensure the user can take over the generated slug themselves without it ever being overridden back + const userOverride = data?.slug !== originalDoc?.slug + + if (!userOverride) { + if (data) { + // If the fallback is an empty string, we want the slug to return to `null` + // This will ensure that live preview conditions continue to run as expected + data.slug = data?.[fallback] ? slugify(data[fallback]) : null + } + } + + if (isPublishing || userOverride) { + return false + } + + // Important: ensure `countVersions` is not called unnecessarily often + // That is why this is buried beneath all these conditions + const versionCount = await countVersions({ + collectionSlug: collection?.slug, + globalSlug: global?.slug, + parentID: originalDoc?.id, + req: args.req, + }) + + if (versionCount <= 2) { + return true + } else { + return false + } + } + } + } diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts new file mode 100644 index 00000000000..562493ad51b --- /dev/null +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -0,0 +1,122 @@ +import type { TextFieldClientProps } from '../../../admin/types.js' +import type { FieldAdmin, RowField } from '../../../fields/config/types.js' + +import { generateSlug } from './generateSlug.js' + +type SlugFieldArgs = { + /** + * Override for the `generateSlug` checkbox field name. + * @default 'generateSlug' + */ + checkboxName?: string + /** + * The name of the field to generate the slug from, when applicable. + * @default 'title' + */ + fieldToUse?: string + /** + * Override for the `slug` field name. + * @default 'slug' + */ + name?: string + /** + * A function used to override te fields at a granular level. + * Passes the row field to you to manipulate beyond the exposed options. + * @example + * ```ts + * slugField({ + * overrides: (field) => { + * field.fields[1].label = 'Custom Slug Label' + * return field + * } + * }) + * ``` + */ + overrides?: (field: RowField) => RowField + position?: FieldAdmin['position'] + /** + * Whether or not the `slug` field is required. + * @default true + */ + required?: boolean +} + +type SlugField = (args?: SlugFieldArgs) => RowField + +export type SlugFieldClientProps = {} & Pick + +export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps + +/** + * A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. + * The `slug` field auto-generates its value based on another field, e.g. "My Title" → "my-title". + * + * The slug should continue to be generated through: + * 1. The `create` operation, unless the user has modified the slug manually + * 2. The `update` operation, if: + * a. Autosave is _not_ enabled and there is no slug + * b. Autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug manually + * + * The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. + * This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. + * + * @experimental This field is experimental and may change or be removed in the future. Use at your own risk. + */ +export const slugField: SlugField = ({ + name: fieldName = 'slug', + checkboxName = 'generateSlug', + fieldToUse = 'title', + overrides, + position = 'sidebar', + required = true, +} = {}) => { + const baseField: RowField = { + type: 'row', + admin: { + position, + }, + fields: [ + { + name: checkboxName, + type: 'checkbox', + admin: { + description: + 'When enabled, the slug will auto-generate from the title field on save and autosave.', + disableBulkEdit: true, + disableGroupBy: true, + disableListColumn: true, + disableListFilter: true, + hidden: true, + }, + defaultValue: true, + hooks: { + beforeChange: [generateSlug(fieldToUse)], + }, + }, + { + name: fieldName, + type: 'text', + admin: { + components: { + Field: { + clientProps: { + fieldToUse, + } satisfies SlugFieldClientProps, + path: '@payloadcms/ui#SlugField', + }, + }, + width: '100%', + }, + index: true, + required, + unique: true, + }, + ], + } + + if (typeof overrides === 'function') { + return overrides(baseField) + } + + return baseField +} diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 38855e637a2..8c4b6835145 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -340,7 +340,7 @@ export type BlocksFilterOptions = ) => BlockSlugOrString | Promise | true) | BlockSlugOrString -type Admin = { +export type FieldAdmin = { className?: string components?: { Cell?: PayloadComponent @@ -504,7 +504,7 @@ export interface FieldBase { read?: FieldAccess update?: FieldAccess } - admin?: Admin + admin?: FieldAdmin /** Extension point to add your custom data. Server only. */ custom?: FieldCustom defaultValue?: DefaultValue @@ -578,12 +578,12 @@ export type NumberField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] /** Set this property to define a placeholder string for the field. */ placeholder?: Record | string /** Set a value for the number field to increment / decrement using browser controls. */ step?: number - } & Admin + } & FieldAdmin /** Maximum value accepted. Used in the default `validate` function. */ max?: number /** Minimum value accepted. Used in the default `validate` function. */ @@ -625,10 +625,10 @@ export type TextField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string rtl?: boolean - } & Admin + } & FieldAdmin maxLength?: number minLength?: number type: 'text' @@ -668,9 +668,9 @@ export type EmailField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string - } & Admin + } & FieldAdmin type: 'email' validate?: EmailFieldValidation } & Omit @@ -688,11 +688,11 @@ export type TextareaField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string rows?: number rtl?: boolean - } & Admin + } & FieldAdmin maxLength?: number minLength?: number type: 'textarea' @@ -712,8 +712,8 @@ export type CheckboxField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] - } & Admin + } & FieldAdmin['components'] + } & FieldAdmin type: 'checkbox' validate?: CheckboxFieldValidation } & Omit @@ -730,10 +730,10 @@ export type DateField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] date?: ConditionalDateProps placeholder?: Record | string - } & Admin + } & FieldAdmin /** * Enable timezone selection in the admin interface. */ @@ -754,9 +754,9 @@ export type GroupBase = { afterInput?: CustomComponent[] beforeInput?: CustomComponent[] Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] hideGutter?: boolean - } & Admin + } & FieldAdmin fields: Field[] type: 'group' validate?: Validate @@ -791,7 +791,7 @@ export type NamedGroupFieldClient = Pick & UnnamedGroup export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient export type RowField = { - admin?: Omit + admin?: Omit fields: Field[] type: 'row' } & Omit @@ -814,9 +814,9 @@ export type CollapsibleField = { Label: CustomComponent< CollapsibleFieldLabelClientComponent | CollapsibleFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean - } & Admin + } & FieldAdmin label?: Required } | { @@ -827,9 +827,9 @@ export type CollapsibleField = { Label?: CustomComponent< CollapsibleFieldLabelClientComponent | CollapsibleFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean - } & Admin + } & FieldAdmin label: Required } ) & @@ -884,7 +884,7 @@ export type UnnamedTab = { export type Tab = NamedTab | UnnamedTab export type TabsField = { - admin?: Omit + admin?: Omit type: 'tabs' } & { tabs: Tab[] @@ -918,7 +918,7 @@ export type UIField = { * The Filter component has to be a client component */ Filter?: PayloadComponent - } & Admin['components'] + } & FieldAdmin['components'] condition?: Condition /** Extension point to add your custom data. Available in server and client. */ custom?: Record @@ -1014,9 +1014,9 @@ type UploadAdmin = { Label?: CustomComponent< RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] isSortable?: boolean -} & Admin +} & FieldAdmin type UploadAdminClient = AdminClient & Pick @@ -1059,10 +1059,10 @@ export type CodeField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] editorOptions?: EditorProps['options'] language?: string - } & Admin + } & FieldAdmin maxLength?: number minLength?: number type: 'code' @@ -1082,10 +1082,10 @@ export type JSONField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] editorOptions?: EditorProps['options'] maxHeight?: number - } & Admin + } & FieldAdmin jsonSchema?: { fileMatch: string[] @@ -1109,11 +1109,11 @@ export type SelectField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] isClearable?: boolean isSortable?: boolean placeholder?: LabelFunction | string - } & Admin + } & FieldAdmin /** * Customize the SQL table name */ @@ -1222,10 +1222,10 @@ type RelationshipAdmin = { Label?: CustomComponent< RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent > - } & Admin['components'] + } & FieldAdmin['components'] isSortable?: boolean placeholder?: LabelFunction | string -} & Admin +} & FieldAdmin type RelationshipAdminClient = AdminClient & Pick @@ -1290,8 +1290,8 @@ export type RichTextField< beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] - } & Admin + } & FieldAdmin['components'] + } & FieldAdmin editor?: | RichTextAdapter | RichTextAdapterProvider @@ -1321,13 +1321,13 @@ export type ArrayField = { Error?: CustomComponent Label?: CustomComponent RowLabel?: RowLabelComponent - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean /** * Disable drag and drop sorting */ isSortable?: boolean - } & Admin + } & FieldAdmin /** * Customize the SQL table name */ @@ -1362,9 +1362,9 @@ export type RadioField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] layout?: 'horizontal' | 'vertical' - } & Admin + } & FieldAdmin /** * Customize the SQL table name */ @@ -1522,13 +1522,13 @@ export type BlocksField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] initCollapsed?: boolean /** * Disable drag and drop sorting */ isSortable?: boolean - } & Admin + } & FieldAdmin /** * Like `blocks`, but allows you to also pass strings that are slugs of blocks defined in `config.blocks`. * @@ -1595,10 +1595,10 @@ export type PointField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] placeholder?: Record | string step?: number - } & Admin + } & FieldAdmin type: 'point' validate?: PointFieldValidation } & Omit @@ -1625,12 +1625,12 @@ export type JoinField = { beforeInput?: CustomComponent[] Error?: CustomComponent Label?: CustomComponent - } & Admin['components'] + } & FieldAdmin['components'] defaultColumns?: string[] disableBulkEdit?: never disableRowTypes?: boolean readOnly?: never - } & Admin + } & FieldAdmin /** * The slug of the collection to relate with. */ diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 1814a92e370..0017a40cd48 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1410,6 +1410,8 @@ export { baseBlockFields } from './fields/baseFields/baseBlockFields.js' export { baseIDField } from './fields/baseFields/baseIDField.js' +export { slugField, type SlugFieldProps } from './fields/baseFields/slug/index.js' + export { createClientField, createClientFields, diff --git a/packages/payload/src/utilities/slugify.ts b/packages/payload/src/utilities/slugify.ts new file mode 100644 index 00000000000..13f1a3a3370 --- /dev/null +++ b/packages/payload/src/utilities/slugify.ts @@ -0,0 +1,5 @@ +export const slugify = (val?: string) => + val + ?.replace(/ /g, '-') + .replace(/[^\w-]+/g, '') + .toLowerCase() diff --git a/packages/payload/src/utilities/toKebabCase.ts b/packages/payload/src/utilities/toKebabCase.ts index c6333191c98..b058e7c4787 100644 --- a/packages/payload/src/utilities/toKebabCase.ts +++ b/packages/payload/src/utilities/toKebabCase.ts @@ -1,5 +1,5 @@ -export const toKebabCase = (string: string) => +export const toKebabCase = (string?: string) => string - .replace(/([a-z])([A-Z])/g, '$1-$2') + ?.replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/\s+/g, '-') .toLowerCase() diff --git a/packages/ui/src/elements/DocumentDrawer/Provider.tsx b/packages/ui/src/elements/DocumentDrawer/Provider.tsx index d5ba73b985d..83c477658db 100644 --- a/packages/ui/src/elements/DocumentDrawer/Provider.tsx +++ b/packages/ui/src/elements/DocumentDrawer/Provider.tsx @@ -23,7 +23,7 @@ export type DocumentDrawerContextProps = { /** * If you want to pass additional data to the onSuccess callback, you can use this context object. * - * @experimental This property is experimental and may change in the future. Use at your own discretion. + * @experimental This property is experimental and may change in the future. Use at your own risk. */ context?: Record doc: TypeWithID diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 9f03d4032c3..9f67db6922a 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -202,6 +202,7 @@ export { RowField } from '../../fields/Row/index.js' export { SelectField, SelectInput } from '../../fields/Select/index.js' export { TabsField, TabsProvider } from '../../fields/Tabs/index.js' export { TabComponent } from '../../fields/Tabs/Tab/index.js' +export { SlugField } from '../../fields/Slug/index.js' export { TextField, TextInput } from '../../fields/Text/index.js' export { JoinField } from '../../fields/Join/index.js' diff --git a/packages/ui/src/fields/Slug/index.scss b/packages/ui/src/fields/Slug/index.scss new file mode 100644 index 00000000000..a67a31a64b9 --- /dev/null +++ b/packages/ui/src/fields/Slug/index.scss @@ -0,0 +1,17 @@ +@layer payload-default { + .slug-field-component { + width: 100%; + + .label-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + gap: calc(var(--base) / 2); + } + + .lock-button { + margin: 0; + padding-bottom: 0.3125rem; + } + } +} diff --git a/packages/ui/src/fields/Slug/index.tsx b/packages/ui/src/fields/Slug/index.tsx new file mode 100644 index 00000000000..89d9813a930 --- /dev/null +++ b/packages/ui/src/fields/Slug/index.tsx @@ -0,0 +1,78 @@ +'use client' +import type { SlugFieldProps } from 'payload' + +import { slugify } from 'payload/shared' +import React, { useCallback, useState } from 'react' + +import { Button } from '../../elements/Button/index.js' +import { useForm } from '../../forms/Form/index.js' +import { useField } from '../../forms/useField/index.js' +import { FieldLabel } from '../FieldLabel/index.js' +import { TextInput } from '../Text/index.js' +import './index.scss' + +/** + * @experimental This component is experimental and may change or be removed in the future. Use at your own risk. + */ +export const SlugField: React.FC = ({ + field, + fieldToUse, + path, + readOnly: readOnlyFromProps, +}) => { + const { label } = field + + const { setValue, value } = useField({ path: path || field.name }) + + const { getDataByPath } = useForm() + + const [isLocked, setIsLocked] = useState(true) + + const handleGenerate = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + + const targetFieldValue = getDataByPath(fieldToUse) + + if (targetFieldValue) { + const formattedSlug = slugify(targetFieldValue as string) + + if (value !== formattedSlug) { + setValue(formattedSlug) + } + } else { + if (value !== '') { + setValue('') + } + } + }, + [setValue, value, fieldToUse, getDataByPath], + ) + + const toggleLock = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsLocked((prev) => !prev) + }, []) + + return ( +
+
+ + {!isLocked && ( + + )} + +
+ +
+ ) +} diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 5cd7a2f9edb..7e24e418081 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -120,7 +120,7 @@ export type Submit = >( ) => Promise diff --git a/packages/ui/src/forms/RenderFields/context.ts b/packages/ui/src/forms/RenderFields/context.ts index 7863482ca61..af6546e4599 100644 --- a/packages/ui/src/forms/RenderFields/context.ts +++ b/packages/ui/src/forms/RenderFields/context.ts @@ -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(undefined) @@ -29,7 +29,7 @@ export const FieldPathContext = React.createContext(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) diff --git a/packages/ui/src/providers/Config/index.tsx b/packages/ui/src/providers/Config/index.tsx index 45769a93ed5..75a437a43b6 100644 --- a/packages/ui/src/providers/Config/index.tsx +++ b/packages/ui/src/providers/Config/index.tsx @@ -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 diff --git a/packages/ui/src/utilities/handleLivePreview.ts b/packages/ui/src/utilities/handleLivePreview.ts index 5e1e90e28c6..e1912cefdc3 100644 --- a/packages/ui/src/utilities/handleLivePreview.ts +++ b/packages/ui/src/utilities/handleLivePreview.ts @@ -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, diff --git a/templates/ecommerce/src/app/(payload)/admin/importMap.js b/templates/ecommerce/src/app/(payload)/admin/importMap.js index ddc9c1f4a07..57d1b2a210b 100644 --- a/templates/ecommerce/src/app/(payload)/admin/importMap.js +++ b/templates/ecommerce/src/app/(payload)/admin/importMap.js @@ -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, } diff --git a/templates/ecommerce/src/collections/Categories.ts b/templates/ecommerce/src/collections/Categories.ts index 9e9c26a2193..7cc180c779a 100644 --- a/templates/ecommerce/src/collections/Categories.ts +++ b/templates/ecommerce/src/collections/Categories.ts @@ -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, }), ], } diff --git a/templates/ecommerce/src/collections/Pages/index.ts b/templates/ecommerce/src/collections/Pages/index.ts index 2776459df58..c382f1f888b 100644 --- a/templates/ecommerce/src/collections/Pages/index.ts +++ b/templates/ecommerce/src/collections/Pages/index.ts @@ -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], diff --git a/templates/ecommerce/src/collections/Products/index.ts b/templates/ecommerce/src/collections/Products/index.ts index c8d07f157cf..9c95e74572d 100644 --- a/templates/ecommerce/src/collections/Products/index.ts +++ b/templates/ecommerce/src/collections/Products/index.ts @@ -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(), ], }) diff --git a/templates/ecommerce/src/endpoints/seed/index.ts b/templates/ecommerce/src/endpoints/seed/index.ts index 5c8e2e3b371..fe71a827638 100644 --- a/templates/ecommerce/src/endpoints/seed/index.ts +++ b/templates/ecommerce/src/endpoints/seed/index.ts @@ -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...`) diff --git a/templates/ecommerce/src/fields/slug/SlugComponent.tsx b/templates/ecommerce/src/fields/slug/SlugComponent.tsx deleted file mode 100644 index f21ae829d2b..00000000000 --- a/templates/ecommerce/src/fields/slug/SlugComponent.tsx +++ /dev/null @@ -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 = ({ - field, - fieldToUse, - checkboxFieldPath: checkboxFieldPathFromProps, - path, - readOnly: readOnlyFromProps, -}) => { - const { label } = field - - const checkboxFieldPath = path?.includes('.') - ? `${path}.${checkboxFieldPathFromProps}` - : checkboxFieldPathFromProps - - const { value, setValue } = useField({ 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) => { - e.preventDefault() - - dispatchFields({ - type: 'UPDATE', - path: checkboxFieldPath, - value: !checkboxValue, - }) - }, - [checkboxValue, checkboxFieldPath, dispatchFields], - ) - - const readOnly = readOnlyFromProps || checkboxValue - - return ( -
-
- - - -
- - -
- ) -} diff --git a/templates/ecommerce/src/fields/slug/formatSlug.ts b/templates/ecommerce/src/fields/slug/formatSlug.ts deleted file mode 100644 index 9129de8932d..00000000000 --- a/templates/ecommerce/src/fields/slug/formatSlug.ts +++ /dev/null @@ -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 - } diff --git a/templates/ecommerce/src/fields/slug/index.scss b/templates/ecommerce/src/fields/slug/index.scss deleted file mode 100644 index e3dd2d8369b..00000000000 --- a/templates/ecommerce/src/fields/slug/index.scss +++ /dev/null @@ -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; - } -} diff --git a/templates/ecommerce/src/fields/slug/index.ts b/templates/ecommerce/src/fields/slug/index.ts deleted file mode 100644 index 77feae0e315..00000000000 --- a/templates/ecommerce/src/fields/slug/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CheckboxField, TextField } from 'payload' - -import { formatSlugHook } from './formatSlug' - -type Overrides = { - slugOverrides?: Partial - checkboxOverrides?: Partial -} - -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 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] -} diff --git a/templates/ecommerce/src/utilities/formatSlug.ts b/templates/ecommerce/src/utilities/formatSlug.ts deleted file mode 100644 index 9d4c53dc8bc..00000000000 --- a/templates/ecommerce/src/utilities/formatSlug.ts +++ /dev/null @@ -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 - } diff --git a/templates/ecommerce/src/utilities/generatePreviewPath.ts b/templates/ecommerce/src/utilities/generatePreviewPath.ts index aaee7555014..653e91c1e8d 100644 --- a/templates/ecommerce/src/utilities/generatePreviewPath.ts +++ b/templates/ecommerce/src/utilities/generatePreviewPath.ts @@ -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, diff --git a/templates/website/src/app/(payload)/admin/importMap.js b/templates/website/src/app/(payload)/admin/importMap.js index bd78e0fbd3d..b7b31112009 100644 --- a/templates/website/src/app/(payload)/admin/importMap.js +++ b/templates/website/src/app/(payload)/admin/importMap.js @@ -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': diff --git a/templates/website/src/collections/Categories.ts b/templates/website/src/collections/Categories.ts index 19d2b0c2065..cca3fc13ab9 100644 --- a/templates/website/src/collections/Categories.ts +++ b/templates/website/src/collections/Categories.ts @@ -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, + }), ], } diff --git a/templates/website/src/collections/Pages/index.ts b/templates/website/src/collections/Pages/index.ts index c81ffa8815e..0f380a746b6 100644 --- a/templates/website/src/collections/Pages/index.ts +++ b/templates/website/src/collections/Pages/index.ts @@ -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], diff --git a/templates/website/src/collections/Posts/index.ts b/templates/website/src/collections/Posts/index.ts index 3ccaeda82b0..68ee3990cc8 100644 --- a/templates/website/src/collections/Posts/index.ts +++ b/templates/website/src/collections/Posts/index.ts @@ -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], diff --git a/templates/website/src/endpoints/seed/index.ts b/templates/website/src/endpoints/seed/index.ts index 323dbd95895..5b8929b4a86 100644 --- a/templates/website/src/endpoints/seed/index.ts +++ b/templates/website/src/endpoints/seed/index.ts @@ -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...`) diff --git a/templates/website/src/fields/slug/SlugComponent.tsx b/templates/website/src/fields/slug/SlugComponent.tsx deleted file mode 100644 index 8114973e16f..00000000000 --- a/templates/website/src/fields/slug/SlugComponent.tsx +++ /dev/null @@ -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 = ({ - field, - fieldToUse, - checkboxFieldPath: checkboxFieldPathFromProps, - path, - readOnly: readOnlyFromProps, -}) => { - const { label } = field - - const checkboxFieldPath = path?.includes('.') - ? `${path}.${checkboxFieldPathFromProps}` - : checkboxFieldPathFromProps - - const { value, setValue } = useField({ path: path || field.name }) - - const { dispatchFields, getDataByPath } = useForm() - - const isLocked = useFormFields(([fields]) => { - return fields[checkboxFieldPath]?.value as string - }) - - const handleGenerate = useCallback( - (e: React.MouseEvent) => { - 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) => { - e.preventDefault() - - dispatchFields({ - type: 'UPDATE', - path: checkboxFieldPath, - value: !isLocked, - }) - }, - [isLocked, checkboxFieldPath, dispatchFields], - ) - - return ( -
-
- - {!isLocked && ( - - )} - -
- -
- ) -} diff --git a/templates/website/src/fields/slug/formatSlug.ts b/templates/website/src/fields/slug/formatSlug.ts deleted file mode 100644 index 0d4b78239b9..00000000000 --- a/templates/website/src/fields/slug/formatSlug.ts +++ /dev/null @@ -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 - } diff --git a/templates/website/src/fields/slug/index.scss b/templates/website/src/fields/slug/index.scss deleted file mode 100644 index 514af3d225a..00000000000 --- a/templates/website/src/fields/slug/index.scss +++ /dev/null @@ -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; - } -} diff --git a/templates/website/src/fields/slug/index.ts b/templates/website/src/fields/slug/index.ts deleted file mode 100644 index 77feae0e315..00000000000 --- a/templates/website/src/fields/slug/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CheckboxField, TextField } from 'payload' - -import { formatSlugHook } from './formatSlug' - -type Overrides = { - slugOverrides?: Partial - checkboxOverrides?: Partial -} - -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 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] -} diff --git a/templates/website/src/hooks/formatSlug.ts b/templates/website/src/hooks/formatSlug.ts deleted file mode 100644 index 43190f88cc2..00000000000 --- a/templates/website/src/hooks/formatSlug.ts +++ /dev/null @@ -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 diff --git a/templates/website/src/utilities/generatePreviewPath.ts b/templates/website/src/utilities/generatePreviewPath.ts index 72093dd97e8..df95ecc0d2e 100644 --- a/templates/website/src/utilities/generatePreviewPath.ts +++ b/templates/website/src/utilities/generatePreviewPath.ts @@ -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, diff --git a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js index 6e009c9a818..b7b31112009 100644 --- a/templates/with-vercel-website/src/app/(payload)/admin/importMap.js +++ b/templates/with-vercel-website/src/app/(payload)/admin/importMap.js @@ -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, } diff --git a/templates/with-vercel-website/src/collections/Categories.ts b/templates/with-vercel-website/src/collections/Categories.ts index 19d2b0c2065..cca3fc13ab9 100644 --- a/templates/with-vercel-website/src/collections/Categories.ts +++ b/templates/with-vercel-website/src/collections/Categories.ts @@ -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, + }), ], } diff --git a/templates/with-vercel-website/src/collections/Pages/index.ts b/templates/with-vercel-website/src/collections/Pages/index.ts index c81ffa8815e..0f380a746b6 100644 --- a/templates/with-vercel-website/src/collections/Pages/index.ts +++ b/templates/with-vercel-website/src/collections/Pages/index.ts @@ -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], diff --git a/templates/with-vercel-website/src/collections/Posts/index.ts b/templates/with-vercel-website/src/collections/Posts/index.ts index 3ccaeda82b0..68ee3990cc8 100644 --- a/templates/with-vercel-website/src/collections/Posts/index.ts +++ b/templates/with-vercel-website/src/collections/Posts/index.ts @@ -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], diff --git a/templates/with-vercel-website/src/endpoints/seed/index.ts b/templates/with-vercel-website/src/endpoints/seed/index.ts index 323dbd95895..5b8929b4a86 100644 --- a/templates/with-vercel-website/src/endpoints/seed/index.ts +++ b/templates/with-vercel-website/src/endpoints/seed/index.ts @@ -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...`) diff --git a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx b/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx deleted file mode 100644 index 8114973e16f..00000000000 --- a/templates/with-vercel-website/src/fields/slug/SlugComponent.tsx +++ /dev/null @@ -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 = ({ - field, - fieldToUse, - checkboxFieldPath: checkboxFieldPathFromProps, - path, - readOnly: readOnlyFromProps, -}) => { - const { label } = field - - const checkboxFieldPath = path?.includes('.') - ? `${path}.${checkboxFieldPathFromProps}` - : checkboxFieldPathFromProps - - const { value, setValue } = useField({ path: path || field.name }) - - const { dispatchFields, getDataByPath } = useForm() - - const isLocked = useFormFields(([fields]) => { - return fields[checkboxFieldPath]?.value as string - }) - - const handleGenerate = useCallback( - (e: React.MouseEvent) => { - 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) => { - e.preventDefault() - - dispatchFields({ - type: 'UPDATE', - path: checkboxFieldPath, - value: !isLocked, - }) - }, - [isLocked, checkboxFieldPath, dispatchFields], - ) - - return ( -
-
- - {!isLocked && ( - - )} - -
- -
- ) -} diff --git a/templates/with-vercel-website/src/fields/slug/formatSlug.ts b/templates/with-vercel-website/src/fields/slug/formatSlug.ts deleted file mode 100644 index 0d4b78239b9..00000000000 --- a/templates/with-vercel-website/src/fields/slug/formatSlug.ts +++ /dev/null @@ -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 - } diff --git a/templates/with-vercel-website/src/fields/slug/index.scss b/templates/with-vercel-website/src/fields/slug/index.scss deleted file mode 100644 index 514af3d225a..00000000000 --- a/templates/with-vercel-website/src/fields/slug/index.scss +++ /dev/null @@ -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; - } -} diff --git a/templates/with-vercel-website/src/fields/slug/index.ts b/templates/with-vercel-website/src/fields/slug/index.ts deleted file mode 100644 index 77feae0e315..00000000000 --- a/templates/with-vercel-website/src/fields/slug/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CheckboxField, TextField } from 'payload' - -import { formatSlugHook } from './formatSlug' - -type Overrides = { - slugOverrides?: Partial - checkboxOverrides?: Partial -} - -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 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] -} diff --git a/templates/with-vercel-website/src/hooks/formatSlug.ts b/templates/with-vercel-website/src/hooks/formatSlug.ts deleted file mode 100644 index 43190f88cc2..00000000000 --- a/templates/with-vercel-website/src/hooks/formatSlug.ts +++ /dev/null @@ -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 diff --git a/templates/with-vercel-website/src/utilities/generatePreviewPath.ts b/templates/with-vercel-website/src/utilities/generatePreviewPath.ts index 72093dd97e8..df95ecc0d2e 100644 --- a/templates/with-vercel-website/src/utilities/generatePreviewPath.ts +++ b/templates/with-vercel-website/src/utilities/generatePreviewPath.ts @@ -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,