diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 9e329073234..693abe7ae81 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -83,6 +83,7 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col collection.endpoints = collection.endpoints ?? [] collection.fields = collection.fields ?? [] collection.folders = collection.folders ?? false + collection.hierarchy = collection.hierarchy ?? false collection.hooks = { afterChange: [], diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b073c8ee992..cdaaa4ca071 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -25,6 +25,7 @@ import { addDefaultsToLoginWithUsernameConfig, } from './defaults.js' import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js' +import { sanitizeHierarchy } from './sanitizeHierarchy.js' import { validateUseAsTitle } from './useAsTitle.js' export const sanitizeCollection = async ( @@ -207,6 +208,8 @@ export const sanitizeCollection = async ( sanitized.folders.browseByFolder = sanitized.folders.browseByFolder ?? true } + sanitizeHierarchy(sanitized, config) + if (sanitized.upload) { if (sanitized.upload === true) { sanitized.upload = {} diff --git a/packages/payload/src/collections/config/sanitizeHierarchy.ts b/packages/payload/src/collections/config/sanitizeHierarchy.ts new file mode 100644 index 00000000000..323fa86ebf6 --- /dev/null +++ b/packages/payload/src/collections/config/sanitizeHierarchy.ts @@ -0,0 +1,102 @@ +import type { Config } from '../../config/types.js' +import type { CollectionConfig } from './types.js' + +import { fieldAffectsData } from '../../fields/config/types.js' +import { addHierarchyToCollection } from '../../hierarchy/addHierarchyToCollection.js' +import { buildParentField } from '../../hierarchy/buildParentField.js' + +/** + * Sanitize and apply hierarchy configuration to a collection config + * + * + * @param collectionConfig + * @param config + * @returns + */ +export const sanitizeHierarchy = (collectionConfig: CollectionConfig, config: Config): void => { + if (!collectionConfig.hierarchy) { + return + } + + // Normalize boolean to object + if (collectionConfig.hierarchy === true) { + collectionConfig.hierarchy = { + parentFieldName: 'parent', + } + } + + const parentFieldName = collectionConfig.hierarchy.parentFieldName + + // Check if parent field already exists + const existingParentField = collectionConfig.fields.find( + (field) => fieldAffectsData(field) && field.name === parentFieldName, + ) + + if (existingParentField) { + // Validate existing parent field configuration + if (existingParentField.type !== 'relationship') { + throw new Error( + `Hierarchy parent field "${parentFieldName}" in collection "${collectionConfig.slug}" must be a relationship field`, + ) + } + + if (existingParentField.relationTo !== collectionConfig.slug) { + throw new Error( + `Hierarchy parent field "${parentFieldName}" in collection "${collectionConfig.slug}" must relate to the same collection (expected relationTo: "${collectionConfig.slug}", got: "${existingParentField.relationTo}")`, + ) + } + + if (existingParentField.hasMany !== false) { + throw new Error( + `Hierarchy parent field "${parentFieldName}" in collection "${collectionConfig.slug}" must have hasMany set to false`, + ) + } + } else { + // Auto-create parent field if it doesn't exist + const parentField = buildParentField({ + collectionSlug: collectionConfig.slug, + parentFieldName, + }) + + collectionConfig.fields.unshift(parentField) + } + + // Apply hierarchy to collection (adds fields and hooks) + const generatePaths = collectionConfig.hierarchy.generatePaths ?? true + + const hierarchyOptions: { + collectionConfig: typeof collectionConfig + config: typeof config + generatePaths: boolean + parentFieldName: string + slugify?: (text: string) => string + slugPathFieldName?: string + titlePathFieldName?: string + } = { + collectionConfig, + config, + generatePaths, + parentFieldName: collectionConfig.hierarchy.parentFieldName, + } + + if (collectionConfig.hierarchy.slugify) { + hierarchyOptions.slugify = collectionConfig.hierarchy.slugify + } + if (collectionConfig.hierarchy.slugPathFieldName) { + hierarchyOptions.slugPathFieldName = collectionConfig.hierarchy.slugPathFieldName + } + if (collectionConfig.hierarchy.titlePathFieldName) { + hierarchyOptions.titlePathFieldName = collectionConfig.hierarchy.titlePathFieldName + } + + addHierarchyToCollection(hierarchyOptions) + + // Set sanitized hierarchy config with defaults + collectionConfig.hierarchy = { + generatePaths, + parentFieldName: collectionConfig.hierarchy.parentFieldName, + slugPathFieldName: hierarchyOptions.slugPathFieldName || '_h_slugPath', + titlePathFieldName: hierarchyOptions.titlePathFieldName || '_h_titlePath', + ...(collectionConfig.hierarchy.slugify && { slugify: collectionConfig.hierarchy.slugify }), + } +} diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 6c4d7a153d1..a33c5ddf170 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -34,6 +34,7 @@ import type { UploadField, } from '../../fields/config/types.js' import type { CollectionFoldersConfiguration } from '../../folders/types.js' +import type { HierarchyConfig, SanitizedHierarchyConfig } from '../../hierarchy/types.js' import type { CollectionSlug, JsonObject, @@ -158,11 +159,13 @@ export type AfterChangeHook = (args: { context: RequestContext data: Partial doc: T + docWithLocales: T /** * Hook operation being performed */ operation: CreateOrUpdateOperation previousDoc: T + previousDocWithLocales: T req: PayloadRequest }) => any @@ -180,6 +183,7 @@ export type AfterReadHook = (args: { collection: SanitizedCollectionConfig context: RequestContext doc: T + docWithLocales: T findMany?: boolean query?: { [key: string]: any } req: PayloadRequest @@ -558,6 +562,25 @@ export type CollectionConfig = { singularName?: string } | false + /** + * Enable hierarchical tree structure for this collection + * + * Use `true` to enable with defaults (auto-detects parent field) + * or provide configuration object + * + * @example + * // Enable with defaults + * hierarchy: true + * + * @example + * // Customize field names and slugify function + * hierarchy: { + * parentFieldName: 'parent', + * slugify: (text) => customSlugify(text), + * slugPathFieldName: '_breadcrumbPath' + * } + */ + hierarchy?: boolean | HierarchyConfig /** * Hooks to modify Payload functionality */ @@ -701,7 +724,15 @@ export type SanitizedJoins = { export interface SanitizedCollectionConfig extends Omit< DeepRequired, - 'admin' | 'auth' | 'endpoints' | 'fields' | 'folders' | 'slug' | 'upload' | 'versions' + | 'admin' + | 'auth' + | 'endpoints' + | 'fields' + | 'folders' + | 'hierarchy' + | 'slug' + | 'upload' + | 'versions' > { admin: CollectionAdminOptions auth: Auth @@ -716,6 +747,7 @@ export interface SanitizedCollectionConfig * Object of collections to join 'Join Fields object keyed by collection */ folders: CollectionFoldersConfiguration | false + hierarchy: false | SanitizedHierarchyConfig joins: SanitizedJoins /** diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 98b6ed550af..3699edf746b 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -221,7 +221,7 @@ export const createOperation = async < // beforeChange - Fields // ///////////////////////////////////// - const resultWithLocales = await beforeChange({ + const dataWithLocales = await beforeChange({ collection: collectionConfig, context: req.context, data, @@ -259,13 +259,13 @@ export const createOperation = async < if (collectionConfig.auth && !collectionConfig.auth.disableLocalStrategy) { if (collectionConfig.auth.verify) { - resultWithLocales._verified = Boolean(resultWithLocales._verified) || false - resultWithLocales._verificationToken = crypto.randomBytes(20).toString('hex') + dataWithLocales._verified = Boolean(dataWithLocales._verified) || false + dataWithLocales._verificationToken = crypto.randomBytes(20).toString('hex') } doc = await registerLocalStrategy({ collection: collectionConfig, - doc: resultWithLocales, + doc: dataWithLocales, password: data.password as string, payload: req.payload, req, @@ -273,13 +273,13 @@ export const createOperation = async < } else { doc = await payload.db.create({ collection: collectionConfig.slug, - data: resultWithLocales, + data: dataWithLocales, req, }) } const verificationToken = doc._verificationToken - let result: Document = sanitizeInternalFields(doc) + const resultWithLocales: Document = sanitizeInternalFields(doc) // ///////////////////////////////////// // Create version @@ -287,10 +287,10 @@ export const createOperation = async < if (collectionConfig.versions) { await saveVersion({ - id: result.id, + id: resultWithLocales.id, autosave, collection: collectionConfig, - docWithLocales: result, + docWithLocales: resultWithLocales, operation: 'create', payload, publishSpecificLocale, @@ -302,7 +302,7 @@ export const createOperation = async < // Send verification email if applicable // ///////////////////////////////////// - if (collectionConfig.auth && collectionConfig.auth.verify && result.email) { + if (collectionConfig.auth && collectionConfig.auth.verify && resultWithLocales.email) { await sendVerificationEmail({ collection: { config: collectionConfig }, config: payload.config, @@ -310,7 +310,7 @@ export const createOperation = async < email: payload.email, req, token: verificationToken, - user: result, + user: resultWithLocales, }) } @@ -318,11 +318,11 @@ export const createOperation = async < // afterRead - Fields // ///////////////////////////////////// - result = await afterRead({ + let result: Document = await afterRead({ collection: collectionConfig, context: req.context, depth: depth!, - doc: result, + doc: resultWithLocales, draft, fallbackLocale: fallbackLocale!, global: null, @@ -345,6 +345,7 @@ export const createOperation = async < collection: collectionConfig, context: req.context, doc: result, + docWithLocales: resultWithLocales, req, })) || result } @@ -377,8 +378,10 @@ export const createOperation = async < context: req.context, data, doc: result, + docWithLocales: resultWithLocales, operation: 'create', previousDoc: {}, + previousDocWithLocales: {}, req: args.req, })) || result } diff --git a/packages/payload/src/collections/operations/findByID.ts b/packages/payload/src/collections/operations/findByID.ts index 7ca512828ec..38ae05a368c 100644 --- a/packages/payload/src/collections/operations/findByID.ts +++ b/packages/payload/src/collections/operations/findByID.ts @@ -170,9 +170,9 @@ export const findByIDOperation = async < throw new NotFound(t) } - const docFromDB = await req.payload.db.findOne(findOneArgs) + const docWithLocales = await req.payload.db.findOne(findOneArgs) - if (!docFromDB && !args.data) { + if (!docWithLocales && !args.data) { if (!disableErrors) { throw new NotFound(req.t) } @@ -180,7 +180,7 @@ export const findByIDOperation = async < } let result: DataFromCollectionSlug = - (args.data as DataFromCollectionSlug) ?? docFromDB! + (args.data as DataFromCollectionSlug) ?? docWithLocales! // ///////////////////////////////////// // Include Lock Status if required @@ -303,6 +303,7 @@ export const findByIDOperation = async < collection: collectionConfig, context: req.context, doc: result, + docWithLocales, query: findOneArgs.where, req, })) || result diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index c50f5d93bcf..62322397538 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -294,10 +294,12 @@ export const updateDocument = async < // Update // ///////////////////////////////////// + let resultWithLocales: JsonObject = result + if (!isSavingDraft) { // Ensure updatedAt date is always updated dataToUpdate.updatedAt = new Date().toISOString() - result = await req.payload.db.updateOne({ + resultWithLocales = await req.payload.db.updateOne({ id, collection: collectionConfig.slug, data: dataToUpdate, @@ -311,7 +313,7 @@ export const updateDocument = async < // ///////////////////////////////////// if (collectionConfig.versions) { - result = await saveVersion({ + resultWithLocales = await saveVersion({ id, autosave, collection: collectionConfig, @@ -333,7 +335,7 @@ export const updateDocument = async < collection: collectionConfig, context: req.context, depth, - doc: result, + doc: resultWithLocales, draft: draftArg, fallbackLocale, global: null, @@ -356,6 +358,7 @@ export const updateDocument = async < collection: collectionConfig, context: req.context, doc: result, + docWithLocales: resultWithLocales, req, })) || result } @@ -388,8 +391,10 @@ export const updateDocument = async < context: req.context, data, doc: result, + docWithLocales: resultWithLocales, operation: 'update', previousDoc: originalDoc, + previousDocWithLocales: docWithLocales, req, })) || result } diff --git a/packages/payload/src/hierarchy/README.md b/packages/payload/src/hierarchy/README.md new file mode 100644 index 00000000000..c66f698d20d --- /dev/null +++ b/packages/payload/src/hierarchy/README.md @@ -0,0 +1,563 @@ +# Hierarchy System + +> Automatic tree structure management for Payload collections + +## Quick Start + +The hierarchy system provides tree/nested structure support for any Payload collection. When enabled, it automatically maintains tree integrity, generates breadcrumb paths, and enables efficient descendant queries. + +**Use it for:** Folders, nested pages, categories, organizational structures, or any hierarchical data. + +**Basic Example:** + +```typescript +// Collection with hierarchy enabled +{ + id: 'doc-c', + name: 'Current Document', + parent: 'doc-b', // User-provided relationship field + + // Auto-generated by hierarchy system: + _h_parentTree: ['doc-a', 'doc-b'], // Ancestors (excluding self) + _h_depth: 2, // Third level (0-indexed from root) + _h_slugPath: 'grandparent/parent/current-document', + _h_titlePath: 'Grandparent/Parent/Current Document' +} +``` + +## How It Works + +### Four Internal Fields + +When hierarchy is enabled on a collection, four fields are automatically added: + +#### 1. `_h_parentTree` (Array of IDs) + +- **Purpose:** Stores the complete ancestor chain +- **Format:** `[grandparent, parent]` - ancestors only, **excluding self** +- **Use Case:** Enables efficient "find all descendants" queries +- **Example Query:** `{ _h_parentTree: { in: ['parent-id'] } }` finds all descendants + +#### 2. `_h_depth` (Number) + +- **Purpose:** Stores the depth/level of the document in the tree +- **Format:** `0` = root level, `1` = first level, `2` = second level, etc. +- **Indexed:** Yes, for fast depth-based queries +- **Use Case:** Filter by depth, limit tree expansion in UI, indentation + +#### 3. `_h_slugPath` (String or Localized Object) + +- **Purpose:** Slugified breadcrumb path for URLs and search +- **Format:** `"grandparent/parent/current"` or `{ en: "...", fr: "..." }` +- **Indexed:** Yes, for fast path-based queries +- **Use Case:** URL generation, prefix searches, breadcrumb display + +#### 4. `_h_titlePath` (String or Localized Object) + +- **Purpose:** Human-readable breadcrumb path +- **Format:** `"Grandparent/Parent/Current"` or `{ en: "...", fr: "..." }` +- **Indexed:** Yes, for display and search +- **Use Case:** UI breadcrumbs, search results, admin list view + +### Automatic Tree Maintenance + +When a document's parent or title changes: + +1. **Document Update:** The document's tree data is recalculated +2. **Descendant Cascade:** ALL descendants are updated recursively +3. **Batch Processing:** Descendants processed in batches of 100 (handles unlimited depth) +4. **Path Regeneration:** All path fields regenerated for consistency + +**Example:** + +```typescript +// Move "Page C" from under "Page A" to under "Page B" +// Before: A → C → D → E +// After: B → C → D → E + +// Updates automatically: +// - C's _h_parentTree: [A] → [B] +// - D's _h_parentTree: [A, C] → [B, C] +// - E's _h_parentTree: [A, C, D] → [B, C, D] +// - All path fields updated for C, D, and E +``` + +## Performance Optimizations + +### 1. Title-Only Change Optimization + +**Problem:** Renaming a document should not require fetching its parent from the database. + +**Solution:** When only the title changes (parent unchanged), derive the parent's path by stripping the last segment from the previous document's path. + +```typescript +// Title changed from "Old Name" to "New Name" +// Previous path: "grandparent/parent/old-name" +// Derived parent path: "grandparent/parent" (strip last segment) +// New path: "grandparent/parent/new-name" +// ✅ No database query needed +``` + +**Performance Impact:** + +- **Before:** 2 DB queries (fetch parent + update document) +- **After:** 1 DB query (update document only) +- **Improvement:** 50% reduction + +See [HIERARCHY_OPTIMIZATION.md](./HIERARCHY_OPTIMIZATION.md) for details. + +### 2. Batch Processing + +**Problem:** Deep trees with thousands of descendants could cause memory issues. + +**Solution:** Process descendants in batches of 100, paginating through all results. + +```typescript +// Handles unlimited tree depth: +// Root → 1000 children → 1000 grandchildren → ... +// Processes 100 at a time using hasNextPage pagination +``` + +### 3. Direct Database Updates + +**Problem:** Running hooks on every descendant update creates N versions and is slow. + +**Solution:** Use `req.payload.db.updateOne()` directly for descendant updates, bypassing hooks and access control. + +**Trade-off:** Descendants don't trigger afterChange hooks during cascade updates. + +## Localization Support + +If the title field is localized, path fields are automatically localized: + +```typescript +// Localized document +{ + name: { en: 'Products', fr: 'Produits' }, + _prefixSlugPath: { en: 'store/products', fr: 'magasin/produits' }, + _prefixTitlePath: { en: 'Store/Products', fr: 'Magasin/Produits' } +} +``` + +**Behavior:** + +- All locales processed simultaneously +- Non-changed locales preserve previous values +- Explicit iteration over `config.localization.localeCodes` + +## Configuration + +To use hierarchy, add the `hierarchy` property to your collection config. + +### Basic Setup + +**Enable with defaults (parent field auto-created):** + +```typescript +{ + slug: 'pages', + fields: [ + { + name: 'title', + type: 'text', + required: true, + } + ], + hierarchy: { + parentFieldName: 'parent', // Field will be auto-created + } +} +``` + +The parent relationship field is automatically created with these defaults: + +- `type: 'relationship'` +- `relationTo: [collection slug]` (self-referential) +- `hasMany: false` +- `index: true` +- `admin.position: 'sidebar'` + +### With Custom Parent Field + +If you need custom validation or UI configuration, define the parent field yourself: + +```typescript +{ + slug: 'pages', + fields: [ + { + name: 'parentPage', + type: 'relationship', + relationTo: 'pages', + hasMany: false, + validate: (value) => { + // Custom validation logic + }, + admin: { + description: 'Select a parent page', + } + }, + { + name: 'title', + type: 'text', + } + ], + hierarchy: { + parentFieldName: 'parentPage', + } +} +``` + +**Note:** If you define the parent field manually, it **must** be configured as: + +- `type: 'relationship'` +- `relationTo: [same collection slug]` (self-referential) +- `hasMany: false` + +Otherwise, a validation error will be thrown. + +### With Custom Options + +```typescript +{ + slug: 'pages', + fields: [ + { + name: 'title', + type: 'text', + } + ], + hierarchy: { + parentFieldName: 'parent', + slugify: (text) => customSlugify(text), // Optional custom slugify function + slugPathFieldName: '_breadcrumbPath', // Optional custom field name + titlePathFieldName: '_breadcrumbTitle', // Optional custom field name + } +} +``` + +### Without Path Generation + +If you only need the parent tree structure for queries (e.g., "find all descendants") without generating breadcrumb paths: + +```typescript +{ + slug: 'categories', + fields: [ + { + name: 'name', + type: 'text', + required: true, + } + ], + hierarchy: { + parentFieldName: 'parent', + generatePaths: false, // Skip path generation, only track parent tree + } +} +``` + +**What you get:** + +- ✅ `parent` field (auto-created relationship) +- ✅ `_h_parentTree` field (for descendant queries) +- ✅ `_h_depth` field (for level tracking) +- ❌ `_h_slugPath` and `_h_titlePath` fields (skipped) + +**Benefits:** + +- Faster operations (no path computation) +- No dependency on `useAsTitle` field +- Simpler data model when URLs/breadcrumbs aren't needed + +### Available Options + +- **`parentFieldName`** (required): Name of the parent relationship field (auto-created if not defined) +- **`generatePaths`** (optional): Whether to generate path fields (`_h_slugPath` and `_h_titlePath`). Set to `false` if you only need parent tree tracking. (default: `true`) +- **`slugify`** (optional): Custom function to slugify text for path generation (only used when `generatePaths` is `true`) +- **`slugPathFieldName`** (optional): Name for slugified path field (default: `'_h_slugPath'`, only used when `generatePaths` is `true`) +- **`titlePathFieldName`** (optional): Name for title path field (default: `'_h_titlePath'`, only used when `generatePaths` is `true`) + +## Use Cases + +### 1. Nested Pages + +Create a hierarchical page structure: + +```typescript +{ + slug: 'pages', + fields: [ + { + name: 'title', + type: 'text', + required: true, + } + ], + hierarchy: { + parentFieldName: 'parent', // Auto-created + } +} +``` + +### 2. Categories + +Build nested category taxonomies: + +```typescript +{ + slug: 'categories', + fields: [ + { + name: 'name', + type: 'text', + required: true, + } + ], + hierarchy: { + parentFieldName: 'parentCategory', // Auto-created + }, + admin: { + useAsTitle: 'name', + } +} +``` + +### 3. Organizational Structure + +Model departments, locations, or organizational hierarchies: + +```typescript +{ + slug: 'departments', + fields: [ + { + name: 'deptName', + type: 'text', + required: true, + } + ], + hierarchy: { + parentFieldName: 'parentDept', // Auto-created + } +} +``` + +### 4. Folders (Internal Payload Feature) + +The folders feature uses hierarchy internally: + +```typescript +// Automatically creates "payload-folders" collection with hierarchy +config.folders = { enabled: true } + +// Other collections get a relationship field pointing to folders +// The folders collection has hierarchy enabled internally +``` + +## Data Model + +### Example Document Structure + +```typescript +// Root document +{ + id: 'doc-a', + name: 'Grandparent', + parent: null, + _h_parentTree: [], + _h_depth: 0, + _h_slugPath: 'grandparent', + _h_titlePath: 'Grandparent' +} + +// Second level +{ + id: 'doc-b', + name: 'Parent', + parent: 'doc-a', + _h_parentTree: ['doc-a'], + _h_depth: 1, + _h_slugPath: 'grandparent/parent', + _h_titlePath: 'Grandparent/Parent' +} + +// Third level +{ + id: 'doc-c', + name: 'Current', + parent: 'doc-b', + _h_parentTree: ['doc-a', 'doc-b'], + _h_depth: 2, + _h_slugPath: 'grandparent/parent/current', + _h_titlePath: 'Grandparent/Parent/Current' +} +``` + +### Query Patterns + +```typescript +// Find all descendants of a document +payload.find({ + collection: 'pages', + where: { + _h_parentTree: { in: ['doc-b'] }, // Finds doc-c and all its children + }, +}) + +// Find all documents at a specific depth +payload.find({ + collection: 'pages', + where: { + _h_depth: { equals: 2 }, // All documents at depth 2 + }, +}) + +// Find all root documents +payload.find({ + collection: 'pages', + where: { + _h_depth: { equals: 0 }, + }, +}) + +// Find all documents up to depth 3 +payload.find({ + collection: 'pages', + where: { + _h_depth: { less_than_equal: 3 }, + }, +}) + +// Find by path prefix +payload.find({ + collection: 'pages', + where: { + _h_slugPath: { starts_with: 'grandparent/' }, // All under grandparent + }, +}) + +// Search within path +payload.find({ + collection: 'pages', + where: { + _h_titlePath: { contains: 'Parent' }, + }, +}) +``` + +## Implementation Details + +### Key Files + +**Configuration:** + +- [addHierarchyToCollection.ts](./addHierarchyToCollection.ts) - Adds fields and hooks to collection + +**Hooks:** + +- [hooks/collectionAfterChange.ts](./hooks/collectionAfterChange.ts) - Main orchestration hook + +**Core Logic:** + +- [utils/computeTreeData.ts](./utils/fetchParentAndComputeTree.ts) - Fetches/derives parent and computes tree +- [utils/updateDescendants.ts](./utils/updateDescendants.ts) - Batch updates all descendants +- [utils/generateTreePaths.ts](./utils/generateTreePaths.ts) - Generates slug and title paths + +**Optimizations:** + +- [utils/deriveParentPathsFromPrevious.ts](./utils/deriveParentPathsFromPrevious.ts) - Derives parent paths from previous doc +- [utils/getTreeChanges.ts](./utils/getTreeChanges.ts) - Detects what changed + +**Path Adjustment:** + +- [utils/adjustDescendantTreePaths.ts](./utils/adjustDescendantTreePaths.ts) - Updates descendant paths + +**Path Adjustment Algorithm:** + +When a document moves to a new parent, all its descendants must have their paths updated. The algorithm: + +1. **Strip old parent prefix**: Remove the previous parent's path from the descendant's current path +2. **Prepend new parent prefix**: Add the new parent's path to the remaining path + +**Example:** + +```typescript +// Document "C" moves from Parent A to Parent B +// Previous parent path: "a/b" +// Descendant path: "a/b/c/descendant" +// New parent path: "x/y" + +// Step 1: Strip "a/b/" → "c/descendant" +// Step 2: Prepend "x/y/" → "x/y/c/descendant" ✓ +``` + +This ensures descendants maintain their relative position in the tree while reflecting the new parent's path. + +### Hook Flow + +``` +1. User updates document (parent or title changed) + ↓ +2. collectionAfterChange hook triggered + ↓ +3. getTreeChanges() detects what changed + ↓ +4. computeTreeData() calculates new tree and paths + ├─ If parent changed: fetch new parent from DB + ├─ If only title changed: derive parent path + └─ generateTreePaths() creates new path fields + ↓ +5. req.payload.db.updateOne() saves document + ↓ +6. updateDescendants() cascades changes + ├─ Query descendants (batch of 100) + ├─ For each: adjustDescendantTreePaths() + ├─ For each: calculate new _h_parentTree + └─ Update all in parallel with Promise.all() + ↓ +7. Repeat until hasNextPage === false +``` + +## Current Implementation Status + +✅ **Completed:** + +- Unlimited descendants support (pagination) +- Optimization for title-only changes +- Explicit localization handling +- Collection-level config option +- Auto-creation of parent field with validation +- Support for custom parent field definitions + +⏳ **Future Work:** + +- Migration script for existing data +- Circular reference prevention +- Background path repair job + +**Key points:** + +1. Process root documents first (order matters) +2. Build paths incrementally from parent paths +3. Update directly via `db.updateOne()` (bypass hooks) +4. Handle localization if needed +5. Parent field auto-created if not defined by user + +## Future Considerations + +### Circular Reference Prevention + +Currently relies on validation elsewhere. Could add: + +- Hook-level check before update +- Validate new parent is not a descendant +- Prevent self-referential parent + +### Path Validation on Read + +Add an afterRead hook to verify paths are in sync: + +- Compare computed path vs stored path +- Log warnings for inconsistencies +- Optional auto-repair mode diff --git a/packages/payload/src/hierarchy/addHierarchyToCollection.ts b/packages/payload/src/hierarchy/addHierarchyToCollection.ts new file mode 100644 index 00000000000..891c18c640b --- /dev/null +++ b/packages/payload/src/hierarchy/addHierarchyToCollection.ts @@ -0,0 +1,107 @@ +import type { CollectionConfig } from '../collections/config/types.js' +import type { Config } from '../config/types.js' + +import { hierarchyCollectionAfterChange } from './hooks/collectionAfterChange.js' +import { defaultSlugify } from './utils/defaultSlugify.js' +import { findUseAsTitleField } from './utils/findUseAsTitle.js' + +export const addHierarchyToCollection = ({ + collectionConfig, + config, + generatePaths = true, + parentFieldName, + slugify = defaultSlugify, + slugPathFieldName = '_h_slugPath', + titlePathFieldName = '_h_titlePath', +}: { + collectionConfig: CollectionConfig + config: Config + generatePaths?: boolean + parentFieldName: string + slugify?: (text: string) => string + slugPathFieldName?: string + titlePathFieldName?: string +}) => { + const { localized, titleFieldName } = findUseAsTitleField(collectionConfig) + const localizeField: boolean = Boolean(config.localization && localized) + + // Conditionally add path fields + if (generatePaths) { + collectionConfig.fields.push( + { + name: slugPathFieldName, + type: 'text', + admin: { + readOnly: true, + // hidden: true, + }, + index: true, + label: ({ t }) => t('general:prefixSlugPathFieldName'), + localized: localizeField, + }, + { + name: titlePathFieldName, + type: 'text', + admin: { + readOnly: true, + // hidden: true, + }, + index: true, + label: ({ t }) => t('general:prefixTitlePathFieldName'), + localized: localizeField, + }, + ) + } + + // Always add parentTree and depth fields + collectionConfig.fields.push( + { + name: '_h_parentTree', + type: 'relationship', + admin: { + allowEdit: false, + hidden: true, + isSortable: false, + readOnly: true, + }, + hasMany: true, + index: true, + maxDepth: 0, + relationTo: collectionConfig.slug, + }, + { + name: '_h_depth', + type: 'number', + admin: { + hidden: true, + readOnly: true, + }, + index: true, + }, + ) + + if (!collectionConfig.admin) { + collectionConfig.admin = {} + } + if (!collectionConfig.admin.listSearchableFields) { + collectionConfig.admin.listSearchableFields = [titleFieldName] + } else if (!collectionConfig.admin.listSearchableFields.includes(titleFieldName)) { + collectionConfig.admin.listSearchableFields.push(titleFieldName) + } + + collectionConfig.hooks = { + ...(collectionConfig.hooks || {}), + afterChange: [ + ...(collectionConfig.hooks?.afterChange || []), + hierarchyCollectionAfterChange({ + generatePaths, + isTitleLocalized: localized, + parentFieldName, + slugify, + slugPathFieldName, + titleFieldName, + titlePathFieldName, + }), + ], + } +} diff --git a/packages/payload/src/hierarchy/buildParentField.ts b/packages/payload/src/hierarchy/buildParentField.ts new file mode 100644 index 00000000000..b56b2d38c81 --- /dev/null +++ b/packages/payload/src/hierarchy/buildParentField.ts @@ -0,0 +1,40 @@ +import type { SingleRelationshipField } from '../fields/config/types.js' + +export const buildParentField = ({ + collectionSlug, + overrides = {}, + parentFieldName, +}: { + collectionSlug: string + overrides?: Partial + parentFieldName: string +}): SingleRelationshipField => { + const field: SingleRelationshipField = { + name: parentFieldName, + type: 'relationship', + admin: { + position: 'sidebar', + }, + hasMany: false, + index: true, + label: 'Parent', + relationTo: collectionSlug, + } + + // Apply overrides + if (overrides?.admin) { + field.admin = { + ...field.admin, + ...(overrides.admin || {}), + } + + if (overrides.admin.components) { + field.admin.components = { + ...field.admin.components, + ...(overrides.admin.components || {}), + } + } + } + + return field +} diff --git a/packages/payload/src/hierarchy/hooks/collectionAfterChange.ts b/packages/payload/src/hierarchy/hooks/collectionAfterChange.ts new file mode 100644 index 00000000000..2ee542bdc51 --- /dev/null +++ b/packages/payload/src/hierarchy/hooks/collectionAfterChange.ts @@ -0,0 +1,177 @@ +import type { CollectionAfterChangeHook, SelectIncludeType } from '../../index.js' + +import { computeTreeData } from '../utils/computeTreeData.js' +import { getTreeChanges } from '../utils/getTreeChanges.js' +import { updateDescendants } from '../utils/updateDescendants.js' + +type Args = { + /** + * Whether to generate path fields + */ + generatePaths?: boolean + /** + * Indicates whether the title field is localized + */ + isTitleLocalized?: boolean + /** + * The name of the field that contains the parent document ID + */ + parentFieldName?: string + slugify?: (text: string) => string + /** + * The name of the field that contains the slug path + * + * example: "parent-folder/current-folder" + */ + slugPathFieldName?: string + /** + * The name of the field that contains the title used to generate the paths + */ + titleFieldName?: string + /** + * The name of the field that contains the title path + * + * example: "Parent Folder/Current Folder" + */ + titlePathFieldName?: string +} +export const hierarchyCollectionAfterChange = + ({ + generatePaths, + isTitleLocalized, + parentFieldName, + slugify, + slugPathFieldName, + titleFieldName, + titlePathFieldName, + }: Required): CollectionAfterChangeHook => + async ({ + collection, + doc, + docWithLocales, + operation, + previousDoc, + previousDocWithLocales, + req, + }) => { + const reqLocale = + req.locale || + (req.payload.config.localization ? req.payload.config.localization.defaultLocale : undefined) + + // handle this better later + if (reqLocale === 'all') { + return + } + + const { newParentID, parentChanged, titleChanged } = getTreeChanges({ + doc, + parentFieldName, + previousDoc, + slugify, + titleFieldName, + }) + + const parentChangedOrCreate = parentChanged || operation === 'create' + + // Only process if parent changed/created, or if title changed and paths are enabled + if (parentChangedOrCreate || (titleChanged && generatePaths)) { + const updatedTreeData = await computeTreeData({ + collection, + docWithLocales, + fieldIsLocalized: isTitleLocalized, + localeCodes: + isTitleLocalized && req.payload.config.localization + ? req.payload.config.localization.localeCodes + : undefined, + newParentID, + parentChanged: parentChangedOrCreate, + previousDocWithLocales, + req, + reqLocale: + reqLocale || + (req.payload.config.localization ? req.payload.config.localization.defaultLocale : 'en'), + slugify, + slugPathFieldName, + titleFieldName, + titlePathFieldName, + }) + + // Build update data + const updateData: Record = { + _h_depth: updatedTreeData._h_parentTree?.length ?? 0, + _h_parentTree: updatedTreeData._h_parentTree, + [parentFieldName]: newParentID, + } + + // Only include path fields if generatePaths is true + if (generatePaths) { + updateData[slugPathFieldName] = updatedTreeData.slugPath + updateData[titlePathFieldName] = updatedTreeData.titlePath + } + + // Build select fields + const selectFields: SelectIncludeType = { + _h_depth: true, + _h_parentTree: true, + } + + if (generatePaths) { + selectFields[slugPathFieldName] = true + selectFields[titlePathFieldName] = true + } + + // NOTE: using the db directly, no hooks or access control here + const updatedDocWithLocales = await req.payload.db.updateOne({ + id: doc.id, + collection: collection.slug, + data: updateData, + locale: 'all', + req, + select: selectFields, + }) + + // Update all descendants in batches to handle unlimited tree sizes + await updateDescendants({ + collection, + fieldIsLocalized: isTitleLocalized, + generatePaths, + localeCodes: + isTitleLocalized && req.payload.config.localization + ? req.payload.config.localization.localeCodes + : undefined, + newParentID: newParentID ?? undefined, + parentDocID: doc.id, + parentDocWithLocales: updatedDocWithLocales, + parentFieldName, + previousParentDocWithLocales: previousDocWithLocales, + req, + slugPathFieldName, + titlePathFieldName, + }) + + // Update doc with new values + if (generatePaths) { + const updatedSlugPath = isTitleLocalized + ? updatedDocWithLocales[slugPathFieldName][reqLocale!] + : updatedDocWithLocales[slugPathFieldName] + const updatedTitlePath = isTitleLocalized + ? updatedDocWithLocales[titlePathFieldName][reqLocale!] + : updatedDocWithLocales[titlePathFieldName] + + if (updatedSlugPath) { + doc[slugPathFieldName] = updatedSlugPath + } + if (updatedTitlePath) { + doc[titlePathFieldName] = updatedTitlePath + } + } + + if (parentChangedOrCreate) { + const updatedParentTree = updatedDocWithLocales._h_parentTree + doc._h_parentTree = updatedParentTree + doc._h_depth = updatedParentTree ? updatedParentTree.length : 0 + } + + return doc + } + } diff --git a/packages/payload/src/hierarchy/types.ts b/packages/payload/src/hierarchy/types.ts new file mode 100644 index 00000000000..3b3316026f2 --- /dev/null +++ b/packages/payload/src/hierarchy/types.ts @@ -0,0 +1,49 @@ +export type HierarchyDataT = { + _h_parentTree?: (number | string)[] | null + slugPath?: Record | string + titlePath?: Record | string +} + +/** + * Configuration options for hierarchy feature + */ +export type HierarchyConfig = { + /** + * Whether to generate path fields (_h_slugPath and _h_titlePath) + * Set to false if you only need parent tree tracking without path generation + * @default true + */ + generatePaths?: boolean + /** + * Name of the field that references the parent document + * Will automatically create this field if it does not exist + * (e.g., 'parent', 'parentPage', 'parentFolder') + */ + parentFieldName: string + /** + * Custom function to slugify text for path generation + * Defaults to internal slugify implementation + * Only used when generatePaths is true + */ + slugify?: (text: string) => string + /** + * Name of the field to store the slugified breadcrumb path + * Only used when generatePaths is true + * @default '_h_slugPath' + */ + slugPathFieldName?: string + /** + * Name of the field to store the title breadcrumb path + * Only used when generatePaths is true + * @default '_h_titlePath' + */ + titlePathFieldName?: string +} + +/** + * Sanitized hierarchy configuration with all defaults applied + */ +export type SanitizedHierarchyConfig = { + generatePaths: boolean + slugify?: (text: string) => string +} & Required> diff --git a/packages/payload/src/hierarchy/utils/adjustDescendantTreePaths.ts b/packages/payload/src/hierarchy/utils/adjustDescendantTreePaths.ts new file mode 100644 index 00000000000..9c4cdc86c94 --- /dev/null +++ b/packages/payload/src/hierarchy/utils/adjustDescendantTreePaths.ts @@ -0,0 +1,101 @@ +import type { HierarchyDataT } from '../types.js' + +type AdjustAffectedTreePathsArgs = { + doc: HierarchyDataT + fieldIsLocalized: boolean + localeCodes?: string[] + parentDoc: HierarchyDataT + previousParentDoc: HierarchyDataT +} + +/** + * Adjusts tree paths for a descendant document when an ancestor's path changes + * Handles both localized and non-localized fields + * + * Algorithm: + * 1. Strip the old parent's path prefix from the descendant's current path + * 2. Prepend the new parent's path prefix + * + * Example: + * - Old parent path: "a/b" + * - Descendant path: "a/b/c/descendant" + * - New parent path: "x/y" + * - Result: "x/y/c/descendant" (strip "a/b/", keep "c/descendant", prepend "x/y/") + */ +export function adjustDescendantTreePaths({ + doc, + fieldIsLocalized, + localeCodes, + parentDoc, + previousParentDoc, +}: AdjustAffectedTreePathsArgs): { + slugPath: Record | string + titlePath: Record | string +} { + if (fieldIsLocalized && localeCodes) { + const slugPathByLocale: Record = {} + const titlePathByLocale: Record = {} + + for (const locale of localeCodes) { + const newParentSlugPath = (parentDoc.slugPath as Record)?.[locale] || '' + const newParentTitlePath = (parentDoc.titlePath as Record)?.[locale] || '' + + const previousParentSlugPath = + (previousParentDoc.slugPath as Record)?.[locale] || '' + const previousParentTitlePath = + (previousParentDoc.titlePath as Record)?.[locale] || '' + + const affectedSlugPath = (doc.slugPath as Record)?.[locale] || '' + const affectedTitlePath = (doc.titlePath as Record)?.[locale] || '' + + // Strip old parent prefix, then add new parent prefix + const slugPathWithoutOldPrefix = previousParentSlugPath + ? affectedSlugPath.slice(previousParentSlugPath.length).replace(/^\/+/, '') + : affectedSlugPath + + const titlePathWithoutOldPrefix = previousParentTitlePath + ? affectedTitlePath.slice(previousParentTitlePath.length).replace(/^\/+/, '') + : affectedTitlePath + + slugPathByLocale[locale] = newParentSlugPath + ? `${newParentSlugPath}/${slugPathWithoutOldPrefix}`.replace(/^\/+|\/+$/g, '') + : slugPathWithoutOldPrefix + + titlePathByLocale[locale] = newParentTitlePath + ? `${newParentTitlePath}/${titlePathWithoutOldPrefix}`.replace(/^\/+|\/+$/g, '') + : titlePathWithoutOldPrefix + } + + return { + slugPath: slugPathByLocale, + titlePath: titlePathByLocale, + } + } else { + const newParentSlugPath = (parentDoc.slugPath as string) || '' + const newParentTitlePath = (parentDoc.titlePath as string) || '' + + const previousParentSlugPath = (previousParentDoc.slugPath as string) || '' + const previousParentTitlePath = (previousParentDoc.titlePath as string) || '' + + const affectedSlugPath = (doc.slugPath as string) || '' + const affectedTitlePath = (doc.titlePath as string) || '' + + // Strip old parent prefix, then add new parent prefix + const slugPathWithoutOldPrefix = previousParentSlugPath + ? affectedSlugPath.slice(previousParentSlugPath.length).replace(/^\/+/, '') + : affectedSlugPath + + const titlePathWithoutOldPrefix = previousParentTitlePath + ? affectedTitlePath.slice(previousParentTitlePath.length).replace(/^\/+/, '') + : affectedTitlePath + + return { + slugPath: newParentSlugPath + ? `${newParentSlugPath}/${slugPathWithoutOldPrefix}`.replace(/^\/+|\/+$/g, '') + : slugPathWithoutOldPrefix, + titlePath: newParentTitlePath + ? `${newParentTitlePath}/${titlePathWithoutOldPrefix}`.replace(/^\/+|\/+$/g, '') + : titlePathWithoutOldPrefix, + } + } +} diff --git a/packages/payload/src/hierarchy/utils/computeTreeData.ts b/packages/payload/src/hierarchy/utils/computeTreeData.ts new file mode 100644 index 00000000000..50eba362cab --- /dev/null +++ b/packages/payload/src/hierarchy/utils/computeTreeData.ts @@ -0,0 +1,118 @@ +import type { SanitizedCollectionConfig } from '../../collections/config/types.js' +import type { Document, PayloadRequest } from '../../index.js' +import type { HierarchyDataT } from '../types.js' + +import { deriveParentPathsFromPrevious } from './deriveParentPathsFromPrevious.js' +import { generateTreePaths } from './generateTreePaths.js' + +type FetchParentAndComputeTreeArgs = { + collection: SanitizedCollectionConfig + docWithLocales: Document + fieldIsLocalized: boolean + localeCodes?: string[] + newParentID: null | number | string | undefined + parentChanged: boolean + previousDocWithLocales: Document + req: PayloadRequest + reqLocale: string + slugify: (text: string) => string + slugPathFieldName: string + titleFieldName: string + titlePathFieldName: string +} + +/** + * Fetches the parent document, computes the new parent tree, and generates the new tree paths. + * + * Handles three scenarios: + * 1. Document moved to a new parent - fetches new parent and computes new tree and paths + * 2. Document moved to root (no parent) - returns empty tree and paths without parent prefix + * 3. Document title changed but parent stayed same - derives parent paths from previous document (optimization) + * + * Performance optimization: When only the title changes, we derive the parent's path + * by stripping the last segment from the previous document's path instead of fetching + * the parent from the database. This assumes paths are always kept in sync (via migration). + */ +export async function computeTreeData({ + collection, + docWithLocales, + fieldIsLocalized, + localeCodes, + newParentID, + parentChanged, + previousDocWithLocales, + req, + reqLocale, + slugify, + slugPathFieldName, + titleFieldName, + titlePathFieldName, +}: FetchParentAndComputeTreeArgs): Promise { + let parentSlugPath: Record | string | undefined + let parentTitlePath: Record | string | undefined + let newParentTree: (number | string)[] = [] + + if (parentChanged && newParentID) { + // Moving document to new parent - must fetch to get parent's tree and paths + const parentDoc = await req.payload.findByID({ + id: newParentID, + collection: collection.slug, + depth: 0, + locale: 'all', + select: { + _h_parentTree: true, + [slugPathFieldName]: true, + [titlePathFieldName]: true, + }, + }) + + newParentTree = [...(parentDoc?._h_parentTree || []), newParentID] + parentSlugPath = parentDoc ? parentDoc[slugPathFieldName] : undefined + parentTitlePath = parentDoc ? parentDoc[titlePathFieldName] : undefined + } else if (parentChanged && !newParentID) { + // Moved document to the root (no parent) + newParentTree = [] + parentSlugPath = undefined + parentTitlePath = undefined + } else { + // Document did not move, but the title changed + const derivedPaths = deriveParentPathsFromPrevious({ + fieldIsLocalized, + localeCodes, + previousDocWithLocales, + slugPathFieldName, + titlePathFieldName, + }) + + parentSlugPath = derivedPaths?.slugPath + parentTitlePath = derivedPaths?.titlePath + newParentTree = docWithLocales._h_parentTree + } + + // Generate the new tree paths using the parent paths we fetched or derived + const treePaths = generateTreePaths({ + docWithLocales, + previousDocWithLocales, + slugify, + titleFieldName, + treeData: { + parentSlugPath, + parentTitlePath, + }, + ...(fieldIsLocalized && localeCodes + ? { + localeCodes, + localized: true, + reqLocale, + } + : { + localized: false, + }), + }) + + return { + _h_parentTree: newParentTree, + slugPath: treePaths.slugPath, + titlePath: treePaths.titlePath, + } +} 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/deriveParentPathsFromPrevious.ts b/packages/payload/src/hierarchy/utils/deriveParentPathsFromPrevious.ts new file mode 100644 index 00000000000..b0f60478b86 --- /dev/null +++ b/packages/payload/src/hierarchy/utils/deriveParentPathsFromPrevious.ts @@ -0,0 +1,95 @@ +import type { Document } from '../../index.js' + +/** + * Derives the parent's path fields by stripping the last segment from the previous document's paths. + * This optimization avoids a database query when only the title changes. + * + * Examples: + * - Non-localized: "grandparent/parent/old-title" → "grandparent/parent" + * - Root level: "my-document" → "" (empty string) + * - Localized: { en: "parent/old", fr: "parent/ancien" } → { en: "parent", fr: "parent" } + * + * @param previousDocWithLocales - The previous version of the document with all locale data + * @param slugPathFieldName - The field name for slug paths (e.g., "_prefixSlugPath") + * @param titlePathFieldName - The field name for title paths (e.g., "_prefixTitlePath") + * @param fieldIsLocalized - Whether the path fields are localized + * @param localeCodes - Array of locale codes to process (only used if fieldIsLocalized is true) + * @returns A mock parent document with derived path fields, or undefined if paths can't be derived + */ +export function deriveParentPathsFromPrevious({ + fieldIsLocalized, + localeCodes, + previousDocWithLocales, + slugPathFieldName, + titlePathFieldName, +}: { + fieldIsLocalized: boolean + localeCodes?: string[] + previousDocWithLocales: Document + slugPathFieldName: string + titlePathFieldName: string +}): + | { + slugPath?: Record | string + titlePath?: Record | string + } + | undefined { + const previousSlugPath = previousDocWithLocales[slugPathFieldName] + const previousTitlePath = previousDocWithLocales[titlePathFieldName] + + // Can't derive if previous paths don't exist + if (!previousSlugPath || !previousTitlePath) { + return undefined + } + + // Helper to strip last segment from a path + const stripLastSegment = (path: string): string => { + const segments = path.split('/') + return segments.slice(0, -1).join('/') + } + + // Handle localized paths - explicitly loop through locale codes + if (fieldIsLocalized && localeCodes && localeCodes.length > 0) { + if (typeof previousSlugPath !== 'object' || typeof previousTitlePath !== 'object') { + // Expected localized but got string - data inconsistency + return undefined + } + + const derivedSlugPath: Record = {} + const derivedTitlePath: Record = {} + + // Explicitly process each locale from config + for (const locale of localeCodes) { + const slugPathForLocale = previousSlugPath[locale] + const titlePathForLocale = previousTitlePath[locale] + + if (typeof slugPathForLocale === 'string') { + derivedSlugPath[locale] = stripLastSegment(slugPathForLocale) + } + + if (typeof titlePathForLocale === 'string') { + derivedTitlePath[locale] = stripLastSegment(titlePathForLocale) + } + } + + return { + slugPath: derivedSlugPath, + titlePath: derivedTitlePath, + } + } + + // Handle non-localized paths (simple strings) + if ( + !fieldIsLocalized && + typeof previousSlugPath === 'string' && + typeof previousTitlePath === 'string' + ) { + return { + slugPath: stripLastSegment(previousSlugPath), + titlePath: stripLastSegment(previousTitlePath), + } + } + + // Data inconsistency - expected non-localized but got object, or vice versa + return undefined +} diff --git a/packages/payload/src/hierarchy/utils/findUseAsTitle.ts b/packages/payload/src/hierarchy/utils/findUseAsTitle.ts new file mode 100644 index 00000000000..c5c64a080f0 --- /dev/null +++ b/packages/payload/src/hierarchy/utils/findUseAsTitle.ts @@ -0,0 +1,68 @@ +import type { CollectionConfig } from '../../collections/config/types.js' +import type { Field } from '../../fields/config/types.js' + +export function findUseAsTitleField(collectionConfig: CollectionConfig): { + localized: boolean + titleFieldName: string +} { + const titleFieldName = collectionConfig.admin?.useAsTitle || 'id' + return iterateFields({ fields: collectionConfig.fields, titleFieldName }) +} + +function iterateFields({ fields, titleFieldName }: { fields: Field[]; titleFieldName: string }): { + localized: boolean + titleFieldName: string +} { + let titleField: { localized: boolean; titleFieldName: string } | undefined + + if (titleFieldName === 'id') { + return { + localized: false, + titleFieldName, + } + } + + for (const field of fields) { + switch (field.type) { + case 'text': + case 'number': + case 'textarea': + if (field.name === titleFieldName) { + return { + localized: Boolean(field.localized), + titleFieldName: field.name, + } + } + break + case 'row': + case 'collapsible': + { + const result = iterateFields({ fields: field.fields, titleFieldName }) + if (result) {titleField = result} + } + break + case 'group': + if (!('name' in field)) { + const result = iterateFields({ fields: field.fields, titleFieldName }) + if (result) {titleField = result} + } + break + case 'tabs': + for (const tab of field.tabs) { + if (!('name' in tab)) { + const result = iterateFields({ fields: tab.fields, titleFieldName }) + if (result) {titleField = result} + } + } + } + + // If we found the field in recursion, return it + if (titleField) { + return titleField + } + } + + throw new Error( + `The hierarchy title field "${titleFieldName}" was not found. It cannot be nested within named fields i.e. named groups, named tabs, etc.`, + ) +} diff --git a/packages/payload/src/hierarchy/utils/generateTreePaths.ts b/packages/payload/src/hierarchy/utils/generateTreePaths.ts new file mode 100644 index 00000000000..ddb53d472ff --- /dev/null +++ b/packages/payload/src/hierarchy/utils/generateTreePaths.ts @@ -0,0 +1,76 @@ +import type { Document } from '../../types/index.js' + +export type GenerateTreePathsArgs = { + docWithLocales: Document + previousDocWithLocales: Document + slugify: (text: string) => string + titleFieldName: string + treeData?: { + parentSlugPath?: Record | string + parentTitlePath?: Record | string + } +} & ( + | { + defaultLocale?: never + localeCodes?: never + localized: false + reqLocale?: never + } + | { + localeCodes: string[] + localized: true + reqLocale: string + } +) +export function generateTreePaths({ + docWithLocales, + localeCodes, + localized, + previousDocWithLocales, + reqLocale, + slugify, + titleFieldName, + treeData, +}: GenerateTreePathsArgs): { + slugPath: Record | string + titlePath: Record | string +} { + if (localized) { + return localeCodes.reduce<{ + slugPath: Record + titlePath: Record + }>( + (acc, locale: string) => { + const slugPrefix = + treeData && typeof treeData?.parentSlugPath === 'object' + ? treeData?.parentSlugPath?.[locale] + : '' + const titlePrefix = + treeData && typeof treeData?.parentTitlePath === 'object' + ? treeData?.parentTitlePath?.[locale] + : '' + let title = docWithLocales[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: string = treeData ? (treeData.parentSlugPath as string) : '' + const titlePrefix: string = treeData ? (treeData.parentTitlePath as string) : '' + const title = docWithLocales[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..473407238bb --- /dev/null +++ b/packages/payload/src/hierarchy/utils/getTreeChanges.ts @@ -0,0 +1,47 @@ +import type { Document } from '../../types/index.js' + +import { extractID } from '../../utilities/extractID.js' + +type GetTreeChanges = { + doc: Document + parentFieldName: string + previousDoc: Document + slugify: (text: string) => string + titleFieldName: string +} + +type GetTreeChangesResult = { + newParentID: null | number | string | undefined + parentChanged: boolean + titleChanged: boolean +} + +export function getTreeChanges({ + doc, + parentFieldName, + previousDoc, + slugify, + titleFieldName, +}: GetTreeChanges): GetTreeChangesResult { + const prevParentID = extractID(previousDoc[parentFieldName]) || null + const newParentID = extractID(doc[parentFieldName]) || null + const prevTitleData = previousDoc[titleFieldName] + ? { + slug: slugify(previousDoc[titleFieldName]), + title: previousDoc[titleFieldName], + } + : undefined + const newTitleData = doc[titleFieldName] + ? { + slug: slugify(doc[titleFieldName]), + title: doc[titleFieldName], + } + : undefined + + return { + newParentID, + parentChanged: prevParentID !== newParentID, + titleChanged: + prevTitleData?.slug !== newTitleData?.slug || prevTitleData?.title !== newTitleData?.title, + } +} diff --git a/packages/payload/src/hierarchy/utils/updateDescendants.ts b/packages/payload/src/hierarchy/utils/updateDescendants.ts new file mode 100644 index 00000000000..8ddba0df75c --- /dev/null +++ b/packages/payload/src/hierarchy/utils/updateDescendants.ts @@ -0,0 +1,131 @@ +import type { SanitizedCollectionConfig } from '../../collections/config/types.js' +import type { JsonObject, PayloadRequest, SelectIncludeType, TypeWithID } from '../../index.js' + +import { adjustDescendantTreePaths } from './adjustDescendantTreePaths.js' + +type UpdateDescendantsArgs = { + batchSize?: number + collection: SanitizedCollectionConfig + fieldIsLocalized: boolean + generatePaths?: boolean + localeCodes?: string[] + newParentID: number | string | undefined + parentDocID: number | string + parentDocWithLocales: JsonObject & TypeWithID + parentFieldName: string + previousParentDocWithLocales: JsonObject & TypeWithID + req: PayloadRequest + slugPathFieldName: string + titlePathFieldName: string +} + +/** + * Updates all descendants of a document when its parent or title changes. + * Processes descendants in batches to handle unlimited tree sizes. + */ +export async function updateDescendants({ + batchSize = 100, + collection, + fieldIsLocalized, + generatePaths = true, + localeCodes, + newParentID, + parentDocID, + parentDocWithLocales, + parentFieldName, + previousParentDocWithLocales, + req, + slugPathFieldName, + titlePathFieldName, +}: UpdateDescendantsArgs): Promise { + let currentPage = 1 + let hasNextPage = true + + while (hasNextPage) { + // Build select fields + const selectFields: SelectIncludeType = { + _h_parentTree: true, + } + if (generatePaths) { + selectFields[slugPathFieldName] = true + selectFields[titlePathFieldName] = true + } + + const descendantDocsQuery = await req.payload.find({ + collection: collection.slug, + depth: 0, + limit: batchSize, + locale: 'all', + page: currentPage, + req, + select: selectFields, + where: { + _h_parentTree: { + in: [parentDocID], + }, + }, + }) + + const updatePromises: Promise[] = [] + descendantDocsQuery.docs.forEach((affectedDoc) => { + let newTreePaths + if (generatePaths) { + newTreePaths = adjustDescendantTreePaths({ + doc: { + _h_parentTree: affectedDoc._h_parentTree, + slugPath: affectedDoc[slugPathFieldName], + titlePath: affectedDoc[titlePathFieldName], + }, + fieldIsLocalized, + localeCodes, + parentDoc: { + _h_parentTree: parentDocWithLocales._h_parentTree || null, + slugPath: parentDocWithLocales[slugPathFieldName], + titlePath: parentDocWithLocales[titlePathFieldName], + }, + previousParentDoc: { + _h_parentTree: previousParentDocWithLocales._h_parentTree || null, + slugPath: previousParentDocWithLocales[slugPathFieldName], + titlePath: previousParentDocWithLocales[titlePathFieldName], + }, + }) + } + + const parentDocIndex = affectedDoc._h_parentTree?.indexOf(parentDocID) ?? -1 + const unchangedParentTree = + parentDocIndex >= 0 ? affectedDoc._h_parentTree.slice(parentDocIndex) : [] + + const newParentTree = [...(parentDocWithLocales._h_parentTree || []), ...unchangedParentTree] + + // Build update data + const updateData: Record = { + _h_depth: newParentTree.length, + _h_parentTree: newParentTree, + [parentFieldName]: newParentID, + } + + if (generatePaths && newTreePaths) { + updateData[slugPathFieldName] = newTreePaths.slugPath + updateData[titlePathFieldName] = newTreePaths.titlePath + } + + updatePromises.push( + // this pattern has an issue bc it will not run hooks on the affected documents + // NOTE: using the db directly, no hooks or access control here + // Using payload.update, we will need to loop over `n` locales and run 1 update per locale and `n` versions will be created + req.payload.db.updateOne({ + id: affectedDoc.id, + collection: collection.slug, + data: updateData, + locale: 'all', + req, + }), + ) + }) + + await Promise.all(updatePromises) + + hasNextPage = descendantDocsQuery.hasNextPage + currentPage++ + } +} diff --git a/packages/payload/src/utilities/extractID.ts b/packages/payload/src/utilities/extractID.ts index 49f986a9d96..e1dda24071c 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?.id } diff --git a/test/hierarchy/config.ts b/test/hierarchy/config.ts new file mode 100644 index 00000000000..117932df1e8 --- /dev/null +++ b/test/hierarchy/config.ts @@ -0,0 +1,121 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +import type { CollectionConfig } from 'payload' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' + +// Simple Pages collection with hierarchy enabled +export const Pages: CollectionConfig = { + slug: 'pages', + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'content', + type: 'text', + }, + ], + hierarchy: { + parentFieldName: 'parent', + }, +} + +// Categories collection with hierarchy enabled (using defaults) +export const Categories: CollectionConfig = { + slug: 'categories', + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + }, + ], + hierarchy: { + parentFieldName: 'parentCategory', + }, +} + +// Departments collection with custom field names +export const Departments: CollectionConfig = { + slug: 'departments', + admin: { + useAsTitle: 'deptName', + }, + fields: [ + { + name: 'deptName', + type: 'text', + required: true, + }, + ], + hierarchy: { + parentFieldName: 'parentDept', + slugPathFieldName: '_breadcrumbSlug', + titlePathFieldName: '_breadcrumbTitle', + }, +} + +// Organizations collection with generatePaths disabled +export const Organizations: CollectionConfig = { + slug: 'organizations', + admin: { + useAsTitle: 'orgName', + }, + fields: [ + { + name: 'orgName', + type: 'text', + required: true, + }, + { + name: 'description', + type: 'text', + }, + ], + hierarchy: { + generatePaths: false, + parentFieldName: 'parentOrg', + }, +} + +export default buildConfigWithDefaults({ + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + collections: [Pages, Categories, Departments, Organizations], + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) + +export { + Categories as CategoriesCollection, + Departments as DepartmentsCollection, + Organizations as OrganizationsCollection, + Pages as PagesCollection, +} diff --git a/test/hierarchy/int.spec.ts b/test/hierarchy/int.spec.ts new file mode 100644 index 00000000000..8eccd81504a --- /dev/null +++ b/test/hierarchy/int.spec.ts @@ -0,0 +1,652 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../helpers/NextRESTClient.js' +import type { Page } from './payload-types.js' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +let payload: Payload +let restClient: NextRESTClient + +describe('Hierarchy', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname)) + }) + + afterAll(async () => { + if (payload) { + await payload.destroy() + } + }) + + describe('Collection Config Property', () => { + it('should add hierarchy fields to collection', () => { + const pagesCollection = payload.collections.pages.config + + // Check that hierarchy fields were added + const hierarchyFields = pagesCollection.fields.filter((field) => field.name.startsWith('_h_')) + + expect(hierarchyFields.length).toBeGreaterThan(0) + + // Check for specific fields + const slugPathField = pagesCollection.fields.find((f) => f.name === '_h_slugPath') + const titlePathField = pagesCollection.fields.find((f) => f.name === '_h_titlePath') + const depthField = pagesCollection.fields.find((f) => f.name === '_h_depth') + const parentTreeField = pagesCollection.fields.find((f) => f.name === '_h_parentTree') + + expect(slugPathField).toBeDefined() + expect(titlePathField).toBeDefined() + expect(depthField).toBeDefined() + expect(parentTreeField).toBeDefined() + }) + + it('should have sanitized hierarchy config', () => { + const pagesCollection = payload.collections.pages.config + + expect(pagesCollection.hierarchy).not.toBe(false) + // eslint-disable-next-line jest/no-conditional-in-test + if (pagesCollection.hierarchy !== false) { + expect(pagesCollection.hierarchy.parentFieldName).toBe('parent') + expect(pagesCollection.hierarchy.slugPathFieldName).toBe('_h_slugPath') + expect(pagesCollection.hierarchy.titlePathFieldName).toBe('_h_titlePath') + } + }) + + it('should support custom field names', () => { + const deptsCollection = payload.collections.departments.config + + expect(deptsCollection.hierarchy).not.toBe(false) + // eslint-disable-next-line jest/no-conditional-in-test + if (deptsCollection.hierarchy !== false) { + expect(deptsCollection.hierarchy.parentFieldName).toBe('parentDept') + expect(deptsCollection.hierarchy.slugPathFieldName).toBe('_breadcrumbSlug') + expect(deptsCollection.hierarchy.titlePathFieldName).toBe('_breadcrumbTitle') + } + }) + }) + + describe('Tree Data Generation', () => { + beforeEach(async () => { + // Clear existing data before each test + await payload.delete({ collection: 'pages', where: {} }) + }) + + afterEach(async () => { + // Clean up data after each test + await payload.delete({ collection: 'pages', where: {} }) + }) + + it('should generate correct tree data for root document', async () => { + const rootPage = await payload.create({ + collection: 'pages', + data: { + parent: null, + title: 'Root Page', + }, + }) + + expect(rootPage._h_depth).toBe(0) + expect(rootPage._h_parentTree).toEqual([]) + expect(rootPage._h_slugPath).toBe('root-page') + expect(rootPage._h_titlePath).toBe('Root Page') + }) + + it('should generate correct tree data for nested documents', async () => { + // Create root + const rootPage = await payload.create({ + collection: 'pages', + data: { + parent: null, + title: 'Root', + }, + }) + + // Create child + const childPage = await payload.create({ + collection: 'pages', + data: { + parent: rootPage.id, + title: 'Child', + }, + }) + + expect(childPage._h_depth).toBe(1) + expect(childPage._h_parentTree).toEqual([rootPage.id]) + expect(childPage._h_slugPath).toBe('root/child') + expect(childPage._h_titlePath).toBe('Root/Child') + + // Create grandchild + const grandchildPage = await payload.create({ + collection: 'pages', + data: { + parent: childPage.id, + title: 'Grandchild', + }, + }) + + expect(grandchildPage._h_depth).toBe(2) + expect(grandchildPage._h_parentTree).toEqual([rootPage.id, childPage.id]) + expect(grandchildPage._h_slugPath).toBe('root/child/grandchild') + expect(grandchildPage._h_titlePath).toBe('Root/Child/Grandchild') + }) + + it('should update descendants when parent changes', async () => { + // Create initial tree: Root -> Child -> Grandchild + const rootPage = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + const anotherRoot = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Another Root' }, + }) + + const childPage = await payload.create({ + collection: 'pages', + data: { parent: rootPage.id, title: 'Child' }, + }) + + const grandchildPage = await payload.create({ + collection: 'pages', + data: { parent: childPage.id, title: 'Grandchild' }, + }) + + // Move child to another root + const updatedChild = await payload.update({ + id: childPage.id, + collection: 'pages', + data: { parent: anotherRoot.id }, + }) + + // Check child was updated + expect(updatedChild._h_parentTree).toEqual([anotherRoot.id]) + expect(updatedChild._h_slugPath).toBe('another-root/child') + expect(updatedChild._h_titlePath).toBe('Another Root/Child') + + // Check grandchild was updated + const updatedGrandchild = await payload.findByID({ + id: grandchildPage.id, + collection: 'pages', + }) + + expect(updatedGrandchild._h_parentTree).toEqual([anotherRoot.id, childPage.id]) + expect(updatedGrandchild._h_slugPath).toBe('another-root/child/grandchild') + expect(updatedGrandchild._h_titlePath).toBe('Another Root/Child/Grandchild') + }) + + it('should update descendants when title changes', async () => { + // Create tree + const rootPage = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + const childPage = await payload.create({ + collection: 'pages', + data: { parent: rootPage.id, title: 'Child' }, + }) + + // Update root title + await payload.update({ + id: rootPage.id, + collection: 'pages', + data: { title: 'Updated Root' }, + }) + + // Check child paths were updated + const updatedChild = await payload.findByID({ + id: childPage.id, + collection: 'pages', + }) + + expect(updatedChild._h_slugPath).toBe('updated-root/child') + expect(updatedChild._h_titlePath).toBe('Updated Root/Child') + }) + + it('should handle moving to root level', async () => { + // Create tree + const rootPage = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + const childPage = await payload.create({ + collection: 'pages', + data: { parent: rootPage.id, title: 'Child' }, + }) + + // Move child to root + const updatedChild = await payload.update({ + id: childPage.id, + collection: 'pages', + data: { parent: null }, + }) + + expect(updatedChild._h_depth).toBe(0) + expect(updatedChild._h_parentTree).toEqual([]) + expect(updatedChild._h_slugPath).toBe('child') + expect(updatedChild._h_titlePath).toBe('Child') + }) + }) + + describe('Query Patterns', () => { + beforeEach(async () => { + // Clear existing data before each test + await payload.delete({ collection: 'pages', where: {} }) + }) + + afterEach(async () => { + // Clean up data after each test + await payload.delete({ collection: 'pages', where: {} }) + }) + + it('should find all descendants of a document', async () => { + // Create test tree + const root = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + const child1 = await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 1' }, + }) + + const child2 = await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 2' }, + }) + + const grandchild1 = await payload.create({ + collection: 'pages', + data: { parent: child1.id, title: 'Grandchild 1' }, + }) + + const descendants = await payload.find({ + collection: 'pages', + where: { + _h_parentTree: { + in: [root.id], + }, + }, + }) + + expect(descendants.docs).toHaveLength(3) // child1, child2, grandchild1 + const ids = descendants.docs.map((d) => d.id) + expect(ids).toContain(child1.id) + expect(ids).toContain(child2.id) + expect(ids).toContain(grandchild1.id) + }) + + it('should find documents at specific depth', async () => { + // Create test tree + const root = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + const child1 = await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 1' }, + }) + + await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 2' }, + }) + + await payload.create({ + collection: 'pages', + data: { parent: child1.id, title: 'Grandchild 1' }, + }) + + const depthOne = await payload.find({ + collection: 'pages', + where: { + _h_depth: { equals: 1 }, + }, + }) + + expect(depthOne.docs).toHaveLength(2) // child1, child2 + }) + + it('should find root documents', async () => { + const root = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 1' }, + }) + + const roots = await payload.find({ + collection: 'pages', + where: { + _h_depth: { equals: 0 }, + }, + }) + + expect(roots.docs).toHaveLength(1) + expect(roots.docs[0]!.id).toBe(root.id) + }) + + it('should find by path prefix', async () => { + const root = await payload.create({ + collection: 'pages', + data: { parent: null, title: 'Root' }, + }) + + await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 1' }, + }) + + await payload.create({ + collection: 'pages', + data: { parent: root.id, title: 'Child 2' }, + }) + + const underRoot = await payload.find({ + collection: 'pages', + where: { + _h_slugPath: { + like: 'root/', + }, + }, + }) + + expect(underRoot.docs.length).toBeGreaterThanOrEqual(2) + }) + }) + + describe('Deep Nesting', () => { + beforeEach(async () => { + // Clear existing data before each test + await payload.delete({ collection: 'pages', where: {} }) + }) + + afterEach(async () => { + // Clean up data after each test + await payload.delete({ collection: 'pages', where: {} }) + }) + + it('should handle deeply nested structures', async () => { + // Create 10-level deep hierarchy + let currentParent: null | Page = null + + for (let i = 0; i < 10; i++) { + currentParent = await payload.create({ + collection: 'pages', + data: { + parent: currentParent?.id || null, + title: `Level ${i}`, + }, + }) + + expect(currentParent._h_depth).toBe(i) + expect(currentParent._h_parentTree?.length || 0).toBe(i) + } + + // Verify the deepest level + // eslint-disable-next-line jest/no-conditional-in-test + if (currentParent) { + expect(currentParent._h_depth).toBe(9) + expect(currentParent._h_parentTree).toHaveLength(9) + expect(currentParent._h_slugPath).toContain('/') + const pathSegments = currentParent._h_slugPath?.split('/') + expect(pathSegments).toHaveLength(10) // level-0 through level-9 + } + }) + }) + + describe('Multiple Collections', () => { + beforeEach(async () => { + // Clear existing data before each test + await payload.delete({ collection: 'categories', where: {} }) + await payload.delete({ collection: 'departments', where: {} }) + }) + + afterEach(async () => { + // Clean up data after each test + await payload.delete({ collection: 'categories', where: {} }) + await payload.delete({ collection: 'departments', where: {} }) + }) + + it('should work with multiple collections having hierarchy', async () => { + // Create in categories + const rootCat = await payload.create({ + collection: 'categories', + data: { name: 'Electronics', parentCategory: null }, + }) + + const childCat = await payload.create({ + collection: 'categories', + data: { name: 'Laptops', parentCategory: rootCat.id }, + }) + + expect(childCat._h_depth).toBe(1) + expect(childCat._h_parentTree).toEqual([rootCat.id]) + + // Create in departments + const rootDept = await payload.create({ + collection: 'departments', + data: { deptName: 'Engineering', parentDept: null }, + }) + + const childDept = await payload.create({ + collection: 'departments', + data: { deptName: 'Frontend', parentDept: rootDept.id }, + }) + + // Check custom field names are used + expect(childDept._breadcrumbSlug).toBeDefined() + expect(childDept._breadcrumbTitle).toBeDefined() + expect(childDept._breadcrumbSlug).toBe('engineering/frontend') + expect(childDept._breadcrumbTitle).toBe('Engineering/Frontend') + }) + }) + + describe('generatePaths: false', () => { + beforeEach(async () => { + // Clear existing data before each test + await payload.delete({ collection: 'organizations', where: {} }) + }) + + afterEach(async () => { + // Clean up data after each test + await payload.delete({ collection: 'organizations', where: {} }) + }) + + it('should not add path fields when generatePaths is false', () => { + const orgsCollection = payload.collections.organizations.config + + // Check that path fields were NOT added + const slugPathField = orgsCollection.fields.find((f) => f.name === '_h_slugPath') + const titlePathField = orgsCollection.fields.find((f) => f.name === '_h_titlePath') + + expect(slugPathField).toBeUndefined() + expect(titlePathField).toBeUndefined() + + // But tree fields SHOULD exist + const depthField = orgsCollection.fields.find((f) => f.name === '_h_depth') + const parentTreeField = orgsCollection.fields.find((f) => f.name === '_h_parentTree') + + expect(depthField).toBeDefined() + expect(parentTreeField).toBeDefined() + + // Check hierarchy config + expect(orgsCollection.hierarchy).not.toBe(false) + if (orgsCollection.hierarchy !== false) { + expect(orgsCollection.hierarchy.generatePaths).toBe(false) + expect(orgsCollection.hierarchy.parentFieldName).toBe('parentOrg') + } + }) + + it('should track parent tree and depth without paths', async () => { + // Create root + const rootOrg = await payload.create({ + collection: 'organizations', + data: { + orgName: 'Acme Corp', + parentOrg: null, + }, + }) + + expect(rootOrg._h_depth).toBe(0) + expect(rootOrg._h_parentTree).toEqual([]) + // Path fields should not exist + expect(rootOrg._h_slugPath).toBeUndefined() + expect(rootOrg._h_titlePath).toBeUndefined() + + // Create child + const childOrg = await payload.create({ + collection: 'organizations', + data: { + orgName: 'Engineering Division', + parentOrg: rootOrg.id, + }, + }) + + expect(childOrg._h_depth).toBe(1) + expect(childOrg._h_parentTree).toEqual([rootOrg.id]) + // Path fields should not exist + expect(childOrg._h_slugPath).toBeUndefined() + expect(childOrg._h_titlePath).toBeUndefined() + + // Create grandchild + const grandchildOrg = await payload.create({ + collection: 'organizations', + data: { + orgName: 'Frontend Team', + parentOrg: childOrg.id, + }, + }) + + expect(grandchildOrg._h_depth).toBe(2) + expect(grandchildOrg._h_parentTree).toEqual([rootOrg.id, childOrg.id]) + expect(grandchildOrg._h_slugPath).toBeUndefined() + expect(grandchildOrg._h_titlePath).toBeUndefined() + }) + + it('should update descendants without paths when parent changes', async () => { + // Create initial tree + const rootOrg = await payload.create({ + collection: 'organizations', + data: { orgName: 'Root', parentOrg: null }, + }) + + const anotherRoot = await payload.create({ + collection: 'organizations', + data: { orgName: 'Another Root', parentOrg: null }, + }) + + const childOrg = await payload.create({ + collection: 'organizations', + data: { orgName: 'Child', parentOrg: rootOrg.id }, + }) + + const grandchildOrg = await payload.create({ + collection: 'organizations', + data: { orgName: 'Grandchild', parentOrg: childOrg.id }, + }) + + // Move child to another root + const updatedChild = await payload.update({ + id: childOrg.id, + collection: 'organizations', + data: { parentOrg: anotherRoot.id }, + }) + + // Check child was updated + expect(updatedChild._h_parentTree).toEqual([anotherRoot.id]) + expect(updatedChild._h_depth).toBe(1) + expect(updatedChild._h_slugPath).toBeUndefined() + expect(updatedChild._h_titlePath).toBeUndefined() + + // Check grandchild was updated + const updatedGrandchild = await payload.findByID({ + id: grandchildOrg.id, + collection: 'organizations', + }) + + expect(updatedGrandchild._h_parentTree).toEqual([anotherRoot.id, childOrg.id]) + expect(updatedGrandchild._h_depth).toBe(2) + expect(updatedGrandchild._h_slugPath).toBeUndefined() + expect(updatedGrandchild._h_titlePath).toBeUndefined() + }) + + it('should support descendant queries without paths', async () => { + // Create test tree + const root = await payload.create({ + collection: 'organizations', + data: { orgName: 'Root', parentOrg: null }, + }) + + const child1 = await payload.create({ + collection: 'organizations', + data: { orgName: 'Child 1', parentOrg: root.id }, + }) + + const child2 = await payload.create({ + collection: 'organizations', + data: { orgName: 'Child 2', parentOrg: root.id }, + }) + + const grandchild1 = await payload.create({ + collection: 'organizations', + data: { orgName: 'Grandchild 1', parentOrg: child1.id }, + }) + + // Query descendants + const descendants = await payload.find({ + collection: 'organizations', + where: { + _h_parentTree: { + in: [root.id], + }, + }, + }) + + expect(descendants.docs).toHaveLength(3) // child1, child2, grandchild1 + const ids = descendants.docs.map((d) => d.id) + expect(ids).toContain(child1.id) + expect(ids).toContain(child2.id) + expect(ids).toContain(grandchild1.id) + }) + + it('should support depth queries without paths', async () => { + const root = await payload.create({ + collection: 'organizations', + data: { orgName: 'Root', parentOrg: null }, + }) + + await payload.create({ + collection: 'organizations', + data: { orgName: 'Child 1', parentOrg: root.id }, + }) + + await payload.create({ + collection: 'organizations', + data: { orgName: 'Child 2', parentOrg: root.id }, + }) + + const depthOne = await payload.find({ + collection: 'organizations', + where: { + _h_depth: { equals: 1 }, + }, + }) + + expect(depthOne.docs).toHaveLength(2) // child1, child2 + }) + }) +}) diff --git a/test/hierarchy/payload-types.ts b/test/hierarchy/payload-types.ts new file mode 100644 index 00000000000..62b53a33e12 --- /dev/null +++ b/test/hierarchy/payload-types.ts @@ -0,0 +1,425 @@ +/* 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: { + pages: Page; + categories: Category; + departments: Department; + organizations: Organization; + 'payload-kv': PayloadKv; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + pages: PagesSelect | PagesSelect; + categories: CategoriesSelect | CategoriesSelect; + departments: DepartmentsSelect | DepartmentsSelect; + organizations: OrganizationsSelect | OrganizationsSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + fallbackLocale: null; + 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` "pages". + */ +export interface Page { + id: string; + parent?: (string | null) | Page; + title: string; + content?: string | null; + updatedAt: string; + createdAt: string; + _h_slugPath?: string | null; + _h_titlePath?: string | null; + _h_parentTree?: (string | Page)[] | null; + _h_depth?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "categories". + */ +export interface Category { + id: string; + parentCategory?: (string | null) | Category; + name: string; + updatedAt: string; + createdAt: string; + _h_slugPath?: string | null; + _h_titlePath?: string | null; + _h_parentTree?: (string | Category)[] | null; + _h_depth?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "departments". + */ +export interface Department { + id: string; + parentDept?: (string | null) | Department; + deptName: string; + updatedAt: string; + createdAt: string; + _breadcrumbSlug?: string | null; + _breadcrumbTitle?: string | null; + _h_parentTree?: (string | Department)[] | null; + _h_depth?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "organizations". + */ +export interface Organization { + id: string; + parentOrg?: (string | null) | Organization; + orgName: string; + description?: string | null; + updatedAt: string; + createdAt: string; + _h_parentTree?: (string | Organization)[] | null; + _h_depth?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: string; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + updatedAt: string; + createdAt: string; + 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` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'pages'; + value: string | Page; + } | null) + | ({ + relationTo: 'categories'; + value: string | Category; + } | null) + | ({ + relationTo: 'departments'; + value: string | Department; + } | null) + | ({ + relationTo: 'organizations'; + value: string | Organization; + } | null) + | ({ + relationTo: 'users'; + value: string | User; + } | 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` "pages_select". + */ +export interface PagesSelect { + parent?: T; + title?: T; + content?: T; + updatedAt?: T; + createdAt?: T; + _h_slugPath?: T; + _h_titlePath?: T; + _h_parentTree?: T; + _h_depth?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "categories_select". + */ +export interface CategoriesSelect { + parentCategory?: T; + name?: T; + updatedAt?: T; + createdAt?: T; + _h_slugPath?: T; + _h_titlePath?: T; + _h_parentTree?: T; + _h_depth?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "departments_select". + */ +export interface DepartmentsSelect { + parentDept?: T; + deptName?: T; + updatedAt?: T; + createdAt?: T; + _breadcrumbSlug?: T; + _breadcrumbTitle?: T; + _h_parentTree?: T; + _h_depth?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "organizations_select". + */ +export interface OrganizationsSelect { + parentOrg?: T; + orgName?: T; + description?: T; + updatedAt?: T; + createdAt?: T; + _h_parentTree?: T; + _h_depth?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + updatedAt?: T; + createdAt?: 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` "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/hierarchy/tsconfig.eslint.json b/test/hierarchy/tsconfig.eslint.json new file mode 100644 index 00000000000..b34cc7afbb8 --- /dev/null +++ b/test/hierarchy/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/hierarchy/tsconfig.json b/test/hierarchy/tsconfig.json new file mode 100644 index 00000000000..3c43903cfdd --- /dev/null +++ b/test/hierarchy/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +}