[6.x] Hierarchical Taxonomies - #15192
Draft
jackmcdade wants to merge 86 commits into
Draft
Conversation
Opt-in trees live beside collection/nav trees, with parent/child/ancestor accessors on terms and a listener that keeps the tree in sync when terms are saved or deleted. Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse the collection page tree so terms can be nested, reordered, and created as children, with max depth and a reorder permission. Co-authored-by: Cursor <cursoragent@cursor.com>
Tree position drives URIs (with a 301 from the old flat path), and taxonomy/collection tags can filter by parent, depth, and descendant terms. Co-authored-by: Cursor <cursoragent@cursor.com>
Indent options by depth, search by path, create missing segments from a typed path, and show ancestor hints on selected items. Co-authored-by: Cursor <cursoragent@cursor.com>
Add a tree endpoint plus parent/children/ancestors/depth on terms so frontends can walk the hierarchy without the CP. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…-scoped twins. Co-authored-by: Cursor <cursoragent@cursor.com>
Make it obvious you can search or type a path, preview the hierarchy as badges in the create option, and drop the redundant parent hint once items are indented. Co-authored-by: Cursor <cursoragent@cursor.com>
A parent typed in the same path was not in the tree yet, so the child never grafted and both terms appeared at the root. Co-authored-by: Cursor <cursoragent@cursor.com>
Association indexes created stub keys for sites the taxonomy doesn't use, so Term::find() returned a title-from-slug stub that overwrote the real file on reload. Co-authored-by: Cursor <cursoragent@cursor.com>
The tree previously always promoted child terms into the deleted parent's place, with no way to remove the whole branch. Co-authored-by: Cursor <cursoragent@cursor.com>
…he tree. Co-authored-by: Cursor <cursoragent@cursor.com>
…ions. Co-authored-by: Cursor <cursoragent@cursor.com>
…m ones. Co-authored-by: Cursor <cursoragent@cursor.com>
…ing with reorder permission. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…rees. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…follow tree order. Co-authored-by: Cursor <cursoragent@cursor.com>
… collections. Co-authored-by: Cursor <cursoragent@cursor.com>
…te URLs follow the selected site. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… branch. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…when structured. Co-authored-by: Cursor <cursoragent@cursor.com>
…reparent from the publish form. Co-authored-by: Cursor <cursoragent@cursor.com>
Tree::save() bails when dispatchSavingEvent() returns false, but TaxonomyTreeSaving inherited Event::dispatch(), which calls event() without halting and so returns an array of listener responses. A listener returning false was ignored and the tree saved anyway. NavTreeSaving and CollectionTreeSaving both override dispatch() to halt. This one was missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
847503a added a tree prop to SelectField, and the binding for it on RelationshipInput, to gate a taggable placeholder branch. 8f18c5f removed that branch as dead -- both arms returned the same string -- but left the prop and binding behind. SelectField never used tree for anything tree-shaped. Selector remains the only consumer, and its binding on RelationshipInput is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The select field was type-sniffing `config.type` to decide which item keys to fuzzy match on. That's the fieldtype declaring how it wants to be searched, so it belongs in `preload()`. Terms now send `search_titles` — the breadcrumb of ancestor titles plus their own — in place of the slug-joined `path`, so what's typed can match what's on screen.
A hierarchy prefix isn't a taxonomy concept — an entry from a structured collection wants the same thing. So `Relationship` gains a `getItemPath()` sibling to `getItemHint()`, `Terms` overrides it with its ancestor titles, and the item row renders whatever `path` it's given as a breadcrumb. Terms were shipping the ancestor chain twice: joined into `hint` and structured as `ancestors`, with a `showHintFallback` computed existing only to stop the two double-printing. The taxonomy name says which bucket a term came from; the ancestors say where it sits inside it. Splitting them lets `hint` go back to meaning what it means for every other relationship fieldtype, so its markup reverts to the right-aligned treatment — which also fixes an entries field with more than one collection rendering `Blog > My Post` instead of `My Post … Blog`. That leaves `taxonomy_title` with nothing to do, since `hint` carries it again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing `animals > cat > calico` into a terms field creates the whole chain on save, but until then the row was synthesised client-side by sniffing `config.type === 'terms'` and splitting on a hard-coded `>`. It rendered a `»`-joined hint where a saved term renders badges, so the optimistic row never looked like the thing it was going to become — and since the term doesn't exist until the entry is saved, that's the row you look at for the whole session. `preload()` now says which character, if any, separates a typed path, and the select field splits on that and emits a `path`, so the row goes through the same breadcrumb branch as a saved one. The two methods that only existed to sniff a term id go with it, along with the create row's third separator glyph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Search results kept their indentation. The list is only sorted into tree order when nothing is searching, sorting or paginating, but `depth` was stamped on every item regardless, so a matching child stayed indented under a parent that wasn't in the results at all. `depth` is now only sent for a list that is actually in tree order, and the option row indents when it has one and renders a breadcrumb when it doesn't. An indent locates an option against the ancestors listed above it; with those gone, a breadcrumb answers the same question on its own. `path` keeps shipping either way, because it describes the term rather than the list — the selected row needs it however the list that produced it was ordered, and select field options are handed straight to that row on click. The breadcrumb is the one the item row already renders, extracted so the option and the row it becomes can't drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In select mode the options are fetched once, without a search, and the combobox filters that list itself. The server has no query to see, so it sends a depth, and filtered options stayed indented under parents that were no longer in the list. Track the query the combobox already emits and suppress the indent while it's set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hint was hidden on anything below the top level, from when it carried a term's ancestors as well as its taxonomy and would have collided with the indent. It only names the taxonomy now, which a multi-taxonomy field needs most on exactly the nested options it was being hidden from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`?fields=` could return a 500. The resource rejected the hierarchy fields from the selection, and a request that asked for nothing else left an empty list, which `AbstractAugmented::select()` reads as "everything" and falls back to the full key set. `children` and `ancestors` reach each other, and nested `AugmentedCollection`s don't inherit shallow nesting, so serializing them recursed until the stack ran out. `?fields=entries` was enough to trigger it, and that one predates any of this — it returns 200 on 6.x. The rejection also ran against explicit selections, so `?fields=id` came back with a `parent` nobody asked for, and it was gated on `hierarchical()` while the augmented methods were gated on `hasStructure()`, leaving an orderable taxonomy emitting `ancestors`, `children` and `is_root: true` on every term. The terms endpoints now behave like the entries ones. Hierarchy fields are opt-in rather than part of the default payload, `parent` comes back shallow when it's asked for, and `children` and `ancestors` come back null: the flat listing stays flat, and the tree endpoint is what traverses a structure, with `fields` and `max_depth` of its own. A shallow child would only reach one level anyway, and offering one would steer people away from the endpoint that answers the question properly. Shaping the values in `AugmentedTerm` rather than the resource means no field is ever removed from a selection, so it can't empty and can't fall back. The exclusions apply to the default key set only, and only on a taxonomy with a structure, so a flat taxonomy keeps blueprint fields that happen to be named `parent` or `children`. Antlers and GraphQL are untouched, since the guard is `isApiRoute()`. `ResolvesRequestedFields` comes along for two reasons: `?fields=` now works on a single term, and looking a parent up re-queries it, which clears the columns the original query selected. Reading the parameter off the request survives that; reading it off the term did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The terms endpoints resolve it from the container, so an app that maps its own over ours gets it there, but the tree constructed one directly and silently ignored the override. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReorderTermsController was a copy of ReorderEntriesController as it stood on 2026-08-13. #15238 fixed the original eight days later, and the 6.x merge brought the fixed entries controller in alongside the unfixed copy. Ports that fix. A positional write replaces zip(), which padded with null and so silently dropped or duplicated branches, and a set-equality check now 409s when the submitted ids aren't a rearrangement of the page being reordered. Between them that covers short payloads, unknown slugs, duplicate ids and out-of-range pages, all of which previously returned 200 and corrupted the tree file. Also brings across #15238's toast change so the 409 message is actually shown. Adds min:1 to page and perPage, which #15238 did not have. Without it, page 0 yields a negative offset that slices from the end of the tree and then writes negative keys onto it. That hole was found here and fixed for collections in #15429. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It collides with the pre-existing LocalizedTerm::isRoot() (default-locale meaning) and nothing internal ever reads the augmented value. depth == 1 is the existing idiom for top-level, already used on the collection/nav side.
On a term page there's no collection in context, so Children::index()
built the handle "collection::" and Structure::ensureStructureExists()
threw CollectionNotFoundException('') trying to find a collection with
an empty handle. Entry pages never hit this because the collection is
always present in context.
This PR makes child terms a concept, which makes {{ children }} the
obvious thing to write on a term template, so it's what makes this
previously unreachable fatal reachable in practice. Guard the missing
collection and return [], matching what the collection path already
produces when there's no structure to build from.
valueRefersToTerm() slugged with the default "en" while the association index and the terms store both slug with the item's language, so on a non-English site a term rename silently failed to rewrite references to it. It now goes through slugFromValue(), like everything else, using the termLang() the fieldtype already uses when creating terms. The language comes from the field's parent, which the reference updater wasn't reliably supplying. Blueprint::fields() returns its cached Fields before re-applying the parent, and the blueprint itself is shared between items, so the parent stuck on whichever item the loop reached first. Nested fields never had one at all. Point both at the item being updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StructureRepository::all() merges taxonomy structures, but the fieldtype never emitted the taxonomy:: prefix that findByHandle() expects, and had no TaxonomyStructure branch in its authorization check. No policy is registered for TaxonomyStructure and there is no global super-user gate bypass, so every user was denied and taxonomy structures were filtered out of the listing entirely. Prefixing the id also removes the handle collision with a nav of the same name, matching how collection structures already work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stored term values have been whole values rather than delimited paths since 735835f, so the path variants this built could never match anything, and a filter value could never arrive with a delimiter in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both readers were reverse lookups that flipped the index to turn a uri back into a handle. Per-taxonomy route control removed the assumption they relied on -- that a taxonomy's uri is derivable from its handle -- so they were replaced with forward route matching and the index went unread. It was also being maintained wrongly by then, caching a Site::current()-dependent value, and null for any taxonomy with routes disabled. Nothing else reads it. An unlisted index still resolves lazily through Store::resolveIndex(), so anything asking for it keeps working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…axonomies # Conflicts: # src/Data/DataReferenceUpdater.php
A terms field with a single taxonomy stores bare slugs, while one with multiple taxonomies stores prefixed term references. The filter expanded everything to bare slugs, so on a multiple taxonomy field it would match an identically slugged term in another taxonomy, and descendants of a hierarchical term wouldn't match at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-site The selector was in the template but never imported, so it rendered as a stray element with a Vue warning. Registering it lets you switch which site's term titles and slugs the tree is shown in. The tree itself is shared between sites, so nothing about saving is per-site. Dropped the site from the tree and reorder payloads, and the unused site validation rule from the reorder controller. The list view's reorder selector went with it, since the listing is always the selected site and the order is global either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There were three max-depth rules across four implementations. This adds assertCanNest(), the single rule for whether a child may be nested under a given parent, plus a public depthOfTerm() for callers that need the depth itself, and drops the private duplicate that graftTerm() was using. It also takes assertDoesNotExceedMaxDepth() out of validateTree(). That assert was running inside the tree getter, so lowering a taxonomy's max_depth below its existing tree made every read throw and left the taxonomy unusable until the YAML was edited by hand. The reorder path already calls the assert explicitly, which is the only place a tree can actually get deeper, so nothing is left unguarded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing "Cat > Kitten" into a terms field checked only the number of typed segments, which ignores how deep the existing segments already sit. With a max_depth of 2 and a tree of animals > cat, that check passed, the kitten term was created and saved, and the graft then threw. The entry save 422'd and a stray term was left behind. The path is now walked before anything is created: segments already in the tree keep their own depth, new ones land under the previous segment, and the whole path is rejected if the leaf would land too deep. The segment parsing lived in both the fieldtype and EnsuresTermPaths; it now lives in segments() and the fieldtype uses it. The duplicated max-depth check in the fieldtype is gone, and ensure() takes the validation key so the error still attaches to the field in the publish form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
update() called toTree() on the request twice, and repaired the result twice with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
store() only touched the tree when a parent was given, so a term created without one was never written to the tree file. It only appeared because validateTree() synthesises missing terms at read time. EntriesController appends unconditionally; terms now do the same. Grafting still happens first. Appending at the root beforehand would put the slug in the tree, and graftTerm() would then see it and bail, leaving a parented term sitting at the root. A term whose parent isn't in the tree now lands at the root rather than being left out of the file entirely. This also switches the controller to the structure's assertCanNest() instead of its own copy of the max-depth check, which is the same edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(I know, finally)
Taxonomies can now be hierarchical, using the same structure/tree model as collections.
Enable it on a taxonomy, drag terms into a tree, and you get nested URLs, parent/children/ancestors/depth in tags, a CP tree view, and a parent relationship field on the publish form. Max depth 1 stays a flat reorderable list, same as collections.
See it in action – https://screen.studio/share/IeuqzNy2
A few constraints worth knowing:
expectsRoot. Terms don't have a collection-style root page.Templating
Tree, same as nav/collection structures. Recursive children work the usual way.
nav:taxonomy:…is an alias.{{ structure:taxonomy:categories }} {{ title }} {{ if children }}{{ *recursive children* }}{{ /if }} {{ /structure:taxonomy:categories }} {{ structure for="taxonomy::categories" from="animals" max_depth="2" }} {{ title }} (depth {{ depth }}) {{ /structure }}On a term, the tree is just variables:
{{ parent:title }} {{ depth }} {{ children }}{{ title }}{{ /children }} {{ ancestors }}{{ title }}{{ /ancestors }}List terms by branch with
{{ taxonomy }}.parentwithoutdepthis direct children; adddepthto go further.depthalone is top N levels of the whole tree.{{ taxonomy from="categories" parent="animals" }} {{ title }} {{ /taxonomy }} {{ taxonomy from="categories" parent="animals" depth="2" }} {{ title }} {{ /taxonomy }} {{ taxonomy from="categories" depth="1" }} {{ title }} {{ /taxonomy }}Entry listings include the whole branch by default. Opt out with
with_descendants="false".{{ collection:blog taxonomy:categories="animals" }} {{ title }} {{ /collection:blog }} {{ collection:blog taxonomy:categories="animals" with_descendants="false" }} {{ title }} {{ /collection:blog }}Still needed
with_descendants, the parent field, and the "typed paths don't re-parent" rule.Test plan
{{ structure:taxonomy:… }}and collection/entry filters include descendants by defaultIssues
Closes statamic/ideas#92
Closes statamic/ideas#264
Closes statamic/ideas#1053
Closes #2334.
Related: #74, statamic/ideas#839, statamic/ideas#580.