Skip to content

feat: hierarchy with ui#15769

Open
JarrodMFlesch wants to merge 369 commits intomainfrom
folder-field-column-drawer
Open

feat: hierarchy with ui#15769
JarrodMFlesch wants to merge 369 commits intomainfrom
folder-field-column-drawer

Conversation

@JarrodMFlesch
Copy link
Contributor

@JarrodMFlesch JarrodMFlesch commented Feb 26, 2026

Hierarchy Feature

This PR introduces a comprehensive hierarchy system for Payload that enables collections to have parent-child relationships with automatic path generation, dedicated sidebar navigation, and specialized UI components for folder and tag patterns.

Overview

The hierarchy feature allows any collection to define parent-child relationships through a declarative hierarchy config property. When enabled, Payload automatically handles relationship management, path computation, circular reference prevention, and injects a dedicated sidebar tab with a tree view for navigation.

At its core, hierarchy manages a parentFieldName relationship field that references documents within the same collection. The system automatically creates this field if it doesn't exist, adds validation to prevent circular references (you can't move a folder into its own subfolder), and computes virtual path fields that provide breadcrumb-style paths from root to each document.

Path Generation

Two virtual fields are automatically added to hierarchy-enabled collections: _h_slugPath and _h_titlePath. These compute breadcrumb paths by walking up the parent chain to root during read operations. For example, a document nested three levels deep might have paths like engineering/frontend/components and Engineering / Frontend / Components. Path computation is cached per-request to avoid redundant ancestor queries, and uses overrideAccess: true to ensure complete paths even when users lack read permission on intermediate ancestors.

The field names are customizable via slugPathFieldName and titlePathFieldName in the hierarchy config. Path generation also respects localization, returning localized strings when the collection uses localized title fields.

Sidebar Tabs

The hierarchy feature builds on a new sidebar tabs system that allows rendering custom tabs alongside the default Collections tab. Each hierarchy collection automatically gets its own tab injected during config resolution. The tab displays a tree view of the hierarchy with expand/collapse functionality, search, and optional collection-type filtering.

When you click a node in the tree, the list view filters to show only that node's children and related documents. The URL updates with a ?parent=<id> parameter, and the tree highlights the currently selected node. Expanded node state persists across sessions via payload-preferences.

Helper Functions

Four helper functions simplify common hierarchy patterns:

createFoldersCollection creates a folder-style hierarchy where each document can have only one parent (single-select). It enforces allowHasMany: false, hides the collection from the main nav (folders are accessed via their sidebar tab), sets a default folder icon, and enables the miller columns header button by default.

createFolderField creates a relationship field for assigning a single folder to documents in other collections. The field renders as a header button that opens a miller columns drawer for folder selection instead of the standard relationship dropdown.

createTagsCollection creates a tag-style hierarchy where documents can have multiple parents (multi-select by default). This is useful for categorization systems where items can belong to multiple categories.

createTagField creates a relationship field for assigning multiple tags to documents. Unlike folder fields, tag fields use the standard relationship UI with hierarchy-aware features.

Setup

To add hierarchy to a collection, add the hierarchy property to your collection config:

const Categories: CollectionConfig = {
  slug: 'categories',
  admin: { useAsTitle: 'name' },
  fields: [{ name: 'name', type: 'text', required: true }],
  hierarchy: {
    parentFieldName: 'parent',
  },
}

For folder patterns, use the helper functions:

import { createFoldersCollection, createFolderField } from 'payload'

const Folders = createFoldersCollection({
  slug: 'folders',
  useAsTitle: 'name',
  fields: [{ name: 'name', type: 'text', required: true }],
})

const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    createFolderField({ relationTo: 'folders' }),
  ],
}

Migration from Previous Folders Implementation

If you used the previous folders config key, migration to the new hierarchy system is straightforward. The old system used payload-folders as the collection slug and folder as the relationship field name. Set parentFieldName: 'folder' on your folders collection to preserve compatibility with existing data:

import { createFoldersCollection, createFolderField } from 'payload'

const Folders = createFoldersCollection({
  slug: 'payload-folders',
  useAsTitle: 'name',
  fields: [{ name: 'name', type: 'text', required: true }],
  hierarchy: {
    parentFieldName: 'folder',
    collectionSpecific: { fieldName: 'folderType' }, // if you were using this before
  },
})

// In collections that were folder-enabled:
const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    createFolderField({ relationTo: 'payload-folders' }),
  ],
}

The parentFieldName setting controls the field name across all related collections. When set to folder, the field created by createFolderField automatically gets renamed from its default (_h_payload-folders) to folder during config resolution, matching your existing database schema.

Collection-Specific Folders

