From b0549211d7e49989a42f160536cff537a82cdbde Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 12 Sep 2025 14:59:06 -0400 Subject: [PATCH 01/31] data wip --- .../src/collections/config/sanitize.ts | 11 + .../payload/src/collections/config/types.ts | 5 + .../operations/utilities/update.ts | 1 + packages/payload/src/config/defaults.ts | 12 + packages/payload/src/config/sanitize.ts | 8 + .../src/hierarchy/addTreeViewFields.ts | 386 ++++++++++++++++++ packages/payload/src/hierarchy/constants.ts | 2 + packages/payload/src/hierarchy/types.ts | 35 ++ packages/payload/src/utilities/extractID.ts | 2 +- 9 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 packages/payload/src/hierarchy/addTreeViewFields.ts create mode 100644 packages/payload/src/hierarchy/constants.ts create mode 100644 packages/payload/src/hierarchy/types.ts diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 617d8cdb0d6..0228525f3d0 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -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 '../../hierarchy/addTreeViewFields.js' import { uploadCollectionEndpoints } from '../../uploads/endpoints/index.js' import { getBaseUploadFields } from '../../uploads/getBaseFields.js' import { flattenAllFields } from '../../utilities/flattenAllFields.js' @@ -202,6 +203,16 @@ export const sanitizeCollection = async ( sanitized.folders.browseByFolder = sanitized.folders.browseByFolder ?? true } + /** + * Hierarchy feature + */ + if (sanitized.hierarchy) { + addTreeViewFields({ + collectionConfig: sanitized, + titleFieldName: 'title', // this needs to be dynamic per collection + }) + } + if (sanitized.upload) { if (sanitized.upload === true) { sanitized.upload = {} diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 53181f5f739..9a64139dfc3 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -149,6 +149,7 @@ export type AfterChangeHook = (args: { */ operation: CreateOrUpdateOperation previousDoc: T + previousDocWithLocales: any req: PayloadRequest }) => any @@ -521,6 +522,10 @@ export type CollectionConfig = { singularName?: string } | false + /** + * Enables hierarchy support for this collection + */ + hierarchy?: boolean /** * Hooks to modify Payload functionality */ diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index da5d7db8036..5b94e53b9a6 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -391,6 +391,7 @@ export const updateDocument = async < doc: result, operation: 'update', previousDoc: originalDoc, + previousDocWithLocales: docWithLocales, req, })) || result } diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index aa8c01a0b74..766b3a7844e 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -165,6 +165,18 @@ export const addDefaultsToConfig = (config: Config): Config => { ...(config.auth || {}), } + if ( + config.hierarchy !== false && + config.collections.some((collection) => Boolean(collection.hierarchy)) + ) { + config.hierarchy = { + slug: config.hierarchy?.slug ?? 'payload-hierarchy', + ...(config.hierarchy || {}), + } + } else { + config.hierarchy = false + } + if ( config.folders !== false && config.collections.some((collection) => Boolean(collection.folders)) diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 043bb34d3df..804a655ad43 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -198,6 +198,9 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise 0) { diff --git a/packages/payload/src/hierarchy/addTreeViewFields.ts b/packages/payload/src/hierarchy/addTreeViewFields.ts new file mode 100644 index 00000000000..acffca763e2 --- /dev/null +++ b/packages/payload/src/hierarchy/addTreeViewFields.ts @@ -0,0 +1,386 @@ +import type { CollectionConfig, TypeWithID } from '../collections/config/types.js' +import type { Document, JsonObject } from '../types/index.js' + +type AddTreeViewFieldsArgs = { + collectionConfig: CollectionConfig + parentDocFieldName?: string + slugify?: (text: string) => string + slugPathFieldName?: string + titleFieldName: string + titlePathFieldName?: string +} + +export function addTreeViewFields({ + collectionConfig, + parentDocFieldName = '_parentDoc', + slugify = defaultSlugify, + slugPathFieldName = 'slugPath', + titleFieldName = 'title', + titlePathFieldName = 'titlePath', +}: AddTreeViewFieldsArgs): void { + collectionConfig.fields.push({ + type: 'group', + 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: '_parentTree', + type: 'relationship', + admin: { + isSortable: false, + readOnly: true, + // hidden: true, + }, + hasMany: true, + index: true, + maxDepth: 0, + relationTo: collectionConfig.slug, + }, + { + name: slugPathFieldName, + type: 'text', + admin: { + // readOnly: true, + // hidden: true, + }, + index: true, + label: ({ t }) => t('general:slugPath'), + localized: true, + }, + { + name: titlePathFieldName, + type: 'text', + admin: { + // readOnly: true, + // hidden: true, + }, + index: true, + label: ({ t }) => t('general:titlePath'), + localized: true, + }, + ], + }) + + collectionConfig.hooks = { + ...(collectionConfig.hooks || {}), + afterChange: [ + async ({ data, doc, previousDoc, previousDocWithLocales, req }) => { + // handle this better later + if (req.locale === 'all') { + return + } + const { newParentID, newSlug, parentChanged, prevParentID, prevSlug, slugChanged } = + getTreeChanges({ doc, parentDocFieldName, previousDoc, slugify, titleFieldName }) + + if (parentChanged || slugChanged) { + let updatedSlugPath + let updatedTitlePath + let updatedParentTree + if (parentChanged) { + const updatedSlugPaths: Record = {} + const updatedTitlePaths: Record = {} + let documentAfterUpdate: JsonObject & TypeWithID = { id: doc.id } + + if (newParentID) { + // query new parent + const newParentFullDoc = await req.payload.findByID({ + id: newParentID, + collection: collectionConfig.slug, + depth: 0, + locale: 'all', + select: { + _parentTree: true, + [slugPathFieldName]: true, + [titlePathFieldName]: true, + }, + }) + + Object.entries(newParentFullDoc).forEach(([key, fieldValue]) => { + if (key === slugPathFieldName) { + // assuming localization here for now + Object.entries(fieldValue as Record).forEach( + ([locale, localizedParentSlugPath]) => { + updatedSlugPaths[locale] = + `${localizedParentSlugPath}/${slugify(previousDocWithLocales[titleFieldName][locale])}` + }, + ) + } else if (key === titlePathFieldName) { + Object.entries(fieldValue as Record).forEach( + ([locale, localizedParentTitlePath]) => { + updatedTitlePaths[locale] = + `${localizedParentTitlePath}/${previousDocWithLocales[titleFieldName][locale]}` + }, + ) + } + }) + + // update current document + documentAfterUpdate = await req.payload.db.updateOne({ + id: doc.id, + collection: collectionConfig.slug, + data: { + _parentTree: [...(newParentFullDoc?._parentTree || []), newParentID], + [slugPathFieldName]: updatedSlugPaths, + [titlePathFieldName]: updatedTitlePaths, + }, + locale: 'all', + req, + select: { + _parentTree: true, + [slugPathFieldName]: true, + [titlePathFieldName]: true, + }, + }) + } else { + // removed parent + + // update current document + documentAfterUpdate = await req.payload.db.updateOne({ + id: doc.id, + collection: collectionConfig.slug, + data: { + _parentTree: [], + [slugPathFieldName]: Object.keys( + previousDocWithLocales[titleFieldName], + ).reduce((acc, locale) => { + if (req.locale === locale) { + acc[locale] = slugify(doc[titleFieldName]) + } else { + acc[locale] = slugify(previousDocWithLocales[titleFieldName][locale]) + } + return acc + }, {}), + [titlePathFieldName]: Object.keys( + previousDocWithLocales[titleFieldName], + ).reduce((acc, locale) => { + if (req.locale === locale) { + acc[locale] = doc[titleFieldName] + } else { + acc[locale] = previousDocWithLocales[titleFieldName][locale] + } + return acc + }, {}), + }, + locale: 'all', + req, + select: { + _parentTree: true, + [slugPathFieldName]: true, + [titlePathFieldName]: true, + }, + }) + } + + updatedSlugPath = documentAfterUpdate[slugPathFieldName][req.locale!] + updatedTitlePath = documentAfterUpdate[titlePathFieldName][req.locale!] + updatedParentTree = documentAfterUpdate._parentTree + + const affectedDocs = await req.payload.find({ + collection: collectionConfig.slug, + depth: 0, + limit: 200, + locale: 'all', + select: { + [titleFieldName]: true, + }, + where: { + _parentTree: { + in: [doc.id], + }, + }, + }) + + const updatePromises: Promise[] = [] + affectedDocs.docs.forEach((affectedDoc) => { + 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: collectionConfig.slug, + data: { + _parentTree: [...(documentAfterUpdate._parentTree || []), doc.id], + [slugPathFieldName]: Object.keys( + affectedDoc[titleFieldName], + ).reduce((acc, locale) => { + acc[locale] = + `${documentAfterUpdate[slugPathFieldName][locale]}/${slugify(affectedDoc[titleFieldName][locale])}` + return acc + }, {}), + [titlePathFieldName]: Object.keys( + affectedDoc[titleFieldName], + ).reduce((acc, locale) => { + acc[locale] = + `${documentAfterUpdate[titlePathFieldName][locale]}/${affectedDoc[titleFieldName][locale]}` + return acc + }, {}), + }, + locale: 'all', + req, + }), + ) + }) + await Promise.all(updatePromises) + } else { + // just slug changed (no localization needed) + let updatedDocument = doc + let prevParentDoc + if (prevParentID) { + // has parent + prevParentDoc = await req.payload.findByID({ + id: prevParentID, + collection: collectionConfig.slug, + depth: 0, + locale: req.locale, + req, + select: { + _parentTree: true, + [slugPathFieldName]: true, + [titleFieldName]: true, + }, + }) + } + + updatedDocument = await req.payload.update({ + id: doc.id, + collection: collectionConfig.slug, + data: { + [slugPathFieldName]: prevParentDoc + ? `${prevParentDoc[slugPathFieldName]}/${newSlug}` + : newSlug, + [titlePathFieldName]: prevParentDoc + ? `${prevParentDoc[titlePathFieldName]}/${doc[titleFieldName]}` + : doc[titleFieldName], + }, + depth: 0, + locale: req.locale, + req, + select: { + _parentTree: true, + [slugPathFieldName]: true, + [titleFieldName]: true, + }, + }) + + updatedSlugPath = updatedDocument[slugPathFieldName] + updatedTitlePath = updatedDocument[titleFieldName] + updatedParentTree = updatedDocument._parentTree + + const affectedDocs = await req.payload.find({ + collection: collectionConfig.slug, + depth: 0, + limit: 200, + select: { + [titleFieldName]: true, + }, + where: { + _parentTree: { + in: [doc.id], + }, + }, + }) + + const updatePromises: Promise[] = [] + affectedDocs.docs.forEach((affectedDoc) => { + 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.update({ + id: affectedDoc.id, + collection: collectionConfig.slug, + data: { + _parentTree: [...(doc._parentTree || []), doc.id], + [slugPathFieldName]: `${updatedDocument[slugPathFieldName]}/${slugify(affectedDoc[titleFieldName])}`, + [titlePathFieldName]: `${updatedDocument[titlePathFieldName]}/${affectedDoc[titleFieldName]}`, + }, + depth: 0, + req, + }), + ) + }) + + await Promise.all(updatePromises) + } + + if (updatedSlugPath) { + doc[slugPathFieldName] = updatedSlugPath + } + if (updatedTitlePath) { + doc[titlePathFieldName] = updatedTitlePath + } + if (parentChanged) { + doc._parentTree = updatedParentTree + } + + return doc + } + }, + // specifically run other hooks _after_ the document tree is updated + ...(collectionConfig.hooks?.afterChange || []), + ], + } +} + +// default slugify function +const defaultSlugify = (title: string): string => { + return title + .toLowerCase() + .trim() + .replace(/\W+/g, '-') // Replace spaces and non-word chars with hyphens + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens +} + +type GetTreeChanges = { + doc: Document + parentDocFieldName: string + previousDoc: Document + slugify: (text: string) => string + titleFieldName: string +} + +type GetTreeChangesResult = { + newParentID: null | number | string | undefined + newSlug: string | undefined + parentChanged: boolean + prevParentID: null | number | string | undefined + prevSlug: string | undefined + slugChanged: boolean +} + +function getTreeChanges({ + doc, + parentDocFieldName, + previousDoc, + slugify, + titleFieldName, +}: GetTreeChanges): GetTreeChangesResult { + const prevParentID = previousDoc[parentDocFieldName] || null + const newParentID = doc[parentDocFieldName] || null + const newSlug = doc[titleFieldName] ? slugify(doc[titleFieldName]) : undefined + const prevSlug = previousDoc[titleFieldName] ? slugify(previousDoc[titleFieldName]) : undefined + + return { + newParentID, + newSlug, + parentChanged: prevParentID !== newParentID, + prevParentID, + prevSlug, + slugChanged: newSlug !== prevSlug, + } +} diff --git a/packages/payload/src/hierarchy/constants.ts b/packages/payload/src/hierarchy/constants.ts new file mode 100644 index 00000000000..6533cc88385 --- /dev/null +++ b/packages/payload/src/hierarchy/constants.ts @@ -0,0 +1,2 @@ +export const hierarchySlug = 'payload-hierarchy' +export const hierarchicalParentFieldName = 'hierarchicalParent' diff --git a/packages/payload/src/hierarchy/types.ts b/packages/payload/src/hierarchy/types.ts new file mode 100644 index 00000000000..6b010830221 --- /dev/null +++ b/packages/payload/src/hierarchy/types.ts @@ -0,0 +1,35 @@ +import type { CollectionConfig } from '../collections/config/types.js' + +export type RootHierarchyConfiguration = { + /** + * An array of functions to be ran when the hierarchy collection is initialized + * This allows plugins to modify the collection configuration + */ + collectionOverrides?: (({ + collection, + }: { + collection: Omit + }) => Omit | Promise>)[] + /** + * Ability to view hidden fields and collections related to the hierarchy + * + * @default false + */ + debug?: boolean + /** + * The hierarchical parent field name + * + * @default "hierarchicalParent" + */ + fieldName?: string + /** + * Slug for the hierarchy collection + * + * @default "payload-hierarchy" + */ + slug?: string +} + +export type HierarchyCollectionConfig = { + hierarchy?: boolean +} diff --git a/packages/payload/src/utilities/extractID.ts b/packages/payload/src/utilities/extractID.ts index 49f986a9d96..20267551952 100644 --- a/packages/payload/src/utilities/extractID.ts +++ b/packages/payload/src/utilities/extractID.ts @@ -5,5 +5,5 @@ export const extractID = ( return objectOrID } - return objectOrID.id + return objectOrID ? objectOrID.id : objectOrID } From 6024ff6601890b3993542847f858b2af3eb19111 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 12 Sep 2025 22:48:05 -0400 Subject: [PATCH 02/31] simplified-ish logic --- packages/payload/src/config/types.ts | 2 + .../src/hierarchy/addTreeViewFields.ts | 370 +++++++++--------- 2 files changed, 185 insertions(+), 187 deletions(-) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index de05ac39693..612e981a035 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -41,6 +41,7 @@ import type { EmailAdapter, SendEmailOptions } from '../email/types.js' import type { ErrorName } from '../errors/types.js' import type { RootFoldersConfiguration } from '../folders/types.js' import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js' +import type { RootHierarchyConfiguration } from '../hierarchy/types.js' import type { Block, FlattenedBlock, @@ -1117,6 +1118,7 @@ export type Config = { */ validationRules?: (args: GraphQL.ExecutionArgs) => GraphQL.ValidationRule[] } + hierarchy?: false | RootHierarchyConfiguration /** * Tap into Payload-wide hooks. * diff --git a/packages/payload/src/hierarchy/addTreeViewFields.ts b/packages/payload/src/hierarchy/addTreeViewFields.ts index acffca763e2..4bf12eb32bb 100644 --- a/packages/payload/src/hierarchy/addTreeViewFields.ts +++ b/packages/payload/src/hierarchy/addTreeViewFields.ts @@ -80,23 +80,44 @@ export function addTreeViewFields({ collectionConfig.hooks = { ...(collectionConfig.hooks || {}), afterChange: [ - async ({ data, doc, previousDoc, previousDocWithLocales, req }) => { + async ({ doc, previousDoc, previousDocWithLocales, req }) => { // handle this better later - if (req.locale === 'all') { + const reqLocale = req.locale + if (reqLocale === 'all') { return } const { newParentID, newSlug, parentChanged, prevParentID, prevSlug, slugChanged } = getTreeChanges({ doc, parentDocFieldName, previousDoc, slugify, titleFieldName }) if (parentChanged || slugChanged) { - let updatedSlugPath - let updatedTitlePath - let updatedParentTree - if (parentChanged) { - const updatedSlugPaths: Record = {} - const updatedTitlePaths: Record = {} - let documentAfterUpdate: JsonObject & TypeWithID = { id: doc.id } + /** + * should look like: + * + * { + * [slugPathFieldName]: { + * [locale]: updatedSlugPath + * }, + * [titlePathFieldName]: { + * [locale]: updatedTitlePath + * }, + * _parentTree: updatedParentTree, + * } + */ + const dataToUpdateDoc: { + _parentTree: (number | string)[] + slugPath: { + [locale: string]: string + } + titlePath: { + [locale: string]: string + } + } = { + _parentTree: [], + slugPath: {}, + titlePath: {}, + } + if (parentChanged) { if (newParentID) { // query new parent const newParentFullDoc = await req.payload.findByID({ @@ -111,212 +132,187 @@ export function addTreeViewFields({ }, }) - Object.entries(newParentFullDoc).forEach(([key, fieldValue]) => { - if (key === slugPathFieldName) { - // assuming localization here for now - Object.entries(fieldValue as Record).forEach( - ([locale, localizedParentSlugPath]) => { - updatedSlugPaths[locale] = - `${localizedParentSlugPath}/${slugify(previousDocWithLocales[titleFieldName][locale])}` - }, - ) - } else if (key === titlePathFieldName) { - Object.entries(fieldValue as Record).forEach( - ([locale, localizedParentTitlePath]) => { - updatedTitlePaths[locale] = - `${localizedParentTitlePath}/${previousDocWithLocales[titleFieldName][locale]}` - }, - ) + dataToUpdateDoc._parentTree = [...(newParentFullDoc?._parentTree || []), newParentID] + req.payload.config.localization.localeCodes.forEach((locale: string) => { + const slugPrefix = + newParentFullDoc?.[slugPathFieldName]?.[locale] || + newParentFullDoc?.[slugPathFieldName]?.[ + req.payload.config.localization.defaultLocale + ] || + '' + const titlePrefix = + newParentFullDoc?.[titlePathFieldName]?.[locale] || + newParentFullDoc?.[titlePathFieldName]?.[ + req.payload.config.localization.defaultLocale + ] || + '' + if (reqLocale === locale) { + dataToUpdateDoc.slugPath[locale] = `${slugPrefix}/${slugify(doc[titleFieldName])}` + dataToUpdateDoc.titlePath[locale] = `${titlePrefix}/${doc[titleFieldName]}` + } else { + // use prev title on previousDocWithLocales + dataToUpdateDoc.slugPath[locale] = + `${slugPrefix}/${slugify(previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName])}` + dataToUpdateDoc.titlePath[locale] = + `${titlePrefix}/${previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName]}` } }) - - // update current document - documentAfterUpdate = await req.payload.db.updateOne({ - id: doc.id, - collection: collectionConfig.slug, - data: { - _parentTree: [...(newParentFullDoc?._parentTree || []), newParentID], - [slugPathFieldName]: updatedSlugPaths, - [titlePathFieldName]: updatedTitlePaths, - }, - locale: 'all', - req, - select: { - _parentTree: true, - [slugPathFieldName]: true, - [titlePathFieldName]: true, - }, - }) } else { // removed parent - - // update current document - documentAfterUpdate = await req.payload.db.updateOne({ - id: doc.id, - collection: collectionConfig.slug, - data: { - _parentTree: [], - [slugPathFieldName]: Object.keys( - previousDocWithLocales[titleFieldName], - ).reduce((acc, locale) => { - if (req.locale === locale) { - acc[locale] = slugify(doc[titleFieldName]) - } else { - acc[locale] = slugify(previousDocWithLocales[titleFieldName][locale]) - } - return acc - }, {}), - [titlePathFieldName]: Object.keys( - previousDocWithLocales[titleFieldName], - ).reduce((acc, locale) => { - if (req.locale === locale) { - acc[locale] = doc[titleFieldName] - } else { - acc[locale] = previousDocWithLocales[titleFieldName][locale] - } - return acc - }, {}), - }, - locale: 'all', - req, - select: { - _parentTree: true, - [slugPathFieldName]: true, - [titlePathFieldName]: true, - }, + dataToUpdateDoc._parentTree = [] + req.payload.config.localization.localeCodes.forEach((locale: string) => { + if (reqLocale === locale) { + // use current title on doc + dataToUpdateDoc.slugPath[locale] = slugify(doc[titleFieldName]) + dataToUpdateDoc.titlePath[locale] = doc[titleFieldName] + } else { + // use prev title on previousDocWithLocales + dataToUpdateDoc.slugPath[locale] = slugify( + previousDocWithLocales?.[titleFieldName]?.[locale] + ? previousDocWithLocales[titleFieldName][locale] + : doc[titleFieldName], + ) + dataToUpdateDoc.titlePath[locale] = previousDocWithLocales?.[titleFieldName]?.[ + locale + ] + ? previousDocWithLocales[titleFieldName][locale] + : doc[titleFieldName] + } }) } - - updatedSlugPath = documentAfterUpdate[slugPathFieldName][req.locale!] - updatedTitlePath = documentAfterUpdate[titlePathFieldName][req.locale!] - updatedParentTree = documentAfterUpdate._parentTree - - const affectedDocs = await req.payload.find({ - collection: collectionConfig.slug, - depth: 0, - limit: 200, - locale: 'all', - select: { - [titleFieldName]: true, - }, - where: { - _parentTree: { - in: [doc.id], - }, - }, - }) - - const updatePromises: Promise[] = [] - affectedDocs.docs.forEach((affectedDoc) => { - 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: collectionConfig.slug, - data: { - _parentTree: [...(documentAfterUpdate._parentTree || []), doc.id], - [slugPathFieldName]: Object.keys( - affectedDoc[titleFieldName], - ).reduce((acc, locale) => { - acc[locale] = - `${documentAfterUpdate[slugPathFieldName][locale]}/${slugify(affectedDoc[titleFieldName][locale])}` - return acc - }, {}), - [titlePathFieldName]: Object.keys( - affectedDoc[titleFieldName], - ).reduce((acc, locale) => { - acc[locale] = - `${documentAfterUpdate[titlePathFieldName][locale]}/${affectedDoc[titleFieldName][locale]}` - return acc - }, {}), - }, - locale: 'all', - req, - }), - ) - }) - await Promise.all(updatePromises) } else { - // just slug changed (no localization needed) - let updatedDocument = doc - let prevParentDoc + // only the title field was updated + let prevParentDoc: Document if (prevParentID) { // has parent prevParentDoc = await req.payload.findByID({ id: prevParentID, collection: collectionConfig.slug, depth: 0, - locale: req.locale, + locale: 'all', req, select: { _parentTree: true, [slugPathFieldName]: true, [titleFieldName]: true, + [titlePathFieldName]: true, }, }) } - updatedDocument = await req.payload.update({ - id: doc.id, - collection: collectionConfig.slug, - data: { - [slugPathFieldName]: prevParentDoc - ? `${prevParentDoc[slugPathFieldName]}/${newSlug}` - : newSlug, - [titlePathFieldName]: prevParentDoc - ? `${prevParentDoc[titlePathFieldName]}/${doc[titleFieldName]}` - : doc[titleFieldName], - }, - depth: 0, - locale: req.locale, - req, - select: { - _parentTree: true, - [slugPathFieldName]: true, - [titleFieldName]: true, - }, + dataToUpdateDoc._parentTree = prevParentDoc + ? [...(prevParentDoc._parentTree || []), prevParentID] + : [] + req.payload.config.localization.localeCodes.forEach((locale: string) => { + const slugPrefix = prevParentDoc?.[slugPathFieldName]?.[locale] + ? prevParentDoc[slugPathFieldName][locale] + : '' + const titlePrefix = prevParentDoc?.[titlePathFieldName]?.[locale] + ? prevParentDoc[titlePathFieldName][locale] + : '' + if (reqLocale === locale) { + dataToUpdateDoc.slugPath[locale] = + `${slugPrefix ? `${slugPrefix}/` : ''}${slugify(doc[titleFieldName])}` + dataToUpdateDoc.titlePath[locale] = + `${titlePrefix ? `${titlePrefix}/` : ''}${doc[titleFieldName]}` + } else { + // use prev title on previousDocWithLocales + dataToUpdateDoc.slugPath[locale] = + `${slugPrefix ? `${slugPrefix}/` : ''}${slugify(previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName])}` + dataToUpdateDoc.titlePath[locale] = + `${titlePrefix ? `${titlePrefix}/` : ''}${previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName]}` + } }) + } - updatedSlugPath = updatedDocument[slugPathFieldName] - updatedTitlePath = updatedDocument[titleFieldName] - updatedParentTree = updatedDocument._parentTree + const documentAfterUpdate = await req.payload.db.updateOne({ + id: doc.id, + collection: collectionConfig.slug, + data: { + _parentTree: dataToUpdateDoc._parentTree, + [slugPathFieldName]: dataToUpdateDoc.slugPath, + [titlePathFieldName]: dataToUpdateDoc.titlePath, + }, + locale: 'all', + req, + select: { + _parentTree: true, + [slugPathFieldName]: true, + [titleFieldName]: true, + [titlePathFieldName]: true, + }, + }) - const affectedDocs = await req.payload.find({ - collection: collectionConfig.slug, - depth: 0, - limit: 200, - select: { - [titleFieldName]: true, - }, - where: { - _parentTree: { - in: [doc.id], - }, + const updatedSlugPath = documentAfterUpdate[slugPathFieldName][reqLocale!] + const updatedTitlePath = documentAfterUpdate[titlePathFieldName][reqLocale!] + const updatedParentTree = documentAfterUpdate._parentTree + + const affectedDocs = await req.payload.find({ + collection: collectionConfig.slug, + depth: 0, + limit: 200, + locale: 'all', + req, + select: { + [titleFieldName]: true, + }, + where: { + _parentTree: { + in: [doc.id], }, - }) + }, + }) - const updatePromises: Promise[] = [] - affectedDocs.docs.forEach((affectedDoc) => { - 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.update({ - id: affectedDoc.id, - collection: collectionConfig.slug, - data: { - _parentTree: [...(doc._parentTree || []), doc.id], - [slugPathFieldName]: `${updatedDocument[slugPathFieldName]}/${slugify(affectedDoc[titleFieldName])}`, - [titlePathFieldName]: `${updatedDocument[titlePathFieldName]}/${affectedDoc[titleFieldName]}`, - }, - depth: 0, - req, - }), - ) - }) + const updatePromises: Promise[] = [] + affectedDocs.docs.forEach((affectedDoc) => { + 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: collectionConfig.slug, + data: { + _parentTree: [...(doc._parentTree || []), doc.id], + [slugPathFieldName]: + req.payload.config.localization.localeCodes.reduce( + (acc: JsonObject, locale: string) => { + const prefix = + documentAfterUpdate?.[slugPathFieldName]?.[locale] || + documentAfterUpdate?.[slugPathFieldName]?.[ + req.payload.config.localization.defaultLocale + ] + const slug = + affectedDoc?.[titleFieldName]?.[locale] || + affectedDoc[titleFieldName][req.payload.config.localization.defaultLocale] + acc[locale] = `${prefix}/${slugify(slug)}` + return acc + }, + {}, + ), + [titlePathFieldName]: + req.payload.config.localization.localeCodes.reduce( + (acc: JsonObject, locale: string) => { + const prefix = + documentAfterUpdate?.[titlePathFieldName]?.[locale] || + documentAfterUpdate?.[titlePathFieldName]?.[ + req.payload.config.localization.defaultLocale + ] + const title = + affectedDoc?.[titleFieldName]?.[locale] || + affectedDoc[titleFieldName][req.payload.config.localization.defaultLocale] + acc[locale] = `${prefix}/${title}` + return acc + }, + {}, + ), + }, + locale: 'all', + req, + }), + ) + }) - await Promise.all(updatePromises) - } + await Promise.all(updatePromises) if (updatedSlugPath) { doc[slugPathFieldName] = updatedSlugPath From 378e5016db1dcdc06dfcd5253d4b945175d9835c Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Mon, 15 Sep 2025 12:01:45 -0400 Subject: [PATCH 03/31] more cleaning --- .../src/hierarchy/addTreeViewFields.ts | 289 ++++++------------ .../utils/adjustAffectedTreePaths.ts | 80 +++++ .../src/hierarchy/utils/defaultSlugify.ts | 7 + .../src/hierarchy/utils/generateTreePaths.ts | 85 ++++++ .../src/hierarchy/utils/getTreeChanges.ts | 36 +++ 5 files changed, 302 insertions(+), 195 deletions(-) create mode 100644 packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts create mode 100644 packages/payload/src/hierarchy/utils/defaultSlugify.ts create mode 100644 packages/payload/src/hierarchy/utils/generateTreePaths.ts create mode 100644 packages/payload/src/hierarchy/utils/getTreeChanges.ts diff --git a/packages/payload/src/hierarchy/addTreeViewFields.ts b/packages/payload/src/hierarchy/addTreeViewFields.ts index 4bf12eb32bb..47ca0194c44 100644 --- a/packages/payload/src/hierarchy/addTreeViewFields.ts +++ b/packages/payload/src/hierarchy/addTreeViewFields.ts @@ -1,5 +1,11 @@ import type { CollectionConfig, TypeWithID } from '../collections/config/types.js' import type { Document, JsonObject } from '../types/index.js' +import type { GenerateTreePathsArgs } from './utils/generateTreePaths.js' + +import { adjustAffectedTreePaths } from './utils/adjustAffectedTreePaths.js' +import { defaultSlugify } from './utils/defaultSlugify.js' +import { generateTreePaths } from './utils/generateTreePaths.js' +import { getTreeChanges } from './utils/getTreeChanges.js' type AddTreeViewFieldsArgs = { collectionConfig: CollectionConfig @@ -56,22 +62,24 @@ export function addTreeViewFields({ name: slugPathFieldName, type: 'text', admin: { - // readOnly: true, + readOnly: true, // hidden: true, }, index: true, label: ({ t }) => t('general:slugPath'), + // TODO: these should only be localized if the title field is also localized localized: true, }, { name: titlePathFieldName, type: 'text', admin: { - // readOnly: true, + readOnly: true, // hidden: true, }, index: true, label: ({ t }) => t('general:titlePath'), + // TODO: these should only be localized if the title field is also localized localized: true, }, ], @@ -81,46 +89,41 @@ export function addTreeViewFields({ ...(collectionConfig.hooks || {}), afterChange: [ async ({ doc, previousDoc, previousDocWithLocales, req }) => { + const fieldIsLocalized = Boolean(req.payload.config.localization) // && fieldIsLocalized Arg // handle this better later const reqLocale = req.locale if (reqLocale === 'all') { return } - const { newParentID, newSlug, parentChanged, prevParentID, prevSlug, slugChanged } = - getTreeChanges({ doc, parentDocFieldName, previousDoc, slugify, titleFieldName }) + const { newParentID, parentChanged, prevParentID, slugChanged } = getTreeChanges({ + doc, + parentDocFieldName, + previousDoc, + slugify, + titleFieldName, + }) + + let parentDocument: Document = undefined if (parentChanged || slugChanged) { - /** - * should look like: - * - * { - * [slugPathFieldName]: { - * [locale]: updatedSlugPath - * }, - * [titlePathFieldName]: { - * [locale]: updatedTitlePath - * }, - * _parentTree: updatedParentTree, - * } - */ - const dataToUpdateDoc: { - _parentTree: (number | string)[] - slugPath: { - [locale: string]: string - } - titlePath: { - [locale: string]: string - } - } = { - _parentTree: [], - slugPath: {}, - titlePath: {}, + let newParentTree: (number | string)[] = [] + + const baseGenerateTreePathsArgs: Omit< + GenerateTreePathsArgs, + 'defaultLocale' | 'localeCodes' | 'localized' | 'parentDocument' | 'reqLocale' + > = { + newDoc: doc, + previousDocWithLocales, + slugify, + slugPathFieldName, + titleFieldName, + titlePathFieldName, } if (parentChanged) { if (newParentID) { - // query new parent - const newParentFullDoc = await req.payload.findByID({ + // set new parent + parentDocument = await req.payload.findByID({ id: newParentID, collection: collectionConfig.slug, depth: 0, @@ -132,60 +135,15 @@ export function addTreeViewFields({ }, }) - dataToUpdateDoc._parentTree = [...(newParentFullDoc?._parentTree || []), newParentID] - req.payload.config.localization.localeCodes.forEach((locale: string) => { - const slugPrefix = - newParentFullDoc?.[slugPathFieldName]?.[locale] || - newParentFullDoc?.[slugPathFieldName]?.[ - req.payload.config.localization.defaultLocale - ] || - '' - const titlePrefix = - newParentFullDoc?.[titlePathFieldName]?.[locale] || - newParentFullDoc?.[titlePathFieldName]?.[ - req.payload.config.localization.defaultLocale - ] || - '' - if (reqLocale === locale) { - dataToUpdateDoc.slugPath[locale] = `${slugPrefix}/${slugify(doc[titleFieldName])}` - dataToUpdateDoc.titlePath[locale] = `${titlePrefix}/${doc[titleFieldName]}` - } else { - // use prev title on previousDocWithLocales - dataToUpdateDoc.slugPath[locale] = - `${slugPrefix}/${slugify(previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName])}` - dataToUpdateDoc.titlePath[locale] = - `${titlePrefix}/${previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName]}` - } - }) + newParentTree = [...(parentDocument?._parentTree || []), newParentID] } else { // removed parent - dataToUpdateDoc._parentTree = [] - req.payload.config.localization.localeCodes.forEach((locale: string) => { - if (reqLocale === locale) { - // use current title on doc - dataToUpdateDoc.slugPath[locale] = slugify(doc[titleFieldName]) - dataToUpdateDoc.titlePath[locale] = doc[titleFieldName] - } else { - // use prev title on previousDocWithLocales - dataToUpdateDoc.slugPath[locale] = slugify( - previousDocWithLocales?.[titleFieldName]?.[locale] - ? previousDocWithLocales[titleFieldName][locale] - : doc[titleFieldName], - ) - dataToUpdateDoc.titlePath[locale] = previousDocWithLocales?.[titleFieldName]?.[ - locale - ] - ? previousDocWithLocales[titleFieldName][locale] - : doc[titleFieldName] - } - }) + newParentTree = [] } } else { - // only the title field was updated - let prevParentDoc: Document + // only the title updated if (prevParentID) { - // has parent - prevParentDoc = await req.payload.findByID({ + parentDocument = await req.payload.findByID({ id: prevParentID, collection: collectionConfig.slug, depth: 0, @@ -194,59 +152,48 @@ export function addTreeViewFields({ select: { _parentTree: true, [slugPathFieldName]: true, - [titleFieldName]: true, [titlePathFieldName]: true, }, }) } - dataToUpdateDoc._parentTree = prevParentDoc - ? [...(prevParentDoc._parentTree || []), prevParentID] - : [] - req.payload.config.localization.localeCodes.forEach((locale: string) => { - const slugPrefix = prevParentDoc?.[slugPathFieldName]?.[locale] - ? prevParentDoc[slugPathFieldName][locale] - : '' - const titlePrefix = prevParentDoc?.[titlePathFieldName]?.[locale] - ? prevParentDoc[titlePathFieldName][locale] - : '' - if (reqLocale === locale) { - dataToUpdateDoc.slugPath[locale] = - `${slugPrefix ? `${slugPrefix}/` : ''}${slugify(doc[titleFieldName])}` - dataToUpdateDoc.titlePath[locale] = - `${titlePrefix ? `${titlePrefix}/` : ''}${doc[titleFieldName]}` - } else { - // use prev title on previousDocWithLocales - dataToUpdateDoc.slugPath[locale] = - `${slugPrefix ? `${slugPrefix}/` : ''}${slugify(previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName])}` - dataToUpdateDoc.titlePath[locale] = - `${titlePrefix ? `${titlePrefix}/` : ''}${previousDocWithLocales?.[titleFieldName]?.[locale] ? previousDocWithLocales[titleFieldName][locale] : doc[titleFieldName]}` - } - }) + 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: collectionConfig.slug, data: { - _parentTree: dataToUpdateDoc._parentTree, - [slugPathFieldName]: dataToUpdateDoc.slugPath, - [titlePathFieldName]: dataToUpdateDoc.titlePath, + _parentTree: newParentTree, + [slugPathFieldName]: newSlugPath, + [titlePathFieldName]: newTitlePath, }, locale: 'all', req, select: { _parentTree: true, [slugPathFieldName]: true, - [titleFieldName]: true, [titlePathFieldName]: true, }, }) - const updatedSlugPath = documentAfterUpdate[slugPathFieldName][reqLocale!] - const updatedTitlePath = documentAfterUpdate[titlePathFieldName][reqLocale!] - const updatedParentTree = documentAfterUpdate._parentTree - const affectedDocs = await req.payload.find({ collection: collectionConfig.slug, depth: 0, @@ -254,7 +201,9 @@ export function addTreeViewFields({ locale: 'all', req, select: { - [titleFieldName]: true, + _parentTree: true, + [slugPathFieldName]: true, + [titlePathFieldName]: true, }, where: { _parentTree: { @@ -265,6 +214,26 @@ export function addTreeViewFields({ const updatePromises: Promise[] = [] affectedDocs.docs.forEach((affectedDoc) => { + const newTreePaths = adjustAffectedTreePaths({ + affectedDoc, + newDoc: doc, + 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 @@ -272,39 +241,9 @@ export function addTreeViewFields({ id: affectedDoc.id, collection: collectionConfig.slug, data: { - _parentTree: [...(doc._parentTree || []), doc.id], - [slugPathFieldName]: - req.payload.config.localization.localeCodes.reduce( - (acc: JsonObject, locale: string) => { - const prefix = - documentAfterUpdate?.[slugPathFieldName]?.[locale] || - documentAfterUpdate?.[slugPathFieldName]?.[ - req.payload.config.localization.defaultLocale - ] - const slug = - affectedDoc?.[titleFieldName]?.[locale] || - affectedDoc[titleFieldName][req.payload.config.localization.defaultLocale] - acc[locale] = `${prefix}/${slugify(slug)}` - return acc - }, - {}, - ), - [titlePathFieldName]: - req.payload.config.localization.localeCodes.reduce( - (acc: JsonObject, locale: string) => { - const prefix = - documentAfterUpdate?.[titlePathFieldName]?.[locale] || - documentAfterUpdate?.[titlePathFieldName]?.[ - req.payload.config.localization.defaultLocale - ] - const title = - affectedDoc?.[titleFieldName]?.[locale] || - affectedDoc[titleFieldName][req.payload.config.localization.defaultLocale] - acc[locale] = `${prefix}/${title}` - return acc - }, - {}, - ), + _parentTree: [...(doc._parentTree || []), ...descendants], + [slugPathFieldName]: newTreePaths.slugPath, + [titlePathFieldName]: newTreePaths.titlePath, }, locale: 'all', req, @@ -314,6 +253,14 @@ export function addTreeViewFields({ 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 } @@ -327,56 +274,8 @@ export function addTreeViewFields({ return doc } }, - // specifically run other hooks _after_ the document tree is updated + // purposefully run other hooks _after_ the document tree is updated ...(collectionConfig.hooks?.afterChange || []), ], } } - -// default slugify function -const defaultSlugify = (title: string): string => { - return title - .toLowerCase() - .trim() - .replace(/\W+/g, '-') // Replace spaces and non-word chars with hyphens - .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens -} - -type GetTreeChanges = { - doc: Document - parentDocFieldName: string - previousDoc: Document - slugify: (text: string) => string - titleFieldName: string -} - -type GetTreeChangesResult = { - newParentID: null | number | string | undefined - newSlug: string | undefined - parentChanged: boolean - prevParentID: null | number | string | undefined - prevSlug: string | undefined - slugChanged: boolean -} - -function getTreeChanges({ - doc, - parentDocFieldName, - previousDoc, - slugify, - titleFieldName, -}: GetTreeChanges): GetTreeChangesResult { - const prevParentID = previousDoc[parentDocFieldName] || null - const newParentID = doc[parentDocFieldName] || null - const newSlug = doc[titleFieldName] ? slugify(doc[titleFieldName]) : undefined - const prevSlug = previousDoc[titleFieldName] ? slugify(previousDoc[titleFieldName]) : undefined - - return { - newParentID, - newSlug, - parentChanged: prevParentID !== newParentID, - prevParentID, - prevSlug, - slugChanged: newSlug !== prevSlug, - } -} diff --git a/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts b/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts new file mode 100644 index 00000000000..9c6ffe922d8 --- /dev/null +++ b/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts @@ -0,0 +1,80 @@ +import type { Document } from '../../types/index.js' + +type AdjustAffectedTreePathsArgs = { + affectedDoc: Document + newDoc: Document + previousDocWithLocales: Document + slugPathFieldName: string + titlePathFieldName: string +} & ( + | { + localeCodes: string[] + localized: true + } + | { + localeCodes?: never + localized: false + } +) +export function adjustAffectedTreePaths({ + affectedDoc, + localeCodes, + localized, + newDoc, + previousDocWithLocales, + slugPathFieldName, + titlePathFieldName, +}: AdjustAffectedTreePathsArgs): { + slugPath: Record | string + titlePath: Record | string +} { + if (localized) { + return localeCodes.reduce<{ + slugPath: Record + titlePath: Record + }>( + (acc, locale) => { + const slugPathToRemove = previousDocWithLocales[slugPathFieldName]?.[locale] + const slugPathToAdd = newDoc[slugPathFieldName]?.[locale] + const titlePathToRemove = previousDocWithLocales[titlePathFieldName]?.[locale] + const titlePathToAdd = newDoc[titlePathFieldName]?.[locale] + + acc.slugPath[locale] = + `${slugPathToAdd || ''}${affectedDoc[slugPathFieldName][locale].slice(slugPathToRemove.length)}`.replace( + /^\/+|\/+$/g, + '', + ) + + acc.titlePath[locale] = + `${titlePathToAdd || ''}${affectedDoc[titlePathFieldName][locale].slice(titlePathToRemove.length)}`.replace( + /^\/+|\/+$/g, + '', + ) + + return acc + }, + { + slugPath: {}, + titlePath: {}, + }, + ) + } else { + const slugPathToRemove = previousDocWithLocales[slugPathFieldName] // A/B/C + const slugPathToAdd = newDoc[slugPathFieldName] // E/F/G + const titlePathToRemove = previousDocWithLocales[titlePathFieldName] + const titlePathToAdd = newDoc[titlePathFieldName] + + return { + slugPath: + `${slugPathToAdd || ''}${affectedDoc[slugPathFieldName].slice(slugPathToRemove.length)}`.replace( + /^\/+|\/+$/g, + '', + ), + titlePath: + `${titlePathToAdd || ''}${affectedDoc[titlePathFieldName].slice(titlePathToRemove.length)}`.replace( + /^\/+|\/+$/g, + '', + ), + } + } +} diff --git a/packages/payload/src/hierarchy/utils/defaultSlugify.ts b/packages/payload/src/hierarchy/utils/defaultSlugify.ts new file mode 100644 index 00000000000..88f00986638 --- /dev/null +++ b/packages/payload/src/hierarchy/utils/defaultSlugify.ts @@ -0,0 +1,7 @@ +export const defaultSlugify = (title: string): string => { + return title + .toLowerCase() + .trim() + .replace(/\W+/g, '-') // Replace spaces and non-word chars with hyphens + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens +} diff --git a/packages/payload/src/hierarchy/utils/generateTreePaths.ts b/packages/payload/src/hierarchy/utils/generateTreePaths.ts new file mode 100644 index 00000000000..cfcdf163c9f --- /dev/null +++ b/packages/payload/src/hierarchy/utils/generateTreePaths.ts @@ -0,0 +1,85 @@ +import type { Document } from '../../types/index.js' + +export type GenerateTreePathsArgs = { + newDoc: Document + parentDocument?: Document + previousDocWithLocales: Document + slugify: (text: string) => string + slugPathFieldName: string + titleFieldName: string + titlePathFieldName: string +} & ( + | { + defaultLocale: string + localeCodes: string[] + localized: true + reqLocale: string + } + | { + defaultLocale?: never + localeCodes?: never + localized: false + reqLocale?: never + } +) +export function generateTreePaths({ + defaultLocale, + localeCodes, + localized, + newDoc, + parentDocument, + previousDocWithLocales, + reqLocale, + slugify, + slugPathFieldName, + titleFieldName, + titlePathFieldName, +}: GenerateTreePathsArgs): { + slugPath: Record | string + titlePath: Record | string +} { + if (localized) { + const fallbackSlugPrefix = parentDocument + ? parentDocument?.[slugPathFieldName]?.[defaultLocale] || '' + : '' + const fallbackTitlePrefix = parentDocument + ? parentDocument?.[titlePathFieldName]?.[defaultLocale] || '' + : '' + + return localeCodes.reduce<{ + slugPath: Record + titlePath: Record + }>( + (acc, locale: string) => { + const slugPrefix = parentDocument + ? parentDocument?.[slugPathFieldName]?.[locale] + : fallbackSlugPrefix + const titlePrefix = parentDocument + ? parentDocument?.[titlePathFieldName]?.[locale] + : fallbackTitlePrefix + + let title = newDoc[titleFieldName] + if (reqLocale !== locale && previousDocWithLocales?.[titleFieldName]?.[locale]) { + title = previousDocWithLocales[titleFieldName][locale] + } + + acc.slugPath[locale] = `${slugPrefix ? `${slugPrefix}/` : ''}${slugify(title)}` + acc.titlePath[locale] = `${titlePrefix ? `${titlePrefix}/` : ''}${title}` + return acc + }, + { + slugPath: {}, + titlePath: {}, + }, + ) + } else { + const slugPrefix = parentDocument ? parentDocument?.[slugPathFieldName] : '' + const titlePrefix = parentDocument ? parentDocument?.[titlePathFieldName] : '' + const title = newDoc[titleFieldName] + + return { + slugPath: `${slugPrefix ? `${slugPrefix}/` : ''}${slugify(title)}`, + titlePath: `${titlePrefix ? `${titlePrefix}/` : ''}${title}`, + } + } +} diff --git a/packages/payload/src/hierarchy/utils/getTreeChanges.ts b/packages/payload/src/hierarchy/utils/getTreeChanges.ts new file mode 100644 index 00000000000..07f4d67f1b9 --- /dev/null +++ b/packages/payload/src/hierarchy/utils/getTreeChanges.ts @@ -0,0 +1,36 @@ +import type { Document } from '../../types/index.js' + +type GetTreeChanges = { + doc: Document + parentDocFieldName: string + previousDoc: Document + slugify: (text: string) => string + titleFieldName: string +} + +type GetTreeChangesResult = { + newParentID: null | number | string | undefined + parentChanged: boolean + prevParentID: null | number | string | undefined + slugChanged: boolean +} + +export function getTreeChanges({ + doc, + parentDocFieldName, + previousDoc, + slugify, + titleFieldName, +}: GetTreeChanges): GetTreeChangesResult { + const prevParentID = previousDoc[parentDocFieldName] || null + const newParentID = doc[parentDocFieldName] || null + const newSlug = doc[titleFieldName] ? slugify(doc[titleFieldName]) : undefined + const prevSlug = previousDoc[titleFieldName] ? slugify(previousDoc[titleFieldName]) : undefined + + return { + newParentID, + parentChanged: prevParentID !== newParentID, + prevParentID, + slugChanged: newSlug !== prevSlug, + } +} From 60ccddce29abce46634ae347919a2498f5699681 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Mon, 15 Sep 2025 12:09:54 -0400 Subject: [PATCH 04/31] small fix --- packages/payload/src/hierarchy/addTreeViewFields.ts | 2 +- .../payload/src/hierarchy/utils/adjustAffectedTreePaths.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/payload/src/hierarchy/addTreeViewFields.ts b/packages/payload/src/hierarchy/addTreeViewFields.ts index 47ca0194c44..2f1c640b193 100644 --- a/packages/payload/src/hierarchy/addTreeViewFields.ts +++ b/packages/payload/src/hierarchy/addTreeViewFields.ts @@ -216,7 +216,7 @@ export function addTreeViewFields({ affectedDocs.docs.forEach((affectedDoc) => { const newTreePaths = adjustAffectedTreePaths({ affectedDoc, - newDoc: doc, + newDoc: documentAfterUpdate, previousDocWithLocales, slugPathFieldName, titlePathFieldName, diff --git a/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts b/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts index 9c6ffe922d8..22b583602ff 100644 --- a/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts +++ b/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts @@ -59,8 +59,8 @@ export function adjustAffectedTreePaths({ }, ) } else { - const slugPathToRemove = previousDocWithLocales[slugPathFieldName] // A/B/C - const slugPathToAdd = newDoc[slugPathFieldName] // E/F/G + const slugPathToRemove = previousDocWithLocales[slugPathFieldName] + const slugPathToAdd = newDoc[slugPathFieldName] const titlePathToRemove = previousDocWithLocales[titlePathFieldName] const titlePathToAdd = newDoc[titlePathFieldName] From 3fcd316cc7fbd90f1f71b409d2b64aed65132218 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Mon, 15 Sep 2025 13:10:44 -0400 Subject: [PATCH 05/31] rename hierarchy to tree view --- .../src/collections/config/sanitize.ts | 7 +- .../payload/src/collections/config/types.ts | 8 +- packages/payload/src/config/defaults.ts | 12 - packages/payload/src/config/sanitize.ts | 8 - packages/payload/src/config/types.ts | 5 +- .../src/hierarchy/addTreeViewFields.ts | 281 ------------------ packages/payload/src/hierarchy/types.ts | 35 --- .../payload/src/treeView/addTreeViewFields.ts | 96 ++++++ .../src/{hierarchy => treeView}/constants.ts | 0 .../treeView/hooks/collectionAfterChange.ts | 210 +++++++++++++ .../utils/adjustAffectedTreePaths.ts | 0 .../utils/defaultSlugify.ts | 0 .../src/treeView/utils/findUseAsTitleField.ts | 53 ++++ .../utils/generateTreePaths.ts | 0 .../utils/getTreeChanges.ts | 0 15 files changed, 368 insertions(+), 347 deletions(-) delete mode 100644 packages/payload/src/hierarchy/addTreeViewFields.ts delete mode 100644 packages/payload/src/hierarchy/types.ts create mode 100644 packages/payload/src/treeView/addTreeViewFields.ts rename packages/payload/src/{hierarchy => treeView}/constants.ts (100%) create mode 100644 packages/payload/src/treeView/hooks/collectionAfterChange.ts rename packages/payload/src/{hierarchy => treeView}/utils/adjustAffectedTreePaths.ts (100%) rename packages/payload/src/{hierarchy => treeView}/utils/defaultSlugify.ts (100%) create mode 100644 packages/payload/src/treeView/utils/findUseAsTitleField.ts rename packages/payload/src/{hierarchy => treeView}/utils/generateTreePaths.ts (100%) rename packages/payload/src/{hierarchy => treeView}/utils/getTreeChanges.ts (100%) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 0228525f3d0..d6df67f4d5f 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -12,7 +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 '../../hierarchy/addTreeViewFields.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' @@ -204,12 +204,11 @@ export const sanitizeCollection = async ( } /** - * Hierarchy feature + * Tree view feature */ - if (sanitized.hierarchy) { + if (sanitized.treeView) { addTreeViewFields({ collectionConfig: sanitized, - titleFieldName: 'title', // this needs to be dynamic per collection }) } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 9a64139dfc3..f44f0e9148a 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -522,10 +522,6 @@ export type CollectionConfig = { singularName?: string } | false - /** - * Enables hierarchy support for this collection - */ - hierarchy?: boolean /** * Hooks to modify Payload functionality */ @@ -615,6 +611,10 @@ export type CollectionConfig = { * @default false */ trash?: boolean + /** + * Enables tree view support for this collection + */ + treeView?: boolean /** * Options used in typescript generation */ diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index 766b3a7844e..aa8c01a0b74 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -165,18 +165,6 @@ export const addDefaultsToConfig = (config: Config): Config => { ...(config.auth || {}), } - if ( - config.hierarchy !== false && - config.collections.some((collection) => Boolean(collection.hierarchy)) - ) { - config.hierarchy = { - slug: config.hierarchy?.slug ?? 'payload-hierarchy', - ...(config.hierarchy || {}), - } - } else { - config.hierarchy = false - } - if ( config.folders !== false && config.collections.some((collection) => Boolean(collection.folders)) diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 804a655ad43..043bb34d3df 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -198,9 +198,6 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise 0) { diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 612e981a035..92aa97413a3 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -41,7 +41,6 @@ import type { EmailAdapter, SendEmailOptions } from '../email/types.js' import type { ErrorName } from '../errors/types.js' import type { RootFoldersConfiguration } from '../folders/types.js' import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js' -import type { RootHierarchyConfiguration } from '../hierarchy/types.js' import type { Block, FlattenedBlock, @@ -1118,7 +1117,6 @@ export type Config = { */ validationRules?: (args: GraphQL.ExecutionArgs) => GraphQL.ValidationRule[] } - hierarchy?: false | RootHierarchyConfiguration /** * Tap into Payload-wide hooks. * @@ -1167,7 +1165,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. @@ -1201,6 +1198,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 + /** * An array of Payload plugins. * @@ -1274,6 +1272,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?: { /** diff --git a/packages/payload/src/hierarchy/addTreeViewFields.ts b/packages/payload/src/hierarchy/addTreeViewFields.ts deleted file mode 100644 index 2f1c640b193..00000000000 --- a/packages/payload/src/hierarchy/addTreeViewFields.ts +++ /dev/null @@ -1,281 +0,0 @@ -import type { CollectionConfig, TypeWithID } from '../collections/config/types.js' -import type { Document, JsonObject } from '../types/index.js' -import type { GenerateTreePathsArgs } from './utils/generateTreePaths.js' - -import { adjustAffectedTreePaths } from './utils/adjustAffectedTreePaths.js' -import { defaultSlugify } from './utils/defaultSlugify.js' -import { generateTreePaths } from './utils/generateTreePaths.js' -import { getTreeChanges } from './utils/getTreeChanges.js' - -type AddTreeViewFieldsArgs = { - collectionConfig: CollectionConfig - parentDocFieldName?: string - slugify?: (text: string) => string - slugPathFieldName?: string - titleFieldName: string - titlePathFieldName?: string -} - -export function addTreeViewFields({ - collectionConfig, - parentDocFieldName = '_parentDoc', - slugify = defaultSlugify, - slugPathFieldName = 'slugPath', - titleFieldName = 'title', - titlePathFieldName = 'titlePath', -}: AddTreeViewFieldsArgs): void { - collectionConfig.fields.push({ - type: 'group', - 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: '_parentTree', - type: 'relationship', - admin: { - isSortable: false, - readOnly: true, - // hidden: true, - }, - hasMany: true, - index: true, - maxDepth: 0, - relationTo: collectionConfig.slug, - }, - { - name: slugPathFieldName, - type: 'text', - admin: { - readOnly: true, - // hidden: true, - }, - index: true, - label: ({ t }) => t('general:slugPath'), - // TODO: these should only be localized if the title field is also localized - localized: true, - }, - { - name: titlePathFieldName, - type: 'text', - admin: { - readOnly: true, - // hidden: true, - }, - index: true, - label: ({ t }) => t('general:titlePath'), - // TODO: these should only be localized if the title field is also localized - localized: true, - }, - ], - }) - - collectionConfig.hooks = { - ...(collectionConfig.hooks || {}), - afterChange: [ - async ({ doc, previousDoc, previousDocWithLocales, req }) => { - const fieldIsLocalized = Boolean(req.payload.config.localization) // && fieldIsLocalized Arg - // handle this better later - const reqLocale = req.locale - 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) { - if (newParentID) { - // set new parent - parentDocument = await req.payload.findByID({ - id: newParentID, - collection: collectionConfig.slug, - depth: 0, - locale: 'all', - select: { - _parentTree: true, - [slugPathFieldName]: true, - [titlePathFieldName]: true, - }, - }) - - newParentTree = [...(parentDocument?._parentTree || []), newParentID] - } else { - // removed parent - newParentTree = [] - } - } else { - // only the title updated - if (prevParentID) { - parentDocument = await req.payload.findByID({ - id: prevParentID, - collection: collectionConfig.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: collectionConfig.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: collectionConfig.slug, - depth: 0, - limit: 200, - locale: 'all', - req, - select: { - _parentTree: true, - [slugPathFieldName]: true, - [titlePathFieldName]: true, - }, - where: { - _parentTree: { - in: [doc.id], - }, - }, - }) - - const updatePromises: Promise[] = [] - 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: collectionConfig.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 - } - }, - // purposefully run other hooks _after_ the document tree is updated - ...(collectionConfig.hooks?.afterChange || []), - ], - } -} diff --git a/packages/payload/src/hierarchy/types.ts b/packages/payload/src/hierarchy/types.ts deleted file mode 100644 index 6b010830221..00000000000 --- a/packages/payload/src/hierarchy/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CollectionConfig } from '../collections/config/types.js' - -export type RootHierarchyConfiguration = { - /** - * An array of functions to be ran when the hierarchy collection is initialized - * This allows plugins to modify the collection configuration - */ - collectionOverrides?: (({ - collection, - }: { - collection: Omit - }) => Omit | Promise>)[] - /** - * Ability to view hidden fields and collections related to the hierarchy - * - * @default false - */ - debug?: boolean - /** - * The hierarchical parent field name - * - * @default "hierarchicalParent" - */ - fieldName?: string - /** - * Slug for the hierarchy collection - * - * @default "payload-hierarchy" - */ - slug?: string -} - -export type HierarchyCollectionConfig = { - hierarchy?: boolean -} diff --git a/packages/payload/src/treeView/addTreeViewFields.ts b/packages/payload/src/treeView/addTreeViewFields.ts new file mode 100644 index 00000000000..8d5c3ae93dc --- /dev/null +++ b/packages/payload/src/treeView/addTreeViewFields.ts @@ -0,0 +1,96 @@ +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, + parentDocFieldName = '_parentDoc', + slugify = defaultSlugify, + slugPathFieldName = 'slugPath', + titlePathFieldName = 'titlePath', +}: AddTreeViewFieldsArgs): void { + const titleField = findUseAsTitleField(collectionConfig) + + 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'), + // TODO: these should only be localized if the title field is also localized + localized: true, + }, + { + name: titlePathFieldName, + type: 'text', + admin: { + readOnly: true, + // hidden: true, + }, + index: true, + label: ({ t }) => t('general:titlePath'), + // TODO: these should only be localized if the title field is also localized + localized: true, + }, + { + 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', + }) + + collectionConfig.hooks = { + ...(collectionConfig.hooks || {}), + afterChange: [ + collectionTreeViewAfterChange({ + parentDocFieldName, + slugify, + slugPathFieldName, + titleField, + titlePathFieldName, + }), + // purposefully run other hooks _after_ the document tree is updated + ...(collectionConfig.hooks?.afterChange || []), + ], + } +} diff --git a/packages/payload/src/hierarchy/constants.ts b/packages/payload/src/treeView/constants.ts similarity index 100% rename from packages/payload/src/hierarchy/constants.ts rename to packages/payload/src/treeView/constants.ts diff --git a/packages/payload/src/treeView/hooks/collectionAfterChange.ts b/packages/payload/src/treeView/hooks/collectionAfterChange.ts new file mode 100644 index 00000000000..9e136f7cbd1 --- /dev/null +++ b/packages/payload/src/treeView/hooks/collectionAfterChange.ts @@ -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> +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[] = [] + 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 + } + } diff --git a/packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts b/packages/payload/src/treeView/utils/adjustAffectedTreePaths.ts similarity index 100% rename from packages/payload/src/hierarchy/utils/adjustAffectedTreePaths.ts rename to packages/payload/src/treeView/utils/adjustAffectedTreePaths.ts diff --git a/packages/payload/src/hierarchy/utils/defaultSlugify.ts b/packages/payload/src/treeView/utils/defaultSlugify.ts similarity index 100% rename from packages/payload/src/hierarchy/utils/defaultSlugify.ts rename to packages/payload/src/treeView/utils/defaultSlugify.ts diff --git a/packages/payload/src/treeView/utils/findUseAsTitleField.ts b/packages/payload/src/treeView/utils/findUseAsTitleField.ts new file mode 100644 index 00000000000..e7ae946c7da --- /dev/null +++ b/packages/payload/src/treeView/utils/findUseAsTitleField.ts @@ -0,0 +1,53 @@ +import type { CollectionConfig } from '../../collections/config/types.js' +import type { Field, FieldAffectingData } from '../../fields/config/types.js' + +export function findUseAsTitleField(collectionConfig: CollectionConfig): FieldAffectingData { + const titleFieldName = collectionConfig.admin?.useAsTitle || 'id' + const titleField = iterateFields({ fields: collectionConfig.fields, titleFieldName }) + + return titleField +} + +function iterateFields({ + fields, + titleFieldName, +}: { + fields: Field[] + titleFieldName: string +}): FieldAffectingData { + let titleField: FieldAffectingData | undefined + + for (const field of fields) { + switch (field.type) { + case 'text': + case 'textarea': + if (field.name === titleFieldName) { + titleField = field + } + break + case 'row': + case 'collapsible': + titleField = iterateFields({ fields: field.fields, titleFieldName }) + break + case 'group': + if (!('name' in field)) { + titleField = iterateFields({ fields: field.fields, titleFieldName }) + } + break + case 'tabs': + for (const tab of field.tabs) { + if (!('name' in tab)) { + titleField = iterateFields({ fields: tab.fields, titleFieldName }) + } + } + } + } + + if (!titleField) { + throw new Error( + `The Tree View title field "${titleFieldName}" was not found. It cannot be nested within named fields i.e. named groups, named tabs, etc.`, + ) + } + + return titleField +} diff --git a/packages/payload/src/hierarchy/utils/generateTreePaths.ts b/packages/payload/src/treeView/utils/generateTreePaths.ts similarity index 100% rename from packages/payload/src/hierarchy/utils/generateTreePaths.ts rename to packages/payload/src/treeView/utils/generateTreePaths.ts diff --git a/packages/payload/src/hierarchy/utils/getTreeChanges.ts b/packages/payload/src/treeView/utils/getTreeChanges.ts similarity index 100% rename from packages/payload/src/hierarchy/utils/getTreeChanges.ts rename to packages/payload/src/treeView/utils/getTreeChanges.ts From 8cecdd58d3f73341243fb8a681e570898bea2a45 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Tue, 16 Sep 2025 10:07:04 -0400 Subject: [PATCH 06/31] add test suite --- .../src/collections/config/sanitize.ts | 1 + .../payload/src/treeView/addTreeViewFields.ts | 16 +- .../treeView/hooks/collectionAfterChange.ts | 2 +- packages/payload/src/treeView/types.ts | 11 + .../src/treeView/utils/findUseAsTitleField.ts | 1 + test/tree-view/collections/Pages/index.ts | 17 + test/tree-view/collections/Tags/index.ts | 17 + test/tree-view/collections/Users/index.ts | 10 + test/tree-view/config.ts | 35 ++ test/tree-view/int.spec.ts | 118 +++++++ test/tree-view/payload-types.ts | 325 ++++++++++++++++++ test/tree-view/shared.ts | 7 + 12 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 packages/payload/src/treeView/types.ts create mode 100644 test/tree-view/collections/Pages/index.ts create mode 100644 test/tree-view/collections/Tags/index.ts create mode 100644 test/tree-view/collections/Users/index.ts create mode 100644 test/tree-view/config.ts create mode 100644 test/tree-view/int.spec.ts create mode 100644 test/tree-view/payload-types.ts create mode 100644 test/tree-view/shared.ts diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index d6df67f4d5f..f4c3f06e7c3 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -209,6 +209,7 @@ export const sanitizeCollection = async ( if (sanitized.treeView) { addTreeViewFields({ collectionConfig: sanitized, + config, }) } diff --git a/packages/payload/src/treeView/addTreeViewFields.ts b/packages/payload/src/treeView/addTreeViewFields.ts index 8d5c3ae93dc..082c54452eb 100644 --- a/packages/payload/src/treeView/addTreeViewFields.ts +++ b/packages/payload/src/treeView/addTreeViewFields.ts @@ -6,12 +6,14 @@ 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', @@ -46,8 +48,7 @@ export function addTreeViewFields({ }, index: true, label: ({ t }) => t('general:slugPath'), - // TODO: these should only be localized if the title field is also localized - localized: true, + localized: localizeField, }, { name: titlePathFieldName, @@ -58,8 +59,7 @@ export function addTreeViewFields({ }, index: true, label: ({ t }) => t('general:titlePath'), - // TODO: these should only be localized if the title field is also localized - localized: true, + localized: localizeField, }, { name: '_parentTree', @@ -79,6 +79,14 @@ export function addTreeViewFields({ 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: [ diff --git a/packages/payload/src/treeView/hooks/collectionAfterChange.ts b/packages/payload/src/treeView/hooks/collectionAfterChange.ts index 9e136f7cbd1..a03b9f0dd08 100644 --- a/packages/payload/src/treeView/hooks/collectionAfterChange.ts +++ b/packages/payload/src/treeView/hooks/collectionAfterChange.ts @@ -14,7 +14,7 @@ import { getTreeChanges } from '../utils/getTreeChanges.js' type Ags = { titleField: FieldAffectingData -} & Required> +} & Required> export const collectionTreeViewAfterChange = ({ parentDocFieldName, diff --git a/packages/payload/src/treeView/types.ts b/packages/payload/src/treeView/types.ts new file mode 100644 index 00000000000..3b6d0e9a6de --- /dev/null +++ b/packages/payload/src/treeView/types.ts @@ -0,0 +1,11 @@ +import type { CollectionConfig } from '../collections/config/types.js' +import type { Config } from '../config/types.js' + +export type AddTreeViewFieldsArgs = { + collectionConfig: CollectionConfig + config: Config + parentDocFieldName?: string + slugify?: (text: string) => string + slugPathFieldName?: string + titlePathFieldName?: string +} diff --git a/packages/payload/src/treeView/utils/findUseAsTitleField.ts b/packages/payload/src/treeView/utils/findUseAsTitleField.ts index e7ae946c7da..593631b77de 100644 --- a/packages/payload/src/treeView/utils/findUseAsTitleField.ts +++ b/packages/payload/src/treeView/utils/findUseAsTitleField.ts @@ -20,6 +20,7 @@ function iterateFields({ for (const field of fields) { switch (field.type) { case 'text': + case 'number': case 'textarea': if (field.name === titleFieldName) { titleField = field diff --git a/test/tree-view/collections/Pages/index.ts b/test/tree-view/collections/Pages/index.ts new file mode 100644 index 00000000000..9f45f77cd74 --- /dev/null +++ b/test/tree-view/collections/Pages/index.ts @@ -0,0 +1,17 @@ +import type { CollectionConfig } from 'payload' + +import { slugs } from '../../shared.js' + +export const PagesCollection: CollectionConfig = { + slug: slugs.pages, + admin: { + useAsTitle: 'title', + }, + treeView: true, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} diff --git a/test/tree-view/collections/Tags/index.ts b/test/tree-view/collections/Tags/index.ts new file mode 100644 index 00000000000..c58ff562945 --- /dev/null +++ b/test/tree-view/collections/Tags/index.ts @@ -0,0 +1,17 @@ +import type { CollectionConfig } from 'payload' + +import { slugs } from '../../shared.js' + +export const TagsCollection: CollectionConfig = { + slug: slugs.tags, + treeView: true, + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], +} diff --git a/test/tree-view/collections/Users/index.ts b/test/tree-view/collections/Users/index.ts new file mode 100644 index 00000000000..44e637d41f7 --- /dev/null +++ b/test/tree-view/collections/Users/index.ts @@ -0,0 +1,10 @@ +import type { CollectionConfig } from 'payload' + +import { slugs } from '../../shared.js' + +export const UsersCollection: CollectionConfig = { + slug: slugs.users, + trash: true, + auth: true, + fields: [], +} diff --git a/test/tree-view/config.ts b/test/tree-view/config.ts new file mode 100644 index 00000000000..6553a7466a9 --- /dev/null +++ b/test/tree-view/config.ts @@ -0,0 +1,35 @@ +import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' +import { PagesCollection } from './collections/Pages/index.js' +import { TagsCollection } from './collections/Tags/index.js' +import { UsersCollection } from './collections/Users/index.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + collections: [UsersCollection, PagesCollection, TagsCollection], + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + editor: lexicalEditor({}), + + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/tree-view/int.spec.ts b/test/tree-view/int.spec.ts new file mode 100644 index 00000000000..6d593ce767d --- /dev/null +++ b/test/tree-view/int.spec.ts @@ -0,0 +1,118 @@ +import type { CollectionSlug, Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { Tag } from './payload-types.js' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { slugs } from './shared.js' + +let payload: Payload + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +async function cleanupDocs({ + collectionSlug, + ids, + payload, +}: { + collectionSlug: CollectionSlug + ids: (number | string)[] + payload: Payload +}) { + await payload.delete({ + collection: collectionSlug, + where: { + id: { + in: ids, + }, + }, + }) +} + +describe('tree view', () => { + beforeAll(async () => { + const initResult = await initPayloadInt(dirname) + + payload = initResult.payload + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + describe('tree paths', () => { + async function createTags() { + const mediaTag = (await payload.create({ + collection: slugs.tags, + data: { + name: 'Media', + }, + })) as Tag + + const typeTag = (await payload.create({ + collection: slugs.tags, + data: { + name: 'Type', + _parentDoc: mediaTag.id, + }, + })) as Tag + + const headshotsTag = (await payload.create({ + collection: slugs.tags, + data: { + name: 'Headshots', + _parentDoc: typeTag.id, + }, + })) as Tag + + return { + mediaTag, + typeTag, + headshotsTag, + } + } + it('should generate correct paths', async () => { + const { mediaTag, typeTag, headshotsTag } = await createTags() + + expect(headshotsTag.slugPath).toEqual('media/type/headshots') + expect(headshotsTag.titlePath).toEqual('Media/Type/Headshots') + + await cleanupDocs({ + collectionSlug: slugs.tags, + ids: [headshotsTag.id, typeTag.id, mediaTag.id], + payload, + }) + }) + + it('should update paths on name change', async () => { + const { mediaTag, typeTag, headshotsTag } = await createTags() + + await payload.update({ + collection: slugs.tags, + id: typeTag.id, + data: { + name: 'Style', + }, + }) + + const updatedHeadshotsTag = (await payload.findByID({ + collection: slugs.tags, + id: headshotsTag.id, + })) as Tag + + expect(updatedHeadshotsTag.slugPath).toEqual('media/style/headshots') + expect(updatedHeadshotsTag.titlePath).toEqual('Media/Style/Headshots') + + await cleanupDocs({ + collectionSlug: slugs.tags, + ids: [headshotsTag.id, typeTag.id, mediaTag.id], + payload, + }) + }) + }) +}) diff --git a/test/tree-view/payload-types.ts b/test/tree-view/payload-types.ts new file mode 100644 index 00000000000..960efe74eea --- /dev/null +++ b/test/tree-view/payload-types.ts @@ -0,0 +1,325 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + users: User; + pages: Page; + tags: Tag; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + pages: PagesSelect | PagesSelect; + tags: TagsSelect | TagsSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + globals: {}; + globalsSelect: {}; + locale: null; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages". + */ +export interface Page { + id: string; + title?: string | null; + updatedAt: string; + createdAt: string; + _parentDoc?: (string | null) | Page; + slugPath?: string | null; + titlePath?: string | null; + _parentTree?: (string | Page)[] | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tags". + */ +export interface Tag { + id: string; + name?: string | null; + updatedAt: string; + createdAt: string; + _parentDoc?: (string | null) | Tag; + slugPath?: string | null; + titlePath?: string | null; + _parentTree?: (string | Tag)[] | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'users'; + value: string | User; + } | null) + | ({ + relationTo: 'pages'; + value: string | Page; + } | null) + | ({ + relationTo: 'tags'; + value: string | Tag; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + updatedAt?: T; + createdAt?: T; + deletedAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages_select". + */ +export interface PagesSelect { + title?: T; + updatedAt?: T; + createdAt?: T; + _parentDoc?: T; + slugPath?: T; + titlePath?: T; + _parentTree?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tags_select". + */ +export interface TagsSelect { + name?: T; + updatedAt?: T; + createdAt?: T; + _parentDoc?: T; + slugPath?: T; + titlePath?: T; + _parentTree?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/tree-view/shared.ts b/test/tree-view/shared.ts new file mode 100644 index 00000000000..1933a76515b --- /dev/null +++ b/test/tree-view/shared.ts @@ -0,0 +1,7 @@ +import type { CollectionSlug } from 'payload' + +export const slugs = { + pages: 'pages' as CollectionSlug, + tags: 'tags' as CollectionSlug, + users: 'users' as CollectionSlug, +} From dacc9fb18dad58516bb0b97fb991323c789873f8 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 2 Oct 2025 14:35:08 -0400 Subject: [PATCH 07/31] rendering placeholder, updated drag logic --- .../views/CollectionTreeView/buildView.tsx | 129 ++++ .../src/views/CollectionTreeView/index.tsx | 23 + packages/next/src/views/List/index.tsx | 4 +- packages/next/src/views/Root/getRouteData.ts | 31 +- packages/next/src/views/Root/index.tsx | 10 +- packages/payload/src/admin/functions/index.ts | 18 + packages/payload/src/admin/types.ts | 22 + packages/payload/src/admin/views/index.ts | 6 + packages/payload/src/admin/views/list.ts | 4 +- .../payload/src/admin/views/treeViewList.ts | 62 ++ packages/payload/src/config/defaults.ts | 9 + packages/payload/src/index.ts | 9 +- packages/payload/src/preferences/types.ts | 4 +- packages/payload/src/treeView/types.ts | 7 + .../src/treeView/utils/findUseAsTitleField.ts | 2 +- .../src/treeView/utils/getTreeViewData.ts | 33 + .../elements/DefaultListViewTabs/index.tsx | 50 +- .../FolderView/DraggableWithClick/index.tsx | 10 +- packages/ui/src/elements/ListHeader/index.tsx | 4 +- .../elements/TreeView/GridTable/index.scss | 0 .../src/elements/TreeView/GridTable/index.tsx | 103 +++ .../TreeView/NestedItemsTable/index.scss | 85 +++ .../TreeView/NestedItemsTable/index.tsx | 267 ++++++++ .../TreeView/TreeViewTable/index.scss | 6 + .../elements/TreeView/TreeViewTable/index.tsx | 111 ++++ .../getTreeViewResultsComponentAndData.tsx | 77 +++ packages/ui/src/exports/client/index.ts | 4 + packages/ui/src/exports/rsc/index.ts | 1 + .../TreeView/groupItemIDsByRelation.ts | 15 + packages/ui/src/providers/TreeView/index.tsx | 627 ++++++++++++++++++ .../ui/src/views/CollectionFolder/index.tsx | 2 +- .../src/views/CollectionTreeView/index.scss | 156 +++++ .../ui/src/views/CollectionTreeView/index.tsx | 327 +++++++++ .../ui/src/views/List/ListHeader/index.tsx | 4 +- 34 files changed, 2194 insertions(+), 28 deletions(-) create mode 100644 packages/next/src/views/CollectionTreeView/buildView.tsx create mode 100644 packages/next/src/views/CollectionTreeView/index.tsx create mode 100644 packages/payload/src/admin/views/treeViewList.ts create mode 100644 packages/payload/src/treeView/utils/getTreeViewData.ts create mode 100644 packages/ui/src/elements/TreeView/GridTable/index.scss create mode 100644 packages/ui/src/elements/TreeView/GridTable/index.tsx create mode 100644 packages/ui/src/elements/TreeView/NestedItemsTable/index.scss create mode 100644 packages/ui/src/elements/TreeView/NestedItemsTable/index.tsx create mode 100644 packages/ui/src/elements/TreeView/TreeViewTable/index.scss create mode 100644 packages/ui/src/elements/TreeView/TreeViewTable/index.tsx create mode 100644 packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx create mode 100644 packages/ui/src/providers/TreeView/groupItemIDsByRelation.ts create mode 100644 packages/ui/src/providers/TreeView/index.tsx create mode 100644 packages/ui/src/views/CollectionTreeView/index.scss create mode 100644 packages/ui/src/views/CollectionTreeView/index.tsx diff --git a/packages/next/src/views/CollectionTreeView/buildView.tsx b/packages/next/src/views/CollectionTreeView/buildView.tsx new file mode 100644 index 00000000000..20a65bd2fc0 --- /dev/null +++ b/packages/next/src/views/CollectionTreeView/buildView.tsx @@ -0,0 +1,129 @@ +import type { AdminViewServerProps, BuildCollectionFolderViewResult, ListQuery } from 'payload' + +import { DefaultCollectionTreeView, HydrateAuthProvider } from '@payloadcms/ui' +import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' +import { getTreeViewResultsComponentAndData, upsertPreferences } from '@payloadcms/ui/rsc' + +export type BuildCollectionTreeViewStateArgs = { + disableBulkDelete?: boolean + disableBulkEdit?: boolean + enableRowSelections: boolean + isInDrawer?: boolean + overrideEntityVisibility?: boolean + query: ListQuery +} & AdminViewServerProps + +export const buildCollectionTreeView = async ( + args: BuildCollectionTreeViewStateArgs, +): Promise => { + const { + disableBulkDelete, + disableBulkEdit, + enableRowSelections, + initPageResult, + isInDrawer, + overrideEntityVisibility, + params, + query: queryFromArgs, + searchParams, + } = args + + const { + collectionConfig, + collectionConfig: { slug: collectionSlug }, + locale: fullLocale, + permissions, + req: { + i18n, + payload, + payload: { config }, + query: queryFromReq, + user, + }, + visibleEntities, + } = initPageResult + + if (!config.treeView) { + throw new Error('not-found') + } + + if (!permissions?.collections?.[collectionSlug]?.read) { + throw new Error('not-found') + } + + if (collectionConfig) { + if (!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) { + throw new Error('not-found') + } + + const query = queryFromArgs || queryFromReq + + const { + routes: { admin: adminRoute }, + } = config + + const { documents, TreeViewResultsComponent } = await getTreeViewResultsComponentAndData({ + collectionSlug, + req: initPageResult.req, + sort: '', + // sort: sortPreference, + // search, + }) + + const serverProps: any = { + collectionConfig, + i18n, + locale: fullLocale, + params, + payload, + permissions, + searchParams, + user, + } + + // We could support slots in the folder view in the future + // const folderViewSlots = renderFolderViewSlots({ + // clientProps: { + // collectionSlug, + // hasCreatePermission, + // newDocumentURL, + // }, + // collectionConfig, + // description: typeof collectionConfig.admin.description === 'function' + // ? collectionConfig.admin.description({ t: i18n.t }) + // : collectionConfig.admin.description, + // payload, + // serverProps, + // }) + + const search = query?.search as string + + return { + View: ( + <> + + {RenderServerComponent({ + clientProps: { + // ...folderViewSlots, + collectionSlug, + disableBulkDelete, + disableBulkEdit, + documents, + enableRowSelections, + // folderFieldName: config.folders.fieldName, + search, + TreeViewResultsComponent, + // sort: sortPreference, + }, + // Component: collectionConfig?.admin?.components?.views?.TreeView?.Component, + Fallback: DefaultCollectionTreeView, + importMap: payload.importMap, + serverProps, + })} + + ), + } + } + + throw new Error('not-found') +} diff --git a/packages/next/src/views/CollectionTreeView/index.tsx b/packages/next/src/views/CollectionTreeView/index.tsx new file mode 100644 index 00000000000..c75fd0977ce --- /dev/null +++ b/packages/next/src/views/CollectionTreeView/index.tsx @@ -0,0 +1,23 @@ +import type React from 'react' + +import { notFound } from 'next/navigation.js' + +import type { BuildCollectionTreeViewStateArgs } from './buildView.js' + +import { buildCollectionTreeView } from './buildView.js' + +export const CollectionTreeView: React.FC = async (args) => { + try { + const { View } = await buildCollectionTreeView(args) + return View + } catch (error) { + if (error?.message === 'NEXT_REDIRECT') { + throw error + } + if (error.message === 'not-found') { + notFound() + } else { + console.error(error) // eslint-disable-line no-console + } + } +} diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 4e98a9c564d..83aad980a8b 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -6,6 +6,7 @@ import type { ListQuery, ListViewClientProps, ListViewServerPropsOnly, + ListViewTypes, PaginatedDocs, QueryPreset, SanitizedCollectionPermission, @@ -47,7 +48,8 @@ type RenderListViewArgs = { * @experimental This prop is subject to change in future releases. */ trash?: boolean -} & AdminViewServerProps + viewType: ListViewTypes +} & Omit /** * This function is responsible for rendering diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts index 88797d8fb61..173c073eba7 100644 --- a/packages/next/src/views/Root/getRouteData.ts +++ b/packages/next/src/views/Root/getRouteData.ts @@ -20,6 +20,7 @@ import { Account } from '../Account/index.js' import { BrowseByFolder } from '../BrowseByFolder/index.js' import { CollectionFolderView } from '../CollectionFolders/index.js' import { TrashView } from '../CollectionTrash/index.js' +import { CollectionTreeView } from '../CollectionTreeView/index.js' import { CreateFirstUserView } from '../CreateFirstUser/index.js' import { Dashboard } from '../Dashboard/index.js' import { Document as DocumentView } from '../Document/index.js' @@ -225,8 +226,10 @@ export const getRouteData = ({ routeParams.collection = collectionConfig.slug if ( + config.folders && + collectionConfig.folders && collectionPreferences?.listViewType && - collectionPreferences.listViewType === 'folders' + collectionPreferences.listViewType === 'collection-folders' ) { // Render folder view by default if set in preferences ViewToRender = { @@ -236,6 +239,20 @@ export const getRouteData = ({ templateClassName = `collection-folders` templateType = 'default' viewType = 'collection-folders' + } else if ( + config.treeView && + collectionConfig.treeView && + collectionPreferences?.listViewType && + collectionPreferences.listViewType === 'collection-tree-view' + ) { + // Render tree view by default if set in preferences + ViewToRender = { + Component: CollectionTreeView, + } + + templateClassName = `collection-tree-view` + templateType = 'default' + viewType = 'collection-tree-view' } else { ViewToRender = { Component: ListView, @@ -338,6 +355,18 @@ export const getRouteData = ({ templateType = 'default' viewType = 'collection-folders' + viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) + } else if (config.treeView && segmentThree === 'tree-view' && collectionConfig.treeView) { + // Collection Tree View + // --> /collections/:collectionSlug/tree-view + ViewToRender = { + Component: CollectionTreeView, + } + + templateClassName = `collection-tree-view` + templateType = 'default' + viewType = 'collection-tree-view' + viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) } else { // Collection Edit Views diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index ff50572dd04..ccba83f5648 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -159,7 +159,15 @@ export const RootPage = async ({ let collectionPreferences: CollectionPreferences = undefined if (collectionConfig && segments.length === 2) { - if (config.folders && collectionConfig.folders && segments[1] !== config.folders.slug) { + const isFolderViewEnabled = + config.folders && collectionConfig.folders && segments[1] !== config.folders.slug + const isTreeViewEnabled = collectionConfig.treeView && config.treeView + + /** + * Fetch collection level preferences, used for: + * - getting default list view tab (list | folder | tree-view) + */ + if (isFolderViewEnabled || isTreeViewEnabled) { await getPreferences( `collection-${collectionConfig.slug}`, req.payload, diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index f6fb7ce060a..4c48d33f818 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -149,3 +149,21 @@ export type GetFolderResultsComponentAndDataArgs = { */ sort: FolderSortKeys } + +export type BuildCollectionTreeViewResult = { + View: React.ReactNode +} + +export type GetTreeViewResultsComponentAndDataArgs = { + /** + * The slug of the collection to get tree view data for. + * i.e. 'posts' + */ + collectionSlug: CollectionSlug + // search?: string + req: PayloadRequest + /** + * The sort order for the results. + */ + sort: any +} diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index ff3792db1c6..a2c119e6cd4 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -574,6 +574,7 @@ export type { BuildTableStateArgs, DefaultServerFunctionArgs, GetFolderResultsComponentAndDataArgs, + GetTreeViewResultsComponentAndDataArgs, ListQuery, ServerFunction, ServerFunctionArgs, @@ -666,6 +667,7 @@ export type { AdminViewServerProps, AdminViewServerPropsOnly, InitPageResult, + ListViewTypes, ServerPropsFromView, ViewDescriptionClientProps, ViewDescriptionServerProps, @@ -694,6 +696,26 @@ export type { ListViewSlotSharedClientProps, } from './views/list.js' +export type { + AfterTreeViewListClientProps, + AfterTreeViewListServerProps, + AfterTreeViewListServerPropsOnly, + AfterTreeViewListTableClientProps, + AfterTreeViewListTableServerProps, + AfterTreeViewListTableServerPropsOnly, + BeforeTreeViewListClientProps, + BeforeTreeViewListServerProps, + BeforeTreeViewListServerPropsOnly, + BeforeTreeViewListTableClientProps, + BeforeTreeViewListTableServerProps, + BeforeTreeViewListTableServerPropsOnly, + TreeViewClientProps, + TreeViewServerProps, + TreeViewServerPropsOnly, + TreeViewSlots, + TreeViewSlotSharedClientProps, +} from './views/treeViewList.js' + type SchemaPath = {} & string export type FieldSchemaMap = Map< SchemaPath, diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts index 028abc29e0e..0ebc1a930b8 100644 --- a/packages/payload/src/admin/views/index.ts +++ b/packages/payload/src/admin/views/index.ts @@ -91,6 +91,7 @@ export type InitPageResult = { export type ViewTypes = | 'account' | 'collection-folders' + | 'collection-tree-view' | 'createFirstUser' | 'dashboard' | 'document' @@ -101,6 +102,11 @@ export type ViewTypes = | 'verify' | 'version' +export type ListViewTypes = Extract< + ViewTypes, + 'collection-folders' | 'collection-tree-view' | 'folders' | 'list' | 'trash' +> + export type ServerPropsFromView = { collectionConfig?: SanitizedConfig['collections'][number] globalConfig?: SanitizedConfig['globals'][number] diff --git a/packages/payload/src/admin/views/list.ts b/packages/payload/src/admin/views/list.ts index b02601abb9d..d29fa2f8d10 100644 --- a/packages/payload/src/admin/views/list.ts +++ b/packages/payload/src/admin/views/list.ts @@ -8,7 +8,7 @@ import type { CollectionPreferences } from '../../preferences/types.js' import type { QueryPreset } from '../../query-presets/types.js' import type { ResolvedFilterOptions } from '../../types/index.js' import type { Column } from '../elements/Table.js' -import type { Data, ViewTypes } from '../types.js' +import type { Data, ListViewTypes, ViewTypes } from '../types.js' export type ListViewSlots = { AfterList?: React.ReactNode @@ -59,7 +59,7 @@ export type ListViewClientProps = { queryPresetPermissions?: SanitizedCollectionPermission renderedFilters?: Map resolvedFilterOptions?: Map - viewType: ViewTypes + viewType: ListViewTypes } & ListViewSlots export type ListViewSlotSharedClientProps = { diff --git a/packages/payload/src/admin/views/treeViewList.ts b/packages/payload/src/admin/views/treeViewList.ts new file mode 100644 index 00000000000..bd404168e05 --- /dev/null +++ b/packages/payload/src/admin/views/treeViewList.ts @@ -0,0 +1,62 @@ +import type { ServerProps } from '../../config/types.js' +import type { FolderBreadcrumb, FolderOrDocument, FolderSortKeys } from '../../folders/types.js' +import type { SanitizedCollectionConfig } from '../../index.js' +export type TreeViewSlots = { + AfterTreeViewList?: React.ReactNode + AfterTreeViewListTable?: React.ReactNode + BeforeTreeViewList?: React.ReactNode + BeforeTreeViewListTable?: React.ReactNode + Description?: React.ReactNode + listMenuItems?: React.ReactNode[] +} + +export type TreeViewServerPropsOnly = { + collectionConfig: SanitizedCollectionConfig + documents: FolderOrDocument[] +} & ServerProps + +export type TreeViewServerProps = TreeViewClientProps & TreeViewServerPropsOnly + +export type TreeViewClientProps = { + beforeActions?: React.ReactNode[] + breadcrumbs: FolderBreadcrumb[] + collectionSlug?: SanitizedCollectionConfig['slug'] + disableBulkDelete?: boolean + disableBulkEdit?: boolean + documents: FolderOrDocument[] + enableRowSelections?: boolean + parentFieldName: string + search?: string + sort?: FolderSortKeys + TreeViewResultsComponent: React.ReactNode +} & TreeViewSlots + +export type TreeViewSlotSharedClientProps = { + collectionSlug: SanitizedCollectionConfig['slug'] + hasCreatePermission: boolean + newDocumentURL: string +} + +// BeforeTreeViewList +export type BeforeTreeViewListClientProps = TreeViewSlotSharedClientProps +export type BeforeTreeViewListServerPropsOnly = {} & TreeViewServerPropsOnly +export type BeforeTreeViewListServerProps = BeforeTreeViewListClientProps & + BeforeTreeViewListServerPropsOnly + +// BeforeTreeViewListTable +export type BeforeTreeViewListTableClientProps = TreeViewSlotSharedClientProps +export type BeforeTreeViewListTableServerPropsOnly = {} & TreeViewServerPropsOnly +export type BeforeTreeViewListTableServerProps = BeforeTreeViewListTableClientProps & + BeforeTreeViewListTableServerPropsOnly + +// AfterTreeViewList +export type AfterTreeViewListClientProps = TreeViewSlotSharedClientProps +export type AfterTreeViewListServerPropsOnly = {} & TreeViewServerPropsOnly +export type AfterTreeViewListServerProps = AfterTreeViewListClientProps & + AfterTreeViewListServerPropsOnly + +// AfterTreeViewListTable +export type AfterTreeViewListTableClientProps = TreeViewSlotSharedClientProps +export type AfterTreeViewListTableServerPropsOnly = {} & TreeViewServerPropsOnly +export type AfterTreeViewListTableServerProps = AfterTreeViewListTableClientProps & + AfterTreeViewListTableServerPropsOnly diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index 77dc94677c7..68bb498c23c 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -179,5 +179,14 @@ export const addDefaultsToConfig = (config: Config): Config => { config.folders = false } + if ( + config.treeView !== false && + config.collections.some((collection) => Boolean(collection.treeView)) + ) { + config.treeView = true + } else { + config.treeView = false + } + return config } diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 1814a92e370..9e3c4d23d66 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1592,11 +1592,11 @@ export type { GlobalConfig, SanitizedGlobalConfig, } from './globals/config/types.js' - export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js' -export { findOneOperation } from './globals/operations/findOne.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' @@ -1631,7 +1631,6 @@ export type { TaskOutput, TaskType, } from './queues/config/types/taskTypes.js' - export type { BaseJob, JobLog, @@ -1642,15 +1641,17 @@ 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 { getTreeViewData } from './treeView/utils/getTreeViewData.js' export * from './types/index.js' export { getFileByPath } from './uploads/getFileByPath.js' export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' diff --git a/packages/payload/src/preferences/types.ts b/packages/payload/src/preferences/types.ts index 2a4baddc6ba..dfc88f3ade2 100644 --- a/packages/payload/src/preferences/types.ts +++ b/packages/payload/src/preferences/types.ts @@ -1,4 +1,4 @@ -import type { DefaultDocumentIDType } from '../index.js' +import type { DefaultDocumentIDType, ListViewTypes, ViewTypes } from '../index.js' import type { PayloadRequest } from '../types/index.js' export type PreferenceRequest = { @@ -39,7 +39,7 @@ export type CollectionPreferences = { editViewType?: 'default' | 'live-preview' groupBy?: string limit?: number - listViewType?: 'folders' | 'list' + listViewType?: Extract preset?: DefaultDocumentIDType sort?: string } diff --git a/packages/payload/src/treeView/types.ts b/packages/payload/src/treeView/types.ts index 3b6d0e9a6de..1e7a671f5d9 100644 --- a/packages/payload/src/treeView/types.ts +++ b/packages/payload/src/treeView/types.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from '../collections/config/types.js' import type { Config } from '../config/types.js' +import type { Document } from '../types/index.js' export type AddTreeViewFieldsArgs = { collectionConfig: CollectionConfig @@ -9,3 +10,9 @@ export type AddTreeViewFieldsArgs = { slugPathFieldName?: string titlePathFieldName?: string } + +export type GetTreeViewDataResult = { + documents: Document[] +} + +export type RootTreeViewConfiguration = {} diff --git a/packages/payload/src/treeView/utils/findUseAsTitleField.ts b/packages/payload/src/treeView/utils/findUseAsTitleField.ts index 593631b77de..81284cfaf0f 100644 --- a/packages/payload/src/treeView/utils/findUseAsTitleField.ts +++ b/packages/payload/src/treeView/utils/findUseAsTitleField.ts @@ -23,7 +23,7 @@ function iterateFields({ case 'number': case 'textarea': if (field.name === titleFieldName) { - titleField = field + return field } break case 'row': diff --git a/packages/payload/src/treeView/utils/getTreeViewData.ts b/packages/payload/src/treeView/utils/getTreeViewData.ts new file mode 100644 index 00000000000..bc533122a6a --- /dev/null +++ b/packages/payload/src/treeView/utils/getTreeViewData.ts @@ -0,0 +1,33 @@ +import type { CollectionSlug } from '../../index.js' +import type { PayloadRequest } from '../../types/index.js' +import type { GetTreeViewDataResult } from '../types.js' + +type GetTreeViewDataArgs = { + collectionSlug: CollectionSlug + parentFieldName: string + req: PayloadRequest + search?: string + sort: any +} + +export const getTreeViewData = async ({ + collectionSlug, + parentFieldName, + req, + search, + sort, +}: GetTreeViewDataArgs): Promise => { + const results = await req.payload.find({ + collection: collectionSlug, + depth: 0, + where: { + [parentFieldName]: { + exists: false, + }, + }, + }) + + return { + documents: results.docs, + } +} diff --git a/packages/ui/src/elements/DefaultListViewTabs/index.tsx b/packages/ui/src/elements/DefaultListViewTabs/index.tsx index f10b9e6a162..e06430bc305 100644 --- a/packages/ui/src/elements/DefaultListViewTabs/index.tsx +++ b/packages/ui/src/elements/DefaultListViewTabs/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ClientCollectionConfig, ClientConfig, ViewTypes } from 'payload' +import type { ClientCollectionConfig, ClientConfig, ListViewTypes, ViewTypes } from 'payload' import { getTranslation } from '@payloadcms/translations' import { useRouter } from 'next/navigation.js' @@ -17,8 +17,8 @@ const baseClass = 'default-list-view-tabs' type DefaultListViewTabsProps = { collectionConfig: ClientCollectionConfig config: ClientConfig - onChange?: (viewType: ViewTypes) => void - viewType?: ViewTypes + onChange?: (viewType: ListViewTypes) => void + viewType?: ListViewTypes } export const DefaultListViewTabs: React.FC = ({ @@ -32,17 +32,27 @@ export const DefaultListViewTabs: React.FC = ({ const router = useRouter() const isTrashEnabled = collectionConfig.trash const isFoldersEnabled = collectionConfig.folders && config.folders + const isTreeViewEnabled = collectionConfig.treeView && config.treeView - if (!isTrashEnabled && !isFoldersEnabled) { + if (!isTrashEnabled && !isFoldersEnabled && !isTreeViewEnabled) { return null } - const handleViewChange = async (newViewType: ViewTypes) => { + const handleViewChange = async ( + newViewType: Extract< + ViewTypes, + 'collection-folders' | 'collection-tree-view' | 'list' | 'trash' + >, + ) => { if (onChange) { onChange(newViewType) } - if (newViewType === 'list' || newViewType === 'folders') { + if ( + newViewType === 'list' || + newViewType === 'collection-folders' || + newViewType === 'collection-tree-view' + ) { await setPreference(`collection-${collectionConfig.slug}`, { listViewType: newViewType, }) @@ -50,11 +60,14 @@ export const DefaultListViewTabs: React.FC = ({ let path: `/${string}` = `/collections/${collectionConfig.slug}` switch (newViewType) { - case 'folders': + case 'collection-folders': if (config.folders) { path = `/collections/${collectionConfig.slug}/${config.folders.slug}` } break + case 'collection-tree-view': + path = `/collections/${collectionConfig.slug}/tree-view` + break case 'trash': path = `/collections/${collectionConfig.slug}/trash` break @@ -92,18 +105,35 @@ export const DefaultListViewTabs: React.FC = ({ buttonStyle="tab" className={[ `${baseClass}__button`, - viewType === 'folders' && `${baseClass}__button--active`, + viewType === 'collection-folders' && `${baseClass}__button--active`, ] .filter(Boolean) .join(' ')} - disabled={viewType === 'folders'} + disabled={viewType === 'collection-folders'} el="button" - onClick={() => handleViewChange('folders')} + onClick={() => handleViewChange('collection-folders')} > {t('folder:byFolder')} )} + {isTreeViewEnabled && ( + + )} + {isTrashEnabled && ( + ) : ( +
+ )} = ({ {hasNestedRows && ( = ({ onDroppableHover={onDroppableHover} onRowClick={onRowClick} onRowDrag={onRowDrag} + onRowKeyPress={onRowKeyPress} + openItemIDs={openItemIDs} parentItems={[...parentItems, rowItem]} rowIndexOffset={absoluteRowIndex + 1} rows={rowItem.rows} segmentWidth={segmentWidth} selectedRowIDs={selectedRowIDs} targetParentID={targetParentID} + toggleRow={toggleRow} /> )} diff --git a/packages/ui/src/elements/TreeView/SeedDataButton/index.tsx b/packages/ui/src/elements/TreeView/SeedDataButton/index.tsx new file mode 100644 index 00000000000..0cca382fd9c --- /dev/null +++ b/packages/ui/src/elements/TreeView/SeedDataButton/index.tsx @@ -0,0 +1,59 @@ +'use client' + +import React from 'react' +import { toast } from 'sonner' + +import { useConfig } from '../../../providers/Config/index.js' +import { Button } from '../../Button/index.js' + +type SeedDataButtonProps = { + collectionSlug: string +} + +export function SeedDataButton({ collectionSlug }: SeedDataButtonProps) { + const { config } = useConfig() + const { routes, serverURL } = config + const [isLoading, setIsLoading] = React.useState(false) + + const handleSeedData = React.useCallback(async () => { + setIsLoading(true) + try { + const response = await fetch( + `${serverURL}${routes.api}/${collectionSlug}/seed-data`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + if (!response.ok) { + throw new Error('Failed to seed data') + } + + const result = await response.json() + toast.success(result.message || 'Seed data created successfully') + + // Reload the page to show the new data + window.location.reload() + } catch (error) { + console.error('Error seeding data:', error) + toast.error('Failed to seed data') + } finally { + setIsLoading(false) + } + }, [serverURL, routes.api, collectionSlug]) + + return ( + + ) +} diff --git a/packages/ui/src/elements/TreeView/TreeViewDragOverlay/index.tsx b/packages/ui/src/elements/TreeView/TreeViewDragOverlay/index.tsx index 2674c75fb50..888a246f766 100644 --- a/packages/ui/src/elements/TreeView/TreeViewDragOverlay/index.tsx +++ b/packages/ui/src/elements/TreeView/TreeViewDragOverlay/index.tsx @@ -1,5 +1,5 @@ import type { Modifier } from '@dnd-kit/core' -import type { TreeViewDocument } from 'payload/shared' +import type { TreeViewItem } from 'payload/shared' import { DragOverlay } from '@dnd-kit/core' import { getEventCoordinates } from '@dnd-kit/utilities' @@ -10,7 +10,7 @@ import './index.scss' const baseClass = 'tree-view-drag-overlay' type TreeViewDragOverlayProps = { - readonly item: TreeViewDocument + readonly item: TreeViewItem readonly selectedCount: number } @@ -52,7 +52,7 @@ export const snapTopLeftToCursor: Modifier = ({ activatorEvent, draggingNodeRect return { ...transform, x: transform.x + offsetX + 5, - y: transform.y + offsetY + 5, + y: transform.y + offsetY + 35, } } diff --git a/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx b/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx index 671e3f614fa..14bd1b76c85 100644 --- a/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx +++ b/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx @@ -1,42 +1,113 @@ 'use client' +import type { DragEndEvent } from '@dnd-kit/core' + +import { useDndMonitor } from '@dnd-kit/core' import React from 'react' +import { toast } from 'sonner' import type { SectionRow } from '../NestedSectionsTable/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { useTreeView } from '../../../providers/TreeView/index.js' import { NestedSectionsTable } from '../NestedSectionsTable/index.js' -import { documentsToSectionRows } from '../utils/documentsToSectionRows.js' +import { SeedDataButton } from '../SeedDataButton/index.js' +import { itemsToSectionRows } from '../utils/documentsToSectionRows.js' import { getAllDescendantIDs } from '../utils/getAllDescendantIDs.js' import './index.scss' const baseClass = 'tree-view-results-table' +const dropContextName = 'tree-view-table' export function TreeViewTable() { const { - checkIfItemIsDisabled, - documents, - dragOverlayItem, - dragStartX, - focusedRowIndex, - isDragging, + clearSelections, + collectionSlug, + getSelectedItems, + items, + moveItems, onItemClick, - onItemDrag, - onItemKeyPress, + openItemIDs, selectedItemKeys, + setFocusedRowIndex, + toggleRow, } = useTreeView() const { i18n, t } = useTranslation() + + // Local drag state + const [isDragging, setIsDragging] = React.useState(false) const [hoveredRowID, setHoveredRowID] = React.useState(null) const [targetParentID, setTargetParentID] = React.useState(null) - // Reset hover state when drag ends - React.useEffect(() => { - if (!isDragging) { + // Compute drag overlay item from selected items + const dragOverlayItem = React.useMemo(() => { + if (!isDragging || selectedItemKeys.size === 0) { + return undefined + } + // Use the first selected item as the drag overlay + const firstKey = Array.from(selectedItemKeys)[0] + return items.find((d) => d.itemKey === firstKey) + }, [isDragging, selectedItemKeys, items]) + + // Handle drag/drop events + const onDragEnd = React.useCallback( + async (event: DragEndEvent) => { + if (!event.over) { + return + } + + if ( + event.over.data.current.type === 'tree-view-table' && + 'targetItem' in event.over.data.current + ) { + const selectedItems = getSelectedItems() + const docIDs = selectedItems.map((doc) => doc.value.id) + const targetItem = event.over.data.current.targetItem + const targetID = targetItem?.rowID + + // Validate: prevent moving a parent into its own descendant + const invalidTargets = getAllDescendantIDs(docIDs, items) + if (targetID && invalidTargets.has(targetID)) { + toast.error(t('general:cannotMoveParentIntoChild')) + return + } + + try { + await moveItems({ + docIDs, + parentID: targetID, + }) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error moving items:', error) + toast.error(t('general:errorMovingItems')) + } + } + }, + [moveItems, getSelectedItems, items, t], + ) + + useDndMonitor({ + onDragCancel() { + setIsDragging(false) setHoveredRowID(null) setTargetParentID(null) - } - }, [isDragging]) + // todo: do this differently + document.body.style.cursor = '' + }, + onDragEnd(event) { + setIsDragging(false) + setHoveredRowID(null) + setTargetParentID(null) + document.body.style.cursor = '' + void onDragEnd(event) + }, + onDragStart(event) { + setIsDragging(true) + document.body.style.cursor = 'grabbing' + }, + }) + const onDroppableHover = React.useCallback( ({ hoveredRowID: newHoveredRowID, @@ -52,15 +123,51 @@ export function TreeViewTable() { ) const onRowDrag = React.useCallback( - ({ event, item }: { event: PointerEvent; item: null | SectionRow }) => { - if (item) { - const index = documents.findIndex((doc) => doc.value.id === item.rowID) - if (index !== -1) { - onItemDrag({ event, item: documents[index] }) + ({ event, item: dragItem }: { event: PointerEvent; item: null | SectionRow }) => { + if (!dragItem) { + return + } + + const dragItemDoc = items.find((doc) => doc.value.id === dragItem.rowID) + if (!dragItemDoc) { + return + } + + const isCtrlPressed = event.ctrlKey || event.metaKey + const isShiftPressed = event.shiftKey + const isCurrentlySelected = selectedItemKeys.has(dragItemDoc.itemKey) + + if (!isCurrentlySelected) { + // Select the dragged item (and maintain ctrl/shift selections) + const indexes: number[] = [] + for (let idx = 0; idx < items.length; idx++) { + const doc = items[idx] + if (doc.itemKey === dragItemDoc.itemKey) { + indexes.push(idx) + } else if ((isCtrlPressed || isShiftPressed) && selectedItemKeys.has(doc.itemKey)) { + indexes.push(idx) + } + } + + // Update selections through provider's onItemClick + if (indexes.length > 0) { + const index = items.findIndex((d) => d.itemKey === dragItemDoc.itemKey) + // Call onItemClick to update selections in provider + onItemClick({ + event: { + ctrlKey: isCtrlPressed, + metaKey: isCtrlPressed, + nativeEvent: event, + shiftKey: isShiftPressed, + } as any, + index, + item: dragItemDoc, + keepSelected: true, + }) } } }, - [documents, onItemDrag], + [items, selectedItemKeys, onItemClick], ) const onRowClick = React.useCallback( @@ -73,23 +180,143 @@ export function TreeViewTable() { from: 'checkbox' | 'dragHandle' row: SectionRow }) => { - const index = documents.findIndex((doc) => doc.value.id === row.rowID) + const index = items.findIndex((doc) => doc.value.id === row.rowID) if (index !== -1) { - const item = documents[index] - // const isDisabled = checkIfItemIsDisabled(item) - // if (isDisabled) { - // return - // } + const item = items[index] + void onItemClick({ event, index, item, keepSelected: from === 'dragHandle' }) + } + }, + [items, onItemClick], + ) - // if the user clicked the checkbox, we want to prevent the onClick event from the DraggableWithClick - // if (from === 'checkbox' && event.type === 'click') { - // event.stopPropagation() - // } + const onRowKeyPress = React.useCallback( + ({ event, row }: { event: React.KeyboardEvent; row: SectionRow }) => { + const index = items.findIndex((doc) => doc.value.id === row.rowID) + if (index === -1) { + return + } - void onItemClick({ event, index, item, keepSelected: from === 'dragHandle' }) + const item = items[index] + const { code, ctrlKey, metaKey, shiftKey } = event + const isShiftPressed = shiftKey + const isCtrlPressed = ctrlKey || metaKey + const isCurrentlySelected = selectedItemKeys.has(item.itemKey) + + switch (code) { + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': { + event.preventDefault() + + const isBackward = code === 'ArrowLeft' || code === 'ArrowUp' + const newItemIndex = isBackward ? index - 1 : index + 1 + + if (newItemIndex < 0 || newItemIndex >= items.length) { + return + } + + setFocusedRowIndex(newItemIndex) + + if (isCtrlPressed) { + break + } + + if (isShiftPressed) { + // Let onItemClick handle shift selection + const newItem = items[newItemIndex] + onItemClick({ + event: { + ctrlKey: false, + metaKey: false, + nativeEvent: {}, + shiftKey: true, + } as any, + index: newItemIndex, + item: newItem, + }) + return + } + + // Single selection without shift + const newItem = items[newItemIndex] + onItemClick({ + event: { + ctrlKey: false, + metaKey: false, + nativeEvent: {}, + shiftKey: false, + } as any, + index: newItemIndex, + item: newItem, + }) + + break + } + case 'Enter': { + if (selectedItemKeys.size === 1) { + setFocusedRowIndex(undefined) + // TODO: Navigate to the selected item + } + break + } + case 'Escape': { + clearSelections() + break + } + case 'KeyA': { + if (isCtrlPressed) { + event.preventDefault() + setFocusedRowIndex(items.length - 1) + // Select all by clicking on each item + items.forEach((doc, idx) => { + onItemClick({ + event: { + ctrlKey: true, + metaKey: true, + nativeEvent: {}, + shiftKey: false, + } as any, + index: idx, + item: doc, + keepSelected: true, + }) + }) + } + break + } + case 'Space': { + event.preventDefault() + onItemClick({ + event: { + ctrlKey: isShiftPressed, + metaKey: isShiftPressed, + nativeEvent: {}, + shiftKey: false, + } as any, + index, + item, + keepSelected: isCurrentlySelected, + }) + break + } + case 'Tab': { + if (isShiftPressed) { + const prevIndex = index - 1 + if (prevIndex < 0 && selectedItemKeys?.size > 0) { + setFocusedRowIndex(prevIndex) + } + } else { + const nextIndex = index + 1 + if (nextIndex === items.length && selectedItemKeys.size > 0) { + setFocusedRowIndex(items.length - 1) + } + } + break + } } }, - [documents, onItemClick], + [items, selectedItemKeys, onItemClick, setFocusedRowIndex, clearSelections], ) const [columns] = React.useState(() => [ @@ -103,41 +330,52 @@ export function TreeViewTable() { }, ]) - const sections = React.useMemo(() => { - return documentsToSectionRows({ documents, i18nLanguage: i18n.language }) - }, [documents, i18n.language]) + const sections = React.useMemo( + () => itemsToSectionRows({ i18nLanguage: i18n.language, items }), + [items, i18n.language], + ) const selectedRowIDs = React.useMemo(() => { return Array.from(selectedItemKeys).map((key) => { - const doc = documents.find((d) => d.itemKey === key) + const doc = items.find((d) => d.itemKey === key) return doc?.value.id || '' }) - }, [selectedItemKeys, documents]) + }, [selectedItemKeys, items]) // Compute invalid drop targets (dragged items + all their descendants) const invalidTargetIDs = React.useMemo(() => { if (!isDragging || selectedRowIDs.length === 0) { return undefined } - return getAllDescendantIDs(selectedRowIDs, documents) - }, [isDragging, selectedRowIDs, documents]) + return getAllDescendantIDs(selectedRowIDs, items) + }, [isDragging, selectedRowIDs, items]) return ( - + <> + {/* TODO: remove this button */} +
+ +
+ + {/* {selectedItemKeys.size > 0 && dragOverlayItem && ( + + )} */} + ) } diff --git a/packages/ui/src/elements/TreeView/utils/documentsToSectionRows.ts b/packages/ui/src/elements/TreeView/utils/documentsToSectionRows.ts index 0468bf0f590..6da65bc0316 100644 --- a/packages/ui/src/elements/TreeView/utils/documentsToSectionRows.ts +++ b/packages/ui/src/elements/TreeView/utils/documentsToSectionRows.ts @@ -1,51 +1,52 @@ -import type { TreeViewDocument } from 'payload/shared' +import type { TreeViewItem } from 'payload/shared' import type { SectionRow } from '../NestedSectionsTable/index.js' type Args = { - documents: TreeViewDocument[] i18nLanguage: string + items: TreeViewItem[] } -export function documentsToSectionRows({ documents, i18nLanguage }: Args): SectionRow[] { +export function itemsToSectionRows({ i18nLanguage, items }: Args): SectionRow[] { // Create a map for quick lookups - const docMap = new Map() - documents.forEach((doc) => { - docMap.set(doc.value.id, doc) + const itemMap = new Map() + items.forEach((item) => { + itemMap.set(item.value.id, item) }) // Create a map to store section rows const sectionRowMap = new Map() - // Convert each document to a SectionRow - documents.forEach((doc) => { + // Convert each item to a SectionRow + items.forEach((item) => { const sectionRow: SectionRow = { - name: doc.value.title, - rowID: doc.value.id, + name: item.value.title, + hasChildren: item.hasChildren, + rowID: item.value.id, rows: [], - updatedAt: doc.value.updatedAt - ? new Date(doc.value.updatedAt).toLocaleDateString(i18nLanguage, { + updatedAt: item.value.updatedAt + ? new Date(item.value.updatedAt).toLocaleDateString(i18nLanguage, { day: 'numeric', month: 'short', year: 'numeric', }) : '', } - sectionRowMap.set(doc.value.id, sectionRow) + sectionRowMap.set(item.value.id, sectionRow) }) // Build the hierarchy const rootSections: SectionRow[] = [] - documents.forEach((doc) => { - const sectionRow = sectionRowMap.get(doc.value.id) + items.forEach((item) => { + const sectionRow = sectionRowMap.get(item.value.id) if (!sectionRow) { return } - if (doc.value.parentID) { + if (item.value.parentID) { // This is a child - add it to its parent's rows array - const parentRow = sectionRowMap.get(doc.value.parentID) + const parentRow = sectionRowMap.get(item.value.parentID) if (parentRow) { if (!parentRow.rows) { parentRow.rows = [] @@ -56,7 +57,7 @@ export function documentsToSectionRows({ documents, i18nLanguage }: Args): Secti rootSections.push(sectionRow) } } else { - // This is a root-level document + // This is a root-level item rootSections.push(sectionRow) } }) diff --git a/packages/ui/src/elements/TreeView/utils/getAllDescendantIDs.ts b/packages/ui/src/elements/TreeView/utils/getAllDescendantIDs.ts index 28e07fa14b8..790b5dac173 100644 --- a/packages/ui/src/elements/TreeView/utils/getAllDescendantIDs.ts +++ b/packages/ui/src/elements/TreeView/utils/getAllDescendantIDs.ts @@ -1,11 +1,11 @@ -import type { TreeViewDocument } from 'payload/shared' +import type { TreeViewItem } from 'payload/shared' /** * Recursively collects all descendant IDs for the given parent IDs. * This is used to determine invalid drop targets when dragging tree items. * * @param draggedItemIDs - Array of IDs for items being dragged - * @param documents - All documents in the tree + * @param items - All documents in the tree * @returns Set containing the dragged IDs and all their descendant IDs at any depth * * @example @@ -21,13 +21,13 @@ import type { TreeViewDocument } from 'payload/shared' */ export function getAllDescendantIDs( draggedItemIDs: (number | string)[], - documents: TreeViewDocument[], + items: TreeViewItem[], ): Set { const invalidTargets = new Set() // Helper to recursively collect all descendants of a parent const collectDescendants = (parentID: number | string) => { - documents.forEach((doc) => { + items.forEach((doc) => { if (doc.value.parentID === parentID) { invalidTargets.add(doc.value.id) // Recursively collect this child's descendants diff --git a/packages/ui/src/icons/DragHandle/index.scss b/packages/ui/src/icons/DragHandle/index.scss index ea8ec9c7871..f402ff7ac92 100644 --- a/packages/ui/src/icons/DragHandle/index.scss +++ b/packages/ui/src/icons/DragHandle/index.scss @@ -2,13 +2,13 @@ @layer payload-default { .icon--drag-handle { - height: $baseline; - width: $baseline; + height: calc(var(--base) * 1); + width: calc(var(--base) * 1); .fill { stroke: currentColor; - stroke-width: $style-stroke-width-s; - fill: var(--theme-elevation-800); + stroke-width: var(--style-stroke-width-s); + fill: var(--theme-elevation-600); } } } diff --git a/packages/ui/src/providers/TreeView/index.tsx b/packages/ui/src/providers/TreeView/index.tsx index cb9601d45b1..99da600ac0a 100644 --- a/packages/ui/src/providers/TreeView/index.tsx +++ b/packages/ui/src/providers/TreeView/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { CollectionSlug, FolderSortKeys } from 'payload' -import type { TreeViewDocument, TreeViewItemKey } from 'payload/shared' +import type { TreeViewItem, TreeViewItemKey } from 'payload/shared' import { useRouter, useSearchParams } from 'next/navigation.js' import { extractID } from 'payload/shared' @@ -12,6 +12,8 @@ import { toast } from 'sonner' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { useConfig } from '../Config/index.js' import { useLocale } from '../Locale/index.js' +import { usePreferences } from '../Preferences/index.js' +import { useRouteCache } from '../RouteCache/index.js' import { useRouteTransition } from '../RouteTransition/index.js' import { useTranslation } from '../Translation/index.js' @@ -22,62 +24,50 @@ type TreeViewQueryParams = { } export type TreeViewContextValue = { - checkIfItemIsDisabled: (item: TreeViewDocument) => boolean + checkIfItemIsDisabled: (item: TreeViewItem) => boolean clearSelections: () => void - documents?: TreeViewDocument[] - dragOverlayItem?: TreeViewDocument | undefined - dragStartX: number + collectionSlug: CollectionSlug focusedRowIndex: number - getSelectedItems?: () => TreeViewDocument[] - isDragging: boolean + getSelectedItems?: () => TreeViewItem[] itemKeysToMove?: Set + items?: TreeViewItem[] moveItems: (args: { docIDs: (number | string)[]; parentID?: number | string }) => Promise onItemClick: (args: { event: React.MouseEvent index: number - item: TreeViewDocument + item: TreeViewItem keepSelected?: boolean }) => void - onItemDrag: (args: { event: PointerEvent; item: TreeViewDocument }) => void - onItemKeyPress: (args: { - event: React.KeyboardEvent - index: number - item: TreeViewDocument - }) => void + openItemIDs: Set parentFieldName: string refineTreeViewData: (args: { query?: TreeViewQueryParams; updateURL: boolean }) => void search: string readonly selectedItemKeys: Set - setDragStartX: React.Dispatch> setFocusedRowIndex: React.Dispatch> - setIsDragging: React.Dispatch> sort: FolderSortKeys TableComponent: React.ReactNode + toggleRow: (docID: number | string) => void } const Context = React.createContext({ checkIfItemIsDisabled: () => false, clearSelections: () => {}, - documents: [], - dragOverlayItem: undefined, - dragStartX: 0, + collectionSlug: '' as CollectionSlug, focusedRowIndex: -1, getSelectedItems: () => [], - isDragging: false, itemKeysToMove: undefined, + items: [], moveItems: () => Promise.resolve(undefined), onItemClick: () => undefined, - onItemDrag: () => undefined, - onItemKeyPress: () => undefined, + openItemIDs: new Set(), parentFieldName: '_parentDoc', refineTreeViewData: () => undefined, search: '', selectedItemKeys: new Set(), - setDragStartX: () => 0, setFocusedRowIndex: () => -1, - setIsDragging: () => false, sort: 'name', TableComponent: null, + toggleRow: () => undefined, }) export type TreeViewProviderProps = { @@ -88,13 +78,13 @@ export type TreeViewProviderProps = { readonly children: React.ReactNode readonly collectionSlug: CollectionSlug /** - * All documents in the tree + * All items in the tree */ - readonly documents: TreeViewDocument[] + readonly items: TreeViewItem[] /** * Optional function to call when an item is clicked */ - readonly onItemClick?: (item: TreeViewDocument) => void + readonly onItemClick?: (item: TreeViewItem) => void /** * The field name that holds the parent reference */ @@ -123,7 +113,7 @@ export function TreeViewProvider({ allowMultiSelection = true, children, collectionSlug, - documents, + items: itemsFromProps, onItemClick: onItemClickFromProps, parentFieldName = '_parentDoc', search, @@ -138,9 +128,12 @@ export function TreeViewProvider({ const { startRouteTransition } = useRouteTransition() const locale = useLocale() const localeCode = locale ? locale.code : undefined + const { setPreference } = usePreferences() const currentlySelectedIndexes = React.useRef(new Set()) + const [items, setItems] = React.useState(itemsFromProps) + const [openItemIDs, setOpenDocumentIDs] = React.useState>(() => new Set()) const [TableComponent, setTableComponentToRender] = React.useState( InitialTableComponent || (() => null), ) @@ -149,21 +142,17 @@ export function TreeViewProvider({ const searchParams = React.useMemo(() => parseSearchParams(rawSearchParams), [rawSearchParams]) const [currentQuery, setCurrentQuery] = React.useState(searchParams) - const [isDragging, setIsDragging] = React.useState(false) - const [dragStartX, setDragStartX] = React.useState(0) const [selectedItemKeys, setSelectedItemKeys] = React.useState>( () => new Set(), ) const [focusedRowIndex, setFocusedRowIndex] = React.useState(-1) const [isDoubleClickEnabled] = React.useState(false) - const [dragOverlayItem, setDragOverlayItem] = React.useState() const lastClickTime = React.useRef(null) - const totalCount = documents.length + const { clearRouteCache } = useRouteCache() const clearSelections = React.useCallback(() => { setFocusedRowIndex(-1) setSelectedItemKeys(new Set()) - setDragOverlayItem(undefined) currentlySelectedIndexes.current = new Set() }, []) @@ -206,9 +195,9 @@ export function TreeViewProvider({ const getItem = React.useCallback( (itemKey: TreeViewItemKey) => { - return documents.find((doc) => doc.itemKey === itemKey) + return items.find((doc) => doc.itemKey === itemKey) }, - [documents], + [items], ) const getSelectedItems = React.useCallback(() => { @@ -233,7 +222,7 @@ export function TreeViewProvider({ const handleShiftSelection = React.useCallback( (targetIndex: number) => { - const allItems = documents + const allItems = items // Find existing selection boundaries const existingIndexes = allItems.reduce((acc, item, idx) => { @@ -287,12 +276,12 @@ export function TreeViewProvider({ return [...new Set([...existingIndexes, ...newRangeIndexes])] } }, - [documents, selectedItemKeys], + [items, selectedItemKeys], ) const updateSelections = React.useCallback( ({ indexes }: { indexes: number[] }) => { - const allItems = documents + const allItems = items const { newSelectedItemKeys } = allItems.reduce( (acc, item, index) => { if (indexes.includes(index)) { @@ -307,158 +296,7 @@ export function TreeViewProvider({ setSelectedItemKeys(newSelectedItemKeys) }, - [documents], - ) - - const onItemKeyPress: TreeViewContextValue['onItemKeyPress'] = React.useCallback( - ({ event, item: currentItem }) => { - const { code, ctrlKey, metaKey, shiftKey } = event - const isShiftPressed = shiftKey - const isCtrlPressed = ctrlKey || metaKey - const isCurrentlySelected = selectedItemKeys.has(currentItem.itemKey) - const allItems = documents - const currentItemIndex = allItems.findIndex((item) => item.itemKey === currentItem.itemKey) - - switch (code) { - case 'ArrowDown': - case 'ArrowLeft': - case 'ArrowRight': - case 'ArrowUp': { - event.preventDefault() - - if (currentItemIndex === -1) { - break - } - - const isBackward = code === 'ArrowLeft' || code === 'ArrowUp' - const newItemIndex = isBackward ? currentItemIndex - 1 : currentItemIndex + 1 - - if (newItemIndex < 0 || newItemIndex > totalCount - 1) { - // out of bounds, keep current selection - return - } - - setFocusedRowIndex(newItemIndex) - - if (isCtrlPressed) { - break - } - - if (isShiftPressed && allowMultiSelection) { - const selectedIndexes = handleShiftSelection(newItemIndex) - updateSelections({ indexes: selectedIndexes }) - return - } - - // Single selection without shift - if (!isShiftPressed) { - const newItem = allItems[newItemIndex] - setSelectedItemKeys(new Set([newItem.itemKey])) - } - - break - } - case 'Enter': { - if (selectedItemKeys.size === 1) { - setFocusedRowIndex(undefined) - navigateAfterSelection({ - collectionSlug: currentItem.relationTo, - docID: extractID(currentItem.value), - }) - return - } - break - } - case 'Escape': { - clearSelections() - break - } - case 'KeyA': { - if (allowMultiSelection && isCtrlPressed) { - event.preventDefault() - setFocusedRowIndex(totalCount - 1) - updateSelections({ - indexes: Array.from({ length: totalCount }, (_, i) => i), - }) - } - break - } - case 'Space': { - if (allowMultiSelection && isShiftPressed) { - event.preventDefault() - updateSelections({ - indexes: allItems.reduce((acc, item, idx) => { - if (item.itemKey === currentItem.itemKey) { - if (isCurrentlySelected) { - return acc - } else { - acc.push(idx) - } - } else if (selectedItemKeys.has(item.itemKey)) { - acc.push(idx) - } - return acc - }, []), - }) - } else { - event.preventDefault() - updateSelections({ - indexes: isCurrentlySelected ? [] : [currentItemIndex], - }) - } - break - } - case 'Tab': { - if (allowMultiSelection && isShiftPressed) { - const prevIndex = currentItemIndex - 1 - if (prevIndex < 0 && selectedItemKeys?.size > 0) { - setFocusedRowIndex(prevIndex) - } - } else { - const nextIndex = currentItemIndex + 1 - if (nextIndex === totalCount && selectedItemKeys.size > 0) { - setFocusedRowIndex(totalCount - 1) - } - } - break - } - } - }, - [ - selectedItemKeys, - documents, - allowMultiSelection, - handleShiftSelection, - updateSelections, - navigateAfterSelection, - clearSelections, - totalCount, - ], - ) - - const onItemDrag: TreeViewContextValue['onItemDrag'] = React.useCallback( - ({ event, item: dragItem }) => { - const isCtrlPressed = event.ctrlKey || event.metaKey - const isShiftPressed = event.shiftKey - const isCurrentlySelected = selectedItemKeys.has(dragItem.itemKey) - const allItems = [...documents] - - if (!isCurrentlySelected) { - updateSelections({ - indexes: allItems.reduce((acc, item, idx) => { - if (item.itemKey === dragItem.itemKey) { - acc.push(idx) - } else if ((isCtrlPressed || isShiftPressed) && selectedItemKeys.has(item.itemKey)) { - acc.push(idx) - } - return acc - }, []), - }) - - setDragOverlayItem(getItem(dragItem.itemKey)) - } - }, - [selectedItemKeys, documents, getItem, updateSelections], + [items], ) const onItemClick: TreeViewContextValue['onItemClick'] = React.useCallback( @@ -469,13 +307,12 @@ export function TreeViewProvider({ const isCurrentlySelected = selectedItemKeys.has(clickedItem.itemKey) if (allowMultiSelection && (isCtrlPressed || isShiftPressed)) { - const currentItemIndex = documents.findIndex((item) => item.itemKey === clickedItem.itemKey) + const currentItemIndex = items.findIndex((item) => item.itemKey === clickedItem.itemKey) if (isCtrlPressed) { - const indexes = documents.reduce((acc, item, idx) => { + const indexes = items.reduce((acc, item, idx) => { if (item.itemKey === clickedItem.itemKey) { if (!isCurrentlySelected || keepSelected) { acc.push(idx) - setDragOverlayItem(getItem(clickedItem.itemKey)) } } else if (selectedItemKeys.has(item.itemKey)) { acc.push(idx) @@ -487,21 +324,22 @@ export function TreeViewProvider({ } else if (currentItemIndex !== -1) { const selectedIndexes = handleShiftSelection(currentItemIndex) updateSelections({ indexes: selectedIndexes }) - setDragOverlayItem(getItem(clickedItem.itemKey)) } } else { // Normal click - select single item const now = Date.now() + const lastSelectedKey = Array.from(selectedItemKeys)[selectedItemKeys.size - 1] + const lastSelectedItem = lastSelectedKey ? getItem(lastSelectedKey) : undefined doubleClicked = - now - lastClickTime.current < 400 && dragOverlayItem?.itemKey === clickedItem.itemKey + now - lastClickTime.current < 400 && lastSelectedItem?.itemKey === clickedItem.itemKey lastClickTime.current = now if (!doubleClicked || !isDoubleClickEnabled) { updateSelections({ indexes: (() => { const indexes: number[] = [] - for (let idx = 0; idx < documents.length; idx++) { - const item = documents[idx] + for (let idx = 0; idx < items.length; idx++) { + const item = items[idx] if (clickedItem.itemKey === item.itemKey) { if (keepSelected || !selectedItemKeys.has(item.itemKey)) { indexes.push(idx) @@ -516,18 +354,6 @@ export function TreeViewProvider({ }) } - if (isCurrentlySelected) { - const selectedArray = Array.from(selectedItemKeys) - const lastSelectedKey = selectedArray[selectedArray.length - 1] - if (lastSelectedKey) { - setDragOverlayItem(getItem(lastSelectedKey)) - } else { - setDragOverlayItem(undefined) - } - } else { - setDragOverlayItem(getItem(clickedItem.itemKey)) - } - if (isDoubleClickEnabled && doubleClicked) { navigateAfterSelection({ collectionSlug: clickedItem.relationTo, @@ -540,9 +366,8 @@ export function TreeViewProvider({ isDoubleClickEnabled, navigateAfterSelection, selectedItemKeys, - documents, + items, allowMultiSelection, - dragOverlayItem, getItem, updateSelections, handleShiftSelection, @@ -556,6 +381,21 @@ export function TreeViewProvider({ return } + // Optimistically update local documents + setItems((prevDocs) => + prevDocs.map((doc) => + docIDs.includes(doc.value.id) + ? { + ...doc, + value: { + ...doc.value, + parentID: parentID || null, + }, + } + : doc, + ), + ) + const queryParams = qs.stringify( { depth: 0, @@ -580,7 +420,10 @@ export function TreeViewProvider({ }, method: 'PATCH', }) + clearRouteCache() } catch (error) { + // Revert optimistic update on error + setItems(itemsFromProps) toast.error(t('general:error')) // eslint-disable-next-line no-console console.error(error) @@ -588,26 +431,55 @@ export function TreeViewProvider({ clearSelections() }, - [clearSelections, routes.api, serverURL, t, localeCode, collectionSlug, parentFieldName], + [ + clearSelections, + routes.api, + serverURL, + t, + localeCode, + collectionSlug, + parentFieldName, + itemsFromProps, + clearRouteCache, + ], + ) + + const toggleRow: TreeViewContextValue['toggleRow'] = React.useCallback( + (docID) => { + const updatedOpenDocIDs = new Set(openItemIDs) + if (updatedOpenDocIDs.has(docID)) { + updatedOpenDocIDs.delete(docID) + } else { + updatedOpenDocIDs.add(docID) + } + + setOpenDocumentIDs(updatedOpenDocIDs) + + void setPreference(`collection-${collectionSlug}-treeView`, { + expandedIDs: Array.from(updatedOpenDocIDs), + }) + clearRouteCache() + }, + [collectionSlug, openItemIDs, setPreference, clearRouteCache], ) const checkIfItemIsDisabled: TreeViewContextValue['checkIfItemIsDisabled'] = React.useCallback( (item) => { - if (isDragging) { - const isSelected = selectedItemKeys.has(item.itemKey) - if (isSelected) { - return true - } - } else if (parentTreeViewContext?.selectedItemKeys?.size) { + if (parentTreeViewContext?.selectedItemKeys?.size) { // Disable selected items from being navigated to in move to drawer if (parentTreeViewContext.selectedItemKeys.has(item.itemKey)) { return true } } }, - [isDragging, selectedItemKeys, parentTreeViewContext?.selectedItemKeys], + [parentTreeViewContext?.selectedItemKeys], ) + // Sync documents when prop changes + React.useEffect(() => { + setItems(itemsFromProps) + }, [itemsFromProps]) + // If a new component is provided, update the state so children can re-render with the new component React.useEffect(() => { if (InitialTableComponent) { @@ -620,26 +492,22 @@ export function TreeViewProvider({ value={{ checkIfItemIsDisabled, clearSelections, - documents, - dragOverlayItem, - dragStartX, + collectionSlug, focusedRowIndex, getSelectedItems, - isDragging, itemKeysToMove: parentTreeViewContext.selectedItemKeys, + items, moveItems, onItemClick, - onItemDrag, - onItemKeyPress, + openItemIDs, parentFieldName, refineTreeViewData, search, selectedItemKeys, - setDragStartX, setFocusedRowIndex, - setIsDragging, sort, TableComponent, + toggleRow, }} > {children} diff --git a/packages/ui/src/scss/app.scss b/packages/ui/src/scss/app.scss index f663bfc3991..1992fe4bac2 100644 --- a/packages/ui/src/scss/app.scss +++ b/packages/ui/src/scss/app.scss @@ -27,6 +27,7 @@ --font-serif: 'Georgia', 'Bitstream Charter', 'Charis SIL', Utopia, 'URW Bookman L', serif; --font-mono: 'SF Mono', Menlo, Consolas, Monaco, monospace; + --style-stroke-width-s: #{$style-stroke-width-s}; --style-radius-s: #{$style-radius-s}; --style-radius-m: #{$style-radius-m}; --style-radius-l: #{$style-radius-l}; diff --git a/packages/ui/src/views/CollectionTreeView/index.tsx b/packages/ui/src/views/CollectionTreeView/index.tsx index 0f33fa05271..5c609c42864 100644 --- a/packages/ui/src/views/CollectionTreeView/index.tsx +++ b/packages/ui/src/views/CollectionTreeView/index.tsx @@ -1,13 +1,9 @@ 'use client' -import type { DragEndEvent } from '@dnd-kit/core' import type { TreeViewClientProps } from 'payload' -import { useDndMonitor } from '@dnd-kit/core' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import React, { Fragment } from 'react' -import { toast } from 'sonner' import { DefaultListViewTabs } from '../../elements/DefaultListViewTabs/index.js' import { SortByPill } from '../../elements/FolderView/SortByPill/index.js' @@ -15,14 +11,7 @@ import { Gutter } from '../../elements/Gutter/index.js' import { ListHeader } from '../../elements/ListHeader/index.js' import { NoListResults } from '../../elements/NoListResults/index.js' import { SearchBar } from '../../elements/SearchBar/index.js' -import { useStepNav } from '../../elements/StepNav/index.js' -import { TreeViewDragOverlay } from '../../elements/TreeView/TreeViewDragOverlay/index.js' -import { getAllDescendantIDs } from '../../elements/TreeView/utils/getAllDescendantIDs.js' import { useConfig } from '../../providers/Config/index.js' -import { useEditDepth } from '../../providers/EditDepth/index.js' -import { usePreferences } from '../../providers/Preferences/index.js' -import { useRouteCache } from '../../providers/RouteCache/index.js' -import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { TreeViewProvider, useTreeView } from '../../providers/TreeView/index.js' import { useWindowInfo } from '../../providers/WindowInfo/index.js' @@ -32,7 +21,7 @@ const baseClass = 'collection-tree-view-list' export function DefaultCollectionTreeView({ collectionSlug, - documents, + items, parentFieldName, search, sort, @@ -42,7 +31,7 @@ export function DefaultCollectionTreeView({ return ( function CollectionTreeViewInContext(props: CollectionTreeViewInContextProps) { @@ -66,33 +55,12 @@ function CollectionTreeViewInContext(props: CollectionTreeViewInContextProps) { BeforeTreeViewListTable, collectionSlug, Description, - disableBulkDelete, - disableBulkEdit, } = props const { config, getEntityConfig } = useConfig() const { i18n, t } = useTranslation() - const drawerDepth = useEditDepth() - const { setStepNav } = useStepNav() - const { setPreference } = usePreferences() - - const { - documents, - dragOverlayItem, - getSelectedItems, - moveItems, - refineTreeViewData, - search, - selectedItemKeys, - setDragStartX, - setIsDragging, - TableComponent: ComponentToRender, - } = useTreeView() - - const router = useRouter() - const { startRouteTransition } = useRouteTransition() - const { clearRouteCache } = useRouteCache() + const { items: items, search, TableComponent: ComponentToRender } = useTreeView() const collectionConfig = getEntityConfig({ collectionSlug }) const { labels, upload } = collectionConfig @@ -103,44 +71,6 @@ function CollectionTreeViewInContext(props: CollectionTreeViewInContextProps) { breakpoints: { s: smallBreak }, } = useWindowInfo() - const onDragEnd = React.useCallback( - async (event: DragEndEvent) => { - if (!event.over) { - return - } - - if ( - event.over.data.current.type === 'tree-view-table' && - 'targetItem' in event.over.data.current - ) { - const selectedItems = getSelectedItems() - const docIDs = selectedItems.map((doc) => doc.value.id) - const targetItem = event.over.data.current.targetItem - const targetID = targetItem?.rowID - - // Validate: prevent moving a parent into its own descendant - const invalidTargets = getAllDescendantIDs(docIDs, documents) - if (targetID && invalidTargets.has(targetID)) { - toast.error(t('general:cannotMoveParentIntoChild')) - return - } - - try { - await moveItems({ - docIDs, - parentID: targetID, - }) - clearRouteCache() - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error moving items:', error) - toast.error(t('general:errorMovingItems')) - } - } - }, - [moveItems, getSelectedItems, clearRouteCache, documents, t], - ) - // React.useEffect(() => { // if (!drawerDepth) { // setStepNav([ @@ -227,12 +157,6 @@ function CollectionTreeViewInContext(props: CollectionTreeViewInContextProps) { return ( - -
{BeforeTreeViewList} @@ -268,8 +192,8 @@ function CollectionTreeViewInContext(props: CollectionTreeViewInContextProps) { searchQueryParam={search} /> {BeforeTreeViewListTable} - {documents.length > 0 && ComponentToRender} - {documents.length === 0 && ( + {items.length > 0 && ComponentToRender} + {items.length === 0 && ( {AfterTreeViewList}
- {selectedItemKeys.size > 0 && dragOverlayItem && ( - - )}
) } - -function DndEventListener({ onDragEnd, setDragStartX, setIsDragging }) { - useDndMonitor({ - onDragCancel() { - setIsDragging(false) - }, - onDragEnd(event) { - setIsDragging(false) - onDragEnd(event) - }, - onDragStart(event) { - setIsDragging(true) - // Capture the drag start X position from the active draggable's data - if (event.active?.data?.current?.dragStartX !== undefined) { - setDragStartX(event.active.data.current.dragStartX) - } - }, - }) - - return null -} From 1107f9b7498e489b96ca83c87239374376113ad8 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 9 Oct 2025 10:47:17 -0400 Subject: [PATCH 20/31] change documents verbiage to items --- .../views/CollectionTreeView/buildView.tsx | 4 ++-- packages/payload/src/treeView/types.ts | 2 +- .../src/treeView/utils/getTreeViewData.ts | 2 +- .../TreeView/NestedSectionsTable/index.scss | 1 + .../TreeView/NestedSectionsTable/index.tsx | 20 ++++++++-------- .../getTreeViewResultsComponentAndData.tsx | 14 ++++------- packages/ui/src/providers/TreeView/index.tsx | 23 ++++++++++++++++--- .../ui/src/views/CollectionTreeView/index.tsx | 2 +- 8 files changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/next/src/views/CollectionTreeView/buildView.tsx b/packages/next/src/views/CollectionTreeView/buildView.tsx index bc9178e6c0e..93af62dbd45 100644 --- a/packages/next/src/views/CollectionTreeView/buildView.tsx +++ b/packages/next/src/views/CollectionTreeView/buildView.tsx @@ -63,7 +63,7 @@ export const buildCollectionTreeView = async ( expandedIDs: (number | string)[] // sort: SortPreference }>(`collection-${collectionSlug}-treeView`, payload, user.id, payload.config.admin.user) - const { documents, TreeViewComponent } = await getTreeViewResultsComponentAndData({ + const { items, TreeViewComponent } = await getTreeViewResultsComponentAndData({ collectionSlug, expandedItemIDs: preferences?.value.expandedIDs || [], req: initPageResult.req, @@ -110,8 +110,8 @@ export const buildCollectionTreeView = async ( collectionSlug, disableBulkDelete, disableBulkEdit, - documents, enableRowSelections, + items, search, TreeViewComponent, // sort: sortPreference, diff --git a/packages/payload/src/treeView/types.ts b/packages/payload/src/treeView/types.ts index d6470a3bb37..d2904b39ce0 100644 --- a/packages/payload/src/treeView/types.ts +++ b/packages/payload/src/treeView/types.ts @@ -12,7 +12,7 @@ export type AddTreeViewFieldsArgs = { } export type GetTreeViewDataResult = { - documents: TreeViewItem[] + items: TreeViewItem[] } export type RootTreeViewConfiguration = {} diff --git a/packages/payload/src/treeView/utils/getTreeViewData.ts b/packages/payload/src/treeView/utils/getTreeViewData.ts index f974f9263ac..27e8eb7636d 100644 --- a/packages/payload/src/treeView/utils/getTreeViewData.ts +++ b/packages/payload/src/treeView/utils/getTreeViewData.ts @@ -93,6 +93,6 @@ export const getTreeViewData = async ({ } return { - documents: result, + items: result, } } diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss index 311d40b5156..b42596af42b 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss @@ -50,6 +50,7 @@ &__tree-toggle { color: var(--theme-elevation-300); + display: flex; height: 20px; width: 20px; diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx index 955450ceca8..148f2c9fea3 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx @@ -53,10 +53,6 @@ interface NestedSectionsTableProps { toggleRow?: (docID: number | string) => void } -interface DivTableHeaderProps { - columns: Column[] -} - interface DivTableSectionProps { columns: Column[] dropContextName: string @@ -206,10 +202,11 @@ export const DivTableSection: React.FC = ({ targetParentID, toggleRow, }) => { - // Helper to count all rows recursively + // Helper to count all rows recursively, only counting visible (open) rows const countRows = (items: SectionRow[]): number => { return items.reduce((count, item) => { - return count + 1 + (item.rows ? countRows(item.rows) : 0) + const isOpen = openItemIDs?.has(item.rowID) + return count + 1 + (item.rows && isOpen ? countRows(item.rows) : 0) }, 0) } @@ -218,7 +215,8 @@ export const DivTableSection: React.FC = ({ let offset = rowIndexOffset for (let i = 0; i < index; i++) { offset += 1 - if (rows[i].rows) { + const isOpen = openItemIDs?.has(rows[i].rowID) + if (rows[i].rows && isOpen) { offset += countRows(rows[i].rows) } } @@ -230,7 +228,7 @@ export const DivTableSection: React.FC = ({ {rows.map((rowItem, sectionRowIndex: number) => { const absoluteRowIndex = getAbsoluteRowIndex(sectionRowIndex) const isLastRow = rows.length - 1 === sectionRowIndex - const hasNestedRows = Boolean(rowItem?.rows?.length) + const hasNestedRows = Boolean(rowItem?.rows?.length) && openItemIDs?.has(rowItem.rowID) const isRowAtRootLevel = level === 0 || (isLastRow && isLastRowOfRoot) // Calculate drop target items based on position in hierarchy @@ -336,7 +334,7 @@ export const DivTableSection: React.FC = ({ width: '100%', }} > - {rowItem.hasChildren || hasNestedRows ? ( + {rowItem.hasChildren || rowItem.rows?.length ? ( ) : (
diff --git a/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx b/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx index 9be1529782d..bf29cf3a897 100644 --- a/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx +++ b/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx @@ -1,9 +1,5 @@ -import type { - Document, - ErrorResult, - GetTreeViewResultsComponentAndDataArgs, - ServerFunction, -} from 'payload' +import type { ErrorResult, GetTreeViewResultsComponentAndDataArgs, ServerFunction } from 'payload' +import type { TreeViewItem } from 'payload/shared' import { APIError, formatErrors, getTreeViewData } from 'payload' @@ -13,12 +9,12 @@ import { } from '../../exports/client/index.js' type GetTreeViewResultsComponentAndDataResult = { - documents?: Document[] + items?: TreeViewItem[] TreeViewComponent: React.ReactNode } type GetTreeViewResultsComponentAndDataErrorResult = { - documents?: never + items?: never } & ( | { message: string @@ -74,7 +70,7 @@ export const getTreeViewResultsComponentAndData = async ({ const TreeViewComponent = return { - documents: treeViewData.documents, + items: treeViewData.items, TreeViewComponent, } } diff --git a/packages/ui/src/providers/TreeView/index.tsx b/packages/ui/src/providers/TreeView/index.tsx index 99da600ac0a..3ab6285ff0a 100644 --- a/packages/ui/src/providers/TreeView/index.tsx +++ b/packages/ui/src/providers/TreeView/index.tsx @@ -133,7 +133,7 @@ export function TreeViewProvider({ const currentlySelectedIndexes = React.useRef(new Set()) const [items, setItems] = React.useState(itemsFromProps) - const [openItemIDs, setOpenDocumentIDs] = React.useState>(() => new Set()) + const [openItemIDs, setOpenItemIDs] = React.useState>(() => new Set()) const [TableComponent, setTableComponentToRender] = React.useState( InitialTableComponent || (() => null), ) @@ -448,19 +448,36 @@ export function TreeViewProvider({ (docID) => { const updatedOpenDocIDs = new Set(openItemIDs) if (updatedOpenDocIDs.has(docID)) { + // When closing a parent, also close all its descendants updatedOpenDocIDs.delete(docID) + + // Find all descendant IDs and remove them from the open set + const descendantIDs = new Set() + const collectDescendants = (parentID: number | string) => { + items.forEach((item) => { + if (item.value.parentID === parentID) { + descendantIDs.add(item.value.id) + collectDescendants(item.value.id) + } + }) + } + collectDescendants(docID) + + descendantIDs.forEach((id) => { + updatedOpenDocIDs.delete(id) + }) } else { updatedOpenDocIDs.add(docID) } - setOpenDocumentIDs(updatedOpenDocIDs) + setOpenItemIDs(updatedOpenDocIDs) void setPreference(`collection-${collectionSlug}-treeView`, { expandedIDs: Array.from(updatedOpenDocIDs), }) clearRouteCache() }, - [collectionSlug, openItemIDs, setPreference, clearRouteCache], + [collectionSlug, openItemIDs, items, setPreference, clearRouteCache], ) const checkIfItemIsDisabled: TreeViewContextValue['checkIfItemIsDisabled'] = React.useCallback( diff --git a/packages/ui/src/views/CollectionTreeView/index.tsx b/packages/ui/src/views/CollectionTreeView/index.tsx index 5c609c42864..32d844ff730 100644 --- a/packages/ui/src/views/CollectionTreeView/index.tsx +++ b/packages/ui/src/views/CollectionTreeView/index.tsx @@ -60,7 +60,7 @@ function CollectionTreeViewInContext(props: CollectionTreeViewInContextProps) { const { config, getEntityConfig } = useConfig() const { i18n, t } = useTranslation() - const { items: items, search, TableComponent: ComponentToRender } = useTreeView() + const { items, search, TableComponent: ComponentToRender } = useTreeView() const collectionConfig = getEntityConfig({ collectionSlug }) const { labels, upload } = collectionConfig From 915f659549bb362bcc06d861d6918b71391e7e4a Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 9 Oct 2025 11:11:38 -0400 Subject: [PATCH 21/31] update loading state for rows --- .../TreeView/NestedSectionsTable/index.scss | 69 +++++++++++++++++++ .../TreeView/NestedSectionsTable/index.tsx | 20 +++++- .../elements/TreeView/TreeViewTable/index.tsx | 2 + .../getTreeViewResultsComponentAndData.tsx | 2 +- packages/ui/src/providers/TreeView/index.tsx | 26 ++++++- 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss index b42596af42b..7d6bbe078fe 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss @@ -1,3 +1,39 @@ +@keyframes spinner-bar--1 { + 0% { + transform: translateY(-1px); + } + 50% { + transform: translateY(1px); + } + 100% { + transform: translateY(-1px); + } +} + +@keyframes spinner-bar--2 { + 0% { + transform: translateY(1px); + } + 50% { + transform: translateY(-1px); + } + 100% { + transform: translateY(1px); + } +} + +@keyframes spinner-bar--3 { + 0% { + transform: translateY(-1px); + } + 50% { + transform: translateY(1px); + } + 100% { + transform: translateY(-1px); + } +} + .nested-sections-table-wrapper { width: 100%; overflow-x: auto; @@ -53,6 +89,12 @@ display: flex; height: 20px; width: 20px; + justify-content: center; + align-items: center; + + .btn__label { + display: flex; + } &:hover { color: var(--theme-elevation-600); @@ -65,6 +107,33 @@ display: inline-block; } + &__tree-toggle-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + height: 12px; + } + + &__spinner-bar { + width: 1px; + background-color: currentColor; + height: 8px; + border-radius: 3px; + + &:nth-child(1) { + animation: spinner-bar--1 1s infinite ease-in-out; + } + + &:nth-child(2) { + animation: spinner-bar--2 1s infinite ease-in-out; + } + + &:nth-child(3) { + animation: spinner-bar--3 1s infinite ease-in-out; + } + } + &__cell { display: table-cell; padding: 8px 12px; diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx index 148f2c9fea3..1ccffacd561 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx @@ -29,6 +29,7 @@ interface NestedSectionsTableProps { initialOffset?: number invalidTargetIDs?: Set isDragging?: boolean + loadingRowIDs?: Set onDroppableHover: (params: { hoveredRowID?: number | string placement?: string @@ -64,6 +65,7 @@ interface DivTableSectionProps { isDragging: boolean isLastRowOfRoot?: boolean level?: number + loadingRowIDs?: Set onDroppableHover: (params: { hoveredRowID?: number | string placement?: string @@ -101,6 +103,7 @@ export const NestedSectionsTable: React.FC = ({ hoveredRowID, invalidTargetIDs, isDragging = false, + loadingRowIDs, onDroppableHover, onRowClick, onRowDrag, @@ -160,6 +163,7 @@ export const NestedSectionsTable: React.FC = ({ hoveredRowID={hoveredRowID} invalidTargetIDs={invalidTargetIDs} isDragging={isDragging} + loadingRowIDs={loadingRowIDs} onDroppableHover={onDroppableHover} onRowClick={onRowClick} onRowDrag={onRowDrag} @@ -189,6 +193,7 @@ export const DivTableSection: React.FC = ({ isDragging, isLastRowOfRoot = false, level = 0, + loadingRowIDs, onDroppableHover, onRowClick, onRowDrag, @@ -345,9 +350,17 @@ export const DivTableSection: React.FC = ({ }} size="small" > - + {loadingRowIDs?.has(rowItem.rowID) ? ( +
+
+
+
+
+ ) : ( + + )} ) : (
@@ -422,6 +435,7 @@ export const DivTableSection: React.FC = ({ isDragging={isDragging} isLastRowOfRoot={isRowAtRootLevel} level={level + 1} + loadingRowIDs={loadingRowIDs} onDroppableHover={onDroppableHover} onRowClick={onRowClick} onRowDrag={onRowDrag} diff --git a/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx b/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx index 14bd1b76c85..203e5b884e3 100644 --- a/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx +++ b/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx @@ -25,6 +25,7 @@ export function TreeViewTable() { collectionSlug, getSelectedItems, items, + loadingRowIDs, moveItems, onItemClick, openItemIDs, @@ -362,6 +363,7 @@ export function TreeViewTable() { hoveredRowID={hoveredRowID} invalidTargetIDs={invalidTargetIDs} isDragging={isDragging} + loadingRowIDs={loadingRowIDs} onDroppableHover={onDroppableHover} onRowClick={onRowClick} onRowDrag={onRowDrag} diff --git a/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx b/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx index bf29cf3a897..0c1f3e9e440 100644 --- a/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx +++ b/packages/ui/src/elements/TreeView/getTreeViewResultsComponentAndData.tsx @@ -67,7 +67,7 @@ export const getTreeViewResultsComponentAndData = async ({ sort, }) - const TreeViewComponent = + const TreeViewComponent = return { items: treeViewData.items, diff --git a/packages/ui/src/providers/TreeView/index.tsx b/packages/ui/src/providers/TreeView/index.tsx index 3ab6285ff0a..4f66b702eee 100644 --- a/packages/ui/src/providers/TreeView/index.tsx +++ b/packages/ui/src/providers/TreeView/index.tsx @@ -31,6 +31,7 @@ export type TreeViewContextValue = { getSelectedItems?: () => TreeViewItem[] itemKeysToMove?: Set items?: TreeViewItem[] + loadingRowIDs: Set moveItems: (args: { docIDs: (number | string)[]; parentID?: number | string }) => Promise onItemClick: (args: { event: React.MouseEvent @@ -57,6 +58,7 @@ const Context = React.createContext({ getSelectedItems: () => [], itemKeysToMove: undefined, items: [], + loadingRowIDs: new Set(), moveItems: () => Promise.resolve(undefined), onItemClick: () => undefined, openItemIDs: new Set(), @@ -134,6 +136,7 @@ export function TreeViewProvider({ const [items, setItems] = React.useState(itemsFromProps) const [openItemIDs, setOpenItemIDs] = React.useState>(() => new Set()) + const [loadingRowIDs, setLoadingRowIDs] = React.useState>(() => new Set()) const [TableComponent, setTableComponentToRender] = React.useState( InitialTableComponent || (() => null), ) @@ -447,6 +450,8 @@ export function TreeViewProvider({ const toggleRow: TreeViewContextValue['toggleRow'] = React.useCallback( (docID) => { const updatedOpenDocIDs = new Set(openItemIDs) + const isOpening = !updatedOpenDocIDs.has(docID) + if (updatedOpenDocIDs.has(docID)) { // When closing a parent, also close all its descendants updatedOpenDocIDs.delete(docID) @@ -466,8 +471,24 @@ export function TreeViewProvider({ descendantIDs.forEach((id) => { updatedOpenDocIDs.delete(id) }) + + // Also deselect all descendant items + setSelectedItemKeys((prevSelectedKeys) => { + const newSelectedKeys = new Set(prevSelectedKeys) + descendantIDs.forEach((id) => { + // Find the item key for this ID + const item = items.find((i) => i.value.id === id) + if (item) { + newSelectedKeys.delete(item.itemKey) + } + }) + return newSelectedKeys + }) } else { updatedOpenDocIDs.add(docID) + + // Add to loading state when opening + setLoadingRowIDs((prev) => new Set(prev).add(docID)) } setOpenItemIDs(updatedOpenDocIDs) @@ -492,9 +513,11 @@ export function TreeViewProvider({ [parentTreeViewContext?.selectedItemKeys], ) - // Sync documents when prop changes + // Sync documents when prop changes and clear loading state React.useEffect(() => { setItems(itemsFromProps) + // Clear loading state since new data has arrived + setLoadingRowIDs(new Set()) }, [itemsFromProps]) // If a new component is provided, update the state so children can re-render with the new component @@ -514,6 +537,7 @@ export function TreeViewProvider({ getSelectedItems, itemKeysToMove: parentTreeViewContext.selectedItemKeys, items, + loadingRowIDs, moveItems, onItemClick, openItemIDs, From b94dc77596f8d52a85a65e2c07ce8d655a313f2e Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 9 Oct 2025 12:10:44 -0400 Subject: [PATCH 22/31] fix loading sticking around --- .../elements/TreeView/NestedSectionsTable/index.scss | 10 ++++++++-- .../elements/TreeView/NestedSectionsTable/index.tsx | 9 ++++++--- packages/ui/src/providers/TreeView/index.tsx | 11 ++++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss index 7d6bbe078fe..b28dc001049 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss @@ -85,7 +85,7 @@ } &__tree-toggle { - color: var(--theme-elevation-300); + color: var(--theme-elevation-600); display: flex; height: 20px; width: 20px; @@ -97,7 +97,7 @@ } &:hover { - color: var(--theme-elevation-600); + color: var(--theme-elevation-400); } } @@ -158,6 +158,12 @@ background: var(--theme-elevation-0); } + &__section--target { + outline: 1px solid var(--theme-success-400); + border-radius: var(--style-radius-s); + z-index: 1; + } + &__section--hovered { z-index: 1; outline: 1px solid var(--theme-elevation-200); diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx index 1ccffacd561..4cb9eda20ff 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx @@ -268,9 +268,12 @@ export const DivTableSection: React.FC = ({ const renderResult = (
{ diff --git a/packages/ui/src/providers/TreeView/index.tsx b/packages/ui/src/providers/TreeView/index.tsx index 4f66b702eee..86e8602a386 100644 --- a/packages/ui/src/providers/TreeView/index.tsx +++ b/packages/ui/src/providers/TreeView/index.tsx @@ -450,7 +450,6 @@ export function TreeViewProvider({ const toggleRow: TreeViewContextValue['toggleRow'] = React.useCallback( (docID) => { const updatedOpenDocIDs = new Set(openItemIDs) - const isOpening = !updatedOpenDocIDs.has(docID) if (updatedOpenDocIDs.has(docID)) { // When closing a parent, also close all its descendants @@ -472,6 +471,9 @@ export function TreeViewProvider({ updatedOpenDocIDs.delete(id) }) + // Remove descendant items from the items array + setItems((prevItems) => prevItems.filter((item) => !descendantIDs.has(item.value.id))) + // Also deselect all descendant items setSelectedItemKeys((prevSelectedKeys) => { const newSelectedKeys = new Set(prevSelectedKeys) @@ -527,6 +529,13 @@ export function TreeViewProvider({ } }, [InitialTableComponent]) + React.useEffect( + () => () => { + setLoadingRowIDs(new Set()) + }, + [], + ) + return ( Date: Thu, 9 Oct 2025 15:13:59 -0400 Subject: [PATCH 23/31] fix: expand items from prefs --- .../views/CollectionTreeView/buildView.tsx | 12 ++++++++-- .../payload/src/admin/views/treeViewList.ts | 1 + .../payload/src/treeView/addTreeViewFields.ts | 1 + .../src/treeView/utils/getTreeViewData.ts | 9 +++++++ .../TreeView/NestedSectionsTable/index.scss | 24 +++++++++++++++---- .../TreeView/NestedSectionsTable/index.tsx | 1 + .../TreeView/SeedDataButton/index.tsx | 18 +++++++------- .../elements/TreeView/TreeViewTable/index.tsx | 4 +--- packages/ui/src/providers/TreeView/index.tsx | 12 ++++++---- .../ui/src/views/CollectionTreeView/index.tsx | 2 ++ 10 files changed, 61 insertions(+), 23 deletions(-) diff --git a/packages/next/src/views/CollectionTreeView/buildView.tsx b/packages/next/src/views/CollectionTreeView/buildView.tsx index 93af62dbd45..83bdb170fda 100644 --- a/packages/next/src/views/CollectionTreeView/buildView.tsx +++ b/packages/next/src/views/CollectionTreeView/buildView.tsx @@ -1,4 +1,9 @@ -import type { AdminViewServerProps, BuildCollectionFolderViewResult, ListQuery } from 'payload' +import type { + AdminViewServerProps, + BuildCollectionFolderViewResult, + ListQuery, + TreeViewClientProps, +} from 'payload' import { DefaultCollectionTreeView, HydrateAuthProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' @@ -107,15 +112,18 @@ export const buildCollectionTreeView = async ( {RenderServerComponent({ clientProps: { // ...folderViewSlots, + breadcrumbs: [], collectionSlug, disableBulkDelete, disableBulkEdit, enableRowSelections, + expandedItemIDs: preferences?.value.expandedIDs || [], items, + parentFieldName: '_parentDoc', search, TreeViewComponent, // sort: sortPreference, - }, + } satisfies TreeViewClientProps, // Component: collectionConfig?.admin?.components?.views?.TreeView?.Component, Fallback: DefaultCollectionTreeView, importMap: payload.importMap, diff --git a/packages/payload/src/admin/views/treeViewList.ts b/packages/payload/src/admin/views/treeViewList.ts index 536857c1970..a4b2cf5d5b0 100644 --- a/packages/payload/src/admin/views/treeViewList.ts +++ b/packages/payload/src/admin/views/treeViewList.ts @@ -25,6 +25,7 @@ export type TreeViewClientProps = { disableBulkDelete?: boolean disableBulkEdit?: boolean enableRowSelections?: boolean + expandedItemIDs?: (number | string)[] items: TreeViewItem[] parentFieldName: string search?: string diff --git a/packages/payload/src/treeView/addTreeViewFields.ts b/packages/payload/src/treeView/addTreeViewFields.ts index 082c54452eb..f7a1a320bd6 100644 --- a/packages/payload/src/treeView/addTreeViewFields.ts +++ b/packages/payload/src/treeView/addTreeViewFields.ts @@ -27,6 +27,7 @@ export function addTreeViewFields({ admin: { disableBulkEdit: true, }, + defaultValue: () => null, filterOptions: ({ id }) => { return { id: { diff --git a/packages/payload/src/treeView/utils/getTreeViewData.ts b/packages/payload/src/treeView/utils/getTreeViewData.ts index 27e8eb7636d..aa85545be1b 100644 --- a/packages/payload/src/treeView/utils/getTreeViewData.ts +++ b/packages/payload/src/treeView/utils/getTreeViewData.ts @@ -27,7 +27,10 @@ export const getTreeViewData = async ({ collection: collectionSlug, depth: 0, limit: 100, + overrideAccess: false, + req, sort, + user: req.user, where: { or: [ { @@ -35,6 +38,11 @@ export const getTreeViewData = async ({ exists: false, }, }, + { + [parentFieldName]: { + equals: null, + }, + }, { [parentFieldName]: { in: expandedItemIDs, @@ -52,6 +60,7 @@ export const getTreeViewData = async ({ value: doc, }), ) + console.log('docs', result) // Identify parent IDs and potential leaf nodes const parentIDsInResult = new Set() diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss index b28dc001049..3d8730551ac 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.scss @@ -42,7 +42,6 @@ .nested-sections-table { display: table; min-width: 100%; - border-collapse: collapse; --cell-inline-padding-start: calc(var(--base) * 0.6); --cell-inline-padding-end: calc(var(--base) * 0.6); @@ -139,11 +138,21 @@ padding: 8px 12px; vertical-align: middle; position: relative; + background: var(--cell-bg-color); + border-top: 1px solid var(--cell-border-color); + border-bottom: 1px solid var(--cell-border-color); + &:first-child { padding-inline-start: calc(var(--base) * (0.8)); + border-left: 1px solid var(--cell-border-color); + border-top-left-radius: var(--style-radius-s); + border-bottom-left-radius: var(--style-radius-s); } &:last-child { padding-inline-end: calc(var(--base) * (0.8)); + border-right: 1px solid var(--cell-border-color); + border-top-right-radius: var(--style-radius-s); + border-bottom-right-radius: var(--style-radius-s); } } @@ -151,11 +160,13 @@ position: relative; display: table-row-group; outline: 1px solid transparent; - background: var(--theme-elevation-50); + --cell-bg-color: var(--theme-elevation-50); + --cell-border-color: var(--theme-elevation-50); } &__section--odd { - background: var(--theme-elevation-0); + --cell-bg-color: var(--theme-elevation-0); + --cell-border-color: var(--theme-elevation-0); } &__section--target { @@ -174,7 +185,12 @@ &__section--selected, &__section:nth-child(odd).nested-sections-table__section--selected { - background-color: var(--theme-success-50); + --cell-bg-color: var(--theme-success-50); + --cell-border-color: var(--theme-success-500); + } + + &__section--selected.nested-sections-table__section--dragging { + opacity: 0.4; } &__placeholder-section { diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx index 4cb9eda20ff..0c97742b791 100644 --- a/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx +++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/index.tsx @@ -270,6 +270,7 @@ export const DivTableSection: React.FC = ({
{ setIsLoading(true) try { - const response = await fetch( - `${serverURL}${routes.api}/${collectionSlug}/seed-data`, - { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, + const response = await fetch(`${serverURL}${routes.api}/${collectionSlug}/seed-data`, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', }, - ) + method: 'POST', + }) if (!response.ok) { throw new Error('Failed to seed data') @@ -48,8 +45,9 @@ export function SeedDataButton({ collectionSlug }: SeedDataButtonProps) { return (