Compare commits

...

13 Commits

Author SHA1 Message Date
Alessio Gravili
f4eb75d9db fix cloud storage 2025-05-16 11:40:52 -07:00
Alessio Gravili
e5eed336ff fix plugin-nested-docs 2025-05-16 11:29:13 -07:00
Alessio Gravili
73a8a1836d fix lint 2025-05-14 11:27:36 -07:00
Alessio Gravili
a9d0b31f3f fix plugin-cloud-storage 2025-05-14 11:15:25 -07:00
Alessio Gravili
c82d130fd7 fix plugin-nested-docs 2025-05-14 11:12:20 -07:00
Alessio Gravili
7a815f7e51 fix plugin-seo 2025-05-14 11:08:34 -07:00
Alessio Gravili
f212870f82 fix typescript 2025-05-13 16:44:32 -07:00
Alessio Gravili
1d07fba5cb ignore typescript 2025-05-13 16:26:29 -07:00
Alessio Gravili
a5c995445b ignore typescript 2025-05-13 16:17:06 -07:00
Alessio Gravili
9bac0398d7 fix plugin handling if config returned is no longer a draft 2025-05-13 12:34:24 -07:00
Alessio Gravili
7af16bd1a7 remove comment 2025-05-10 22:37:11 -06:00
Alessio Gravili
00f27bba16 fix it 2025-05-10 22:01:06 -06:00
Alessio Gravili
ceda0ad028 int test reproduction 2025-05-10 21:51:35 -06:00
16 changed files with 226 additions and 40 deletions

View File

@@ -99,6 +99,7 @@
"get-tsconfig": "4.8.1",
"http-status": "2.1.0",
"image-size": "2.0.2",
"immer": "10.1.1",
"jose": "5.9.6",
"json-schema-to-typescript": "15.0.3",
"minimist": "1.2.8",

View File

