diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index 8cbd33f072d..d42bbc95a34 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -217,10 +217,11 @@ The slug field exposes a few top-level config options for easy customization: | `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`. | +| `useAsSlug` | The name of the top-level field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. | | `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. | | `position` | The position of the slug field. [More details](./overview#admin-options). | | `required` | Require the slug field. Defaults to `true`. | +| `slugify` | Override the default slugify function. [More details](#custom-slugify-function). | ### Slug Overrides @@ -245,3 +246,38 @@ export const ExampleCollection: CollectionConfig = { ], } ``` + +### Custom Slugify Function + +You can also override the default slugify function of the slug field. This is necessary if the slug requires special treatment, such as character encoding, additional language support, etc. + +This functions receives the value of the `useAsSlug` field as `valueToSlugify` and must return a string. + +For example, if you wanted to use the [`slugify`](https://www.npmjs.com/package/slugify) package, you could do something like this: + +```ts +import type { CollectionConfig } from 'payload' +import { slugField } from 'payload' +import slugify from 'slugify' + +export const MyCollection: CollectionConfig = { + // ... + fields: [ + // ... + slugField({ + slugify: ({ valueToSlugify }) => + slugify(valueToSlugify, { + // ...additional `slugify` options here + }), + }), + ], +} +``` + +The following args are provided to the custom `slugify` function: + +| Argument | Type | Description | +| ---------------- | ---------------- | ------------------------------------------------ | +| `valueToSlugify` | `string` | The value of the field specified in `useAsSlug`. | +| `data` | `object` | The full document data being saved. | +| `req` | `PayloadRequest` | The Payload request object. | diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx index 397038ddee7..99837384042 100644 --- a/packages/next/src/layouts/Root/index.tsx +++ b/packages/next/src/layouts/Root/index.tsx @@ -88,6 +88,7 @@ export const RootLayout = async ({ importMap, user: req.user, }) + await applyLocaleFiltering({ clientConfig, config, req }) return ( diff --git a/packages/next/src/utilities/handleServerFunctions.ts b/packages/next/src/utilities/handleServerFunctions.ts index 7c723333e6a..79d4e2b3fdb 100644 --- a/packages/next/src/utilities/handleServerFunctions.ts +++ b/packages/next/src/utilities/handleServerFunctions.ts @@ -10,6 +10,7 @@ import { renderDocumentHandler } from '../views/Document/handleServerFunction.js import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js' import { renderListHandler } from '../views/List/handleServerFunction.js' import { initReq } from './initReq.js' +import { slugifyHandler } from './slugify.js' const baseServerFunctions: Record> = { 'copy-data-from-locale': copyDataFromLocaleHandler, @@ -20,6 +21,7 @@ const baseServerFunctions: Record> = { 'render-field': _internal_renderFieldHandler, 'render-list': renderListHandler, 'schedule-publish': schedulePublishHandler, + slugify: slugifyHandler, 'table-state': buildTableStateHandler, } diff --git a/packages/next/src/utilities/slugify.ts b/packages/next/src/utilities/slugify.ts new file mode 100644 index 00000000000..fa64c640bca --- /dev/null +++ b/packages/next/src/utilities/slugify.ts @@ -0,0 +1,58 @@ +import type { Slugify } from 'payload/shared' + +import { + flattenAllFields, + getFieldByPath, + type ServerFunction, + type SlugifyServerFunctionArgs, + UnauthorizedError, +} from 'payload' +import { slugify as defaultSlugify } from 'payload/shared' + +/** + * This server function is directly related to the {@link https://payloadcms.com/docs/fields/text#slug-field | Slug Field}. + * This is a server function that is used to invoke the user's custom slugify function from the client. + * This pattern is required, as there is no other way for us to pass their function across the client-server boundary. + * - Not through props + * - Not from a server function defined within a server component (see below) + * When a server function contains non-serializable data within its closure, it gets passed through the boundary (and breaks). + * The only way to pass server functions to the client (that contain non-serializable data) is if it is globally defined. + * But we also cannot define this function alongside the server component, as we will not have access to their custom slugify function. + * See `ServerFunctionsProvider` for more details. + */ +export const slugifyHandler: ServerFunction< + SlugifyServerFunctionArgs, + Promise> +> = async (args) => { + const { collectionSlug, data, globalSlug, path, req, valueToSlugify } = args + + if (!req.user) { + throw new UnauthorizedError() + } + + const docConfig = collectionSlug + ? req.payload.collections[collectionSlug]?.config + : globalSlug + ? req.payload.config.globals.find((g) => g.slug === globalSlug) + : null + + if (!docConfig) { + throw new Error() + } + + const { field } = getFieldByPath({ + config: req.payload.config, + fields: flattenAllFields({ fields: docConfig.fields }), + path, + }) + + const customSlugify = ( + typeof field?.custom?.slugify === 'function' ? field.custom.slugify : undefined + ) as Slugify + + const result = customSlugify + ? await customSlugify({ data, req, valueToSlugify }) + : defaultSlugify(valueToSlugify) + + return result +} diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index f6fb7ce060a..90ca0099ff7 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -1,7 +1,14 @@ import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { SanitizedConfig } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' -import type { CollectionSlug, ColumnPreference, FolderSortKeys } from '../../index.js' +import type { Slugify } from '../../fields/baseFields/slug/index.js' +import type { + CollectionSlug, + ColumnPreference, + FieldPaths, + FolderSortKeys, + GlobalSlug, +} from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js' @@ -149,3 +156,9 @@ export type GetFolderResultsComponentAndDataArgs = { */ sort: FolderSortKeys } + +export type SlugifyServerFunctionArgs = { + collectionSlug?: CollectionSlug + globalSlug?: GlobalSlug + path?: FieldPaths['path'] +} & Omit[0], 'req'> diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index bff4d15fd3c..265ff233279 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -583,6 +583,7 @@ export type { ServerFunctionClientArgs, ServerFunctionConfig, ServerFunctionHandler, + SlugifyServerFunctionArgs, } from './functions/index.js' export type { LanguageOptions } from './LanguageOptions.js' diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index 50f9106b6c5..fd854f8ca97 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -13,6 +13,8 @@ export { defaults as collectionDefaults } from '../collections/config/defaults.j export { serverProps } from '../config/types.js' +export { type Slugify } from '../fields/baseFields/slug/index.js' + export { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js' export { @@ -39,8 +41,8 @@ export { } from '../fields/config/types.js' export { getFieldPaths } from '../fields/getFieldPaths.js' - export * from '../fields/validations.js' + export type { FolderBreadcrumb, FolderDocumentItemKey, @@ -52,41 +54,41 @@ export type { } from '../folders/types.js' export { buildFolderWhereConstraints } from '../folders/utils/buildFolderWhereConstraints.js' - export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js' export { validOperators, validOperatorSet } from '../types/constants.js' + export { formatFilesize } from '../uploads/formatFilesize.js' export { isImage } from '../uploads/isImage.js' - export { appendUploadSelectFields } from '../utilities/appendUploadSelectFields.js' export { applyLocaleFiltering } from '../utilities/applyLocaleFiltering.js' export { combineWhereConstraints } from '../utilities/combineWhereConstraints.js' + export { deepCopyObject, deepCopyObjectComplex, deepCopyObjectSimple, deepCopyObjectSimpleWithoutReactComponents, } from '../utilities/deepCopyObject.js' - export { deepMerge, deepMergeWithCombinedArrays, deepMergeWithReactComponents, deepMergeWithSourceArrays, } from '../utilities/deepMerge.js' + 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 { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js' export { getDataByPath } from '../utilities/getDataByPath.js' export { getFieldPermissions } from '../utilities/getFieldPermissions.js' export { getObjectDotNotation } from '../utilities/getObjectDotNotation.js' + export { getSafeRedirect } from '../utilities/getSafeRedirect.js' export { getSelectMode } from '../utilities/getSelectMode.js' diff --git a/packages/payload/src/fields/baseFields/slug/generateSlug.ts b/packages/payload/src/fields/baseFields/slug/generateSlug.ts index 03e0bdd071e..29f2371e1c3 100644 --- a/packages/payload/src/fields/baseFields/slug/generateSlug.ts +++ b/packages/payload/src/fields/baseFields/slug/generateSlug.ts @@ -1,14 +1,31 @@ +import type { PayloadRequest } from '../../../types/index.js' import type { FieldHook } from '../../config/types.js' +import type { SlugFieldArgs, Slugify } from './index.js' -import { slugify } from '../../../utilities/slugify.js' +import { slugify as defaultSlugify } from '../../../utilities/slugify.js' import { countVersions } from './countVersions.js' type HookArgs = { - /** - * Current field name for the slug. Defaults to `slug`. - */ - fieldName?: string - fieldToUse: string + slugFieldName: NonNullable +} & Pick & + Required> + +const slugify = ({ + customSlugify, + data, + req, + valueToSlugify, +}: { + customSlugify?: Slugify + data: Record + req: PayloadRequest + valueToSlugify?: string +}) => { + if (customSlugify) { + return customSlugify({ data, req, valueToSlugify }) + } + + return defaultSlugify(valueToSlugify) } /** @@ -16,18 +33,21 @@ type HookArgs = { * See `slugField` for more details. */ export const generateSlug = - ({ fieldName = 'slug', fieldToUse }: HookArgs): FieldHook => - async (args) => { - const { collection, data, global, operation, originalDoc, req, value: isChecked } = args - - // Ensure user-defined slugs are not overwritten during create - // Use a generic falsy check here to include empty strings + ({ slugFieldName, slugify: customSlugify, useAsSlug }: HookArgs): FieldHook => + async ({ collection, data, global, operation, originalDoc, req, value: isChecked }) => { if (operation === 'create') { if (data) { - data[fieldName] = slugify(data?.[fieldName] || data?.[fieldToUse]) + data[slugFieldName] = slugify({ + customSlugify, + data, + req, + // Ensure user-defined slugs are not overwritten during create + // Use a generic falsy check here to include empty strings + valueToSlugify: data?.[slugFieldName] || data?.[useAsSlug], + }) } - return Boolean(!data?.[fieldName]) + return Boolean(!data?.[slugFieldName]) } if (operation === 'update') { @@ -45,22 +65,34 @@ export const generateSlug = if (!autosaveEnabled) { // We can generate the slug at this point if (data) { - data[fieldName] = slugify(data?.[fieldToUse]) + data[slugFieldName] = slugify({ + customSlugify, + data, + req, + valueToSlugify: data?.[useAsSlug], + }) } - return Boolean(!data?.[fieldName]) + return Boolean(!data?.[slugFieldName]) } 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?.[fieldName] !== originalDoc?.[fieldName] + const userOverride = data?.[slugFieldName] !== originalDoc?.[slugFieldName] 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[fieldName] = data?.[fieldToUse] ? slugify(data[fieldToUse]) : null + data[slugFieldName] = data?.[useAsSlug] + ? slugify({ + customSlugify, + data, + req, + valueToSlugify: data?.[useAsSlug], + }) + : null } } @@ -74,7 +106,7 @@ export const generateSlug = collectionSlug: collection?.slug, globalSlug: global?.slug, parentID: originalDoc?.id, - req: args.req, + req, }) if (versionCount <= 2) { diff --git a/packages/payload/src/fields/baseFields/slug/index.ts b/packages/payload/src/fields/baseFields/slug/index.ts index bb79f2e8577..32f78fab98d 100644 --- a/packages/payload/src/fields/baseFields/slug/index.ts +++ b/packages/payload/src/fields/baseFields/slug/index.ts @@ -1,17 +1,24 @@ import type { TextFieldClientProps } from '../../../admin/types.js' +import type { TypeWithID } from '../../../collections/config/types.js' import type { FieldAdmin, RowField, TextField } from '../../../fields/config/types.js' +import type { PayloadRequest } from '../../../types/index.js' import { generateSlug } from './generateSlug.js' -type SlugFieldArgs = { +export type Slugify = (args: { + data: T + req: PayloadRequest + valueToSlugify?: any +}) => Promise | string | undefined + +export 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' + * @deprecated use `useAsSlug` instead. */ fieldToUse?: string /** @@ -24,7 +31,7 @@ type SlugFieldArgs = { */ name?: string /** - * A function used to override te fields at a granular level. + * A function used to override the slug field(s) at a granular level. * Passes the row field to you to manipulate beyond the exposed options. * @example * ```ts @@ -43,13 +50,25 @@ type SlugFieldArgs = { * @default true */ required?: TextField['required'] + /** + * Provide your own slugify function to override the default. + */ + slugify?: Slugify + /** + * The name of the top-level field to generate the slug from, when applicable. + * @default 'title' + */ + useAsSlug?: string } -type SlugField = (args?: SlugFieldArgs) => RowField - -export type SlugFieldClientProps = {} & Pick +export type SlugField = (args?: SlugFieldArgs) => RowField -export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps +export type SlugFieldClientPropsOnly = Pick +/** + * These are the props that the `SlugField` client component accepts. + * The `SlugField` server component is responsible for passing down the `slugify` function. + */ +export type SlugFieldClientProps = SlugFieldClientPropsOnly & TextFieldClientProps /** * A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. @@ -67,14 +86,18 @@ export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps * @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', + name: slugFieldName = 'slug', checkboxName = 'generateSlug', - fieldToUse = 'title', + fieldToUse, localized, overrides, position = 'sidebar', required = true, + slugify, + useAsSlug: useAsSlugFromArgs = 'title', } = {}) => { + const useAsSlug = fieldToUse || useAsSlugFromArgs + const baseField: RowField = { type: 'row', admin: { @@ -95,24 +118,30 @@ export const slugField: SlugField = ({ }, defaultValue: true, hooks: { - beforeChange: [generateSlug({ fieldName, fieldToUse })], + beforeChange: [generateSlug({ slugFieldName, slugify, useAsSlug })], }, localized, }, { - name: fieldName, + name: slugFieldName, type: 'text', admin: { components: { Field: { clientProps: { - fieldToUse, - } satisfies SlugFieldClientProps, + useAsSlug, + } satisfies SlugFieldClientPropsOnly, path: '@payloadcms/ui#SlugField', }, }, width: '100%', }, + custom: { + /** + * This is needed so we can access it from the `slugifyHandler` server function. + */ + slugify, + }, index: true, localized, required, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 935aefd5aac..c9a5f123956 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1448,7 +1448,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 { slugField, type SlugFieldClientProps } from './fields/baseFields/slug/index.js' +export { type SlugField } from './fields/baseFields/slug/index.js' export { createClientField, @@ -1457,6 +1458,8 @@ export { type ServerOnlyFieldProperties, } from './fields/config/client.js' +export { sanitizeFields } from './fields/config/sanitize.js' + export interface FieldCustom extends Record {} export interface CollectionCustom extends Record {} @@ -1467,8 +1470,6 @@ export interface GlobalCustom extends Record {} export interface GlobalAdminCustom extends Record {} -export { sanitizeFields } from './fields/config/sanitize.js' - export type { AdminClient, ArrayField, @@ -1578,16 +1579,16 @@ export type { } from './fields/config/types.js' export { getDefaultValue } from './fields/getDefaultValue.js' -export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js' +export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js' export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js' + export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js' export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js' export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js' - export { sortableFieldTypes } from './fields/sortableFieldTypes.js' -export { validateBlocksFilterOptions, validations } from './fields/validations.js' +export { validateBlocksFilterOptions, validations } from './fields/validations.js' export type { ArrayFieldValidation, BlocksFieldValidation, @@ -1619,6 +1620,7 @@ export type { UploadFieldValidation, UsernameFieldValidation, } from './fields/validations.js' + export type { FolderSortKeys } from './folders/types.js' export { getFolderData } from './folders/utils/getFolderData.js' export { @@ -1642,10 +1644,10 @@ export type { } from './globals/config/types.js' export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js' export { findOneOperation } from './globals/operations/findOne.js' - export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js' export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js' + export { restoreVersionOperation as restoreVersionOperationGlobal } from './globals/operations/restoreVersion.js' export { updateOperation as updateOperationGlobal } from './globals/operations/update.js' export * from './kv/adapters/DatabaseKVAdapter.js' @@ -1682,7 +1684,6 @@ export type { TaskOutput, TaskType, } from './queues/config/types/taskTypes.js' - export type { BaseJob, JobLog, @@ -1693,20 +1694,21 @@ export type { WorkflowHandler, WorkflowTypes, } from './queues/config/types/workflowTypes.js' + export { countRunnableOrActiveJobsForQueue } from './queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.js' export { importHandlerPath } from './queues/operations/runJobs/runJob/importHandlerPath.js' - export { _internal_jobSystemGlobals, _internal_resetJobSystemGlobals, getCurrentDate, } from './queues/utilities/getCurrentDate.js' + export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' export { getFileByPath } from './uploads/getFileByPath.js' export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' - export type * from './uploads/types.js' + export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js' export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js' export { canAccessAdmin } from './utilities/canAccessAdmin.js' diff --git a/packages/payload/src/utilities/slugify.ts b/packages/payload/src/utilities/slugify.ts index 13f1a3a3370..4c190f2a049 100644 --- a/packages/payload/src/utilities/slugify.ts +++ b/packages/payload/src/utilities/slugify.ts @@ -1,4 +1,4 @@ -export const slugify = (val?: string) => +export const slugify = (val?: string): string | undefined => val ?.replace(/ /g, '-') .replace(/[^\w-]+/g, '') diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 633f91a0d83..acb389fcd45 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -3,6 +3,7 @@ export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js' export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js' export { FolderField } from '../../elements/FolderView/FolderField/index.server.js' export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js' +export { SlugField } from '../../fields/Slug/index.js' export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' diff --git a/packages/ui/src/fields/Slug/index.tsx b/packages/ui/src/fields/Slug/index.tsx index 09d5476ef48..74f5d9aa3d9 100644 --- a/packages/ui/src/fields/Slug/index.tsx +++ b/packages/ui/src/fields/Slug/index.tsx @@ -1,12 +1,14 @@ 'use client' -import type { SlugFieldProps } from 'payload' -import { slugify } from 'payload/shared' +import type { SlugFieldClientProps } from 'payload' + 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 { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { FieldLabel } from '../FieldLabel/index.js' import { TextInput } from '../Text/index.js' @@ -15,41 +17,57 @@ 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 = ({ +export const SlugField: React.FC = ({ field, - fieldToUse, path, readOnly: readOnlyFromProps, + useAsSlug, }) => { const { label } = field const { t } = useTranslation() + const { collectionSlug, globalSlug } = useDocumentInfo() + + const { slugify } = useServerFunctions() + const { setValue, value } = useField({ path: path || field.name }) - const { getDataByPath } = useForm() + const { getData, getDataByPath } = useForm() const [isLocked, setIsLocked] = useState(true) + /** + * This method allows the user to generate their slug on demand, e.g. when they click the "generate" button. + * It uses the `slugify` server function to gain access to their custom slugify function defined in their field config. + */ const handleGenerate = useCallback( - (e: React.MouseEvent) => { + async (e: React.MouseEvent) => { e.preventDefault() - const targetFieldValue = getDataByPath(fieldToUse) + const valueToSlugify = getDataByPath(useAsSlug) - if (targetFieldValue) { - const formattedSlug = slugify(targetFieldValue as string) + const formattedSlug = await slugify({ + collectionSlug, + data: getData(), + globalSlug, + path, + valueToSlugify, + }) + + if (formattedSlug === null || formattedSlug === undefined) { + setValue('') + return + } - if (value !== formattedSlug) { - setValue(formattedSlug) - } - } else { - if (value !== '') { - setValue('') - } + /** + * The result may be the same as the current value, and if so, we don't want to trigger a re-render. + */ + if (value !== formattedSlug) { + setValue(formattedSlug) } }, - [setValue, value, fieldToUse, getDataByPath], + [setValue, value, useAsSlug, getData, slugify, getDataByPath, collectionSlug, globalSlug, path], ) const toggleLock = useCallback((e: React.MouseEvent) => { diff --git a/packages/ui/src/providers/ServerFunctions/index.tsx b/packages/ui/src/providers/ServerFunctions/index.tsx index d7e9a543f71..354b2491107 100644 --- a/packages/ui/src/providers/ServerFunctions/index.tsx +++ b/packages/ui/src/providers/ServerFunctions/index.tsx @@ -12,7 +12,9 @@ import type { RenderDocumentVersionsProperties, ServerFunction, ServerFunctionClient, + SlugifyServerFunctionArgs, } from 'payload' +import type { Slugify } from 'payload/shared' import React, { createContext, useCallback } from 'react' @@ -47,6 +49,12 @@ type GetTableStateClient = ( } & Omit, ) => ReturnType +type SlugifyClient = ( + args: { + signal?: AbortSignal + } & Omit, +) => ReturnType + export type RenderDocumentResult = { data: any Document: React.ReactNode @@ -105,6 +113,7 @@ type GetFolderResultsComponentAndDataClient = ( ) => ReturnType type RenderFieldClient = (args: RenderFieldServerFnArgs) => Promise + export type ServerFunctionsContextType = { _internal_renderField: RenderFieldClient copyDataFromLocale: CopyDataFromLocaleClient @@ -115,6 +124,7 @@ export type ServerFunctionsContextType = { renderDocument: RenderDocumentServerFunctionHookFn schedulePublish: SchedulePublishClient serverFunction: ServerFunctionClient + slugify: SlugifyClient } export const ServerFunctionsContext = createContext( @@ -300,6 +310,24 @@ export const ServerFunctionsProvider: React.FC<{ [serverFunction], ) + const slugify = useCallback( + async (args) => { + const { signal: remoteSignal, ...rest } = args || {} + + try { + const result = (await serverFunction({ + name: 'slugify', + args: { ...rest }, + })) as Awaited> // TODO: infer this type when `strictNullChecks` is enabled + + return result + } catch (_err) { + console.error(_err) // eslint-disable-line no-console + } + }, + [serverFunction], + ) + return ( {children} diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index d642db8e9b2..f3cd1bcb997 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -99,6 +99,7 @@ const buildTableState = async ( importMap: payload.importMap, user, }) + await applyLocaleFiltering({ clientConfig, config, req }) const permissions = await getAccessResults({ req }) diff --git a/test/fields/collections/SlugField/e2e.spec.ts b/test/fields/collections/SlugField/e2e.spec.ts index b12fe1779f3..3dd47c63ad2 100644 --- a/test/fields/collections/SlugField/e2e.spec.ts +++ b/test/fields/collections/SlugField/e2e.spec.ts @@ -21,7 +21,6 @@ import { reInitializeDB } from '../../../helpers/reInitializeDB.js' import { RESTClient } from '../../../helpers/rest.js' import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' import { slugFieldsSlug } from '../../slugs.js' -import { slugFieldDoc } from './shared.js' const filename = fileURLToPath(import.meta.url) const currentFolder = path.dirname(filename) @@ -36,6 +35,20 @@ let serverURL: string // If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) let url: AdminUrlUtil +const unlockSlug = async (fieldName: string = 'slug') => { + const fieldID = `#field-${fieldName}` + const unlockButton = page.locator(`#field-${fieldName}-lock`) + await unlockButton.click() + const slugField = page.locator(fieldID) + await expect(slugField).toBeEnabled() +} + +const regenerateSlug = async (fieldName: string = 'slug') => { + await unlockSlug(fieldName) + const generateButton = page.locator(`#field-${fieldName}-generate`) + await generateButton.click() +} + describe('SlugField', () => { beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) @@ -52,6 +65,7 @@ describe('SlugField', () => { await ensureCompilationIsDone({ page, serverURL }) }) + beforeEach(async () => { await reInitializeDB({ serverURL, @@ -62,13 +76,14 @@ describe('SlugField', () => { if (client) { await client.logout() } + client = new RESTClient({ defaultSlug: 'users', serverURL }) await client.login() await ensureCompilationIsDone({ page, serverURL }) }) - test('should generate slug for title field', async () => { + test('should generate slug for title field on save', async () => { await page.goto(url.create) await page.locator('#field-title').fill('Test title') @@ -77,6 +92,18 @@ describe('SlugField', () => { await expect(page.locator('#field-slug')).toHaveValue('test-title') }) + test('should generate slug on demand from client side', async () => { + await page.goto(url.create) + await page.locator('#field-title').fill('Test title client side') + + await saveDocAndAssert(page) + + await page.locator('#field-title').fill('This should have regenerated') + await regenerateSlug('slug') + + await expect(page.locator('#field-slug')).toHaveValue('this-should-have-regenerated') + }) + test('custom values should be kept', async () => { await page.goto(url.create) await page.locator('#field-title').fill('Test title with custom slug') @@ -87,9 +114,7 @@ describe('SlugField', () => { await expect(slugField).toHaveValue('test-title-with-custom-slug') await expect(slugField).toBeDisabled() - const unlockButton = page.locator('#field-generateSlug + div .lock-button') - await unlockButton.click() - await expect(slugField).toBeEnabled() + await unlockSlug('slug') await slugField.fill('custom-slug-value') @@ -98,6 +123,23 @@ describe('SlugField', () => { await expect(slugField).toHaveValue('custom-slug-value') }) + test('custom slugify functions are supported', async () => { + await page.goto(url.create) + await page.locator('#field-title').fill('Test Custom Slugify') + + await saveDocAndAssert(page) + + await expect(page.locator('#field-customSlugify')).toHaveValue('TEST CUSTOM SLUGIFY') + + // Ensure it can be regenerated from the client-side + const titleField = page.locator('#field-title') + await titleField.fill('Another Custom Slugify') + + await regenerateSlug('customSlugify') + + await expect(page.locator('#field-customSlugify')).toHaveValue('ANOTHER CUSTOM SLUGIFY') + }) + describe('localized slugs', () => { test('should generate slug for localized fields', async () => { await page.goto(url.create) diff --git a/test/fields/collections/SlugField/index.ts b/test/fields/collections/SlugField/index.ts index 208194ce909..be44ab94fc3 100644 --- a/test/fields/collections/SlugField/index.ts +++ b/test/fields/collections/SlugField/index.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' import { slugField } from 'payload' -import { defaultText, slugFieldSlug } from './shared.js' +import { slugFieldSlug } from './shared.js' const SlugField: CollectionConfig = { slug: slugFieldSlug, @@ -22,11 +22,16 @@ const SlugField: CollectionConfig = { localized: true, }, slugField({ - fieldToUse: 'localizedTitle', + slugify: ({ valueToSlugify }) => valueToSlugify?.toUpperCase(), + name: 'customSlugify', + checkboxName: 'generateCustomSlug', + }), + slugField({ + useAsSlug: 'localizedTitle', name: 'localizedSlug', - checkboxName: 'generateLocalizedSlug', localized: true, required: false, + checkboxName: 'generateLocalizedSlug', }), ], } diff --git a/test/fields/collections/SlugField/shared.ts b/test/fields/collections/SlugField/shared.ts index c5ab8231f01..2e6bb1c874e 100644 --- a/test/fields/collections/SlugField/shared.ts +++ b/test/fields/collections/SlugField/shared.ts @@ -10,4 +10,5 @@ export const slugFieldDoc: RequiredDataFromCollection = { slug: 'seeded-text-document', localizedTitle: 'Localized text', localizedSlug: 'localized-text', + customSlugify: 'SEEDED-TEXT-DOCUMENT', } diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 5c3b11b33dd..6e0d9913672 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -158,6 +158,7 @@ export interface Config { db: { defaultIDType: string; }; + fallbackLocale: ('false' | 'none' | 'null') | false | null | ('en' | 'es') | ('en' | 'es')[]; globals: {}; globalsSelect: {}; locale: 'en' | 'es'; @@ -1528,6 +1529,11 @@ export interface SlugField { generateSlug?: boolean | null; slug: string; localizedTitle?: string | null; + /** + * When enabled, the slug will auto-generate from the title field on save and autosave. + */ + generateCustomSlug?: boolean | null; + customSlugify: string; /** * When enabled, the slug will auto-generate from the title field on save and autosave. */ @@ -3233,6 +3239,8 @@ export interface SlugFieldsSelect { generateSlug?: T; slug?: T; localizedTitle?: T; + generateCustomSlug?: T; + customSlugify?: T; generateLocalizedSlug?: T; localizedSlug?: T; updatedAt?: T; diff --git a/tools/claude-plugin/skills/payload/reference/FIELDS.md b/tools/claude-plugin/skills/payload/reference/FIELDS.md index 587682bd61d..9f6e04fced7 100644 --- a/tools/claude-plugin/skills/payload/reference/FIELDS.md +++ b/tools/claude-plugin/skills/payload/reference/FIELDS.md @@ -40,7 +40,7 @@ export const Pages: CollectionConfig = { { name: 'title', type: 'text', required: true }, slugField({ name: 'slug', // defaults to 'slug' - fieldToUse: 'title', // defaults to 'title' + useAsSlug: 'title', // defaults to 'title' checkboxName: 'generateSlug', // defaults to 'generateSlug' localized: true, required: true,