[6.x] Taxonomy structures (hierarchies), routing, and more - #15192
Draft
jackmcdade wants to merge 108 commits into
Draft
jackmcdade wants to merge 108 commits into
jackmcdade wants to merge 108 commits into
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>
…-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>
…axonomies # Conflicts: # src/Http/Controllers/CP/Taxonomies/ExtractsFromTermFields.php # src/Http/Controllers/CP/Taxonomies/TermsController.php # tests/Feature/Taxonomies/UpdateTermTest.php
…axonomies # Conflicts: # src/StaticCaching/DefaultInvalidator.php
An unknown ?site= handle fatalled with "Call to a member function absoluteUrl() on null" because the handle was passed straight to LocalizedTerm, and a real site the taxonomy isn't available in silently returned the default site's terms. Mirrors TaxonomyTermsController::show(). The tree itself remains site-agnostic; only the localization target is validated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tree branch referencing a term that no longer exists was rejected along with its entire subtree, so its children vanished from tree() instead of moving up into its place. The missing term re-append couldn't bring them back either, since they were already counted in the tree's slugs. removeTermReferencesFromTree() now promotes children, matching what removeDuplicateTermsFromTree() and deleting a term already do. depthOfTerm() applies the same strip, so the max depth rules stop counting levels that aren't in the tree the user sees. Previously a taxonomy with a dangling branch could render an empty tree and still reject nesting under it as exceeding max depth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`{{ entries }}` on a term already includes entries tagged with any of its
descendants, but `entries_count` queried the associations index by the
term's own slug, so the two disagreed on a nested taxonomy.
Extract the subtree walk to `Taxonomy::termWithDescendants()` so the count
and the entry query share one implementation. It returns just the term's
slug on a flat taxonomy, leaving those counts untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad mode The terms fieldtype promised that searching a parent would surface its descendants, but only delivered it in select mode, where the whole list is fetched once and the combobox fuzzysorts it over each term's breadcrumb. In typeahead mode every keystroke hits the server, which matched on title alone, so descendants were dropped before the client ever saw them. Expand each title match into its subtree server-side, so both modes agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They're the keys the combobox fuzzysorts when it filters the list itself, which reads as a promise about the fieldtype as a whole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restores single-line rows by truncating titles instead of wrapping, and puts the editable title back to text-sm so it matches the read-only and invalid states. Ancestors render as subdued text with chevrons rather than badges, collapse in the middle past four segments, and give up their space before the title does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the separator used everywhere else in the CP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A typed path is only ever acted on when one taxonomy is configured, since that's the only case where the save creates terms. Shipping the delimiter for a multi-taxonomy field meant a typed value was split into a leaf and an ancestor breadcrumb the field then had no way to honour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…axonomies # Conflicts: # src/Stache/Stores/TaxonomyTermsStore.php
A terms field configured with multiple taxonomies receives an unprefixed slug, so a stored value from another taxonomy would match on slug alone and be rewritten or removed along with the real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A term only gets appended to the tree when the tree is read, so anything that read it beforehand cached a version without it. Orderable collections already flush for this reason in Entry::save(); taxonomies never did, so a term created after the tree had been read stayed invisible to page(), depth(), parent(), ancestors() and children() for the rest of the request. Delete had the same gap running backwards. UpdateTaxonomyTree::handleDeleted only saves the tree when the branch was actually in it, so deleting a term that was only ever lazily appended left the slug cached, and validateTree put it straight back into the tree everything reads. Gated on hasStructure() rather than orderable(), since both taxonomy modes share the same lazy-append path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The taxonomy tree flattened its raw branches to work out a term's order, so any term that was only lazily appended to the tree — which for orderable taxonomies is every term the control panel has ever created — had no order at all. Collections have read the validated tree for this since they gained orderable structures. The Stache `order` index was wrong for the same terms. It's resolved from the term's order at save time, which is before the tree knows about the term, so it cached a null and nothing rewrote it. On a descending orderable taxonomy that made reordering impossible: the listing sorted the tied nulls by insertion order while the reorder controller read the tree, the two disagreed, and every reorder was rejected with a 409. Terms now re-index their order after the save, mirroring what entries do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code and the docs disagreed. statamic/docs#1991 already uses "nestable" throughout and never says "hierarchical", so the code was the outlier. This renames Taxonomy::hierarchical() to nestable() and its derivatives, along with the CP tab handle and one English string. Only the maxDepth !== 1 predicate moves. hasStructure() and orderable() are verbatim copies of Collection's and are untouched, so "structured" keeps its meaning. Config, GraphQL, REST, Antlers and JS have zero occurrences, so there is no data migration and no public surface beyond the eight PHP symbols, all of which are new in this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single-select combobox renders its current value through the placeholder — the selected option's label is returned as the placeholder text and restyled to look like a value. The taggable guard in that computed returned the raw placeholder for every taggable combobox, so it short-circuited ahead of the branch that resolves the selected label, and single-select taggable fields stopped displaying their value entirely. Field condition rows read "Field" instead of the chosen field, formatting locales rendered an empty input, and a select fieldtype configured taggable without multiple showed nothing. The reasoning behind the guard only holds for multiple selection, where the selections are listed separately below the field rather than shown in it, so narrow it to taggable and multiple. That preserves 847503a's intent for the terms field, which keeps its search-or-type-a-path prompt once terms are picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TermInterface declared parent, children, ancestors and depth for every taxonomy, and TermType merges the interface's fields after the blueprint's, so the interface won on a key collision. A flat taxonomy whose term blueprint declared its own parent field got parent: TermInterface in the schema instead of the blueprint's type, turning queries that work on 6.x into validation errors or internal server errors. Reordering the merge wouldn't help, since interface conformance requires the concrete type's field to stay compatible with the interface's — the fields have to leave the interface.
They now live on TermType, gated on nestable() rather than hasStructure(): on an orderable taxonomy the four values are constants, so there's nothing to expose. Blueprint-derived fields already live only on the concrete types, so structure-derived ones sitting there too matches the schema's existing shape.
The cost is that reaching them from an interface-typed field — term, terms, a terms fieldtype, a tree branch — now needs an inline fragment, e.g. { term(id: "...") { ... on Term_Categories_Category { parent { title } } } }. These fields are unreleased, so nobody is broken by that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing a path like "dog > dog" into a nestable taxonomy's terms field produced one of two bad outcomes depending on the tree. When the repeated term wasn't in the persisted tree yet, the graft appended a placeholder branch to hold the parent and then nested the same term inside it, permanently writing the term as its own child in the tree YAML — repairTree() masks that on read, so nothing surfaced or self-healed it. When the term was already in the tree, the graft no-opped and the leaf slug came back as though the path was fine, silently coercing meaningless input. The rule is "a term can't appear twice in a path" rather than an adjacency check, because a term lives in exactly one place in the tree: "cat > dog > cat" asks for a cycle and is just as unexpressible as "dog > dog", and one rule covers both and is easier to explain. It's enforced in ensure() alongside the max-depth check, before anything is created or grafted, so an invalid path leaves no partial terms behind. graftTerm() also gets a guard for nesting a term under itself. It's a public method and that's invalid at the structure layer no matter which caller asks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TaxonomiesStore was coercing any non-null structure value with ?: [], so a falsy structure: false became an empty array. hasStructure() only checks for null, so that empty array made hasStructure() return true, silently turning on an unbounded structure. Now the structure is only set when the YAML value is an array, so false and true both resolve to no structure. structure: {} still deliberately means an unbounded structure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The taxonomy tree's Discard Changes button called `PageTree.cancel()` and then emptied `deletedTerms` in the same breath. But `cancel()` only opens the confirmation modal — the actual discard happens later in `confirmDiscard()`. So deleting a term, hitting Discard, then backing out of the modal left the tree with the branch already removed from the UI and `deletedTerms` emptied. Saving from there posted a tree without the term and without any deletions, so the term was never deleted. Instead `TaxonomyStructure::validateTree()` re-appended it at root level, flattening it and its children out of their place in the hierarchy and changing their URIs. Nothing listened to PageTree's `canceled` event either, so after a discard that *was* confirmed the Save and Discard buttons stayed on screen, since the page owns the dirty state and only cleared it on save. Both resets now live in a `@canceled` handler, which is what `pages/navigation/Show.vue` already does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `routes` array that omitted one of the taxonomy's sites produced a hybrid route: `termRoute($site)` is site aware and correctly fell back to the automagic `/tags/{slug}` for the omitted site, but `hasCustomRoutes()` wasn't, so everything that branches on it treated that site as if it had a custom route. The omitted site ended up with an automagic route shape but custom-route behaviour everywhere else.
Concretely, with `routes: ['en' => '/topics/{slug}']` on a taxonomy in `[en, fr]` scoped to a collection mounted at `/blog`: `fr` terms got a URI of `/tags/foo` instead of `/blog/tags/foo`, and `/blog/tags/foo` 404'd because `findByUri`'s automagic-only pass skips any taxonomy with custom routes. Static caching invalidated the wrong set of URLs for the same reason.
Collections already answer this per site — `Collection::route($site)` is genuinely null for a missing site — so `hasCustomRoutes()` now takes an optional site and resolves it through the same `routeForSite()` that `termRoute()` uses. A site with no entry in the array, or a blank or null one (which is what the CP's `emptyRouteToNull` leaves behind), has no custom route and now behaves exactly like an automagic route: collection-prefixed URLs, and resolvable by the automagic-only lookup. Calling it without a site still means "any site", which is what the CP's route field wants.
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/orderable/nestable, using the same structure/tree model as collections. While in there, taxonomies also got the routing control, view scaffolding and API surface that collections have had for a while.
Enable a structure 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:
>and will create missing segments, but they won't re-parent a term that's already in the tree. A segment that doesn't exist in the tree yet is grafted in at the root so the child can nest under it.expectsRoot. Terms don't have a collection-style root page.Routing
Taxonomy and term URLs are now configurable per taxonomy, under Routing & URLs in the taxonomy config.
/{handle}/{parent_uri}/{slug}.{parent_uri}is the new placeholder for the ancestor path. A custom route is treated as a complete URL pattern, so a collection-scoped taxonomy no longer gets a second collection-prefixed URL for free.$taxonomy->url()and friends returnnull, and the front-end responses 404.A nestable term is still resolvable by slug anywhere under the taxonomy — its old flat URL, for instance — and permanently redirects (301) to the canonical nested one. Makes migrating an existing flat taxonomy painless.
Scaffolding
Taxonomies get a Scaffold Views screen, same as collections. It generates index and show views (nestable-aware — the stubs emit
ancestorsandchildrenloops when the taxonomy is nestable) and writestemplate/term_templateback onto the taxonomy.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 }}Also in here
New surface
termtype, alongside entries and assets, with ataxonomiesconfig.GET /api/taxonomies/{taxonomy}/tree, matching the collection and nav tree endpoints.with_descendants=falseon the collection-entries and term-entries endpoints.Taxonomy.structurewith atree(site:)field, andparent/children/ancestors/depthonTermInterface.with_descendantson theentriesquery.reorder {taxonomy} terms, nested underedit {taxonomy} terms. Existing roles will need it granted.TaxonomyTreeSaving/Saved/Deleted, wired into git automation and static cache invalidation.content/trees/taxonomies/.Behaviour changes
None of this is a breaking change, but a few things shift for existing sites.
AC/DCfor a term sluggedac-dc) are now rewritten or removed where 6.x left them dangling, and fields that storetaxonomy::slugvalues are updated too — 6.x only matched a bare slug, so those were never touched.TermRepository::findByUri()matches against each taxonomy's term route now, rather than doing a uri-index lookup. Flat taxonomies go through the same path. No regression found, but it's worth knowing so a bug report is easy to place.parent,children,ancestorsordepthloses those handles to the structure once you enable a structure. Flat taxonomies keep the blueprint field and its fieldtype, so nothing changes on upgrade.Fixes
?fields=is now honoured on the terms API show route, completing [6.x] REST API: honor fields param on entries and assets #15319 — which addedResolvesRequestedFieldsbut only wired it intoEntryResourceandAssetResource.{{ children }}no longer 500s on a term page. It 500s on released 6.x too —AugmentedTermhas never had achildrenkey — so this isn't a regression being cleaned up, just a hole being filled.Term::find()prefers the taxonomy's own site now, and the terms store falls back to the term's actual file. Single-site installs can't be affected either way.References