@@ -1,3 +1,5 @@
import { createDraft, finishDraft, isDraft, setAutoFreeze } from 'immer'
import type { Config, SanitizedConfig } from './types.js'
import { sanitizeConfig } from './sanitize.js'
@@ -9,10 +11,27 @@ import { sanitizeConfig } from './sanitize.js'
*/
export async function buildConfig(config: Config): Promise<SanitizedConfig> {
if (Array.isArray(config.plugins)) {
let configAfterPlugins = config
// Not freezing objects is faster
setAutoFreeze(false)
// Use Immer to ensure config modifications by plugins are immutable and do not affect the original config / shared references
let mutableConfig: Config = createDraft(config) as Config
for (const plugin of config.plugins) {
configAfterPlugins = await plugin(configAfterPlugins)
const newConfig = await plugin(mutableConfig)
if (isDraft(newConfig)) {
mutableConfig = newConfig
} else {
// If the plugin returns a new config object that is no longer a draft, we need to merge it into the mutable config
for (const key of Object.keys(newConfig)) {
// @ts-expect-error - We know this is safe - not worth fixing
mutableConfig[key as keyof Config] = newConfig[key as keyof Config]
}
}
}
const configAfterPlugins = finishDraft(mutableConfig)
return await sanitizeConfig(configAfterPlugins)
}

View File

@@ -63,7 +63,12 @@ export const getFields = ({
...(existingURLField || {}),
hooks: {
afterRead: [
getAfterReadHook({ adapter, collection, disablePayloadAccessControl, generateFileURL }),
getAfterReadHook({
adapter,
collectionSlug: collection.slug,
disablePayloadAccessControl,
generateFileURL,
}),
...(existingURLField?.hooks?.afterRead || []),
],
},
@@ -114,7 +119,7 @@ export const getFields = ({
afterRead: [
getAfterReadHook({
adapter,
collection,
collectionSlug: collection.slug,
disablePayloadAccessControl,
generateFileURL,
size,

View File

@@ -4,14 +4,15 @@ import type { GeneratedAdapter, TypeWithPrefix } from '../types.js'
interface Args {
adapter: GeneratedAdapter
collection: CollectionConfig
collectionSlug: string
}
export const getAfterDeleteHook = ({
adapter,
collection,
collectionSlug,
}: Args): CollectionAfterDeleteHook<FileData & TypeWithID & TypeWithPrefix> => {
return async ({ doc, req }) => {
const collection = req.payload.collections[collectionSlug]?.config as CollectionConfig
try {
const filesToDelete: string[] = [
doc.filename,

View File

@@ -4,15 +4,23 @@ import type { GeneratedAdapter, GenerateFileURL } from '../types.js'
interface Args {
adapter: GeneratedAdapter
collection: CollectionConfig
collectionSlug: string
disablePayloadAccessControl?: boolean
generateFileURL?: GenerateFileURL
size?: ImageSize
}
export const getAfterReadHook =
({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook =>
async ({ data, value }) => {
({
adapter,
collectionSlug,
disablePayloadAccessControl,
generateFileURL,
size,
}: Args): FieldHook =>
async ({ data, req, value }) => {
const collection = req.payload.collections[collectionSlug]?.config as CollectionConfig
const filename = size ? data?.sizes?.[size.name]?.filename : data?.filename
const prefix = data?.prefix
let url = value

View File

@@ -6,12 +6,14 @@ import { getIncomingFiles } from '../utilities/getIncomingFiles.js'
interface Args {
adapter: GeneratedAdapter
collection: CollectionConfig
collectionSlug: string
}
export const getBeforeChangeHook =
({ adapter, collection }: Args): CollectionBeforeChangeHook<FileData & TypeWithID> =>
({ adapter, collectionSlug }: Args): CollectionBeforeChangeHook<FileData & TypeWithID> =>
async ({ data, originalDoc, req }) => {
const collection = req.payload.collections[collectionSlug]?.config as CollectionConfig
try {
const files = getIncomingFiles({ data, req })

View File

@@ -17,15 +17,16 @@ import { getBeforeChangeHook } from './hooks/beforeChange.js'
export const cloudStoragePlugin =
(pluginOptions: PluginOptions) =>
(incomingConfig: Config): Config => {
(config: Config): Config => {
const { collections: allCollectionOptions, enabled } = pluginOptions
const config = { ...incomingConfig }
// Return early if disabled. Only webpack config mods are applied.
if (enabled === false) {
return config
}
const onInit = config.onInit
const initFunctions: Array<() => void> = []
return {
@@ -77,11 +78,11 @@ export const cloudStoragePlugin =
...(existingCollection.hooks || {}),
afterDelete: [
...(existingCollection.hooks?.afterDelete || []),
getAfterDeleteHook({ adapter, collection: existingCollection }),
getAfterDeleteHook({ adapter, collectionSlug: existingCollection.slug }),
],
beforeChange: [
...(existingCollection.hooks?.beforeChange || []),
getBeforeChangeHook({ adapter, collection: existingCollection }),
getBeforeChangeHook({ adapter, collectionSlug: existingCollection.slug }),
],
},
upload: {
@@ -100,8 +101,8 @@ export const cloudStoragePlugin =
}),
onInit: async (payload) => {
initFunctions.forEach((fn) => fn())
if (config.onInit) {
await config.onInit(payload)
if (onInit) {
await onInit(payload)
}
},
}

View File

@@ -110,8 +110,12 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs)
}
export const resaveChildren =
(pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook =>
(pluginConfig: NestedDocsPluginConfig, collectionSlug: string): CollectionAfterChangeHook =>
async ({ doc, req }) => {
const collection = req.payload.collections[collectionSlug]?.config
if (!collection) {
throw new Error(`Collection ${collectionSlug} not found`)
}
await resave({
collection,
doc,

View File

@@ -1,4 +1,4 @@
import type { CollectionAfterChangeHook, CollectionConfig } from 'payload'
import type { CollectionAfterChangeHook } from 'payload'
import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
@@ -6,12 +6,17 @@ import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
// so that we can build its breadcrumbs with the newly created document's ID.
export const resaveSelfAfterCreate =
(pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook =>
(pluginConfig: NestedDocsPluginConfig, collectionSlug: string): CollectionAfterChangeHook =>
async ({ doc, operation, req }) => {
if (operation !== 'create') {
return undefined
}
const collection = req.payload.collections[collectionSlug]?.config
if (!collection) {
throw new Error(`Collection ${collectionSlug} not found`)
}
const { locale, payload } = req
const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs'
const breadcrumbs = doc[breadcrumbSlug] as unknown as Breadcrumb[]

View File

@@ -47,19 +47,28 @@ export const nestedDocsPlugin =
fields.push(createBreadcrumbsField(collection.slug))
}
const collectionSlug = collection.slug
return {
...collection,
fields,
hooks: {
...(collection.hooks || {}),
afterChange: [
resaveChildren(pluginConfig, collection),
resaveSelfAfterCreate(pluginConfig, collection),
resaveChildren(pluginConfig, collection.slug),
resaveSelfAfterCreate(pluginConfig, collection.slug),
...(collection?.hooks?.afterChange || []),
],
beforeChange: [
async ({ data, originalDoc, req }) =>
populateBreadcrumbs(req, pluginConfig, collection, data, originalDoc),
async ({ data, originalDoc, req }) => {
const collectionConfig = req.payload.collections[collectionSlug]?.config
if (!collectionConfig) {
throw new Error(`Collection ${collectionSlug} not found`)
}
return populateBreadcrumbs(req, pluginConfig, collectionConfig, data, originalDoc)
},
...(collection?.hooks?.beforeChange || []),
],
},

View File

@@ -144,10 +144,11 @@ export const seoPlugin =
const result = pluginConfig.generateTitle
? await pluginConfig.generateTitle({
...data,
collectionConfig: config.collections?.find(
(c) => c.slug === reqData.collectionSlug,
collectionConfig:
req.payload.collections[reqData.collectionSlug as string]?.config,
globalConfig: req.payload.config.globals?.find(
(g) => g.slug === reqData.globalSlug,
),
globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug),
req,
} satisfies Parameters<GenerateTitle>[0])
: ''
@@ -168,10 +169,12 @@ export const seoPlugin =
const result = pluginConfig.generateDescription
? await pluginConfig.generateDescription({
...data,
collectionConfig: config.collections?.find(
(c) => c.slug === reqData.collectionSlug,
collectionConfig:
req.payload.collections[reqData.collectionSlug as string]?.config,
globalConfig: req.payload.config.globals?.find(
(g) => g.slug === reqData.globalSlug,
),
globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug),
req,
} satisfies Parameters<GenerateDescription>[0])
: ''
@@ -192,10 +195,12 @@ export const seoPlugin =
const result = pluginConfig.generateURL
? await pluginConfig.generateURL({
...data,
collectionConfig: config.collections?.find(
(c) => c.slug === reqData.collectionSlug,
collectionConfig:
req.payload.collections[reqData.collectionSlug as string]?.config,
globalConfig: req.payload.config.globals?.find(
(g) => g.slug === reqData.globalSlug,
),
globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug),
req,
} satisfies Parameters<GenerateURL>[0])
: ''
@@ -216,10 +221,12 @@ export const seoPlugin =
const result = pluginConfig.generateImage
? await pluginConfig.generateImage({
...data,
collectionConfig: config.collections?.find(
(c) => c.slug === reqData.collectionSlug,
collectionConfig:
req.payload.collections[reqData.collectionSlug as string]?.config,
globalConfig: req.payload.config.globals?.find(
(g) => g.slug === reqData.globalSlug,
),
globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug),
req,
} satisfies Parameters<GenerateImage>[0])
: ''

9
pnpm-lock.yaml generated
View File

@@ -847,6 +847,9 @@ importers:
image-size:
specifier: 2.0.2
version: 2.0.2
immer:
specifier: 10.1.1
version: 10.1.1
jose:
specifier: 5.9.6
version: 5.9.6
@@ -7570,6 +7573,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
@@ -8581,6 +8587,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.4:
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
@@ -17451,6 +17458,8 @@ snapshots:
immediate@3.0.6: {}
immer@10.1.1: {}
immer@9.0.21: {}
immutable@4.3.7: {}

View File

@@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { SanitizedConfig } from 'payload'
import type { CollectionConfig, SanitizedConfig } from 'payload'
import { APIError } from 'payload'
@@ -22,6 +22,17 @@ import Users, { seedHooksUsers } from './collections/Users/index.js'
import { ValueCollection } from './collections/Value/index.js'
import { DataHooksGlobal } from './globals/Data/index.js'
const sharedHooks: CollectionConfig['hooks'] = {
afterRead: [
({ doc }) => {
return {
...doc,
afterRead1: true,
}
},
],
}
export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
admin: {
importMap: {
@@ -42,6 +53,43 @@ export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
DataHooks,
FieldPaths,
ValueCollection,
{
slug: 'sharedHooks1',
hooks: sharedHooks,
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'sharedHooks2',
hooks: sharedHooks,
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
plugins: [
(config) => {
const sharedHooks1 = config?.collections?.find((c) => c.slug === 'sharedHooks1')
if (!sharedHooks1) {
return config
}
;((sharedHooks1.hooks ??= {}).afterRead ??= []).push(({ doc }) => {
return {
...doc,
afterRead2: true,
}
})
return config
},
],
globals: [DataHooksGlobal],
endpoints: [

View File

@@ -665,4 +665,20 @@ describe('Hooks', () => {
expect(updateResult).toBeDefined()
})
})
it('plugins adding hooks should only affect targetted collection, regardless of hooks object reference', async () => {
const sharedHooks1: any = await payload.create({
collection: 'sharedHooks1',
data: {},
})
const sharedHooks2: any = await payload.create({
collection: 'sharedHooks2',
data: {},
})
expect(sharedHooks1.afterRead1).toBeTruthy()
expect(sharedHooks1.afterRead2).toBeTruthy()
expect(sharedHooks2.afterRead1).toBeTruthy()
expect(sharedHooks2.afterRead2).toBeUndefined()
})
})

View File

@@ -80,6 +80,8 @@ export interface Config {
'data-hooks': DataHook;
'field-paths': FieldPath;
'value-hooks': ValueHook;
sharedHooks1: SharedHooks1;
sharedHooks2: SharedHooks2;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -99,6 +101,8 @@ export interface Config {
'data-hooks': DataHooksSelect<false> | DataHooksSelect<true>;
'field-paths': FieldPathsSelect<false> | FieldPathsSelect<true>;
'value-hooks': ValueHooksSelect<false> | ValueHooksSelect<true>;
sharedHooks1: SharedHooks1Select<false> | SharedHooks1Select<true>;
sharedHooks2: SharedHooks2Select<false> | SharedHooks2Select<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -625,6 +629,26 @@ export interface ValueHook {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sharedHooks1".
*/
export interface SharedHooks1 {
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sharedHooks2".
*/
export interface SharedHooks2 {
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -683,6 +707,14 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'value-hooks';
value: string | ValueHook;
} | null)
| ({
relationTo: 'sharedHooks1';
value: string | SharedHooks1;
} | null)
| ({
relationTo: 'sharedHooks2';
value: string | SharedHooks2;
} | null);
globalSlug?: string | null;
user: {
@@ -940,6 +972,24 @@ export interface ValueHooksSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sharedHooks1_select".
*/
export interface SharedHooks1Select<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sharedHooks2_select".
*/
export interface SharedHooks2Select<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -3,8 +3,9 @@ import type { ArrayField, Payload, RelationshipField } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { Page } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { Page } from './payload-types.js'
let payload: Payload
@@ -98,8 +99,8 @@ describe('@payloadcms/plugin-nested-docs', () => {
},
})
const firstUpdatedChildBreadcrumbs = docs[0]?.breadcrumbs as Page['breadcrumbs']
const lastUpdatedChildBreadcrumbs = docs[10]?.breadcrumbs as Page['breadcrumbs']
const firstUpdatedChildBreadcrumbs = docs[0]?.breadcrumbs
const lastUpdatedChildBreadcrumbs = docs[10]?.breadcrumbs
expect(firstUpdatedChildBreadcrumbs).toHaveLength(2)
// @ts-ignore