Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/payload/src/collections/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col
collection.endpoints = collection.endpoints ?? []
collection.fields = collection.fields ?? []
collection.folders = collection.folders ?? false
collection.hierarchy = collection.hierarchy ?? false

collection.hooks = {
afterChange: [],
Expand Down
3 changes: 3 additions & 0 deletions packages/payload/src/collections/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
addDefaultsToLoginWithUsernameConfig,
} from './defaults.js'
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
import { sanitizeHierarchy } from './sanitizeHierarchy.js'
import { validateUseAsTitle } from './useAsTitle.js'

export const sanitizeCollection = async (
Expand Down Expand Up @@ -207,6 +208,8 @@ export const sanitizeCollection = async (
sanitized.folders.browseByFolder = sanitized.folders.browseByFolder ?? true
}

sanitizeHierarchy(sanitized, config)

if (sanitized.upload) {
if (sanitized.upload === true) {
sanitized.upload = {}
Expand Down
102 changes: 102 additions & 0 deletions packages/payload/src/collections/config/sanitizeHierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Config } from '../../config/types.js'
import type { CollectionConfig } from './types.js'

import { fieldAffectsData } from '../../fields/config/types.js'
import { addHierarchyToCollection } from '../../hierarchy/addHierarchyToCollection.js'
import { buildParentField } from '../../hierarchy/buildParentField.js'

/**
* Sanitize and apply hierarchy configuration to a collection config
*
*
* @param collectionConfig
* @param config
* @returns
*/
export const sanitizeHierarchy = (collectionConfig: CollectionConfig, config: Config): void => {
if (!collectionConfig.hierarchy) {
return
}

// Normalize boolean to object
if (collectionConfig.hierarchy === true) {
collectionConfig.hierarchy = {
parentFieldName: 'parent',
}
}

const parentFieldName = collectionConfig.hierarchy.parentFieldName

// Check if parent field already exists
const existingParentField = collectionConfig.fields.find(
(field) => fieldAffectsData(field) && field.name === parentFieldName,
)

if (existingParentField) {
// Validate existing parent field configuration
if (existingParentField.type !== 'relationship') {
throw new Error(
`Hierarchy parent field "${parentFieldName}" in collection "${collectionConfig.slug}" must be a relationship field`,
)
}

if (existingParentField.relationTo !== collectionConfig.slug) {
throw new Error(
`Hierarchy parent field "${parentFieldName}" in collection "${collectionConfig.slug}" must relate to the same collection (expected relationTo: "${collectionConfig.slug}", got: "${existingParentField.relationTo}")`,
)
}

if (existingParentField.hasMany !== false) {
throw new Error(
`Hierarchy parent field "${parentFieldName}" in collection "${collectionConfig.slug}" must have hasMany set to false`,
)
}
} else {
// Auto-create parent field if it doesn't exist
const parentField = buildParentField({
collectionSlug: collectionConfig.slug,
parentFieldName,
})

collectionConfig.fields.unshift(parentField)
}

// Apply hierarchy to collection (adds fields and hooks)
const generatePaths = collectionConfig.hierarchy.generatePaths ?? true

const hierarchyOptions: {
collectionConfig: typeof collectionConfig
config: typeof config
generatePaths: boolean
parentFieldName: string
slugify?: (text: string) => string
slugPathFieldName?: string
titlePathFieldName?: string
} = {
collectionConfig,
config,
generatePaths,
parentFieldName: collectionConfig.hierarchy.parentFieldName,
}

if (collectionConfig.hierarchy.slugify) {
hierarchyOptions.slugify = collectionConfig.hierarchy.slugify
}
if (collectionConfig.hierarchy.slugPathFieldName) {
hierarchyOptions.slugPathFieldName = collectionConfig.hierarchy.slugPathFieldName
}
if (collectionConfig.hierarchy.titlePathFieldName) {
hierarchyOptions.titlePathFieldName = collectionConfig.hierarchy.titlePathFieldName
}

addHierarchyToCollection(hierarchyOptions)

// Set sanitized hierarchy config with defaults
collectionConfig.hierarchy = {
generatePaths,
parentFieldName: collectionConfig.hierarchy.parentFieldName,
slugPathFieldName: hierarchyOptions.slugPathFieldName || '_h_slugPath',
titlePathFieldName: hierarchyOptions.titlePathFieldName || '_h_titlePath',
...(collectionConfig.hierarchy.slugify && { slugify: collectionConfig.hierarchy.slugify }),
}
}
34 changes: 33 additions & 1 deletion packages/payload/src/collections/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
UploadField,
} from '../../fields/config/types.js'
import type { CollectionFoldersConfiguration } from '../../folders/types.js'
import type { HierarchyConfig, SanitizedHierarchyConfig } from '../../hierarchy/types.js'
import type {
CollectionSlug,
JsonObject,
Expand Down Expand Up @@ -158,11 +159,13 @@ export type AfterChangeHook<T extends TypeWithID = any> = (args: {
context: RequestContext
data: Partial<T>
doc: T
docWithLocales: T
/**
* Hook operation being performed
*/
operation: CreateOrUpdateOperation
previousDoc: T
previousDocWithLocales: T
req: PayloadRequest
}) => any

Expand All @@ -180,6 +183,7 @@ export type AfterReadHook<T extends TypeWithID = any> = (args: {
collection: SanitizedCollectionConfig
context: RequestContext
doc: T
docWithLocales: T
findMany?: boolean
query?: { [key: string]: any }
req: PayloadRequest
Expand Down Expand Up @@ -558,6 +562,25 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
singularName?: string
}
| false
/**
* Enable hierarchical tree structure for this collection
*
* Use `true` to enable with defaults (auto-detects parent field)
* or provide configuration object
*
* @example
* // Enable with defaults
* hierarchy: true
*
* @example
* // Customize field names and slugify function
* hierarchy: {
* parentFieldName: 'parent',
* slugify: (text) => customSlugify(text),
* slugPathFieldName: '_breadcrumbPath'
* }
*/
hierarchy?: boolean | HierarchyConfig
/**
* Hooks to modify Payload functionality
*/
Expand Down Expand Up @@ -701,7 +724,15 @@ export type SanitizedJoins = {
export interface SanitizedCollectionConfig
extends Omit<
DeepRequired<CollectionConfig>,
'admin' | 'auth' | 'endpoints' | 'fields' | 'folders' | 'slug' | 'upload' | 'versions'
| 'admin'
| 'auth'
| 'endpoints'
| 'fields'
| 'folders'
| 'hierarchy'
| 'slug'
| 'upload'
| 'versions'
> {
admin: CollectionAdminOptions
auth: Auth
Expand All @@ -716,6 +747,7 @@ export interface SanitizedCollectionConfig
* Object of collections to join 'Join Fields object keyed by collection
*/
folders: CollectionFoldersConfiguration | false
hierarchy: false | SanitizedHierarchyConfig
joins: SanitizedJoins

/**
Expand Down
27 changes: 15 additions & 12 deletions packages/payload/src/collections/operations/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export const createOperation = async <
// beforeChange - Fields
// /////////////////////////////////////

const resultWithLocales = await beforeChange<JsonObject>({
const dataWithLocales = await beforeChange<JsonObject>({
collection: collectionConfig,
context: req.context,
data,
Expand Down Expand Up @@ -259,38 +259,38 @@ export const createOperation = async <

if (collectionConfig.auth && !collectionConfig.auth.disableLocalStrategy) {
if (collectionConfig.auth.verify) {
resultWithLocales._verified = Boolean(resultWithLocales._verified) || false
resultWithLocales._verificationToken = crypto.randomBytes(20).toString('hex')
dataWithLocales._verified = Boolean(dataWithLocales._verified) || false
dataWithLocales._verificationToken = crypto.randomBytes(20).toString('hex')
}

doc = await registerLocalStrategy({
collection: collectionConfig,
doc: resultWithLocales,
doc: dataWithLocales,
password: data.password as string,
payload: req.payload,
req,
})
} else {
doc = await payload.db.create({
collection: collectionConfig.slug,
data: resultWithLocales,
data: dataWithLocales,
req,
})
}

const verificationToken = doc._verificationToken
let result: Document = sanitizeInternalFields(doc)
const resultWithLocales: Document = sanitizeInternalFields(doc)

// /////////////////////////////////////
// Create version
// /////////////////////////////////////

if (collectionConfig.versions) {
await saveVersion({
id: result.id,
id: resultWithLocales.id,
autosave,
collection: collectionConfig,
docWithLocales: result,
docWithLocales: resultWithLocales,
operation: 'create',
payload,
publishSpecificLocale,
Expand All @@ -302,27 +302,27 @@ export const createOperation = async <
// Send verification email if applicable
// /////////////////////////////////////

if (collectionConfig.auth && collectionConfig.auth.verify && result.email) {
if (collectionConfig.auth && collectionConfig.auth.verify && resultWithLocales.email) {
await sendVerificationEmail({
collection: { config: collectionConfig },
config: payload.config,
disableEmail: disableVerificationEmail!,
email: payload.email,
req,
token: verificationToken,
user: result,
user: resultWithLocales,
})
}

// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////

result = await afterRead({
let result: Document = await afterRead({
collection: collectionConfig,
context: req.context,
depth: depth!,
doc: result,
doc: resultWithLocales,
draft,
fallbackLocale: fallbackLocale!,
global: null,
Expand All @@ -345,6 +345,7 @@ export const createOperation = async <
collection: collectionConfig,
context: req.context,
doc: result,
docWithLocales: resultWithLocales,
req,
})) || result
}
Expand Down Expand Up @@ -377,8 +378,10 @@ export const createOperation = async <
context: req.context,
data,
doc: result,
docWithLocales: resultWithLocales,
operation: 'create',
previousDoc: {},
previousDocWithLocales: {},
req: args.req,
})) || result
}
Expand Down
7 changes: 4 additions & 3 deletions packages/payload/src/collections/operations/findByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,17 @@ export const findByIDOperation = async <
throw new NotFound(t)
}

const docFromDB = await req.payload.db.findOne(findOneArgs)
const docWithLocales = await req.payload.db.findOne(findOneArgs)

if (!docFromDB && !args.data) {
if (!docWithLocales && !args.data) {
if (!disableErrors) {
throw new NotFound(req.t)
}
return null!
}

let result: DataFromCollectionSlug<TSlug> =
(args.data as DataFromCollectionSlug<TSlug>) ?? docFromDB!
(args.data as DataFromCollectionSlug<TSlug>) ?? docWithLocales!

// /////////////////////////////////////
// Include Lock Status if required
Expand Down Expand Up @@ -303,6 +303,7 @@ export const findByIDOperation = async <
collection: collectionConfig,
context: req.context,
doc: result,
docWithLocales,
query: findOneArgs.where,
req,
})) || result
Expand Down
11 changes: 8 additions & 3 deletions packages/payload/src/collections/operations/utilities/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,12 @@ export const updateDocument = async <
// Update
// /////////////////////////////////////

let resultWithLocales: JsonObject = result

if (!isSavingDraft) {
// Ensure updatedAt date is always updated
dataToUpdate.updatedAt = new Date().toISOString()
result = await req.payload.db.updateOne({
resultWithLocales = await req.payload.db.updateOne({
id,
collection: collectionConfig.slug,
data: dataToUpdate,
Expand All @@ -311,7 +313,7 @@ export const updateDocument = async <
// /////////////////////////////////////

if (collectionConfig.versions) {
result = await saveVersion({
resultWithLocales = await saveVersion({
id,
autosave,
collection: collectionConfig,
Expand All @@ -333,7 +335,7 @@ export const updateDocument = async <
collection: collectionConfig,
context: req.context,
depth,
doc: result,
doc: resultWithLocales,
draft: draftArg,
fallbackLocale,
global: null,
Expand All @@ -356,6 +358,7 @@ export const updateDocument = async <
collection: collectionConfig,
context: req.context,
doc: result,
docWithLocales: resultWithLocales,
req,
})) || result
}
Expand Down Expand Up @@ -388,8 +391,10 @@ export const updateDocument = async <
context: req.context,
data,
doc: result,
docWithLocales: resultWithLocales,
operation: 'update',
previousDoc: originalDoc,
previousDocWithLocales: docWithLocales,
req,
})) || result
}
Expand Down
Loading
Loading