Skip to content
Closed
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
11 changes: 11 additions & 0 deletions packages/payload/src/collections/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TimestampsRequired } from '../../errors/TimestampsRequired.js'
import { sanitizeFields } from '../../fields/config/sanitize.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import { mergeBaseFields } from '../../fields/mergeBaseFields.js'
import { addTreeViewFields } from '../../treeView/addTreeViewFields.js'
import { uploadCollectionEndpoints } from '../../uploads/endpoints/index.js'
import { getBaseUploadFields } from '../../uploads/getBaseFields.js'
import { flattenAllFields } from '../../utilities/flattenAllFields.js'
Expand Down Expand Up @@ -202,6 +203,16 @@ export const sanitizeCollection = async (
sanitized.folders.browseByFolder = sanitized.folders.browseByFolder ?? true
}

/**
* Tree view feature
*/
if (sanitized.treeView) {
addTreeViewFields({
collectionConfig: sanitized,
config,
})
}

if (sanitized.upload) {
if (sanitized.upload === true) {
sanitized.upload = {}
Expand Down
5 changes: 5 additions & 0 deletions packages/payload/src/collections/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export type AfterChangeHook<T extends TypeWithID = any> = (args: {
*/
operation: CreateOrUpdateOperation
previousDoc: T
previousDocWithLocales: any
req: PayloadRequest
}) => any

Expand Down Expand Up @@ -633,6 +634,10 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
* @default false
*/
trash?: boolean
/**
* Enables tree view support for this collection
*/
treeView?: boolean
/**
* Options used in typescript generation
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ export const updateDocument = async <
doc: result,
operation: 'update',
previousDoc: originalDoc,
previousDocWithLocales: docWithLocales,
req,
})) || result
}
Expand Down
3 changes: 2 additions & 1 deletion packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,7 +1164,6 @@ export type Config = {
* ```
*/
logger?: 'sync' | { destination?: DestinationStream; options: pino.LoggerOptions } | PayloadLogger

/**
* Override the log level of errors for Payload's error handler or disable logging with `false`.
* Levels can be any of the following: 'trace', 'debug', 'info', 'warn', 'error', 'fatal' or false.
Expand Down Expand Up @@ -1198,6 +1197,7 @@ export type Config = {

/** A function that is called immediately following startup that receives the Payload instance as its only argument. */
onInit?: (payload: Payload) => Promise<void> | void

/**
* An array of Payload plugins.
*
Expand Down Expand Up @@ -1271,6 +1271,7 @@ export type Config = {
sharp?: SharpDependency
/** Send anonymous telemetry data about general usage. */
telemetry?: boolean
treeView?: boolean
/** Control how typescript interfaces are generated from your collections. */
typescript?: {
/**
Expand Down
104 changes: 104 additions & 0 deletions packages/payload/src/treeView/addTreeViewFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { AddTreeViewFieldsArgs } from './types.js'

import { collectionTreeViewAfterChange } from './hooks/collectionAfterChange.js'
import { defaultSlugify } from './utils/defaultSlugify.js'
import { findUseAsTitleField } from './utils/findUseAsTitleField.js'

export function addTreeViewFields({
collectionConfig,
config,
parentDocFieldName = '_parentDoc',
slugify = defaultSlugify,
slugPathFieldName = 'slugPath',
titlePathFieldName = 'titlePath',
}: AddTreeViewFieldsArgs): void {
const titleField = findUseAsTitleField(collectionConfig)
const localizeField: boolean = Boolean(config.localization && titleField.localized)

collectionConfig.fields.push({
type: 'group',
admin: {
position: 'sidebar',
},
fields: [
{
name: parentDocFieldName,
type: 'relationship',
admin: {
disableBulkEdit: true,
},
filterOptions: ({ id }) => {
return {
id: {
not_in: [id],
},
}
},
index: true,
label: 'Parent Document',
maxDepth: 0,
relationTo: collectionConfig.slug,
},
{
name: slugPathFieldName,
type: 'text',
admin: {
readOnly: true,
// hidden: true,
},
index: true,
label: ({ t }) => t('general:slugPath'),
localized: localizeField,
},
{
name: titlePathFieldName,
type: 'text',
admin: {
readOnly: true,
// hidden: true,
},
index: true,
label: ({ t }) => t('general:titlePath'),
localized: localizeField,
},
{
name: '_parentTree',
type: 'relationship',
admin: {
allowEdit: false,
hidden: true,
isSortable: false,
readOnly: true,
},
hasMany: true,
index: true,
maxDepth: 0,
relationTo: collectionConfig.slug,
},
],
label: 'Document Tree',
})

if (!collectionConfig.admin) {
collectionConfig.admin = {}
}
if (!collectionConfig.admin.listSearchableFields) {
collectionConfig.admin.listSearchableFields = []
}
collectionConfig.admin.listSearchableFields.push(titlePathFieldName)

collectionConfig.hooks = {
...(collectionConfig.hooks || {}),
afterChange: [
collectionTreeViewAfterChange({
parentDocFieldName,
slugify,
slugPathFieldName,
titleField,
titlePathFieldName,
}),
// purposefully run other hooks _after_ the document tree is updated
...(collectionConfig.hooks?.afterChange || []),
],
}
}
2 changes: 2 additions & 0 deletions packages/payload/src/treeView/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const hierarchySlug = 'payload-hierarchy'
export const hierarchicalParentFieldName = 'hierarchicalParent'
210 changes: 210 additions & 0 deletions packages/payload/src/treeView/hooks/collectionAfterChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import type {
CollectionAfterChangeHook,
Document,
FieldAffectingData,
JsonObject,
TypeWithID,
} from '../../index.js'
import type { AddTreeViewFieldsArgs } from '../types.js'
import type { GenerateTreePathsArgs } from '../utils/generateTreePaths.js'

import { adjustAffectedTreePaths } from '../utils/adjustAffectedTreePaths.js'
import { generateTreePaths } from '../utils/generateTreePaths.js'
import { getTreeChanges } from '../utils/getTreeChanges.js'

type Ags = {
titleField: FieldAffectingData
} & Required<Omit<AddTreeViewFieldsArgs, 'collectionConfig' | 'config'>>
export const collectionTreeViewAfterChange =
({
parentDocFieldName,
slugify,
slugPathFieldName,
titleField,
titlePathFieldName,
}: Ags): CollectionAfterChangeHook =>
async ({ collection, doc, previousDoc, previousDocWithLocales, req }) => {
const fieldIsLocalized = Boolean(titleField.localized)
const titleFieldName: string = titleField.name!
const reqLocale = req.locale

// handle this better later
if (reqLocale === 'all') {
return
}
const { newParentID, parentChanged, prevParentID, slugChanged } = getTreeChanges({
doc,
parentDocFieldName,
previousDoc,
slugify,
titleFieldName,
})

let parentDocument: Document = undefined

if (parentChanged || slugChanged) {
let newParentTree: (number | string)[] = []

const baseGenerateTreePathsArgs: Omit<
GenerateTreePathsArgs,
'defaultLocale' | 'localeCodes' | 'localized' | 'parentDocument' | 'reqLocale'
> = {
newDoc: doc,
previousDocWithLocales,
slugify,
slugPathFieldName,
titleFieldName,
titlePathFieldName,
}

if (parentChanged && newParentID) {
// set new parent
parentDocument = await req.payload.findByID({
id: newParentID,
collection: collection.slug,
depth: 0,
locale: 'all',
select: {
_parentTree: true,
[slugPathFieldName]: true,
[titlePathFieldName]: true,
},
})

newParentTree = [...(parentDocument?._parentTree || []), newParentID]
} else if (parentChanged && !newParentID) {
newParentTree = []
} else {
// only the title updated
if (prevParentID) {
parentDocument = await req.payload.findByID({
id: prevParentID,
collection: collection.slug,
depth: 0,
locale: 'all',
req,
select: {
_parentTree: true,
[slugPathFieldName]: true,
[titlePathFieldName]: true,
},
})
}

newParentTree = doc._parentTree
}

const treePaths = generateTreePaths({
...baseGenerateTreePathsArgs,
parentDocument,
...(fieldIsLocalized && req.payload.config.localization
? {
defaultLocale: req.payload.config.localization.defaultLocale,
localeCodes: req.payload.config.localization.localeCodes,
localized: true,
reqLocale: reqLocale as string,
}
: {
localized: false,
}),
})
const newSlugPath = treePaths.slugPath
const newTitlePath = treePaths.titlePath

const documentAfterUpdate = await req.payload.db.updateOne({
id: doc.id,
collection: collection.slug,
data: {
_parentTree: newParentTree,
[slugPathFieldName]: newSlugPath,
[titlePathFieldName]: newTitlePath,
},
locale: 'all',
req,
select: {
_parentTree: true,
[slugPathFieldName]: true,
[titlePathFieldName]: true,
},
})

const affectedDocs = await req.payload.find({
collection: collection.slug,
depth: 0,
limit: 200,
locale: 'all',
req,
select: {
_parentTree: true,
[slugPathFieldName]: true,
[titlePathFieldName]: true,
},
where: {
_parentTree: {
in: [doc.id],
},
},
})

const updatePromises: Promise<JsonObject & TypeWithID>[] = []
affectedDocs.docs.forEach((affectedDoc) => {
const newTreePaths = adjustAffectedTreePaths({
affectedDoc,
newDoc: documentAfterUpdate,
previousDocWithLocales,
slugPathFieldName,
titlePathFieldName,
...(req.payload.config.localization && fieldIsLocalized
? {
localeCodes: req.payload.config.localization.localeCodes,
localized: true,
}
: {
localized: false,
}),
})

// Find the index of doc.id in affectedDoc's parent tree
const docIndex = affectedDoc._parentTree?.indexOf(doc.id) ?? -1
const descendants = docIndex >= 0 ? affectedDoc._parentTree.slice(docIndex) : []

updatePromises.push(
// this pattern has an issue bc it will not run hooks on the affected documents
// if we use payload.update, then we will need to loop over `n` locales and run 1 update per locale
req.payload.db.updateOne({
id: affectedDoc.id,
collection: collection.slug,
data: {
_parentTree: [...(doc._parentTree || []), ...descendants],
[slugPathFieldName]: newTreePaths.slugPath,
[titlePathFieldName]: newTreePaths.titlePath,
},
locale: 'all',
req,
}),
)
})

await Promise.all(updatePromises)

const updatedSlugPath = fieldIsLocalized
? documentAfterUpdate[slugPathFieldName][reqLocale!]
: documentAfterUpdate[slugPathFieldName]
const updatedTitlePath = fieldIsLocalized
? documentAfterUpdate[titlePathFieldName][reqLocale!]
: documentAfterUpdate[titlePathFieldName]
const updatedParentTree = documentAfterUpdate._parentTree

if (updatedSlugPath) {
doc[slugPathFieldName] = updatedSlugPath
}
if (updatedTitlePath) {
doc[titlePathFieldName] = updatedTitlePath
}
if (parentChanged) {
doc._parentTree = updatedParentTree
}

return doc
}
}
Loading
Loading