diff --git a/content/collections/pages/collections.md b/content/collections/pages/collections.md index 0f039a511..3899bb1cb 100644 --- a/content/collections/pages/collections.md +++ b/content/collections/pages/collections.md @@ -253,6 +253,16 @@ A structured collection will **not** have a maximum depth unless you set one, al
These reorderable entries have a max depth of 1.
+### Turning ordering off + +:::warning +Switching **Orderable** back off **deletes the collection's tree immediately**, without a confirmation, and there's no undoing it. + +Your entries are untouched, but they stop being nested — so every nested entry's URL moves. An entry that lived at `/about/staff` falls back to whatever its collection's route produces without a `parent_uri`, and the old URL returns a 404. Set up [redirects](/routing#redirects) before you flip the switch on a live site. + +[Structured taxonomies](/taxonomies#turning-it-off) behave the same way. +::: + ### Default sort order in listings For non-structured collections, you can choose which field and direction to sort the list of entries in the Control Panel by setting the `sort_by` and `sort_dir` variables in your collection.yaml. By default, the Title field will be used. diff --git a/content/collections/pages/graphql.md b/content/collections/pages/graphql.md index b19bea23a..f7bcd6035 100644 --- a/content/collections/pages/graphql.md +++ b/content/collections/pages/graphql.md @@ -237,6 +237,7 @@ Returns a [paginated](#pagination) list of [EntryInterface](#entry-interface) ty | `page` | `Int` | The paginated page to be shown. Defaults to `1`. | `filter` | `JsonArgument` | Narrows down the results based on [filters](#filtering). | `sort` | `[String]` | [Sorts](#sorting) the results based on one or more fields and directions. +| `with_descendants` | `Boolean` | Defaults to `true`. When filtering by a term on a [nestable taxonomy](/taxonomies#ordering-and-nesting), entries tagged with its descendant terms are included. Pass `false` to match only the exact term. Example query and response: diff --git a/content/collections/pages/rest-api.md b/content/collections/pages/rest-api.md index d400f6764..2c0670671 100644 --- a/content/collections/pages/rest-api.md +++ b/content/collections/pages/rest-api.md @@ -63,10 +63,11 @@ You may send requests to the following endpoints: - [Sites](#sites) - [Collections](#collections) / [Collection](#collection) - [Entries](#entries) / [Entry](#entry) -- [Collection Tree](#collection-tree) / [Navigation Tree](#navigation-tree) +- [Collection Tree](#collection-tree) / [Navigation Tree](#navigation-tree) / [Taxonomy Tree](#taxonomy-tree) - [Navs](#navs) / [Nav](#nav) - [Taxonomies](#taxonomies) / [Taxonomy](#taxonomy) - [Taxonomy Terms](#taxonomy-terms) / [Taxonomy Term](#taxonomy-term) +- [Taxonomy Term Entries](#taxonomy-term-entries) - [Asset Containers](#asset-containers) / [Asset Container](#asset-container) - [Assets](#assets) / [Asset](#asset) - [Globals](#globals) / [Global](#global) @@ -367,6 +368,12 @@ Gets entries within a collection. If you are using [Multi-Site](/multi-site), the entries endpoint will serve from all sites at once. If needed, you can limit the fetched data to a specific site with the `site` query parameter (ie. `?site=fr`), or a `site` [filter](#filtering) (ie. `&filter[site]=fr`). ::: +When you [filter](#filtering) by a term on a [nestable taxonomy](/taxonomies#ordering-and-nesting), entries tagged with that term's descendants are included. Add `?with_descendants=false` to match only the exact term. + +```url +/api/collections/products/entries?filter[taxonomy:product_categories]=clothing&with_descendants=false +``` + ## Entry @@ -569,6 +576,72 @@ Gets a single taxonomy term. } ``` +## Taxonomy Term Entries + +`GET` `/api/taxonomies/{taxonomy}/terms/{slug}/entries` + +Gets the entries tagged with a taxonomy term. + +``` json +{ + "data": [ + { + "title": "My First Day" + } + ], + "links": {...}, + "meta": {...} +} +``` + +On a [nestable taxonomy](/taxonomies#ordering-and-nesting), the entries tagged with the term's descendants are included. Add `?with_descendants=false` to get only the entries tagged with this exact term. + +```url +/api/taxonomies/product_categories/terms/clothing/entries?with_descendants=false +``` + +## Taxonomy Tree + +`GET` `/api/taxonomies/{taxonomy}/tree` + +Gets the term tree for a [structured taxonomy](/taxonomies#ordering-and-nesting). Returns a 404 if the taxonomy isn't structured. + +``` json +{ + "data": [ + { + "term": { + "title": "Clothing", + "url": "/product-categories/clothing" + }, + "depth": 1, + "children": [ + { + "term": { + "title": "Shirts", + "url": "/product-categories/clothing/shirts" + }, + "depth": 2, + "children": [] + } + ] + } + ] +} +``` + +### Params + +On this endpoint, the [fields](#selecting-fields) param will allow you to select fields within each `term` object. You may also set a `max_depth` to limit nesting depth, or `site` to choose the site. + +```url +/api/taxonomies/{taxonomy}/tree?fields=title,url&max_depth=2&site=fr +``` + +:::warning +Taxonomy trees are **not per-site**. The `site` param localizes each `term` payload — its title, slug, and URL — but the tree's shape and order are the same for every site. Requesting a site the taxonomy isn't available in returns a 404. +::: + ## Globals `GET` `/api/globals` diff --git a/content/collections/pages/structures.md b/content/collections/pages/structures.md index 61a7dc7ba..556781e61 100644 --- a/content/collections/pages/structures.md +++ b/content/collections/pages/structures.md @@ -17,6 +17,7 @@ Every structure is a hierarchy of branches. What differs is *what the hierarchy 1. **Structured collections** — the tree *is* the content hierarchy. Nesting and order drive URLs (and sibling order). Your sitemap lives on the collection. 2. **Navigations** — the tree is a menu. Mix entry references, hard URLs, and text nodes. Position in the tree does **not** rewrite entry URLs. +3. **Structured taxonomies** — the tree nests terms inside each other. Nesting and order drive term URLs, and entry queries for a term include its whole subtree. Covered in [Taxonomies](/taxonomies#ordering-and-nesting). Same drag-and-drop UI. Same YAML tree shape. Different jobs. @@ -44,13 +45,14 @@ Same drag-and-drop UI. Same YAML tree shape. Different jobs. **Use both when** a pages collection owns the URLs and one or more navs compose what appears in chrome. That's normal — not overkill. -| | Structured collection | Navigation | -| --- | --- | --- | -| Owns URLs | Yes — position drives routes | No — entries keep their collection URLs | -| Entry once | Yes | No — can repeat | -| Freeform links / text | Entry-link redirects (via collection settings) | Yes — URLs and text nodes | -| Config lives in | The collection itself | `content/navigation` | -| Tree lives in | `content/trees/collections` | `content/trees/navigation` | +| | Structured collection | Navigation | Structured taxonomy | +| --- | --- | --- | --- | +| Owns URLs | Yes — position drives routes | No — entries keep their collection URLs | Yes — position drives term routes | +| Item once | Yes | No — can repeat | Yes | +| Freeform links / text | Entry-link redirects (via collection settings) | Yes — URLs and text nodes | No | +| Config lives in | The collection itself | `content/navigation` | The taxonomy itself | +| Tree lives in | `content/trees/collections` | `content/trees/navigation` | `content/trees/taxonomies` | +| Tree per site | Yes | Yes | No — one tree, shared | ## Structured collections @@ -109,6 +111,8 @@ Each page may have an optional `children` array which is itself another tree. Ne _\* Text and link branches are only available in navs._ +In a taxonomy's tree, branches reference a term by slug with a `term` key instead — see [Taxonomies](/taxonomies#the-tree). + ## Templating Loop either kind of structure with the [nav tag](/tags/nav). diff --git a/content/collections/pages/taxonomies.md b/content/collections/pages/taxonomies.md index cb8a7d2a3..704ed37b8 100644 --- a/content/collections/pages/taxonomies.md +++ b/content/collections/pages/taxonomies.md @@ -65,6 +65,8 @@ For each taxonomy [assigned to a collection](#collections) you will also get the - Accessible at `/{collection-url}/{taxonomy-slug}/{term-slug}` (eg. `/products/tags/t-shirts`) - The `{collection_handle}/{taxonomy_handle}/show` view will be used. (eg. `products/tags/show.antlers.html`) +If the taxonomy is [nestable](#nested-term-urls), term URLs include the slugs of the term's ancestors. + ## Term values and slugs A term **value** is how you might identify a term in your content. For example, “Star Wars”. @@ -88,6 +90,179 @@ Titles are saved on a first-come, first-serve basis, which means consistency is To further clarify, `Star wars`, `star wars`, `StAr WaRS`, and `star-wars` are all treated as the same term. If case-sensitivity is important, you can add a `title` field to the taxonomy blueprint. +## Ordering and nesting + +Flick on the **Orderable** switch in the "Ordering & Nesting" area of a taxonomy's settings and you'll have a drag and drop UI in the control panel to order and nest the terms. The taxonomy is now "structured". Learn more about [structures](/structures). + +Existing terms are added to the tree in their current sort order, so turning the switch on doesn't rearrange anything on its own. + +### Constraining depth + +A structured taxonomy will **not** have a maximum depth unless you set one, allowing you to nest terms as deep as you like. Set the **Max Depth** option to limit this behavior. Setting it to `1` gives you a flat, reorderable list — order without nesting, and term URLs stay flat. + +``` yaml +# content/taxonomies/product_categories.yaml +title: 'Product Categories' +structure: + max_depth: 3 +``` + +:::tip +Max depth is enforced on the server, not just in the tree UI. Nesting a term too deep — by dragging it, or by choosing a parent when creating one — will be rejected. +::: + +### Nested term URLs + +Once a taxonomy is nestable (structured with a max depth other than `1`), the default term route gains a `{parent_uri}` segment: + +```url +/{taxonomy-slug}/{parent_uri}/{term-slug} +``` + +So a `shirts` term nested under `clothing` lives at `/product-categories/clothing/shirts`. Root terms have an empty `parent_uri` and keep their existing URL. + +### The tree + +A taxonomy's tree is stored in a single file at `content/trees/taxonomies/{taxonomy_handle}.yaml`, and each branch references a term by slug. + +``` yaml +tree: + - + term: clothing + children: + - + term: shirts + - + term: footwear +``` + +:::tip +You *can* edit the tree in the file. You *shouldn't*, unless you enjoy YAML indentation as a hobby. The Control Panel's drag-and-drop UI is the move. +::: + +### Multi-site + +**Taxonomy trees are not per-site.** There is one tree per taxonomy, shared by every site — which is why there's only one tree file, with no site directory. Shape and order are global. Titles, slugs, and therefore parent URIs *are* localized, exactly as they already were for terms. + +This is a **deliberate divergence from [collection structures](/collections#ordering)**, which do have a tree per site. A long-standing complaint about collection trees is that people want to arrange things once and have it apply everywhere, which is closer to how terms already work. + +Two consequences are worth stating outright, because both read the other way round at first glance: + +- **The site selector on a taxonomy switches which site the tree is _rendered_ in, not which tree you are editing.** It swaps the titles, slugs, and URLs shown on each branch. Dragging a term to a new position applies that move to every site. The same control on a collection means something different. +- **The `site` parameter on the [tree endpoint](/rest-api#taxonomy-tree) localizes the term payloads, not the tree's shape or order.** The parameter reads as though it selects a tree; it doesn't. + +Branches reference a term by its slug in the default site, so renaming a slug in a secondary site never moves a term in the tree. + +### Nesting variables + +On a structured taxonomy, terms get these variables in addition to the usual ones. + +| Variable | Description | +|----------|-------------| +| `parent` | The term one level up, or `null` for a root term. | +| `children` | The terms directly beneath this one. | +| `ancestors` | Every term above this one, root first. | +| `depth` | How deep the term sits in the tree. Root terms are `1`. | + +::tabs + +::tab antlers +```antlers +{{ ancestors }} + {{ title }} / +{{ /ancestors }} + +