Folders can optionally restrict which collection types they accept. Enable this with collectionSpecific:

const Folders = createFoldersCollection({
  slug: 'folders',
  useAsTitle: 'name',
  fields: [{ name: 'name', type: 'text', required: true }],
  hierarchy: {
    collectionSpecific: true, // or { fieldName: 'allowedTypes' }
  },
})

This adds a multi-select field to folders where you can specify which collections can be placed in that folder. When filtering the sidebar tree, only folders that accept the current collection type are shown.

Join Field

For querying all children of a hierarchy item (both nested items and related documents from other collections), configure the joinField option:

hierarchy: {
  parentFieldName: 'parent',
  joinField: { name: 'children' },
}

This creates a virtual join field that aggregates all documents referencing each hierarchy item as their parent.

CleanShot.2026-03-17.at.08.26.22.mp4

JarrodMFlesch and others added 30 commits February 10, 2026 12:58
Injects relationship fields into related collections and computes
sanitized relatedCollections with fieldName and hasMany info.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Field injection happens after all collections are sanitized but before
sidebar tabs are added.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update types to not allow `taxonomy: true`
- Simplify typeof checks throughout codebase
- Update sanitizeTaxonomy to not normalize boolean
- Update relatedCollections default to {} instead of []

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove runtime field traversal functions
- Use sanitized relatedCollections with fieldName and hasMany
- Include fieldInfo in response for client-side pagination

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove runtime field traversal. Use sanitized relatedCollections
with pre-computed fieldName and hasMany values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use fieldInfo passed from server instead of traversing fields client-side.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Users now declare `taxonomy.relatedCollections` as an explicit object
  with config per collection: `{ posts: { hasMany: true } }`
- Payload auto-injects relationship fields (`_t_<taxonomySlug>`) into
  related collections during config sanitization
- Moved `injectTaxonomyFields` call to before collection sanitization
  loop so injected fields are properly sanitized
- All relationship metadata is computed once at sanitization time,
  eliminating runtime field traversal
- Removed boolean `taxonomy: true` support - config must be an object
- Updated tests to use auto-injected `_t_tags` field name

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Make root breadcrumb always clickable when viewing nested taxonomy items
- Allow last StepNav item to be a link if it has a URL
- Add mergeCheckboxHeader prop to SlotTable for spanning Name header
  across checkbox and content columns
- Remove unused replace prop from StepNavItem type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add URL-based search with proper debouncing and page reload prefill
- Use startRouteTransition for loading state during search navigation
- Update breadcrumbs to show TagIcon + collection label
- Search state now persists in URL query params

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add color prop to TagIcon with 'muted', 'dark', 'default' variants
- Apply flex layout with gap for step nav icon + label
- Use muted color for TagIcon in taxonomy list breadcrumbs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Replace hasMany prop with fieldOverrides for full relationship field customization
- fieldOverrides accepts all SingleRelationshipField props except name, type, relationTo
- Remove buildTaxonomyRelationshipField helper (inlined in injectTaxonomyFields)
- Export new types: TaxonomyRelatedCollectionConfig, TaxonomyRelationshipFieldOverrides
- Update test config to use new fieldOverrides pattern

BREAKING CHANGE: taxonomy.relatedCollections now uses fieldOverrides instead of hasMany
Before: { posts: { hasMany: true } }
After:  { posts: { fieldOverrides: { hasMany: true } } }

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace magic field injection with explicit field creation:
- Add createTaxonomyField() helper function for creating taxonomy relationship fields
- Change relatedCollections from object config to simple string[] of collection slugs
- Add validateTaxonomyFields() to validate fields exist and throw helpful errors
- Remove injectTaxonomyFields() - no more magic field injection

New API:
```typescript
// Taxonomy collection:
taxonomy: {
  relatedCollections: ['posts', 'pages'],
}

// Related collection - add field explicitly:
fields: [
  createTaxonomyField({ taxonomySlug: 'tags', hasMany: true }),
]
```

Benefits:
- Users see exactly what fields are in their collections
- Full control over field placement and ordering
- Clear error messages if field is missing
- No magic injection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…mentButton

- Add allowHasMany config option to taxonomy to control relationship cardinality
- Refactor TaxonomyViewData types and rename relatedDocuments to relatedDocumentsByCollection
- Add CreateDocumentButton component for multi-collection create dropdown
- Improve TaxonomyList with empty states, single-item edit, and show all related collections
- Cache sidebar tab content with Activity component for better performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
JarrodMFlesch and others added 15 commits March 13, 2026 09:39
- Add dynamic property to SidebarTab config type
- Mark hierarchy tabs as dynamic so they reload with fresh baseFilter
- Add SidebarTabsProvider for tab control functions
- Remove stale detection logic from HierarchySidebarTab
- Fix cannot-collapse bug: contextSeededRef tracks whether context has
  been seeded since last navigation, preventing fallback to
  initialExpandedNodesProp after user collapses all nodes
