From 45eff81edf009f82af0ef88a820b8cce20ff2274 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 23:31:25 -0400 Subject: [PATCH 01/32] Add taxonomy structures so terms can be hierarchical. 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 --- config/stache.php | 5 + src/Auth/CorePermissions.php | 1 + src/Contracts/Structures/TaxonomyTree.php | 7 + .../Structures/TaxonomyTreeRepository.php | 12 + .../Concerns/ListensForContentEvents.php | 2 + src/Events/TaxonomyTreeDeleted.php | 19 ++ src/Events/TaxonomyTreeSaved.php | 19 ++ src/Events/TaxonomyTreeSaving.php | 10 + src/Listeners/UpdateTaxonomyTree.php | 102 ++++++ src/Policies/TaxonomyPolicy.php | 2 +- src/Providers/AppServiceProvider.php | 1 + src/Providers/EventServiceProvider.php | 1 + .../Repositories/TaxonomyTreeRepository.php | 48 +++ src/Stache/Stores/TaxonomiesStore.php | 8 +- src/Stache/Stores/TaxonomyTreeStore.php | 41 +++ src/Structures/TaxonomyStructure.php | 257 +++++++++++++++ src/Structures/TaxonomyTree.php | 107 ++++++ src/Taxonomies/AugmentedTerm.php | 34 ++ src/Taxonomies/LocalizedTerm.php | 99 +++++- src/Taxonomies/Taxonomy.php | 101 +++++- tests/Data/Taxonomies/AugmentedTermTest.php | 5 + .../Taxonomies/HierarchicalTaxonomyTest.php | 309 ++++++++++++++++++ 22 files changed, 1183 insertions(+), 7 deletions(-) create mode 100644 src/Contracts/Structures/TaxonomyTree.php create mode 100644 src/Contracts/Structures/TaxonomyTreeRepository.php create mode 100644 src/Events/TaxonomyTreeDeleted.php create mode 100644 src/Events/TaxonomyTreeSaved.php create mode 100644 src/Events/TaxonomyTreeSaving.php create mode 100644 src/Listeners/UpdateTaxonomyTree.php create mode 100644 src/Stache/Repositories/TaxonomyTreeRepository.php create mode 100644 src/Stache/Stores/TaxonomyTreeStore.php create mode 100644 src/Structures/TaxonomyStructure.php create mode 100644 src/Structures/TaxonomyTree.php create mode 100644 tests/Data/Taxonomies/HierarchicalTaxonomyTest.php diff --git a/config/stache.php b/config/stache.php index 6cca12baba0..47ea8110840 100644 --- a/config/stache.php +++ b/config/stache.php @@ -76,6 +76,11 @@ 'directory' => base_path('content/trees/navigation'), ], + 'taxonomy-trees' => [ + 'class' => Stores\TaxonomyTreeStore::class, + 'directory' => base_path('content/trees/taxonomies'), + ], + 'globals' => [ 'class' => Stores\GlobalsStore::class, 'directory' => base_path('content/globals'), diff --git a/src/Auth/CorePermissions.php b/src/Auth/CorePermissions.php index 1d371fa080c..1dc1f53280b 100644 --- a/src/Auth/CorePermissions.php +++ b/src/Auth/CorePermissions.php @@ -151,6 +151,7 @@ protected function registerTaxonomies() $this->permission('edit {taxonomy} terms')->children([ $this->permission('create {taxonomy} terms'), $this->permission('delete {taxonomy} terms'), + $this->permission('reorder {taxonomy} terms'), ]), ])->replacements('taxonomy', function () { return Taxonomy::all()->map(function ($taxonomy) { diff --git a/src/Contracts/Structures/TaxonomyTree.php b/src/Contracts/Structures/TaxonomyTree.php new file mode 100644 index 00000000000..53148af52a7 --- /dev/null +++ b/src/Contracts/Structures/TaxonomyTree.php @@ -0,0 +1,7 @@ + 'handleSaved', + TermDeleted::class => 'handleDeleted', + ]; + + /** + * When a term's slug is renamed, update its reference in the taxonomy tree. + */ + public function handleSaved(TermSaved $event) + { + $term = $event->term; + + if (! ($taxonomy = $term->taxonomy())->hasStructure()) { + return; + } + + $originalSlug = $term->getOriginal('slug'); + $newSlug = $term->slug(); + + if (! $originalSlug || $originalSlug === $newSlug) { + return; + } + + $tree = $taxonomy->structure()->tree(); + + // Operate on the repaired persisted tree to avoid the read-time + // validation (which appends missing terms) kicking in. + $raw = $taxonomy->structure()->repairTree($tree->fileData()['tree'] ?? []); + + $renamed = $this->renameBranches($raw, $originalSlug, $newSlug); + + if ($renamed !== $raw) { + $tree->tree($renamed)->save(); + } + } + + /** + * When a term is deleted, remove its branch and promote its children into its position. + */ + public function handleDeleted(TermDeleted $event) + { + $term = $event->term; + + if (! ($taxonomy = $term->taxonomy())->hasStructure()) { + return; + } + + $tree = $taxonomy->structure()->tree(); + + $raw = $taxonomy->structure()->repairTree($tree->fileData()['tree'] ?? []); + + $removed = $this->removeBranchPromotingChildren($raw, $term->slug()); + + if ($removed !== $raw) { + $tree->tree($removed)->save(); + } + } + + private function renameBranches(array $branches, string $from, string $to): array + { + return collect($branches)->map(function ($branch) use ($from, $to) { + if (($branch['term'] ?? null) === $from) { + $branch['term'] = $to; + } + + if (isset($branch['children'])) { + $branch['children'] = $this->renameBranches($branch['children'], $from, $to); + } + + return $branch; + })->all(); + } + + private function removeBranchPromotingChildren(array $branches, string $slug): array + { + return collect($branches)->flatMap(function ($branch) use ($slug) { + if (($branch['term'] ?? null) === $slug) { + return $this->removeBranchPromotingChildren($branch['children'] ?? [], $slug); + } + + if (isset($branch['children'])) { + $branch['children'] = $this->removeBranchPromotingChildren($branch['children'], $slug); + + if (empty($branch['children'])) { + unset($branch['children']); + } + } + + return [$branch]; + })->values()->all(); + } +} diff --git a/src/Policies/TaxonomyPolicy.php b/src/Policies/TaxonomyPolicy.php index 3161bfb7969..9442a314225 100644 --- a/src/Policies/TaxonomyPolicy.php +++ b/src/Policies/TaxonomyPolicy.php @@ -68,6 +68,6 @@ public function reorder($user, $taxonomy) { $user = User::fromUser($user); - return $taxonomy->orderable() && $user->hasPermission("reorder {$taxonomy->handle()} terms"); + return $taxonomy->hasStructure() && $user->hasPermission("reorder {$taxonomy->handle()} terms"); } } diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index b30e1d23b6a..f3be1097ced 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -161,6 +161,7 @@ public function register() \Statamic\Contracts\Structures\StructureRepository::class => \Statamic\Structures\StructureRepository::class, \Statamic\Contracts\Structures\CollectionTreeRepository::class => \Statamic\Stache\Repositories\CollectionTreeRepository::class, \Statamic\Contracts\Structures\NavTreeRepository::class => \Statamic\Stache\Repositories\NavTreeRepository::class, + \Statamic\Contracts\Structures\TaxonomyTreeRepository::class => \Statamic\Stache\Repositories\TaxonomyTreeRepository::class, \Statamic\Contracts\Structures\NavigationRepository::class => \Statamic\Stache\Repositories\NavigationRepository::class, \Statamic\Contracts\Assets\AssetRepository::class => \Statamic\Assets\AssetRepository::class, \Statamic\Contracts\Forms\FormRepository::class => \Statamic\Forms\FormRepository::class, diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 0c8fa5b6a91..f5c0adbaa44 100755 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -37,6 +37,7 @@ class EventServiceProvider extends ServiceProvider \Statamic\Listeners\GeneratePresetImageManipulations::class, \Statamic\Listeners\UpdateAssetReferences::class, \Statamic\Listeners\UpdateTermReferences::class, + \Statamic\Listeners\UpdateTaxonomyTree::class, \Statamic\Listeners\InvalidateNavCache::class, ]; diff --git a/src/Stache/Repositories/TaxonomyTreeRepository.php b/src/Stache/Repositories/TaxonomyTreeRepository.php new file mode 100644 index 00000000000..816da75932d --- /dev/null +++ b/src/Stache/Repositories/TaxonomyTreeRepository.php @@ -0,0 +1,48 @@ +stache = $stache; + $this->store = $stache->store('taxonomy-trees'); + } + + public function find(string $handle): ?Tree + { + return $this->store->getItem($handle.'::'.Site::default()->handle()); + } + + public function save(Tree $tree) + { + $this->store->save($tree); + + return true; + } + + public function delete(Tree $tree) + { + $this->store->delete($tree); + + return true; + } + + public static function bindings() + { + return [ + TreeContract::class => TaxonomyTree::class, + ]; + } +} diff --git a/src/Stache/Stores/TaxonomiesStore.php b/src/Stache/Stores/TaxonomiesStore.php index b8e9ca58420..468f1121f3e 100644 --- a/src/Stache/Stores/TaxonomiesStore.php +++ b/src/Stache/Stores/TaxonomiesStore.php @@ -40,7 +40,7 @@ public function makeItemFromFile($path, $contents) $sites = Arr::get($data, 'sites', Site::multiEnabled() ? [] : [Site::default()->handle()]); - return Taxonomy::make($handle) + $taxonomy = Taxonomy::make($handle) ->title(Arr::get($data, 'title')) ->cascade(Arr::get($data, 'inject', [])) ->searchIndex(Arr::get($data, 'search_index')) @@ -52,6 +52,12 @@ public function makeItemFromFile($path, $contents) ->termTemplate(Arr::get($data, 'term_template', null)) ->template(Arr::get($data, 'template', null)) ->layout(Arr::get($data, 'layout', null)); + + if (($structure = Arr::get($data, 'structure')) !== null) { + $taxonomy->structureContents($structure ?: []); + } + + return $taxonomy; } protected function getDefaultPublishState($data) diff --git a/src/Stache/Stores/TaxonomyTreeStore.php b/src/Stache/Stores/TaxonomyTreeStore.php new file mode 100644 index 00000000000..79d716ddd69 --- /dev/null +++ b/src/Stache/Stores/TaxonomyTreeStore.php @@ -0,0 +1,41 @@ +parseTreePath(Path::tidy($file->getPathname())); + + if (! ($taxonomy = Taxonomy::findByHandle($handle))) { + return false; + } + + return $taxonomy->hasStructure(); + } + + protected function newTreeClassByPath($path) + { + [$site, $handle] = $this->parseTreePath($path); + + return (new TaxonomyTree) + ->initialPath($path) + ->locale($site) + ->handle($handle); + } +} diff --git a/src/Structures/TaxonomyStructure.php b/src/Structures/TaxonomyStructure.php new file mode 100644 index 00000000000..25bb4db1ffb --- /dev/null +++ b/src/Structures/TaxonomyStructure.php @@ -0,0 +1,257 @@ +taxonomy()->title(); + } + + public function taxonomy() + { + return Blink::once('taxonomy-structure-taxonomy-'.$this->handle(), function () { + return Taxonomy::findByHandle($this->handle()); + }); + } + + public function expectsRoot($expectsRoot = null) + { + if (func_num_args() === 1) { + throw new \LogicException('Taxonomy structures do not support root terms.'); + } + + return false; + } + + public function collections($collections = null) + { + // + } + + public function newTreeInstance() + { + return app(TaxonomyTree::class); + } + + /** + * Get the localized ancestor slug path for a term (e.g. "animals/cat"), or an + * empty string for root-level terms. Returns null for terms not in the tree. + */ + public function termParentUri($term): ?string + { + $page = $this->tree()->find($term->inDefaultLocale()->slug()); + + if (! $page) { + return null; + } + + return collect($this->ancestorsOf($page)) + ->map(fn ($slug) => $this->localizedSlug($slug, $term->locale())) + ->implode('/'); + } + + /** + * Get the default-locale slugs of a page's ancestors, root-first. + */ + public function ancestorsOf(Page $page): array + { + $ancestors = []; + + while ($page = $page->parent()) { + array_unshift($ancestors, $page->id()); + } + + return $ancestors; + } + + private function localizedSlug(string $slug, string $site) + { + $term = Term::find($this->handle().'::'.$slug); + + return $term ? $term->in($site)->slug() : $slug; + } + + /** + * Normalize branch keys/IDs and drop duplicate slugs, without appending + * missing terms. Use this before mutating the persisted tree. + */ + public function repairTree(array $tree): array + { + $tree = $this->normalizeTree($tree); + + if ($this->getTermSlugsFromTree($tree)->duplicates()->isNotEmpty()) { + $tree = $this->removeDuplicateTermsFromTree($tree); + } + + return $tree; + } + + public function validateTree(array $tree, string $locale): array + { + $tree = $this->repairTree($tree); + $slugs = $this->getTermSlugsFromTree($tree); + + $existingSlugs = Blink::once('taxonomy-structure-term-slugs-'.$this->handle(), function () { + return Term::query() + ->where('taxonomy', $this->handle()) + ->get() + ->map(fn ($term) => $term->inDefaultLocale()->slug()); + }); + + if (($nonExistent = $slugs->diff($existingSlugs))->isNotEmpty()) { + $tree = $this->removeTermReferencesFromTree($tree, $nonExistent); + } + + $missingTerms = $existingSlugs->diff($slugs)->map(function ($slug) { + return ['term' => $slug]; + })->values()->all(); + + return array_merge($tree, $missingTerms); + } + + /** + * Coerce branches to `term: {slug}`. Older trees (and Tree::append) stored + * collection-style `entry: taxonomy::slug` keys and/or full term IDs. + */ + protected function normalizeTree(array $tree): array + { + return collect($tree) + ->map(function ($branch) { + $slug = $this->slugFromBranch($branch); + + if (! $slug) { + return null; + } + + $normalized = ['term' => $slug]; + + if (isset($branch['children'])) { + $normalized['children'] = $this->normalizeTree($branch['children']); + } + + return $normalized; + }) + ->filter() + ->values() + ->all(); + } + + protected function slugFromBranch(array $branch): ?string + { + $value = $branch['term'] ?? $branch['entry'] ?? null; + + if (! $value) { + return null; + } + + return Str::after($value, $this->handle().'::'); + } + + /** + * Keep the first occurrence of each slug (and its children); later + * duplicates have their children promoted into place. + */ + protected function removeDuplicateTermsFromTree(array $tree, $seen = null): array + { + $seen ??= collect(); + + return collect($tree)->flatMap(function ($branch) use ($seen) { + $slug = $branch['term'] ?? null; + $children = isset($branch['children']) + ? $this->removeDuplicateTermsFromTree($branch['children'], $seen) + : []; + + if (! $slug || $seen->contains($slug)) { + return $children; + } + + $seen->push($slug); + + if ($children) { + $branch['children'] = $children; + } else { + unset($branch['children']); + } + + return [$branch]; + })->values()->all(); + } + + protected function getTermSlugsFromTree($tree) + { + return collect($tree) + ->map(function ($item) { + return [ + 'term' => $item['term'] ?? null, + 'children' => isset($item['children']) ? $this->getTermSlugsFromTree($item['children']) : null, + ]; + }) + ->flatten() + ->filter(); + } + + protected function removeTermReferencesFromTree($tree, $slugs) + { + return collect($tree) + ->reject(function ($branch) use ($slugs) { + return $slugs->contains($branch['term'] ?? null); + }) + ->map(function ($branch) use ($slugs) { + if (isset($branch['children'])) { + $branch['children'] = $this->removeTermReferencesFromTree($branch['children'], $slugs); + + if (empty($branch['children'])) { + unset($branch['children']); + } + } + + return $branch; + }) + ->values() + ->all(); + } + + public function save() + { + $this->taxonomy()->structure($this)->save(); + + return true; + } + + public function tree() + { + return $this->in(null); + } + + public function trees() + { + return collect([$this->tree()]); + } + + public function in($site) + { + return Blink::once("taxonomy-structure-tree-{$this->handle()}", function () { + $tree = app(TaxonomyTreeRepository::class)->find($this->handle()); + + return $tree ?? $this->makeTree($this->taxonomy()->sites()->first()); + }); + } + + public function existsIn($site) + { + return $this->taxonomy()->sites()->contains($site); + } +} diff --git a/src/Structures/TaxonomyTree.php b/src/Structures/TaxonomyTree.php new file mode 100644 index 00000000000..0354ce9e5c1 --- /dev/null +++ b/src/Structures/TaxonomyTree.php @@ -0,0 +1,107 @@ +tree[] = ['term' => $this->termSlug($entry)]; + + return $this; + } + + public function appendTo($parent, $page) + { + if (! is_null($page) && ! is_array($page)) { + $page = $this->termSlug($page); + } + + return parent::appendTo($parent, $page); + } + + private function termSlug($term): string + { + if (is_object($term)) { + return $term->inDefaultLocale()->slug(); + } + + return Str::after($term, $this->handle().'::'); + } + + public function structure() + { + if ($this->structureCache) { + return $this->structureCache; + } + + return $this->structureCache = Blink::once('taxonomy-tree-structure-'.$this->handle(), function () { + return Taxonomy::findByHandle($this->handle())->structure(); + }); + } + + public function taxonomy() + { + return $this->structure()->taxonomy(); + } + + public function path() + { + $path = Stache::store('taxonomy-trees')->directory(); + + return "{$path}{$this->handle()}.yaml"; + } + + protected function dispatchSavedEvent() + { + TaxonomyTreeSaved::dispatch($this); + } + + protected function dispatchSavingEvent() + { + return TaxonomyTreeSaving::dispatch($this); + } + + protected function dispatchDeletedEvent() + { + TaxonomyTreeDeleted::dispatch($this); + } + + protected function repository() + { + return app(TaxonomyTreeRepository::class); + } + + public function save() + { + $saved = parent::save(); + + if ($saved) { + Blink::forget("taxonomy-structure-tree-{$this->handle()}"); + Blink::forget('taxonomy-structure-term-slugs-'.$this->handle()); + } + + return $saved; + } +} diff --git a/src/Taxonomies/AugmentedTerm.php b/src/Taxonomies/AugmentedTerm.php index dd82c49a277..727bcb631c5 100644 --- a/src/Taxonomies/AugmentedTerm.php +++ b/src/Taxonomies/AugmentedTerm.php @@ -44,9 +44,43 @@ private function commonKeys() 'collection', 'updated_at', 'updated_by', + 'parent', + 'children', + 'ancestors', + 'depth', + 'is_root', ]; } + protected function parent() + { + return $this->data->parent(); + } + + protected function children() + { + return $this->data->children(); + } + + protected function ancestors() + { + return $this->data->ancestors(); + } + + protected function depth() + { + return $this->data->depth(); + } + + protected function isRoot() + { + if (! $depth = $this->data->depth()) { + return null; + } + + return $depth === 1; + } + protected function updatedBy() { $user = $this->data->lastModifiedBy(); diff --git a/src/Taxonomies/LocalizedTerm.php b/src/Taxonomies/LocalizedTerm.php index ccc033ae6d6..aad6d9b5d16 100644 --- a/src/Taxonomies/LocalizedTerm.php +++ b/src/Taxonomies/LocalizedTerm.php @@ -29,6 +29,7 @@ use Statamic\Facades\Antlers; use Statamic\Facades\Blink; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\GraphQL\ResolvesValues; use Statamic\Http\Responses\DataResponse; use Statamic\Routing\Routable; @@ -296,7 +297,9 @@ public function apiUrl() public function route() { - $route = '/'.str_replace('_', '-', $this->taxonomyHandle()).'/{slug}'; + $slug = $this->taxonomy()->hierarchical() ? '{parent_uri}/{slug}' : '{slug}'; + + $route = '/'.str_replace('_', '-', $this->taxonomyHandle()).'/'.$slug; if ($this->collection()) { $collectionUrl = $this->collection()->uri($this->locale()) ?? $this->collection()->handle(); @@ -308,10 +311,71 @@ public function route() public function routeData() { - return $this->values()->merge([ + $data = $this->values()->merge([ 'id' => $this->id(), 'slug' => $this->slug(), - ])->all(); + ]); + + if ($this->taxonomy()->hierarchical()) { + $data->put('parent_uri', $this->taxonomy()->structure()->termParentUri($this) ?? ''); + } + + return $data->all(); + } + + /** + * The term's page in the taxonomy's structure tree, if it has one. + */ + public function page() + { + if (! $this->taxonomy()->hasStructure()) { + return null; + } + + return $this->taxonomy()->structure()->tree()->find($this->inDefaultLocale()->slug()); + } + + public function depth() + { + return $this->page()?->depth(); + } + + public function parent() + { + if (! $parent = $this->page()?->parent()) { + return null; + } + + return $this->termFromSlug($parent->id()); + } + + public function ancestors() + { + if (! $page = $this->page()) { + return collect(); + } + + return collect($this->taxonomy()->structure()->ancestorsOf($page)) + ->map(fn ($slug) => $this->termFromSlug($slug)) + ->filter() + ->values(); + } + + public function children() + { + if (! $page = $this->page()) { + return collect(); + } + + return $page->pages()->all() + ->map(fn ($child) => $this->termFromSlug($child->id())) + ->filter() + ->values(); + } + + private function termFromSlug($slug) + { + return Facades\Term::find($this->taxonomyHandle().'::'.$slug)?->in($this->locale); } public function status() @@ -329,9 +393,38 @@ public function toResponse($request) throw new NotFoundHttpException; } + if ($redirect = $this->canonicalUriRedirect($request)) { + return $redirect; + } + return (new DataResponse($this))->toResponse($request); } + /** + * Hierarchical terms are resolvable by their slug at any path within the taxonomy + * (e.g. their old flat URL), but should permanently redirect to the canonical + * nested URL to avoid serving duplicate content. + */ + private function canonicalUriRedirect($request) + { + if (! $this->taxonomy()->hierarchical()) { + return null; + } + + $requested = URL::tidy($request->url(), withTrailingSlash: false); + $canonical = URL::tidy($this->absoluteUrl(), withTrailingSlash: false); + + if (! $canonical || $requested === $canonical) { + return null; + } + + if ($query = $request->getQueryString()) { + $canonical .= '?'.$query; + } + + return redirect($canonical, 301); + } + public function template($template = null) { if (func_num_args() === 0) { diff --git a/src/Taxonomies/Taxonomy.php b/src/Taxonomies/Taxonomy.php index 40957d48c73..5292a6d5c9a 100644 --- a/src/Taxonomies/Taxonomy.php +++ b/src/Taxonomies/Taxonomy.php @@ -51,6 +51,8 @@ class Taxonomy implements Arrayable, ArrayAccess, AugmentableContract, ContainsQ protected $template; protected $termTemplate; protected $layout; + protected $structure; + protected $structureContents; protected $afterSaveCallbacks = []; protected $withEvents = true; @@ -180,6 +182,88 @@ public function hasVisibleTermBlueprint() return $this->termBlueprints()->reject->hidden()->isNotEmpty(); } + public function structure($structure = null) + { + return $this + ->fluentlyGetOrSet('structure') + ->getter(function ($structure) { + return Blink::once("taxonomy-{$this->id()}-structure", function () use ($structure) { + if (! $structure && $this->structureContents !== null) { + $structure = $this->structure = $this->makeStructureFromContents(); + } + + return $structure; + }); + }) + ->setter(function ($structure) { + if ($structure) { + $structure->handle($this->handle()); + } + + $this->structureContents = null; + Blink::forget("taxonomy-{$this->id()}-structure"); + + return $structure; + }) + ->args(func_get_args()); + } + + public function structureContents(?array $contents = null) + { + return $this + ->fluentlyGetOrSet('structureContents') + ->setter(function ($contents) { + Blink::forget("taxonomy-{$this->id()}-structure"); + $this->structure = null; + + return $contents; + }) + ->getter(function ($contents) { + if (! $structure = $this->structure()) { + return null; + } + + // Empty arrays are stripped by ExistsAsFile::fileContents(), so + // keep a placeholder when there's no max depth. Collections get + // the same protection from their always-present `root` key. + return Arr::removeNullValues([ + 'max_depth' => $structure->maxDepth(), + ]) ?: ['max_depth' => null]; + }) + ->args(func_get_args()); + } + + protected function makeStructureFromContents() + { + return (new \Statamic\Structures\TaxonomyStructure) + ->handle($this->handle()) + ->maxDepth($this->structureContents['max_depth'] ?? null); + } + + public function structureHandle() + { + if (! $this->hasStructure()) { + return null; + } + + return $this->structure()->handle(); + } + + public function hasStructure() + { + return $this->structure !== null || $this->structureContents !== null; + } + + public function orderable() + { + return optional($this->structure())->maxDepth() === 1; + } + + public function hierarchical() + { + return $this->hasStructure() && $this->structure()->maxDepth() !== 1; + } + public function sortField() { return $this->sortField ?? 'title'; @@ -251,6 +335,10 @@ public function save() Facades\Taxonomy::save($this); + Blink::forget("taxonomy-{$this->id()}-structure"); + Blink::forget("taxonomy-structure-taxonomy-{$this->handle()}"); + Blink::forget("taxonomy-structure-tree-{$this->handle()}"); + if ($withEvents) { if ($isNew) { TaxonomyCreated::dispatch($this); @@ -278,6 +366,10 @@ public function delete() return false; } + if ($this->hasStructure()) { + $this->structure()->trees()->each->delete(); + } + $this->queryTerms()->get()->each->delete(); Facades\Taxonomy::delete($this); @@ -312,6 +404,10 @@ public function fileData() 'sort_dir' => $this->sortDirection, ])); + if ($this->hasStructure()) { + $data['structure'] = $this->structureContents(); + } + if (Site::multiEnabled()) { $data['sites'] = $this->sites; } @@ -614,8 +710,9 @@ private function queryableMethods(): array { return [ 'absoluteUrl', 'collection', 'collections', 'defaultPublishState', 'editUrl', 'handle', - 'hasSearchIndex', 'id', 'layout', 'path', 'revisionsEnabled', 'searchIndex', 'sites', - 'sortDirection', 'sortField', 'template', 'termTemplate', 'title', 'uri', 'url', + 'hasSearchIndex', 'hasStructure', 'id', 'layout', 'orderable', 'path', 'revisionsEnabled', + 'searchIndex', 'sites', 'sortDirection', 'sortField', 'structureHandle', 'template', + 'termTemplate', 'title', 'uri', 'url', ]; } } diff --git a/tests/Data/Taxonomies/AugmentedTermTest.php b/tests/Data/Taxonomies/AugmentedTermTest.php index 6d1202c0b2b..b7136b5ffb9 100644 --- a/tests/Data/Taxonomies/AugmentedTermTest.php +++ b/tests/Data/Taxonomies/AugmentedTermTest.php @@ -70,6 +70,11 @@ public function it_gets_values() 'updated_at' => ['type' => Carbon::class, 'value' => '2017-02-03 14:10'], 'updated_by' => ['type' => UserContract::class, 'value' => 'test-user'], 'collection' => ['type' => 'null', 'value' => null], + 'parent' => ['type' => 'null', 'value' => null], + 'children' => ['type' => \Illuminate\Support\Collection::class], + 'ancestors' => ['type' => \Illuminate\Support\Collection::class], + 'depth' => ['type' => 'null', 'value' => null], + 'is_root' => ['type' => 'null', 'value' => null], ]; $this->assertAugmentedCorrectly($expectations, $augmented); diff --git a/tests/Data/Taxonomies/HierarchicalTaxonomyTest.php b/tests/Data/Taxonomies/HierarchicalTaxonomyTest.php new file mode 100644 index 00000000000..1f4feab414c --- /dev/null +++ b/tests/Data/Taxonomies/HierarchicalTaxonomyTest.php @@ -0,0 +1,309 @@ +title('Categories')->structureContents([]))->save(); + + foreach (['animals', 'cat', 'calico', 'furniture'] as $slug) { + tap(Term::make($slug)->taxonomy('categories')->data(['title' => ucfirst($slug)]))->save(); + } + + $taxonomy->structure()->tree()->tree([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat', 'children' => [ + ['term' => 'calico'], + ]], + ]], + ['term' => 'furniture'], + ])->save(); + + return $taxonomy; + } + + #[Test] + public function a_taxonomy_without_structure_is_not_hierarchical() + { + $taxonomy = tap(Taxonomy::make('tags'))->save(); + + $this->assertFalse($taxonomy->hasStructure()); + $this->assertFalse($taxonomy->hierarchical()); + $this->assertFalse($taxonomy->orderable()); + $this->assertNull($taxonomy->structure()); + } + + #[Test] + public function a_taxonomy_with_structure_is_hierarchical() + { + $taxonomy = tap(Taxonomy::make('categories')->structureContents(['max_depth' => 3]))->save(); + + $this->assertTrue($taxonomy->hasStructure()); + $this->assertTrue($taxonomy->hierarchical()); + $this->assertFalse($taxonomy->orderable()); + $this->assertInstanceOf(TaxonomyStructure::class, $structure = $taxonomy->structure()); + $this->assertEquals(3, $structure->maxDepth()); + $this->assertFalse($structure->expectsRoot()); + $this->assertInstanceOf(TaxonomyTree::class, $structure->tree()); + } + + #[Test] + public function a_taxonomy_with_max_depth_of_one_is_orderable_but_not_hierarchical() + { + $taxonomy = tap(Taxonomy::make('categories')->structureContents(['max_depth' => 1]))->save(); + + $this->assertTrue($taxonomy->hasStructure()); + $this->assertFalse($taxonomy->hierarchical()); + $this->assertTrue($taxonomy->orderable()); + } + + #[Test] + public function it_gets_hierarchy_from_the_tree() + { + $this->makeHierarchicalTaxonomy(); + + $calico = Term::find('categories::calico'); + $animals = Term::find('categories::animals'); + + $this->assertEquals(3, $calico->depth()); + $this->assertEquals('categories::cat', $calico->parent()->id()); + $this->assertEquals(['animals', 'cat'], $calico->ancestors()->map->slug()->all()); + $this->assertEquals(['cat'], $animals->in('en')->children()->map->slug()->all()); + + $this->assertEquals(1, $animals->depth()); + $this->assertNull($animals->in('en')->parent()); + } + + #[Test] + public function hierarchical_terms_get_nested_uris() + { + $this->makeHierarchicalTaxonomy(); + + $this->assertEquals('/categories/animals', Term::find('categories::animals')->uri()); + $this->assertEquals('/categories/animals/cat', Term::find('categories::cat')->uri()); + $this->assertEquals('/categories/animals/cat/calico', Term::find('categories::calico')->uri()); + $this->assertEquals('/categories/furniture', Term::find('categories::furniture')->uri()); + } + + #[Test] + public function it_finds_hierarchical_terms_by_nested_uri() + { + $this->makeHierarchicalTaxonomy(); + + $term = Term::findByUri('/categories/animals/cat/calico'); + + $this->assertNotNull($term); + $this->assertEquals('categories::calico', $term->id()); + } + + #[Test] + public function it_finds_hierarchical_terms_by_flat_uri_for_redirecting() + { + $this->makeHierarchicalTaxonomy(); + + $term = Term::findByUri('/categories/calico'); + + $this->assertNotNull($term); + $this->assertEquals('categories::calico', $term->id()); + } + + #[Test] + public function it_doesnt_find_terms_by_nested_uri_on_flat_taxonomies() + { + tap(Taxonomy::make('tags'))->save(); + tap(Term::make('foo')->taxonomy('tags')->data([]))->save(); + + $this->assertNotNull(Term::findByUri('/tags/foo')); + $this->assertNull(Term::findByUri('/tags/nested/foo')); + } + + #[Test] + public function validating_a_tree_appends_missing_terms() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + tap(Term::make('dog')->taxonomy('categories')->data(['title' => 'Dog']))->save(); + + $tree = $taxonomy->structure()->validateTree([ + ['term' => 'animals'], + ], 'en'); + + $slugs = collect($tree)->pluck('term')->all(); + + $this->assertContains('animals', $slugs); + $this->assertContains('dog', $slugs); + $this->assertContains('furniture', $slugs); + } + + #[Test] + public function validating_a_tree_removes_non_existent_terms() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + $tree = $taxonomy->structure()->validateTree([ + ['term' => 'animals'], + ['term' => 'nonexistent'], + ['term' => 'cat'], + ['term' => 'calico'], + ['term' => 'furniture'], + ], 'en'); + + $this->assertNotContains('nonexistent', collect($tree)->pluck('term')->all()); + } + + #[Test] + public function validating_a_tree_drops_duplicate_terms_keeping_the_first() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + $tree = $taxonomy->structure()->validateTree([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat'], + ]], + ['term' => 'animals'], + ['term' => 'furniture'], + ['term' => 'calico'], + ], 'en'); + + $this->assertEquals([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat'], + ]], + ['term' => 'furniture'], + ['term' => 'calico'], + ], $tree); + } + + #[Test] + public function validating_a_tree_normalizes_entry_keys_and_full_term_ids() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + $tree = $taxonomy->structure()->validateTree([ + ['entry' => 'categories::animals', 'children' => [ + ['entry' => 'categories::cat', 'children' => [ + ['term' => 'categories::calico'], + ]], + ]], + ['term' => 'furniture'], + ['term' => 'animals'], + ['term' => 'cat'], + ['term' => 'calico'], + ], 'en'); + + $this->assertEquals([ + ['term' => 'animals', 'children' => [ + ['term' => 'cat', 'children' => [ + ['term' => 'calico'], + ]], + ]], + ['term' => 'furniture'], + ], $tree); + } + + #[Test] + public function appending_a_term_stores_the_slug_under_the_term_key() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + $term = Term::find('categories::furniture'); + + $tree = $taxonomy->structure()->tree(); + $tree->tree([ + ['term' => 'animals'], + ]); + $tree->append($term); + + $this->assertEquals([ + ['term' => 'animals'], + ['term' => 'furniture'], + ], $tree->fileData()['tree']); + } + + #[Test] + public function it_gets_the_term_parent_uri() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + $structure = $taxonomy->structure(); + + $this->assertEquals('', $structure->termParentUri(Term::find('categories::animals')->in('en'))); + $this->assertEquals('animals', $structure->termParentUri(Term::find('categories::cat')->in('en'))); + $this->assertEquals('animals/cat', $structure->termParentUri(Term::find('categories::calico')->in('en'))); + } + + #[Test] + public function deleting_a_term_removes_its_branch_and_promotes_children() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + Term::find('categories::cat')->delete(); + + $tree = $taxonomy->structure()->tree()->tree(); + + $this->assertEquals([ + ['term' => 'animals', 'children' => [ + ['term' => 'calico'], + ]], + ['term' => 'furniture'], + ], $tree); + } + + #[Test] + public function renaming_a_term_slug_updates_the_tree() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + $term = Term::find('categories::cat'); + $term->slug('feline'); + $term->save(); + + $tree = $taxonomy->structure()->tree()->tree(); + + $this->assertEquals([ + ['term' => 'animals', 'children' => [ + ['term' => 'feline', 'children' => [ + ['term' => 'calico'], + ]], + ]], + ['term' => 'furniture'], + ], $tree); + } + + #[Test] + public function deleting_a_taxonomy_deletes_its_tree() + { + $taxonomy = $this->makeHierarchicalTaxonomy(); + + $tree = $taxonomy->structure()->tree(); + + $taxonomy->delete(); + + $this->assertNull(\Statamic\Facades\Blink::store()->get('taxonomy-structure-tree-categories')); + } + + #[Test] + public function augmented_term_includes_hierarchy_keys() + { + $this->makeHierarchicalTaxonomy(); + + $augmented = Term::find('categories::calico')->in('en')->toAugmentedArray(['parent', 'ancestors', 'children', 'depth', 'is_root']); + + $this->assertEquals('categories::cat', $augmented['parent']->value()->id()); + $this->assertEquals(3, $augmented['depth']->value()); + $this->assertFalse($augmented['is_root']->value()); + $this->assertCount(2, $augmented['ancestors']->value()); + $this->assertCount(0, $augmented['children']->value()); + } +} From 10f3c15991bbe4528a4220fc2f6316e8e7734932 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 23:31:31 -0400 Subject: [PATCH 02/32] Add a CP tree view and orderable settings for hierarchical taxonomies. 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 --- lang/en/messages.php | 3 + resources/js/components/terms/PublishForm.vue | 20 +- resources/js/pages/taxonomies/Show.vue | 182 +++++++++++++++++- resources/js/pages/terms/Create.vue | 2 + resources/js/pages/terms/Edit.vue | 2 + routes/cp.php | 3 + .../CP/Taxonomies/TaxonomiesController.php | 65 +++++++ .../CP/Taxonomies/TaxonomyTreeController.php | 88 +++++++++ .../CP/Taxonomies/TermsController.php | 31 +++ .../Resources/CP/Taxonomies/ListedTerm.php | 4 + tests/Feature/Taxonomies/TaxonomyTreeTest.php | 141 ++++++++++++++ .../Feature/Taxonomies/UpdateTaxonomyTest.php | 62 ++++++ 12 files changed, 598 insertions(+), 5 deletions(-) create mode 100644 src/Http/Controllers/CP/Taxonomies/TaxonomyTreeController.php create mode 100644 tests/Feature/Taxonomies/TaxonomyTreeTest.php diff --git a/lang/en/messages.php b/lang/en/messages.php index f6339ba62df..ad3fd3b41fb 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -254,6 +254,9 @@ 'sync_term_field_confirmation_text' => 'Are you sure? This field\'s value will be replaced by the value in the originating term.', 'taxonomies_blueprints_instructions' => 'Terms in this taxonomy may use any of these blueprints.', 'taxonomies_collections_instructions' => 'The collections that use this taxonomy.', + 'taxonomies_max_depth_instructions' => 'Set the max term nesting level. A depth of 1 allows reordering without nesting, keeping URLs flat.', + 'term_delete_with_children_confirmation' => 'Are you sure you want to delete this term? Its child terms will be moved up into its position.', + 'taxonomies_orderable_instructions' => 'Enable manual ordering and hierarchy (parent/child terms) via drag & drop. Nested terms get nested URLs.', 'taxonomies_preview_target_refresh_instructions' => 'Automatically refresh the preview while editing. Disabling this will use postMessage.', 'taxonomies_preview_targets_instructions' => 'The URLs to be viewable within Live Preview. Learn more in the [documentation](https://statamic.dev/live-preview#preview-targets).', 'taxonomy_configure_handle_instructions' => 'Used to reference this taxonomy on the frontend. This cannot be easily changed later.', diff --git a/resources/js/components/terms/PublishForm.vue b/resources/js/components/terms/PublishForm.vue index b5a2f60a61e..2a51fcbb0e9 100644 --- a/resources/js/components/terms/PublishForm.vue +++ b/resources/js/components/terms/PublishForm.vue @@ -79,8 +79,16 @@ - diff --git a/resources/js/components/inputs/relationship/SelectField.vue b/resources/js/components/inputs/relationship/SelectField.vue index 628d716ae12..6cd060e47ca 100644 --- a/resources/js/components/inputs/relationship/SelectField.vue +++ b/resources/js/components/inputs/relationship/SelectField.vue @@ -18,8 +18,12 @@ @update:modelValue="itemsSelected" @search="search" > -