{{ title }}

+ + +``` +::tab blade +```blade +@foreach ($ancestors as $ancestor) + {{ $ancestor->title }} / +@endforeach + +

{{ $title }}

+ + +``` +:: + +:::tip +Terms don't have an `is_root` variable. To check whether you're on a top-level term, compare the depth — `{{ if depth == 1 }}` in Antlers, or `@if ($depth == 1)` in Blade. That's the same check Statamic uses internally. + +If you're writing PHP, don't reach for `LocalizedTerm::isRoot()` for this. It's unrelated, and answers a different question: whether the term is in the default site. +::: + +### Descendant entries + +Filtering entries by a term on a nestable taxonomy **includes the entries of that term's whole subtree by default**. Asking for entries in `clothing` gets you everything tagged `shirts` and `shoes` too, which is almost always what you want from a category page. + +When it isn't, pass `with_descendants="false"` to limit the results to entries tagged with that exact term. + +::tabs + +::tab antlers +```antlers +{{ collection:products taxonomy:product_categories="clothing" with_descendants="false" }} + {{ title }} +{{ /collection:products }} +``` +::tab blade +```blade + + {{ $title }} + +``` +:: + +The opt-out is available wherever you can filter entries by a term: + +| Where | How | +|-------|-----| +| [Collection tag](/tags/collection) | `with_descendants="false"` | +| `{{ entries }}` on a [term route](#routing) | `with_descendants="false"` | +| The `{{ query }}` tag, and any other tag pair that loops over a query builder | `with_descendants="false"` | +| [REST API](/rest-api#entries) collection entries | `?with_descendants=false` | +| [REST API](/rest-api#taxonomy-term-entries) term entries | `?with_descendants=false` | +| [GraphQL](/graphql#entries-query) `entries` query | `with_descendants: false` | + +On a flat taxonomy — or one with a max depth of `1` — there are no descendants, so the parameter does nothing. + +:::tip +In PHP, call `withTaxonomyDescendants(false)` on the [query builder](/content-queries). + +```php +Entry::query() + ->whereTaxonomy('product_categories::clothing') + ->withTaxonomyDescendants(false) + ->get(); +``` +::: + +The `entries_count` variable counts descendants too, so it agrees with what `{{ entries }}` gives you. It has no opt-out — query the entries yourself if you need a count of only the directly tagged ones. + +### Turning it off + +:::warning +Switching **Orderable** back off **deletes the taxonomy's tree file immediately**, without a confirmation, and there's no undoing it. + +Your terms are untouched, but they stop being nested — so every nested term's URL moves. A term that lived at `/product-categories/clothing/shirts` is now at `/product-categories/shirts`, and the old URL returns a 404. Set up [redirects](/routing#redirects) before you flip the switch on a live site. + +Structured collections behave the same way when you turn **Orderable** off. +::: + ## Templating ### Views @@ -214,6 +389,8 @@ When on a [term route](#routing), you can list the entries by using an `entries` ``` :: +On a nestable taxonomy this includes the entries of the term's [descendants](#descendant-entries). Add `with_descendants="false"` to get only the entries tagged with this exact term. + ## Search indexes You can configure search indexes for your collections to improve the efficiency and relevancy of your users searches. Learn [how to connect indexes](/search#connecting-indexes). diff --git a/content/collections/tags/collection.md b/content/collections/tags/collection.md index ad17a1e4f..c2622648b 100644 --- a/content/collections/tags/collection.md +++ b/content/collections/tags/collection.md @@ -66,6 +66,11 @@ parameters: type: mixed description: 'A multitude of ways to filter by taxonomies. [More details](#taxonomies)' required: false + - + name: with_descendants + type: 'boolean *true*' + description: 'When filtering by a term on a nestable taxonomy, entries tagged with its descendant terms are included. Set to `false` to match only the exact term. [More details](#descendant-terms)' + required: false - name: paginate type: 'boolean|int *false*' @@ -286,6 +291,32 @@ It is important that the collection has been [configured to use this taxonomy](/ There are several different ways to use this filtering parameter. They are explained in depth on the [Conditions page](/conditions#taxonomy-conditions). ::: +#### Descendant terms + +If the taxonomy is [nestable](/taxonomies#ordering-and-nesting), filtering by a term also matches entries tagged with any of that term's descendants. Asking for `clothing` gets you the entries tagged `shirts` and `shoes` too. + +Pass `with_descendants="false"` to match only the entries tagged with the exact term. + +::tabs +::tab antlers +```antlers +{{ collection:products taxonomy:product_categories="clothing" with_descendants="false" }} +``` + +::tab blade + +```blade + + + +``` +:: + +On a flat taxonomy there are no descendants, so the parameter does nothing. + ### Published Status By default, only `published` entries are included. Entries can be queried against `any`, `draft`, `scheduled`, or `expired` status with [conditions](#conditions) on `status` like this: diff --git a/content/collections/variables/entries_count.md b/content/collections/variables/entries_count.md index b26fe9182..2de6dfab8 100644 --- a/content/collections/variables/entries_count.md +++ b/content/collections/variables/entries_count.md @@ -23,3 +23,5 @@ There are {{ $entries_count }} 'news' entries. ```html There are 85 'news' entries. ``` + +On a [nestable taxonomy](/taxonomies#ordering-and-nesting) this counts the entries tagged with the term's descendants too, so it agrees with what `{{ entries }}` returns. There's no way to opt out — [query the entries yourself](/taxonomies#descendant-entries) with `with_descendants="false"` if you need a count of only the directly tagged ones.