- Fix children flash on expansion: useChildren returns effectiveChildren
  (children ?? cache fallback) so cache pre-populated by Tree's useMemo
  is used synchronously before state is set
- Fix tree clearing after refreshTree: loadRootNodes effect fetches root
  nodes when initialData is unavailable post-refresh
- Fix post-refresh navigation flashes: effectiveInitialData blocks only
  the stale snapshot captured at refresh time; fresh initialData from
  router.refresh() is used once it arrives
- Add TreeNode useEffect to auto-load children when expanded with no
  data, handling both programmatic expansion and post-refresh remounts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace refreshTree() + router.refresh() race with a direct call to
useSidebarTabs().reloadTabContent(), which re-runs HierarchySidebarTabServer
and replaces the tab with fresh initialData in one step.

Removes all complexity that worked around the race:
- treeRefreshKey / effectiveInitialData blocking in HierarchySidebarTab
- loadRootNodes effect in Tree
- TreeNode useEffect for post-refresh child loading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vider

HierarchyListHeader is in the main content area, outside SidebarTabsProvider,
so useSidebarTabs() returns null there. Instead, HierarchySidebarTab (which
renders inside the provider) watches treeRefreshKey and calls reloadTabContent
on itself when it changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… tree

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@PP-Tom
Copy link

PP-Tom commented Mar 17, 2026

If there are 200 items in a folder, does this paginate?

@JarrodMFlesch
Copy link
Contributor Author

If there are 200 items in a folder, does this paginate?

It's a "load more" approach currently, so yes. Might bring pagination in if we need to. It would have to be per table which could flood the url. It's definitely an option if we need it.

@jhb-dev
Copy link
Contributor

jhb-dev commented Mar 18, 2026

Hi @JarrodMFlesch, thank you for the incredible work on this! I've pulled down the branch and tested it a bit. I have some questions and feedback:

Questions

  1. Replace the nested-docs plugin? The Tree View RFC mentioned moving toward a built-in solution. If so, are there plans to cover the nested-docs use cases that hierarchy doesn't yet handle (e.g. cross-collection parents, customizable breadcrumb fields)?

  2. Cross-collection parent support. Currently hierarchy is self-collection-referential only (relationTo: collectionSlug). Are there plans to support a parent field that references a different collection? (related issue)

Feedback

  1. View modes. Enabling hierarchy replaces the list view with a folder view that only shows children of the current parent. There's no way to get a flat list of all documents (e.g. "show me all pages"). A view mode switcher (list / folder / tree) would solve this nicely.

  2. Sidebar tabs opt-out. Currently every hierarchy-enabled collection automatically adds a sidebar tab. In projects with 10+ collections that have parent-child relationships, this would overwhelm the sidebar. It would be great to have an option like admin: { sidebarTab: false } in the hierarchy config to disable the tab for collections where it's not needed.

  3. Configurable slug field for path generation. The _h_slugPath is built by running slugify() on the useAsTitle field. There's no way to point it at a dedicated slug field instead. A slugField option in the hierarchy config would be useful for cases where the title and slug differ (e.g. title: "About Us", slug: "about").

  4. Path fields are empty in the admin UI. The _h_slugPath and _h_titlePath fields are visible in the document edit view but always empty because the admin panel doesn't pass computeHierarchyPaths=true when fetching documents. Either the fields should be populated in the UI, or hidden from the edit view.

  5. select-based path computation is broken. The afterRead hook checks if path fields are selected via ?select[_h_slugPath]=true, but this doesn't work in practice: select strips the parent and title fields needed for ancestor traversal, resulting in flat paths (e.g. "Advanced Tips" instead of "Home/Blog/Tutorials/Advanced Tips"). Only ?computeHierarchyPaths=true without select produces correct full paths.

Thanks again, appreciate any response!

@JarrodMFlesch
Copy link
Contributor Author

  1. Replace the nested-docs plugin? The Tree View RFC mentioned moving toward a built-in solution. If so, are there plans to cover the nested-docs use cases that hierarchy doesn't yet handle (e.g. cross-collection parents, customizable breadcrumb fields)?
  • Since nested-docs does not support cross-collection hierarchy I would consider that moot for now.
  • What breadcrumb customizations would you like to see?
  1. Cross-collection parent support. Currently hierarchy is self-collection-referential only (relationTo: collectionSlug). Are there plans to support a parent field that references a different collection? (related issue)

