diff --git a/resources/js/components/entries/Listing.vue b/resources/js/components/entries/Listing.vue
index 08abfe662a3..2f08affbc13 100644
--- a/resources/js/components/entries/Listing.vue
+++ b/resources/js/components/entries/Listing.vue
@@ -19,6 +19,7 @@
+
@@ -39,6 +40,7 @@
diff --git a/resources/js/components/inputs/relationship/Selector.vue b/resources/js/components/inputs/relationship/Selector.vue
index 341ad83e3fe..fd9f46ae8c0 100644
--- a/resources/js/components/inputs/relationship/Selector.vue
+++ b/resources/js/components/inputs/relationship/Selector.vue
@@ -41,6 +41,7 @@
+
@@ -145,6 +146,7 @@ import {
StatusIndicator,
Badge,
} from '@/components/ui';
+import StructureBreadcrumbBadge from '../../entries/StructureBreadcrumbBadge.vue';
export default {
components: {
@@ -163,6 +165,7 @@ export default {
Icon,
StatusIndicator,
Badge,
+ StructureBreadcrumbBadge,
},
// todo, when opening and closing the stack, you cant save?
diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php
index 05e0458fe59..2b1f5ae6ab8 100644
--- a/src/Entries/Entry.php
+++ b/src/Entries/Entry.php
@@ -976,6 +976,41 @@ public function parent()
return optional($this->page())->parent();
}
+ public function structureBreadcrumbs(): array
+ {
+ if (! $this->hasStructure()) {
+ return [];
+ }
+
+ $titles = [];
+ $page = $this->parent();
+ $skipRoot = $this->structure()->expectsRoot();
+
+ while ($page) {
+ // With a collection root, every page sits under it — omit it so
+ // breadcrumbs stay useful (e.g. "Fruit" instead of "Home ‹ Fruit").
+ if ($skipRoot && $page->isRoot()) {
+ break;
+ }
+
+ if ($title = $page->title()) {
+ // Nearest parent first, matching CP document titles (Title ‹ Section).
+ $titles[] = $title;
+ }
+
+ $page = $page->parent();
+ }
+
+ return $titles;
+ }
+
+ public function structureBreadcrumb(string $separator = ' ‹ '): ?string
+ {
+ $titles = $this->structureBreadcrumbs();
+
+ return empty($titles) ? null : implode($separator, $titles);
+ }
+
public function page()
{
if (! $this->hasStructure()) {
diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php
index 8b070789cac..9e2c9a300dc 100644
--- a/src/Fieldtypes/Entries.php
+++ b/src/Fieldtypes/Entries.php
@@ -563,10 +563,13 @@ public function preload()
public function getItemHint($item): ?string
{
- return collect([
+ $hint = collect([
+ $item->structureBreadcrumb(),
count($this->getConfiguredCollections()) > 1 ? __($item->collection()->title()) : null,
$this->canSelectAcrossSites() && count($this->availableSites()) > 1 ? $item->site()->name() : null,
])->filter()->implode(' • ');
+
+ return $hint !== '' ? $hint : null;
}
private function addColumn(Columns $columns, string $columnKey): void
diff --git a/src/Http/Resources/CP/Entries/ListedEntry.php b/src/Http/Resources/CP/Entries/ListedEntry.php
index 67e800115ea..edc242bd99f 100644
--- a/src/Http/Resources/CP/Entries/ListedEntry.php
+++ b/src/Http/Resources/CP/Entries/ListedEntry.php
@@ -40,6 +40,7 @@ public function toArray($request)
$this->merge($this->values(['slug' => $entry->slug()])),
+ 'breadcrumb' => ($breadcrumbs = $entry->structureBreadcrumbs()) ? $breadcrumbs : null,
'permalink' => $entry->absoluteUrl(),
'edit_url' => $entry->editUrl(),
'collection' => array_merge($entry->collection()->toArray(), ['dated' => $entry->collection()->dated()]),
diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php
index 2ea53291037..dfcde535661 100644
--- a/tests/Data/Entries/EntryTest.php
+++ b/tests/Data/Entries/EntryTest.php
@@ -1185,6 +1185,81 @@ public function it_gets_the_order_from_the_collections_structure()
$this->assertEquals(3, $four->order());
}
+ #[Test]
+ public function it_gets_a_structure_breadcrumb_from_ancestors()
+ {
+ $collection = tap(Collection::make('pages')->routes('{parent_uri}/{slug}'))->save();
+
+ $home = tap((new Entry)->locale('en')->id('home')->collection($collection)->slug('home')->data(['title' => 'Home']))->save();
+ $fruit = tap((new Entry)->locale('en')->id('fruit')->collection($collection)->slug('fruit')->data(['title' => 'Fruit › Bowl']))->save();
+ $tomato = tap((new Entry)->locale('en')->id('tomato')->collection($collection)->slug('tomato')->data(['title' => 'Tomato']))->save();
+ $seed = tap((new Entry)->locale('en')->id('seed')->collection($collection)->slug('seed')->data(['title' => 'Seed']))->save();
+
+ $collection->structureContents([
+ 'max_depth' => 4,
+ ])->save();
+ $collection->structure()->in('en')->tree([
+ [
+ 'entry' => 'home',
+ 'children' => [
+ [
+ 'entry' => 'fruit',
+ 'children' => [
+ [
+ 'entry' => 'tomato',
+ 'children' => [
+ ['entry' => 'seed'],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ])->save();
+
+ $this->assertSame([], $home->structureBreadcrumbs());
+ $this->assertSame(['Home'], $fruit->structureBreadcrumbs());
+ $this->assertSame(['Fruit › Bowl', 'Home'], $tomato->structureBreadcrumbs());
+ $this->assertSame(['Tomato', 'Fruit › Bowl', 'Home'], $seed->structureBreadcrumbs());
+ $this->assertNull($home->structureBreadcrumb());
+ $this->assertEquals('Home', $fruit->structureBreadcrumb());
+ $this->assertEquals('Fruit › Bowl ‹ Home', $tomato->structureBreadcrumb());
+ $this->assertEquals('Tomato ‹ Fruit › Bowl ‹ Home', $seed->structureBreadcrumb());
+
+ // With a collection root, siblings of the root become its children.
+ $collection->structureContents([
+ 'max_depth' => 4,
+ 'root' => true,
+ ])->save();
+ $collection->structure()->in('en')->tree([
+ ['entry' => 'home'],
+ [
+ 'entry' => 'fruit',
+ 'children' => [
+ [
+ 'entry' => 'tomato',
+ 'children' => [
+ ['entry' => 'seed'],
+ ],
+ ],
+ ],
+ ],
+ ])->save();
+
+ $this->assertSame([], $home->fresh()->structureBreadcrumbs());
+ $this->assertSame([], $fruit->fresh()->structureBreadcrumbs());
+ $this->assertSame(['Fruit › Bowl'], $tomato->fresh()->structureBreadcrumbs());
+ $this->assertSame(['Tomato', 'Fruit › Bowl'], $seed->fresh()->structureBreadcrumbs());
+ $this->assertNull($home->fresh()->structureBreadcrumb());
+ $this->assertNull($fruit->fresh()->structureBreadcrumb());
+ $this->assertEquals('Fruit › Bowl', $tomato->fresh()->structureBreadcrumb());
+ $this->assertEquals('Tomato ‹ Fruit › Bowl', $seed->fresh()->structureBreadcrumb());
+
+ $flat = tap(Collection::make('flat'))->save();
+ $this->assertSame([], (new Entry)->collection($flat)->structureBreadcrumbs());
+ $this->assertNull((new Entry)->collection($flat)->structureBreadcrumb());
+ }
+
#[Test]
public function it_gets_the_order_from_the_collections_structure_when_the_tree_contains_a_null_item()
{
diff --git a/tests/Fieldtypes/EntriesTest.php b/tests/Fieldtypes/EntriesTest.php
index a13bcc6a0ad..16da42b077c 100644
--- a/tests/Fieldtypes/EntriesTest.php
+++ b/tests/Fieldtypes/EntriesTest.php
@@ -417,6 +417,36 @@ public function it_doesnt_inherit_the_item_cache_from_another_fieldtype_instance
$this->assertTrue($second->getItemData(['123'])->first()['invalid']);
}
+ #[Test]
+ public function it_includes_structure_breadcrumb_before_collection_in_item_hints()
+ {
+ $pages = tap(Facades\Collection::make('pages')->routes('{parent_uri}/{slug}'))->save();
+ $home = EntryFactory::id('home')->collection($pages)->slug('home')->data(['title' => 'Home'])->create();
+ $fruit = EntryFactory::id('fruit')->collection($pages)->slug('fruit')->data(['title' => 'Fruit'])->create();
+ $tomato = EntryFactory::id('tomato')->collection($pages)->slug('tomato')->data(['title' => 'Tomato'])->create();
+
+ $pages->structureContents(['max_depth' => 3])->save();
+ $pages->structure()->in('en')->tree([
+ [
+ 'entry' => 'home',
+ 'children' => [
+ [
+ 'entry' => 'fruit',
+ 'children' => [
+ ['entry' => 'tomato'],
+ ],
+ ],
+ ],
+ ],
+ ])->save();
+
+ $fieldtype = $this->fieldtype(['collections' => ['pages', 'blog']]);
+
+ $this->assertEquals('Fruit ‹ Home • Pages', $fieldtype->getItemHint($tomato->fresh()));
+ $this->assertEquals('Pages', $fieldtype->getItemHint($home->fresh()));
+ $this->assertNull($this->fieldtype(['collections' => ['pages']])->getItemHint($home->fresh()));
+ }
+
public function fieldtype($config = [], $parent = null)
{
$field = new Field('test', array_merge([