feat: global beforeOperation hook (#13768)

Adds support for the `beforeOperation` hook on globals. Runs before all
other hooks to either modify the arguments that operations receive, or
perform side-effects before an operation begins.

```ts
import type { GlobalConfig } from 'payload'

const MyGlobal: GlobalConfig = {
  // ...
  hooks: {
    beforeOperation: []
  }
}
```

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211317005907890
This commit is contained in:
Jacob Fletcher
2025-09-10 16:46:49 -04:00
committed by GitHub
parent 4482eaf9ad
commit 3af546eeee
11 changed files with 143 additions and 18 deletions

View File

@@ -67,8 +67,6 @@ export const CollectionWithHooks: CollectionConfig = {
The `beforeOperation` hook can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins. The `beforeOperation` hook can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins.
Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh`, and `forgotPassword`.
```ts ```ts
import type { CollectionBeforeOperationHook } from 'payload' import type { CollectionBeforeOperationHook } from 'payload'
@@ -83,12 +81,12 @@ const beforeOperationHook: CollectionBeforeOperationHook = async ({
The following arguments are provided to the `beforeOperation` hook: The following arguments are provided to the `beforeOperation` hook:
| Option | Description | | Option | Description |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. | | **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. Available options include: `autosave`, `count`, `countVersions`, `create`, `delete`, `forgotPassword`, `login`, `read`, `readDistinct`, `refresh`, `resetPassword`, `restoreVersion`, and `update`. |
| **`context`** | Custom context passed between Hooks. [More details](./context). | | **`context`** | Custom context passed between Hooks. [More details](./context). |
| **`operation`** | The name of the operation that this hook is running within. | | **`operation`** | The name of the operation that this hook is running within. |
| **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | | **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. |
### beforeValidate ### beforeValidate

View File

@@ -38,6 +38,7 @@ const GlobalWithHooks: GlobalConfig = {
// ... // ...
// highlight-start // highlight-start
hooks: { hooks: {
beforeOperation: [(args) => {...}],
beforeValidate: [(args) => {...}], beforeValidate: [(args) => {...}],
beforeChange: [(args) => {...}], beforeChange: [(args) => {...}],
beforeRead: [(args) => {...}], beforeRead: [(args) => {...}],
@@ -48,6 +49,31 @@ const GlobalWithHooks: GlobalConfig = {
} }
``` ```
### beforeOperation
The `beforeOperation` hook can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins.
```ts
import type { GlobalBeforeOperationHook } from 'payload'
const beforeOperationHook: GlobalBeforeOperationHook = async ({
args,
operation,
req,
}) => {
return args // return modified operation arguments as necessary
}
```
The following arguments are provided to the `beforeOperation` hook:
| Option | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`global`** | The [Global](../configuration/globals) in which this Hook is running against. Available operation include: `countVersions`, `read`, `restoreVersion`, and `update`. |
| **`context`** | Custom context passed between Hooks. [More details](./context). |
| **`operation`** | The name of the operation that this hook is running within. |
| **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. |
### beforeValidate ### beforeValidate
Runs during the `update` operation. This hook allows you to add or format data before the incoming data is validated server-side. Runs during the `update` operation. This hook allows you to add or format data before the incoming data is validated server-side.

View File

@@ -92,7 +92,9 @@ type CreateOrUpdateOperation = Extract<HookOperationType, 'create' | 'update'>
export type BeforeOperationHook = (args: { export type BeforeOperationHook = (args: {
args?: any args?: any
/** The collection which this hook is being run on */ /**
* The collection which this hook is being run on
*/
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
context: RequestContext context: RequestContext
/** /**

View File

@@ -24,6 +24,7 @@ export const sanitizeGlobal = async (
if (global._sanitized) { if (global._sanitized) {
return global as SanitizedGlobalConfig return global as SanitizedGlobalConfig
} }
global._sanitized = true global._sanitized = true
global.label = global.label || toWords(global.slug) global.label = global.label || toWords(global.slug)
@@ -33,12 +34,15 @@ export const sanitizeGlobal = async (
// ///////////////////////////////// // /////////////////////////////////
global.endpoints = global.endpoints ?? [] global.endpoints = global.endpoints ?? []
if (!global.hooks) { if (!global.hooks) {
global.hooks = {} global.hooks = {}
} }
if (!global.access) { if (!global.access) {
global.access = {} global.access = {}
} }
if (!global.admin) { if (!global.admin) {
global.admin = {} global.admin = {}
} }
@@ -46,6 +50,7 @@ export const sanitizeGlobal = async (
if (!global.access.read) { if (!global.access.read) {
global.access.read = defaultAccess global.access.read = defaultAccess
} }
if (!global.access.update) { if (!global.access.update) {
global.access.update = defaultAccess global.access.update = defaultAccess
} }
@@ -53,15 +58,19 @@ export const sanitizeGlobal = async (
if (!global.hooks.beforeValidate) { if (!global.hooks.beforeValidate) {
global.hooks.beforeValidate = [] global.hooks.beforeValidate = []
} }
if (!global.hooks.beforeChange) { if (!global.hooks.beforeChange) {
global.hooks.beforeChange = [] global.hooks.beforeChange = []
} }
if (!global.hooks.afterChange) { if (!global.hooks.afterChange) {
global.hooks.afterChange = [] global.hooks.afterChange = []
} }
if (!global.hooks.beforeRead) { if (!global.hooks.beforeRead) {
global.hooks.beforeRead = [] global.hooks.beforeRead = []
} }
if (!global.hooks.afterRead) { if (!global.hooks.afterRead) {
global.hooks.afterRead = [] global.hooks.afterRead = []
} }

View File

@@ -77,6 +77,22 @@ export type AfterReadHook = (args: {
req: PayloadRequest req: PayloadRequest
}) => any }) => any
export type HookOperationType = 'countVersions' | 'read' | 'restoreVersion' | 'update'
export type BeforeOperationHook = (args: {
args?: any
context: RequestContext
/**
* The Global which this hook is being run on
* */
global: SanitizedGlobalConfig
/**
* Hook operation being performed
*/
operation: HookOperationType
req: PayloadRequest
}) => any
export type GlobalAdminOptions = { export type GlobalAdminOptions = {
/** /**
* Custom admin components * Custom admin components
@@ -187,6 +203,7 @@ export type GlobalConfig<TSlug extends GlobalSlug = any> = {
afterChange?: AfterChangeHook[] afterChange?: AfterChangeHook[]
afterRead?: AfterReadHook[] afterRead?: AfterReadHook[]
beforeChange?: BeforeChangeHook[] beforeChange?: BeforeChangeHook[]
beforeOperation?: BeforeOperationHook[]
beforeRead?: BeforeReadHook[] beforeRead?: BeforeReadHook[]
beforeValidate?: BeforeValidateHook[] beforeValidate?: BeforeValidateHook[]
} }

View File

@@ -28,6 +28,23 @@ export const countGlobalVersionsOperation = async <TSlug extends GlobalSlug>(
const req = args.req! const req = args.req!
const { payload } = req const { payload } = req
// /////////////////////////////////////
// beforeOperation - Global
// /////////////////////////////////////
if (global.hooks?.beforeOperation?.length) {
for (const hook of global.hooks.beforeOperation) {
args =
(await hook({
args,
context: req.context,
global,
operation: 'countVersions',
req,
})) || args
}
}
// ///////////////////////////////////// // /////////////////////////////////////
// Access // Access
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -52,6 +52,23 @@ export const findOneOperation = async <T extends Record<string, unknown>>(
} = args } = args
try { try {
// /////////////////////////////////////
// beforeOperation - Global
// /////////////////////////////////////
if (globalConfig.hooks?.beforeOperation?.length) {
for (const hook of globalConfig.hooks.beforeOperation) {
args =
(await hook({
args,
context: args.req.context,
global: globalConfig,
operation: 'read',
req: args.req,
})) || args
}
}
// ///////////////////////////////////// // /////////////////////////////////////
// Retrieve and execute access // Retrieve and execute access
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -31,6 +31,23 @@ export const restoreVersionOperation = async <T extends TypeWithVersion<T> = any
try { try {
const shouldCommit = await initTransaction(req) const shouldCommit = await initTransaction(req)
// /////////////////////////////////////
// beforeOperation - Global
// /////////////////////////////////////
if (globalConfig.hooks?.beforeOperation?.length) {
for (const hook of globalConfig.hooks.beforeOperation) {
args =
(await hook({
args,
context: req.context,
global: globalConfig,
operation: 'restoreVersion',
req,
})) || args
}
}
// ///////////////////////////////////// // /////////////////////////////////////
// Access // Access
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -77,6 +77,23 @@ export const updateOperation = async <
try { try {
const shouldCommit = !disableTransaction && (await initTransaction(req)) const shouldCommit = !disableTransaction && (await initTransaction(req))
// /////////////////////////////////////
// beforeOperation - Global
// /////////////////////////////////////
if (globalConfig.hooks?.beforeOperation?.length) {
for (const hook of globalConfig.hooks.beforeOperation) {
args =
(await hook({
args,
context: args.req.context,
global: globalConfig,
operation: 'update',
req: args.req,
})) || args
}
}
let { data } = args let { data } = args
const shouldSaveDraft = Boolean(draftArg && globalConfig.versions?.drafts) const shouldSaveDraft = Boolean(draftArg && globalConfig.versions?.drafts)

View File

@@ -1575,6 +1575,7 @@ export type {
AfterChangeHook as GlobalAfterChangeHook, AfterChangeHook as GlobalAfterChangeHook,
AfterReadHook as GlobalAfterReadHook, AfterReadHook as GlobalAfterReadHook,
BeforeChangeHook as GlobalBeforeChangeHook, BeforeChangeHook as GlobalBeforeChangeHook,
BeforeOperationHook as GlobalBeforeOperationHook,
BeforeReadHook as GlobalBeforeReadHook, BeforeReadHook as GlobalBeforeReadHook,
BeforeValidateHook as GlobalBeforeValidateHook, BeforeValidateHook as GlobalBeforeValidateHook,
DataFromGlobalSlug, DataFromGlobalSlug,

View File

@@ -277,7 +277,7 @@ describe('Hooks', () => {
const document = await payload.create({ const document = await payload.create({
collection: contextHooksSlug, collection: contextHooksSlug,
context: { context: {
secretValue: 'data from local API', secretValue: 'data from Local API',
}, },
data: { data: {
value: 'wrongvalue', value: 'wrongvalue',
@@ -289,28 +289,28 @@ describe('Hooks', () => {
collection: contextHooksSlug, collection: contextHooksSlug,
}) })
expect(retrievedDoc.value).toEqual('data from local API') expect(retrievedDoc.value).toEqual('data from Local API')
}) })
it('should pass context from local API to global hooks', async () => { it('should pass context from Local API to global hooks', async () => {
const globalDocument = await payload.findGlobal({ const globalDocument = await payload.findGlobal({
slug: dataHooksGlobalSlug, slug: dataHooksGlobalSlug,
}) })
expect(globalDocument.field_globalAndField).not.toEqual('data from local API context') expect(globalDocument.field_globalAndField).not.toEqual('data from Local API context')
const globalDocumentWithContext = await payload.findGlobal({ const globalDocumentWithContext = await payload.findGlobal({
slug: dataHooksGlobalSlug, slug: dataHooksGlobalSlug,
context: { context: {
field_beforeChange_GlobalAndField_override: 'data from local API context', field_beforeChange_GlobalAndField_override: 'data from Local API context',
}, },
}) })
expect(globalDocumentWithContext.field_globalAndField).toEqual('data from local API context') expect(globalDocumentWithContext.field_globalAndField).toEqual('data from Local API context')
}) })
it('should pass context from rest API to hooks', async () => { it('should pass context from REST API to hooks', async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
context_secretValue: 'data from rest API', context_secretValue: 'data from REST API',
}) })
// send context as query params. It will be parsed by the beforeOperation hook // send context as query params. It will be parsed by the beforeOperation hook
const { doc } = await restClient const { doc } = await restClient
@@ -326,7 +326,7 @@ describe('Hooks', () => {
id: doc.id, id: doc.id,
}) })
expect(retrievedDoc.value).toEqual('data from rest API') expect(retrievedDoc.value).toEqual('data from REST API')
}) })
}) })
@@ -426,15 +426,19 @@ describe('Hooks', () => {
expect(JSON.parse(doc.collection_beforeOperation_collection)).toStrictEqual( expect(JSON.parse(doc.collection_beforeOperation_collection)).toStrictEqual(
sanitizedHooksCollection, sanitizedHooksCollection,
) )
expect(JSON.parse(doc.collection_beforeChange_collection)).toStrictEqual( expect(JSON.parse(doc.collection_beforeChange_collection)).toStrictEqual(
sanitizedHooksCollection, sanitizedHooksCollection,
) )
expect(JSON.parse(doc.collection_afterChange_collection)).toStrictEqual( expect(JSON.parse(doc.collection_afterChange_collection)).toStrictEqual(
sanitizedHooksCollection, sanitizedHooksCollection,
) )
expect(JSON.parse(doc.collection_afterRead_collection)).toStrictEqual( expect(JSON.parse(doc.collection_afterRead_collection)).toStrictEqual(
sanitizedHooksCollection, sanitizedHooksCollection,
) )
expect(JSON.parse(doc.collection_afterOperation_collection)).toStrictEqual( expect(JSON.parse(doc.collection_afterOperation_collection)).toStrictEqual(
sanitizedHooksCollection, sanitizedHooksCollection,
) )