I'm not actually sure. I know we were not initially planning on this. I will have to think through how this would work. If you have any thoughts or input I am all ears! Im not sure how you would configure this either.

  1. View modes. Enabling hierarchy replaces the list view with a folder view that only shows children of the current parent. There's no way to get a flat list of all documents (e.g. "show me all pages"). A view mode switcher (list / folder / tree) would solve this nicely.

I agree, I thought about this while I was building it out. It would be nice to still have the ability to get to the default list view on these collections.

  1. Sidebar tabs opt-out. Currently every hierarchy-enabled collection automatically adds a sidebar tab. In projects with 10+ collections that have parent-child relationships, this would overwhelm the sidebar. It would be great to have an option like admin: { sidebarTab: false } in the hierarchy config to disable the tab for collections where it's not needed.

Good call, it would be nice to have this.

  1. Configurable slug field for path generation. The _h_slugPath is built by running slugify() on the useAsTitle field. There's no way to point it at a dedicated slug field instead. A slugField option in the hierarchy config would be useful for cases where the title and slug differ (e.g. title: "About Us", slug: "about").

Also sounds like a good idea, I feel like there was a reason I did not do this to start, but agree that we should look into it.

  1. Path fields are empty in the admin UI. The _h_slugPath and _h_titlePath fields are visible in the document edit view but always empty because the admin panel doesn't pass computeHierarchyPaths=true when fetching documents. Either the fields should be populated in the UI, or hidden from the edit view.

Yes, I was thinking it might be nice to have the fields have like a "generate" or "show" type of toggle that would allow them to populate. I don't really want to query the document with the flag.

  1. select-based path computation is broken. The afterRead hook checks if path fields are selected via ?select[_h_slugPath]=true, but this doesn't work in practice: select strips the parent and title fields needed for ancestor traversal, resulting in flat paths (e.g. "Advanced Tips" instead of "Home/Blog/Tutorials/Advanced Tips"). Only ?computeHierarchyPaths=true without select produces correct full paths.

Yes, internally I do this by selecting the fields. But we could be smart about it probably and select the fields we know we will need.

Thanks again, appreciate any response!

Than you for your feedback!

JarrodMFlesch and others added 6 commits March 18, 2026 11:46
- Collections with sidebar tabs are automatically hidden from nav
- Collections with explicit `group` appear in both nav and sidebar tab
- Add e2e tests for sidebar tab visibility behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Allows using a dedicated field (e.g., 'slug') for _h_slugPath instead
of slugifying the title. Supports localized slug fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…parameter

- Add beforeOperation hook to detect select queries with path fields
- Auto-include parent/title/slug fields needed for path computation
- Strip auto-added fields from response to respect user's select
- Add computeHierarchyPathsViaSelect context flag for afterRead

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@JarrodMFlesch
Copy link
Contributor Author

@jhb-dev update here, I added support for items 3, 4, 5, 7 from above. Still thinking about 6.

@jhb-dev
Copy link
Contributor

jhb-dev commented Mar 19, 2026

Hi @JarrodMFlesch, thanks for implementing the feedback!

Regarding the view mode switch on the collection page, I see the intent behind "by {collection_label}", but I'm not sure users will immediately understand what it means. The concatination also feels fragile for localization.

What do you think of these three view modes:

  • List view: default list view
  • Tree view: a nested, expandable tree (as shown in the RFC, for navigating parent/child relationships
    within a collection), not only in the sidebar, but as its own view mode on the collection page itself with column support
  • Folder view: a folder-based view for browsing documents grouped by parent (what the current "by
    Pages" button does)

The way I think about it: hierarchy is the underlying data model (a collection with a parent field), while tree and folder are two distinct views/use cases built on top of it.

For example, a media collection benefits most from the folder view, while tags or web pages are better served by a tree view. Making these explicit view modes would let users pick the one that fits the mental model of the collection.

}),
position: 'sidebar',
},
hasMany: false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding filterOptions: ({ id }) => (id ? { id: { not_equals: id } } : true) here to exclude the current document from the dropdown would be a nice UX improvement.

The beforeChange hook prevents self-referencing at the data level anyway.

Comment on lines +1 to +6
/**
* beforeOperation Hook Responsibilities:
* - Detect when path fields (_h_slugPath, _h_titlePath) are selected
* - Auto-include required fields (parent, title, slugField) for ancestor traversal
* - Track auto-added fields in context so afterRead can strip them from response
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition! I implemented something similar in my plugin.

One thing to watch out for: this currently only handles select include mode, not exclude.

But ideally this would be solved at the framework level for all virtual fields, not just hierarchy. See #14468

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooo the force select fn is interesting!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants