Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
45eff81
Add taxonomy structures so terms can be hierarchical.
jackmcdade Aug 13, 2026
10f3c15
Add a CP tree view and orderable settings for hierarchical taxonomies.
jackmcdade Aug 13, 2026
5bb6d3d
Nest hierarchical term URLs and expose parent/depth in tags.
jackmcdade Aug 13, 2026
c4f7503
Show term hierarchy in the terms fieldtype.
jackmcdade Aug 13, 2026
b67202b
Expose taxonomy trees in the REST API and GraphQL.
jackmcdade Aug 13, 2026
a78f822
Add per-taxonomy route control so URLs can be customized or disabled.
jackmcdade Aug 13, 2026
0cf33e0
Treat custom taxonomy routes as full URL patterns, without collection…
jackmcdade Aug 13, 2026
847503a
Improve creating nested terms in the terms fieldtype.
jackmcdade Aug 13, 2026
eabec93
Nest newly created term paths in the persisted taxonomy tree.
jackmcdade Aug 13, 2026
b6df8cb
Fix term edits not persisting when associated in another site.
jackmcdade Aug 13, 2026
9ccca78
Let you choose whether deleting a parent term also deletes its children.
jackmcdade Aug 13, 2026
88694c6
Fix deleted terms lingering in entries, and nest YAML term paths in t…
jackmcdade Aug 13, 2026
cee51e0
Treat max-depth-1 taxonomies as a flat reorderable list, like collect…
jackmcdade Aug 13, 2026
9524988
Invalidate static cache using actual taxonomy routes, including custo…
jackmcdade Aug 13, 2026
b11a022
Show the taxonomy tree to anyone who can view it, and only allow edit…
jackmcdade Aug 13, 2026
4077ebd
Validate taxonomy tree max depth on the server, not just in the UI.
jackmcdade Aug 13, 2026
c6ecf18
Rewrite nested term path references when a term is renamed.
jackmcdade Aug 13, 2026
4855d99
Add a structure tag for taxonomy trees, matching collection and nav t…
jackmcdade Aug 13, 2026
9671f59
Include descendant terms in taxonomy filters by default.
jackmcdade Aug 13, 2026
6cb9333
Default hierarchical terms fields to the select dropdown so they can …
jackmcdade Aug 13, 2026
d21e752
Sort structured taxonomy terms by tree order in the CP list, matching…
jackmcdade Aug 13, 2026
27a4d4c
Add a site selector to the taxonomy tree so localized titles and crea…
jackmcdade Aug 13, 2026
0331ce3
Show parent breadcrumbs when creating a child term.
jackmcdade Aug 13, 2026
a2e8974
Expand CP entry listing term filters to include descendant terms in a…
jackmcdade Aug 13, 2026
6b8b044
Add a term option to the link fieldtype, matching entry and asset.
jackmcdade Aug 13, 2026
59dbd36
Scaffold taxonomy index and term templates, including hierarchy tags …
jackmcdade Aug 13, 2026
7cad640
Replace term parent breadcrumbs with a relationship field so you can …
jackmcdade Aug 13, 2026
4e95283
Import translation helpers
jackmcdade Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/stache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
1 change: 1 addition & 0 deletions lang/en/fieldtypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
'integer.title' => 'Integer',
'link.config.collections' => 'Entries from these collections will be available. Leaving this empty will make entries from routable collections available.',
'link.config.container' => 'Choose which asset container to use for this field.',
'link.config.taxonomies' => 'Terms from these taxonomies will be available. Leaving this empty will make terms from routable taxonomies available.',
'link.config.default_option' => 'The default Link type option, if available for the fieldtype\'s configuration.',
'link.title' => 'Link',
'list.config.add_row' => 'Customize the label of the "Add Item" button.',
Expand Down
6 changes: 6 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,12 @@
'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.',
'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).',
'taxonomies_routes_instructions' => 'The route controls the terms URL pattern. Learn more in the [documentation](https://statamic.dev/taxonomies#routing).',
'taxonomies_route_instructions' => 'The URL pattern for term pages. Include `{slug}` (and `{parent_uri}` when nested).',
'taxonomy_configure_handle_instructions' => 'Used to reference this taxonomy on the frontend. This cannot be easily changed later.',
'taxonomy_configure_intro' => 'A taxonomy is a system of classifying data around a set of unique characteristics, such as categories, tags, or colors.',
'taxonomy_configure_layout_instructions' => 'Set this taxonomy\'s default layout. Terms can override this setting with a `layout` field.',
Expand All @@ -265,6 +269,8 @@
'taxonomy_next_steps_blueprints_description' => 'Manage the blueprints and fields available for this taxonomy.',
'taxonomy_next_steps_configure_description' => 'Configure names, associate collections, define blueprints, and more.',
'taxonomy_next_steps_create_term_description' => 'Create the first term or stub out a handful of placeholder terms—it\'s up to you.',
'taxonomy_next_steps_scaffold_description' => 'Generate index and detail views from the taxonomy name at the click of a button.',
'taxonomy_scaffold_instructions' => 'Select and configure views to generate. Any existing files will be preserved.',
'theme_share_instructions' => 'Publish this theme through your statamic.com account to make it available to others.',
'try_again_in_seconds' => '{0,1}Try again now.|Try again in :count seconds.',
'try_again_in_minutes' => 'Try again in a minute.|Try again in :count minutes.',
Expand Down
3 changes: 3 additions & 0 deletions lang/en/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,12 @@
'options_require_keys' => 'All options must have keys.',
'origin_cannot_be_disabled' => 'Cannot select a disabled origin.',
'parent_cannot_be_itself' => 'Cannot be its own parent.',
'parent_cannot_be_descendant' => 'Cannot be nested under one of its descendants.',
'parent_causes_root_children' => 'This would cause the root page to have children.',
'parent_exceeds_max_depth' => 'This would exceed the maximum depth.',
'reserved' => 'This is a reserved word.',
'term_path_exceeds_max_depth' => 'The path :path exceeds the maximum depth of :max.',
'taxonomy_route_requires_slug' => 'The route must include {slug}.',
'reserved_field_handle' => 'Field with a handle of :handle is a reserved word.',
'unique_entry_value' => 'This value has already been taken.',
'unique_form_handle' => 'This value has already been taken.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
ref="input"
:name="name"
:value="value"
:mode="config.mode"
:mode="mode"
:can-edit="canEdit"
:config="config"
:can-create="canCreate"
Expand Down Expand Up @@ -48,6 +48,10 @@ export default {
},

