[6.x] Taxonomy structures (hierarchies), routing, and more - #15192
Draft
jackmcdade wants to merge 102 commits into
Draft
jackmcdade wants to merge 102 commits into
jackmcdade wants to merge 102 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>
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>
…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>
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