computed: {
mode() {
return this.meta?.mode || this.config.mode;
},

maxItems() {
return this.config.max_items || Infinity;
},
Expand Down
114 changes: 74 additions & 40 deletions resources/js/components/inputs/relationship/Item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,51 @@
<div
class="shadow-ui-sm relative z-(--z-index-above) flex w-full h-full items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 [&:has(.cursor-grab)]:px-1.5 py-1.5 mb-1.5 last:mb-0 text-base dark:border-gray-700 dark:with-contrast:border-gray-500 dark:bg-gray-900"
>
<ui-icon name="handles" class="item-move sortable-handle size-4 cursor-grab text-gray-300 dark:text-gray-700" v-if="sortable" />
<div class="flex flex-1 items-center line-clamp-1 text-sm text-gray-600 dark:text-gray-300">
<ui-status-indicator v-if="item.status" :status="item.status" class="me-2" />
<ui-icon name="handles" class="item-move sortable-handle size-4 shrink-0 cursor-grab text-gray-300 dark:text-gray-700" v-if="sortable" />
<ui-status-indicator v-if="item.status" :status="item.status" class="shrink-0" />

<div class="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-sm text-gray-600 dark:text-gray-300">
<div
v-if="item.invalid"
v-tooltip.top="__('messages.relationship_item_unavailable')"
v-text="__(item.title)"
class="line-clamp-1 text-sm text-gray-500 dark:text-gray-400"
class="text-sm text-gray-500 dark:text-gray-400"
/>

<a
v-if="!item.invalid && editable"
@click.prevent="edit"
v-text="__(item.title)"
class="line-clamp-1 text-sm text-gray-600 dark:text-gray-300"
v-tooltip="item.title"
:href="item.edit_url"
/>
<template v-else>
<span
v-if="hintTaxonomy"
v-text="hintTaxonomy"
class="text-xs text-gray-400 dark:text-gray-500"
/>
<template v-if="hintPathSegments.length">
<ui-badge
v-for="(segment, i) in hintPathSegments"
:key="i"
size="sm"
:text="segment"
/>
</template>
<template v-else-if="item.hint">
<span
v-text="item.hint"
:title="item.hint"
class="text-xs text-gray-400 dark:text-gray-500"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest this should be text-gray-500 dark:text-gray-400 to hit AA

/>
<span class="text-xs text-gray-300 dark:text-gray-600" aria-hidden="true">»</span>
</template>

<a
v-if="editable"
@click.prevent="edit"
v-text="__(item.title)"
class="text-sm text-gray-600 dark:text-gray-300"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest this should be text-gray-900 dark:text-gray-100 to match other bits of the UI

v-tooltip="item.title"
:href="item.edit_url"
/>

<div v-if="!item.invalid && !editable" v-text="__(item.title)" />
<div v-else v-text="__(item.title)" />
</template>

<inline-edit-form
v-if="isEditing"
Expand All @@ -33,34 +57,26 @@
@updated="itemUpdated"
@closed="isEditing = false"
/>
</div>

<div class="flex flex-1 items-center justify-end">
<div
v-if="item.hint"
v-text="item.hint"
class="text-2xs tracking-tight me-2 hidden whitespace-nowrap text-gray-500 @sm:block"
/>

<div class="flex items-center" v-if="!readOnly">
<Dropdown>
<template #trigger>
<Button icon="dots" variant="ghost" size="xs" v-bind="$attrs" :aria-label="__('Open dropdown menu')" />
</template>
<DropdownMenu>
<DropdownItem
v-if="editable"
:text="__('Edit')"
@click="edit"
/>
<DropdownItem
:text="__('Unlink')"
variant="destructive"
@click="$emit('removed')"
/>
</DropdownMenu>
</Dropdown>
</div>
</div>
<div class="flex shrink-0 items-center" v-if="!readOnly">
<Dropdown>
<template #trigger>
<Button icon="dots" variant="ghost" size="xs" v-bind="$attrs" :aria-label="__('Open dropdown menu')" />
</template>
<DropdownMenu>
<DropdownItem
v-if="editable"
:text="__('Edit')"
@click="edit"
/>
<DropdownItem
:text="__('Unlink')"
variant="destructive"
@click="$emit('removed')"
/>
</DropdownMenu>
</Dropdown>
</div>
</div>
</template>
Expand Down Expand Up @@ -103,6 +119,24 @@ export default {
};
},

computed: {
hintTaxonomy() {
if (!this.item.hint?.includes(' • ') || !this.item.hint.includes(' » ')) return null;

return this.item.hint.split(' • ')[0];
},

hintPathSegments() {
if (!this.item.hint?.includes(' » ')) return [];

const path = this.item.hint.includes(' • ')
? this.item.hint.split(' • ').slice(1).join(' • ')
: this.item.hint;

return path.split(' » ');
},
},

methods: {
edit() {
if (!this.editable) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
:read-only="readOnly"
:url="selectionsUrl"
:site="site"
:tree="tree"
@input="selectFieldSelected"
@focus="$emit('focus')"
@blur="$emit('blur')"
Expand Down
76 changes: 70 additions & 6 deletions resources/js/components/inputs/relationship/SelectField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
:model-value="items.map((item) => item.id)"
:multiple
:options="comboboxOptions"
:placeholder="__(config.placeholder) || __('Choose...')"
:placeholder="fieldPlaceholder"
:read-only="readOnly"
:taggable="isTaggable"
:close-on-select="isTaggable"
Expand All @@ -18,11 +18,33 @@
@update:modelValue="itemsSelected"
@search="search"
>
<template #option="{ title, hint, status }">
<div class="flex w-full text-left items-center gap-2">
<template #option="{ title, hint, status, depth, id, _created }">
<div
v-if="_created || isTypedTermPath(id)"
class="flex w-full min-w-0 text-left items-center gap-1.5"
>
<span class="text-xs text-gray-600 dark:text-gray-400 shrink-0" v-text="__('Create')" />
<template v-if="isTypedTermPath(id)">
<template v-for="(segment, i) in termPathSegments(title)" :key="i">
<span
v-if="i > 0"
class="text-xs text-gray-500 dark:text-gray-400"
aria-hidden="true"
>→</span>
<ui-badge size="sm" :text="segment" />
</template>
</template>
<span v-else v-text="title" class="truncate" />
</div>
<div
v-else
class="flex w-full text-left items-center gap-2"
:style="depth > 1 ? { paddingInlineStart: `${(depth - 1) * 1}rem` } : null"
>
<span v-if="depth > 1" class="text-gray-400 dark:text-gray-600" aria-hidden="true">↳</span>
<StatusIndicator v-if="status" :status="status" />
<div v-text="title" class="truncate grow" />
<ui-badge v-if="hint" size="sm" v-text="hint" />
<ui-badge v-if="hint && !(depth > 1)" size="sm" v-text="hint" />
</div>
</template>
<template #no-options>
Expand Down Expand Up @@ -64,6 +86,7 @@ export default {
config: Object,
readOnly: Boolean,
site: String,
tree: Object,
},
data() {
Expand All @@ -84,6 +107,16 @@ export default {
return this.taggable;
},
fieldPlaceholder() {
if (this.config.placeholder) return __(this.config.placeholder);
if (this.isTaggable && this.config.type === 'terms' && this.tree) {
return __('Search or create...');
}
if (this.isTaggable) return __('Search or create...');
return __('Choose...');
},
parameters() {
return {
site: this.site,
Expand All @@ -94,8 +127,13 @@ export default {
// The `users` fieldtype falls back to displaying a user's email as their title when
// they have no name, but doesn't show it otherwise, so it needs to be searchable too.
// Terms in hierarchical taxonomies expose their slug path (e.g. `animals/cat`) so
// searching a parent surfaces its descendants too.
searchKeys() {
return this.config.type === 'users' ? ['title', 'email'] : null;
if (this.config.type === 'users') return ['title', 'email'];
if (this.config.type === 'terms') return ['title', 'path'];
return null;
},
cacheKey() {
Expand Down Expand Up @@ -186,16 +224,42 @@ export default {
let option = this.options.find((option) => option.id === id);
let existing = this.items.find((item) => item.id === id);
return existing || option || { id: id, title: id };
return existing || option || this.newItemFromId(id);
});
this.$emit('input', items);
},
// A typed term path like `animals/cat/calico` attaches the leaf, so render
// the badge as `calico · animals » cat` until the save normalizes it.
newItemFromId(id) {
if (this.config.type === 'terms' && typeof id === 'string' && id.includes('/')) {
const segments = id.split('/').filter((segment) => segment.trim().length);
const title = segments.pop();
if (title && segments.length) {
return { id, title, hint: segments.join(' » ') };
}
}
return { id: id, title: id };
},
createOption(value) {
const existing = this.options.find((option) => option.title === value);
return existing || { id: value, title: value };
},
isTypedTermPath(id) {
return this.config.type === 'terms'
&& typeof id === 'string'
&& id.includes('/')
&& !id.includes('::');
},
termPathSegments(title) {
return String(title).split('/').map((segment) => segment.trim()).filter(Boolean);
},
},
};
</script>
2 changes: 1 addition & 1 deletion resources/js/components/inputs/relationship/Selector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

<div v-if="view === 'tree'" class="flex flex-1 min-h-0 flex-col gap-4 p-4">
<div class="flex items-center justify-between gap-2 sm:gap-3 min-h-16">
<Heading :text="__('Pages')" size="lg" />
<Heading :text="tree.title ? __(tree.title) : __('Pages')" size="lg" />
<ui-toggle-group v-model="view" v-if="canUseTree">
<ui-toggle-item icon="hierarchy" value="tree" />
<ui-toggle-item icon="layout-list" value="list" />
Expand Down
Loading
Loading