From a8794cad3af2ce3606057d7000aeec27c2c42b1a Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 5 Feb 2026 17:36:35 +0100 Subject: [PATCH 1/3] add form breadcrumb test --- tests/Http/BreadcrumbTest.php | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Http/BreadcrumbTest.php b/tests/Http/BreadcrumbTest.php index e4437f0d0..ff19f0746 100644 --- a/tests/Http/BreadcrumbTest.php +++ b/tests/Http/BreadcrumbTest.php @@ -254,6 +254,44 @@ public function find($id): array ); }); +it('uses custom labels limit on form leaf if configured', function () { + fakeFormFor('person', new class() extends PersonForm + { + public function buildFormFields(FieldsContainer $formFields): void + { + $formFields->addField(SharpFormEditorField::make('name')); + } + + public function buildFormConfig(): void + { + $this->configureBreadcrumbCustomLabelAttribute('name', limit: 20); + } + + public function find($id): array + { + return $this->transform([ + 'id' => 1, + 'name' => 'A very long name that should be limited', + ]); + } + }); + + $this + ->get( + route('code16.sharp.form.edit', [ + 'parentUri' => 's-list/person', + 'person', + 1, + ]) + ) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('breadcrumb.items.0.label', 'List') + // Data is not formatted for breadcrumb: + ->where('breadcrumb.items.1.label', 'Edit “A very long name tha...”') + ); +}); + it('uses localized custom labels on form leaf if configured', function () { fakeFormFor('person', new class() extends PersonForm { From e3b09d98a6dc5c0ffc73e536fc80a188dc9703e2 Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 5 Feb 2026 18:51:59 +0100 Subject: [PATCH 2/3] Add breadcrumb query shows feature --- src/Config/SharpConfigBuilder.php | 19 ++ src/Data/BreadcrumbItemData.php | 1 - src/Http/Context/SharpBreadcrumb.php | 333 +++++++++++++------ tests/Http/BreadcrumbTest.php | 91 +++++ tests/Http/Form/FormControllerTest.php | 37 +++ tests/Unit/Config/SharpConfigBuilderTest.php | 8 + 6 files changed, 395 insertions(+), 94 deletions(-) diff --git a/src/Config/SharpConfigBuilder.php b/src/Config/SharpConfigBuilder.php index 65baaa6e6..ea66ad17d 100644 --- a/src/Config/SharpConfigBuilder.php +++ b/src/Config/SharpConfigBuilder.php @@ -34,6 +34,11 @@ class SharpConfigBuilder 'custom_url_segment' => 'sharp', 'display_sharp_version_in_title' => true, 'display_breadcrumb' => true, + 'breadcrumb' => [ + 'query_shows' => true, + 'cache' => true, + 'cache_duration' => 30, + ], 'entities' => [], 'entity_resolver' => null, 'global_filters' => [], @@ -137,6 +142,20 @@ public function displayBreadcrumb(bool $displayBreadcrumb = true): self return $this; } + public function configureBreadcrumb( + bool $queryShows = true, + bool $cache = true, + int $cacheDuration = 30, + ): self { + $this->config['breadcrumb'] = [ + 'query_shows' => $queryShows, + 'cache' => $cache, + 'cache_duration' => $cacheDuration, + ]; + + return $this; + } + /** @deprecated use declareEntity instead, and set the entityKey in the SharpEntity class */ public function addEntity(string $key, string $entityClass): self { diff --git a/src/Data/BreadcrumbItemData.php b/src/Data/BreadcrumbItemData.php index 8c5c20658..784a276be 100644 --- a/src/Data/BreadcrumbItemData.php +++ b/src/Data/BreadcrumbItemData.php @@ -8,7 +8,6 @@ final class BreadcrumbItemData extends Data { public function __construct( - public string $type, public string $label, public ?string $documentTitleLabel, public string $entityKey, diff --git a/src/Http/Context/SharpBreadcrumb.php b/src/Http/Context/SharpBreadcrumb.php index 2019da910..e9ef0e5b2 100644 --- a/src/Http/Context/SharpBreadcrumb.php +++ b/src/Http/Context/SharpBreadcrumb.php @@ -27,21 +27,16 @@ public function setCurrentInstanceLabel(?string $label): self public function allSegments(): array { - $url = sharp()->config()->get('custom_url_segment') - .'/'.sharp()->context()->globalFilterUrlSegmentValue(); - - return $this - ->breadcrumbItems() - ->map(function ($item, $index) use (&$url) { - $url = sprintf('%s/%s/%s', - $url, - $item->type, - isset($item->instance) ? "{$item->key}/{$item->instance}" : $item->key, - ); - $isLeaf = $index === count($this->breadcrumbItems()) - 1; + $segments = $this->breadcrumbItems(); + $url = $this->baseUrlPrefix(); + $lastIndex = $segments->count() - 1; + + return $segments + ->map(function (BreadcrumbItem $item, int $index) use (&$url, $lastIndex) { + $url = $this->appendSegmentToUrl($url, $item); + $isLeaf = $index === $lastIndex; return [ - 'type' => $this->getFrontTypeNameFor($item->type), 'label' => sharp()->config()->get('display_breadcrumb') ? $this->getBreadcrumbLabelFor($item, $isLeaf) : '', @@ -77,9 +72,8 @@ public function getCurrentSegmentUrl(): string { return url( sprintf( - '%s/%s/%s', - sharp()->config()->get('custom_url_segment'), - sharp()->context()->globalFilterUrlSegmentValue(), + '%s/%s', + $this->baseUrlPrefix(), $this->getCurrentPath() ) ); @@ -87,22 +81,16 @@ public function getCurrentSegmentUrl(): string public function getCurrentPath(): ?string { - return $this->breadcrumbItems() - ->map(fn (BreadcrumbItem $item) => $item->toUri()) - ->implode('/'); + return $this->breadcrumbPathFor($this->breadcrumbItems()); } public function getPreviousSegmentUrl(): string { return url( sprintf( - '%s/%s/%s', - sharp()->config()->get('custom_url_segment'), - sharp()->context()->globalFilterUrlSegmentValue(), - $this->breadcrumbItems() - ->slice(0, -1) - ->map(fn (BreadcrumbItem $item) => $item->toUri()) - ->implode('/') + '%s/%s', + $this->baseUrlPrefix(), + $this->breadcrumbPathFor($this->breadcrumbItems()->slice(0, -1)) ) ); } @@ -139,56 +127,15 @@ private function findPreviousSegment(string $type, ?string $entityKeyOrClassName ->first(); } - private function getFrontTypeNameFor(string $type): string - { - return match ($type) { - 's-list' => 'list', - 's-form' => 'form', - 's-show' => 'show', - 's-dashboard' => 'dashboard', - default => '', - }; - } - private function getBreadcrumbLabelFor(BreadcrumbItem $item, bool $isLeaf): string { - switch ($item->type) { - case 's-list': - return app(SharpMenuManager::class) - ->getEntityMenuItem($item->key) - ?->getLabel() ?: trans('sharp::breadcrumb.entityList'); - case 's-dashboard': - return app(SharpMenuManager::class) - ->getEntityMenuItem($item->key) - ?->getLabel() ?: trans('sharp::breadcrumb.dashboard'); - case 's-show': - return trans('sharp::breadcrumb.show', [ - 'entity' => $this->getEntityLabelForInstance($item, $isLeaf), - ]); - case 's-form': - // A Form is always a leaf - $previousItem = $this->breadcrumbItems()[$item->depth - 1]; - - if ( - ($previousItem->isShow() && ! $this->isSameEntityKeys($previousItem->key, $item->key, true)) - || ($item->isForm() && $this->currentInstanceLabel) - ) { - // The form entityKey is different from the previous entityKey in the breadcrumb: we are in a EEL case. - return isset($item->instance) - ? trans('sharp::breadcrumb.form.edit_entity', [ - 'entity' => $this->getEntityLabelForInstance($item, true), - ]) - : trans('sharp::breadcrumb.form.create_entity', [ - 'entity' => $this->getEntityLabelForInstance($item, true), - ]); - } - - return isset($item->instance) || ($previousItem->type === 's-show' && ! isset($previousItem->instance)) - ? trans('sharp::breadcrumb.form.edit') - : trans('sharp::breadcrumb.form.create'); - } - - return $item->key; + return match ($item->type) { + 's-list' => $this->labelForList($item), + 's-dashboard' => $this->labelForDashboard($item), + 's-show' => $this->labelForShow($item, $isLeaf), + 's-form' => $this->labelForForm($item), + default => $item->key, + }; } /** @@ -201,51 +148,114 @@ private function getDocumentTitleLabelFor(BreadcrumbItem $item, bool $isLeaf): ? return null; } - $previousItem = $this->breadcrumbItems()[$item->depth - 1] ?? null; + $previousItem = $this->previousItemFor($item); return match ($item->type) { - 's-show' => trans('sharp::breadcrumb.show', [ - 'entity' => $this->getEntityLabelForInstance($item, $isLeaf), - ]), - 's-form' => isset($item->instance) || ($previousItem->type === 's-show' && ! isset($previousItem->instance)) + 's-show' => $this->labelForShow($item, $isLeaf), + 's-form' => $this->shouldUseEditLabelForForm($item, $previousItem) ? trans('sharp::breadcrumb.form.edit_entity', [ - 'entity' => $this->getEntityLabelForInstance($item, $isLeaf), + 'entity' => $this->resolveEntityLabelForItem($item, true), ]) : trans('sharp::breadcrumb.form.create_entity', [ - 'entity' => $this->getEntityLabelForInstance($item, $isLeaf), + 'entity' => $this->resolveEntityLabelForItem($item, true), ]), default => null }; } + private function labelForList(BreadcrumbItem $item): string + { + return app(SharpMenuManager::class) + ->getEntityMenuItem($item->key) + ?->getLabel() ?: trans('sharp::breadcrumb.entityList'); + } + + private function labelForDashboard(BreadcrumbItem $item): string + { + return app(SharpMenuManager::class) + ->getEntityMenuItem($item->key) + ?->getLabel() ?: trans('sharp::breadcrumb.dashboard'); + } + + private function labelForShow(BreadcrumbItem $item, bool $isLeaf): string + { + return trans('sharp::breadcrumb.show', [ + 'entity' => $this->resolveEntityLabelForItem($item, $isLeaf), + ]); + } + + private function labelForForm(BreadcrumbItem $item): string + { + // A Form is always a leaf + $previousItem = $this->previousItemFor($item); + + if ($this->shouldUseEntityLabelForForm($item, $previousItem)) { + // The form entityKey is different from the previous entityKey in the breadcrumb: we are in a EEL case. + return isset($item->instance) + ? trans('sharp::breadcrumb.form.edit_entity', [ + 'entity' => $this->resolveEntityLabelForItem($item, true), + ]) + : trans('sharp::breadcrumb.form.create_entity', [ + 'entity' => $this->resolveEntityLabelForItem($item, true), + ]); + } + + return $this->shouldUseEditLabelForForm($item, $previousItem) + ? trans('sharp::breadcrumb.form.edit') + : trans('sharp::breadcrumb.form.create'); + } + + private function shouldUseEntityLabelForForm(BreadcrumbItem $item, ?BreadcrumbItem $previousItem): bool + { + return ($previousItem->isShow() && ! $this->isSameEntityKeys($previousItem->key, $item->key, true)) + || ($item->isForm() && $this->currentInstanceLabel); + } + + private function shouldUseEditLabelForForm(BreadcrumbItem $item, ?BreadcrumbItem $previousItem): bool + { + return isset($item->instance) + || ($previousItem->type === 's-show' && ! isset($previousItem->instance)); + } + public function getParentShowCachedBreadcrumbLabel(): ?string { $item = $this->breadcrumbItems()->last(); - return Cache::get("sharp.breadcrumb.{$item->key}.s-show.{$item->instance}"); + if (! $item || ! $item->isForm()) { + return null; + } + + return $this->resolveParentShowLabel($item); } /** * Only for Shows and Forms. */ - private function getEntityLabelForInstance(BreadcrumbItem $item, bool $isLeaf): string + private function resolveEntityLabelForItem(BreadcrumbItem $item, bool $isLeaf): string { - $cacheKey = "sharp.breadcrumb.{$item->key}.{$item->type}.{$item->instance}"; - if ($isLeaf && $this->currentInstanceLabel) { - Cache::put($cacheKey, $this->currentInstanceLabel, now()->addMinutes(30)); + $this->storeCachedLabel($item, $this->currentInstanceLabel); return $this->currentInstanceLabel; } - if ($item->isForm() && ($cached = $this->getParentShowCachedBreadcrumbLabel())) { - return $cached; + if ($item->isForm()) { + if ($label = $this->resolveParentShowLabel($item)) { + return $label; + } } if (! $isLeaf) { - // The breadcrumb custom label may have been cached on the way up - if ($value = Cache::get($cacheKey)) { - return $value; + if ($label = $this->getCachedLabel($item)) { + return $label; + } + } + + if ($item->isShow() && ! $isLeaf && sharp()->config()->get('breadcrumb.query_shows')) { + if ($label = $this->queryShowBreadcrumbLabel($item)) { + $this->storeCachedLabel($item, $label); + + return $label; } } @@ -254,6 +264,143 @@ private function getEntityLabelForInstance(BreadcrumbItem $item, bool $isLeaf): ->getLabelOrFail((new EntityKey($item->key))->multiformKey()); } + private function resolveParentShowLabel(BreadcrumbItem $formItem): ?string + { + if (! $formItem->isForm()) { + return null; + } + + if ($formItem->instanceId() === null && ! $formItem->isSingleForm()) { + return null; + } + + if ($label = $this->getCachedLabel($formItem, 's-show')) { + return $label; + } + + if (! sharp()->config()->get('breadcrumb.query_shows')) { + return null; + } + + $items = $this->breadcrumbItems()->values(); + $formIndex = $items->search(fn (BreadcrumbItem $item) => $item->is($formItem)); + + if ($formIndex === false) { + return null; + } + + $parentShow = $items + ->slice(0, $formIndex) + ->reverse() + ->first(fn (BreadcrumbItem $item) => $item->isShow() + && $item->key === $formItem->key + && $item->instanceId() === $formItem->instanceId() + ); + + if (! $parentShow || ! $this->canQueryShowLabel($parentShow)) { + return null; + } + + $label = $this->queryShowBreadcrumbLabel($parentShow); + + if (! $label) { + return null; + } + + $this->storeCachedLabel($parentShow, $label); + + return $label; + } + + private function queryShowBreadcrumbLabel(BreadcrumbItem $showItem): ?string + { + if (! $this->canQueryShowLabel($showItem)) { + return null; + } + + $show = app(SharpEntityManager::class) + ->entityFor($showItem->key) + ->getShowOrFail(); + $show->buildShowConfig(); + + $data = $show->instance($showItem->instanceId()); + + return $show->getBreadcrumbCustomLabel($data); + } + + private function canQueryShowLabel(BreadcrumbItem $showItem): bool + { + if (! $showItem->isShow()) { + return false; + } + + if ($showItem->instanceId() !== null) { + return true; + } + + return $showItem->isSingleShow(); + } + + private function breadcrumbCacheKey(BreadcrumbItem $item, ?string $type = null): string + { + $type = $type ?: $item->type; + + return "sharp.breadcrumb.{$item->key}.{$type}.{$item->instance}"; + } + + private function getCachedLabel(BreadcrumbItem $item, ?string $type = null): ?string + { + if (! sharp()->config()->get('breadcrumb.cache')) { + return null; + } + + return Cache::get($this->breadcrumbCacheKey($item, $type)); + } + + private function storeCachedLabel(BreadcrumbItem $item, string $label, ?string $type = null): void + { + if (! sharp()->config()->get('breadcrumb.cache')) { + return; + } + + Cache::put( + $this->breadcrumbCacheKey($item, $type), + $label, + now()->addMinutes((int) sharp()->config()->get('breadcrumb.cache_duration')), + ); + } + + private function baseUrlPrefix(): string + { + return sprintf( + '%s/%s', + sharp()->config()->get('custom_url_segment'), + sharp()->context()->globalFilterUrlSegmentValue(), + ); + } + + private function appendSegmentToUrl(string $url, BreadcrumbItem $item): string + { + return sprintf( + '%s/%s/%s', + $url, + $item->type, + isset($item->instance) ? "{$item->key}/{$item->instance}" : $item->key, + ); + } + + private function breadcrumbPathFor(Collection $items): string + { + return $items + ->map(fn (BreadcrumbItem $item) => $item->toUri()) + ->implode('/'); + } + + private function previousItemFor(BreadcrumbItem $item): ?BreadcrumbItem + { + return $this->breadcrumbItems()[$item->depth - 1] ?? null; + } + private function isSameEntityKeys(string $key1, string $key2, bool $compareBaseEntities): bool { if ($compareBaseEntities) { @@ -271,7 +418,7 @@ private function buildBreadcrumbFromRequest(): void $depth = 0; if (count($segments) !== 0) { - $this->breadcrumbItems()->add( + $this->breadcrumbItems->add( (new BreadcrumbItem($segments[0], $segments[1]))->setDepth($depth++), ); @@ -293,7 +440,7 @@ private function buildBreadcrumbFromRequest(): void $segments = $segments->slice($instance !== null ? 2 : 1)->values(); - $this->breadcrumbItems()->add( + $this->breadcrumbItems->add( (new BreadcrumbItem($type, $key)) ->setDepth($depth++) ->setInstance($instance), diff --git a/tests/Http/BreadcrumbTest.php b/tests/Http/BreadcrumbTest.php index ff19f0746..ab3595459 100644 --- a/tests/Http/BreadcrumbTest.php +++ b/tests/Http/BreadcrumbTest.php @@ -8,6 +8,7 @@ use Code16\Sharp\Tests\Fixtures\Sharp\PersonShow; use Code16\Sharp\Utils\Entities\SharpEntityManager; use Code16\Sharp\Utils\Fields\FieldsContainer; +use Illuminate\Support\Facades\Cache; use Inertia\Testing\AssertableInertia as Assert; beforeEach(function () { @@ -107,6 +108,96 @@ ); }); +it('queries parent show labels when not cached', function () { + Cache::flush(); + + $show = new class() extends PersonShow + { + public static array $requestedIds = []; + + public function buildShowConfig(): void + { + $this->configureBreadcrumbCustomLabelAttribute('name'); + } + + public function find($id): array + { + $id = (int) $id; + self::$requestedIds[] = $id; + + return $this->transform([ + 'id' => $id, + 'name' => $id === 1 ? 'Marie Curie' : 'Albert Einstein', + ]); + } + }; + + fakeShowFor('person', $show); + + $this + ->get( + route('code16.sharp.show.show', [ + 'parentUri' => 's-list/person/s-show/person/1', + 'person', + 2, + ]) + ) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('breadcrumb.items.1.label', 'Marie Curie') + ->where('breadcrumb.items.2.label', 'Albert Einstein') + ); + + expect($show::$requestedIds)->toContain(1); +}); + +it('does not query parent show labels when disabled', function () { + Cache::flush(); + sharp()->config()->configureBreadcrumb(queryShows: false); + + app(SharpEntityManager::class) + ->entityFor('person') + ->setLabel('Scientist'); + + $show = new class() extends PersonShow + { + public static array $requestedIds = []; + + public function buildShowConfig(): void + { + $this->configureBreadcrumbCustomLabelAttribute('name'); + } + + public function find($id): array + { + $id = (int) $id; + self::$requestedIds[] = $id; + + return $this->transform([ + 'id' => $id, + 'name' => $id === 1 ? 'Marie Curie' : 'Albert Einstein', + ]); + } + }; + + fakeShowFor('person', $show); + + $this + ->get( + route('code16.sharp.show.show', [ + 'parentUri' => 's-list/person/s-show/person/1', + 'person', + 2, + ]) + ) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('breadcrumb.items.1.label', 'Scientist') + ); + + expect($show::$requestedIds)->not->toContain(1); +}); + it('uses custom labels on show leaf if configured', function () { fakeShowFor('person', new class() extends PersonShow { diff --git a/tests/Http/Form/FormControllerTest.php b/tests/Http/Form/FormControllerTest.php index de49f84af..603297d00 100644 --- a/tests/Http/Form/FormControllerTest.php +++ b/tests/Http/Form/FormControllerTest.php @@ -21,6 +21,7 @@ use Code16\Sharp\Utils\Entities\SharpEntityManager; use Code16\Sharp\Utils\Fields\FieldsContainer; use Code16\Sharp\Utils\PageAlerts\PageAlert; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Exceptions; use Inertia\Testing\AssertableInertia as Assert; @@ -544,6 +545,42 @@ public function find($id): array ); }); +it('formats form title based on queried parent show breadcrumb', function () { + Cache::flush(); + + $show = new class() extends PersonShow + { + public static array $requestedIds = []; + + public function buildShowConfig(): void + { + $this->configureBreadcrumbCustomLabelAttribute('name'); + } + + public function find($id): array + { + $id = (int) $id; + self::$requestedIds[] = $id; + + return $this->transform([ + 'id' => $id, + 'name' => 'Marie Curie', + ]); + } + }; + + fakeShowFor('person', $show); + fakeFormFor('person', new PersonForm()); + + $this->get('/sharp/root/s-list/person/s-show/person/1/s-form/person/1') + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('form.title', 'Edit “Marie Curie”') + ); + + expect($show::$requestedIds)->toContain(1); +}); + it('formats form title based on configured breadcrumb attribute', function () { fakeShowFor('person', new class() extends PersonShow { diff --git a/tests/Unit/Config/SharpConfigBuilderTest.php b/tests/Unit/Config/SharpConfigBuilderTest.php index 37265138c..503de954f 100644 --- a/tests/Unit/Config/SharpConfigBuilderTest.php +++ b/tests/Unit/Config/SharpConfigBuilderTest.php @@ -38,3 +38,11 @@ class WithEntityKeyEntity extends SharpEntity ->toHaveKey('person') ->toHaveKey('single-person'); }); + +it('allows to configure breadcrumb options', function () { + sharp()->config()->configureBreadcrumb(queryShows: false, cache: false, cacheDuration: 42); + + expect(sharp()->config()->get('breadcrumb.query_shows'))->toBeFalse() + ->and(sharp()->config()->get('breadcrumb.cache'))->toBeFalse() + ->and(sharp()->config()->get('breadcrumb.cache_duration'))->toBe(42); +}); From 36e92defd9be0bfe003028f0670e15db732fa392 Mon Sep 17 00:00:00 2001 From: antoine Date: Fri, 6 Feb 2026 19:55:26 +0100 Subject: [PATCH 3/3] Fix breadcrumb for some cases + docs --- docs/guide/sharp-breadcrumb.md | 84 +++++++++++++++---- resources/js/Pages/Dashboard/Dashboard.vue | 2 +- resources/js/Pages/Form/Form.vue | 2 +- resources/js/Pages/Show/Show.vue | 2 +- src/Config/SharpConfigBuilder.php | 33 ++++---- src/Http/Context/SharpBreadcrumb.php | 64 ++++++++++---- src/Http/Context/Util/BreadcrumbItem.php | 5 ++ src/Http/Middleware/HandleInertiaRequests.php | 2 +- src/Utils/Traits/HandleCustomBreadcrumb.php | 5 ++ tests/Http/BreadcrumbTest.php | 62 +++++++++++++- tests/Unit/Config/SharpConfigBuilderTest.php | 16 +++- 11 files changed, 218 insertions(+), 59 deletions(-) diff --git a/docs/guide/sharp-breadcrumb.md b/docs/guide/sharp-breadcrumb.md index 0ec393b56..6e1207338 100644 --- a/docs/guide/sharp-breadcrumb.md +++ b/docs/guide/sharp-breadcrumb.md @@ -2,22 +2,6 @@ Under the hood Sharp manages a breadcrumb to keep track of stacked pages. -## Display the breadcrumb - -You can activate the breadcrumb display in sharp's configuration: - -```php -class SharpServiceProvider extends SharpAppServiceProvider -{ - protected function configureSharp(SharpConfigBuilder $config): void - { - $config - ->displayBreadcrumb() - // [...] - } -} -``` - ## Configure entity label In Entity classes, you can define how an entity should be labeled in the breadcrumb with the `label` attribute: @@ -74,6 +58,72 @@ class PostShow extends \Code16\Sharp\Show\SharpShow In the Form, the breadcrumb label is only used in one particular case: when coming from an embedded Entity List inside a Show Page. In this case, the Show Page and the Form entity are different, and the breadcrumb helps to keep track of the current edited entity. ::: +## Configure custom labels cache + +Breadcrumb labels are cached for 30 minutes to reduce DB queries between each navigation. If you don't want to cache them, which means all `SharpShow` in breadcrumb are loaded on every navigation, you can update the config in the SharpServiceProvider: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->configureBreadcrumbLabelsCache(false) + // ... + } +} +``` + +Alternatively, you can change the cache duration (default is 30 minutes): + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->configureBreadcrumbLabelsCache(duration: 10) + // ... + } +} +``` + +### Lazy loading + +In some cases, having the labels replaced by the default Entity label is acceptable and you want to have less DB queries, you can activate the lazy loading: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->enableBreadcrumbLabelsLazyLoading() + } +} +``` +::: warning +Be aware that the user may see the breadcrumb with default entity labels (e.g. "Posts > Post > Category > Edit") when : +- a nested page is accessed directly (e.g. direct link) +- cached labels are expired +::: + +## Hide the breadcrumb + +If you don't want any breadcrumb, you can hide it in sharp's configuration: + +```php +class SharpServiceProvider extends SharpAppServiceProvider +{ + protected function configureSharp(SharpConfigBuilder $config): void + { + $config + ->displayBreadcrumb(false) + // [...] + } +} +``` + ## Interact with Sharp's Breadcrumb -Refer to [the Context documentation](context.md) to find out how to interact with Sharp's breadcrumb. \ No newline at end of file +Refer to [the Context documentation](context.md) to find out how to interact with Sharp's breadcrumb. diff --git a/resources/js/Pages/Dashboard/Dashboard.vue b/resources/js/Pages/Dashboard/Dashboard.vue index c1f934ca8..ff7eec47b 100644 --- a/resources/js/Pages/Dashboard/Dashboard.vue +++ b/resources/js/Pages/Dashboard/Dashboard.vue @@ -56,7 +56,7 @@ <template #breadcrumb> - <template v-if="config('sharp.display_breadcrumb')"> + <template v-if="config('sharp.breadcrumb.display')"> <PageBreadcrumb :breadcrumb="breadcrumb" /> </template> </template> diff --git a/resources/js/Pages/Form/Form.vue b/resources/js/Pages/Form/Form.vue index f2d8250ca..9c1da75cc 100644 --- a/resources/js/Pages/Form/Form.vue +++ b/resources/js/Pages/Form/Form.vue @@ -77,7 +77,7 @@ <Title :breadcrumb="breadcrumb" /> <template #breadcrumb> - <template v-if="config('sharp.display_breadcrumb')"> + <template v-if="config('sharp.breadcrumb.display')"> <PageBreadcrumb :breadcrumb="breadcrumb" /> </template> </template> diff --git a/resources/js/Pages/Show/Show.vue b/resources/js/Pages/Show/Show.vue index cca1e86bd..2256dd48b 100644 --- a/resources/js/Pages/Show/Show.vue +++ b/resources/js/Pages/Show/Show.vue @@ -128,7 +128,7 @@ <Title :breadcrumb="breadcrumb" /> <template #breadcrumb> - <template v-if="config('sharp.display_breadcrumb')"> + <template v-if="config('sharp.breadcrumb.display')"> <PageBreadcrumb :breadcrumb="breadcrumb" /> </template> </template> diff --git a/src/Config/SharpConfigBuilder.php b/src/Config/SharpConfigBuilder.php index ea66ad17d..eec017334 100644 --- a/src/Config/SharpConfigBuilder.php +++ b/src/Config/SharpConfigBuilder.php @@ -33,11 +33,13 @@ class SharpConfigBuilder 'name' => 'Sharp', 'custom_url_segment' => 'sharp', 'display_sharp_version_in_title' => true, - 'display_breadcrumb' => true, 'breadcrumb' => [ - 'query_shows' => true, - 'cache' => true, - 'cache_duration' => 30, + 'display' => true, + 'labels' => [ + 'lazy_loading' => false, + 'cache' => true, + 'cache_duration' => 30, + ], ], 'entities' => [], 'entity_resolver' => null, @@ -137,21 +139,22 @@ public function displaySharpVersionInTitle(bool $displaySharpVersionInTitle = tr public function displayBreadcrumb(bool $displayBreadcrumb = true): self { - $this->config['display_breadcrumb'] = $displayBreadcrumb; + $this->config['breadcrumb']['display'] = $displayBreadcrumb; return $this; } - public function configureBreadcrumb( - bool $queryShows = true, - bool $cache = true, - int $cacheDuration = 30, - ): self { - $this->config['breadcrumb'] = [ - 'query_shows' => $queryShows, - 'cache' => $cache, - 'cache_duration' => $cacheDuration, - ]; + public function enableBreadcrumbLabelsLazyLoading(bool $lazyLoading = true): self + { + $this->config['breadcrumb']['labels']['lazy_loading'] = $lazyLoading; + + return $this; + } + + public function configureBreadcrumbLabelsCache(bool $cache = true, int $duration = 30): self + { + $this->config['breadcrumb']['labels']['cache'] = $cache; + $this->config['breadcrumb']['labels']['cache_duration'] = $duration; return $this; } diff --git a/src/Http/Context/SharpBreadcrumb.php b/src/Http/Context/SharpBreadcrumb.php index e9ef0e5b2..e5ee1c245 100644 --- a/src/Http/Context/SharpBreadcrumb.php +++ b/src/Http/Context/SharpBreadcrumb.php @@ -2,6 +2,7 @@ namespace Code16\Sharp\Http\Context; +use Closure; use Code16\Sharp\Http\Context\Util\BreadcrumbItem; use Code16\Sharp\Utils\Entities\SharpEntityManager; use Code16\Sharp\Utils\Entities\ValueObjects\EntityKey; @@ -37,7 +38,7 @@ public function allSegments(): array $isLeaf = $index === $lastIndex; return [ - 'label' => sharp()->config()->get('display_breadcrumb') + 'label' => sharp()->config()->get('breadcrumb.display') ? $this->getBreadcrumbLabelFor($item, $isLeaf) : '', 'documentTitleLabel' => $this->getDocumentTitleLabelFor($item, $isLeaf), @@ -251,8 +252,8 @@ private function resolveEntityLabelForItem(BreadcrumbItem $item, bool $isLeaf): } } - if ($item->isShow() && ! $isLeaf && sharp()->config()->get('breadcrumb.query_shows')) { - if ($label = $this->queryShowBreadcrumbLabel($item)) { + if ($item->isShow() && ! $isLeaf && ! sharp()->config()->get('breadcrumb.labels.lazy_loading')) { + if ($label = $this->loadShowBreadcrumbLabel($item)) { $this->storeCachedLabel($item, $label); return $label; @@ -278,7 +279,7 @@ private function resolveParentShowLabel(BreadcrumbItem $formItem): ?string return $label; } - if (! sharp()->config()->get('breadcrumb.query_shows')) { + if (sharp()->config()->get('breadcrumb.labels.lazy_loading')) { return null; } @@ -289,7 +290,7 @@ private function resolveParentShowLabel(BreadcrumbItem $formItem): ?string return null; } - $parentShow = $items + $parentShowItem = $items ->slice(0, $formIndex) ->reverse() ->first(fn (BreadcrumbItem $item) => $item->isShow() @@ -297,38 +298,50 @@ private function resolveParentShowLabel(BreadcrumbItem $formItem): ?string && $item->instanceId() === $formItem->instanceId() ); - if (! $parentShow || ! $this->canQueryShowLabel($parentShow)) { + if (! $parentShowItem || ! $this->canLoadShowLabel($parentShowItem)) { return null; } - $label = $this->queryShowBreadcrumbLabel($parentShow); + $label = $this->loadShowBreadcrumbLabel($parentShowItem); if (! $label) { return null; } - $this->storeCachedLabel($parentShow, $label); + $this->storeCachedLabel($parentShowItem, $label); return $label; } - private function queryShowBreadcrumbLabel(BreadcrumbItem $showItem): ?string + private function loadShowBreadcrumbLabel(BreadcrumbItem $showItem): ?string { - if (! $this->canQueryShowLabel($showItem)) { + if (! $this->canLoadShowLabel($showItem)) { return null; } $show = app(SharpEntityManager::class) ->entityFor($showItem->key) ->getShowOrFail(); + $show->buildShowConfig(); - $data = $show->instance($showItem->instanceId()); + if (! $show->getBreadcrumbAttribute()) { + return null; + } + + $data = []; + + $this->forceRequestSegments( + $this->getFakeRequestSegmentsFor($showItem), + function () use ($show, $showItem, &$data) { + $data = $show->instance($showItem->instanceId()); + } + ); return $show->getBreadcrumbCustomLabel($data); } - private function canQueryShowLabel(BreadcrumbItem $showItem): bool + private function canLoadShowLabel(BreadcrumbItem $showItem): bool { if (! $showItem->isShow()) { return false; @@ -350,7 +363,7 @@ private function breadcrumbCacheKey(BreadcrumbItem $item, ?string $type = null): private function getCachedLabel(BreadcrumbItem $item, ?string $type = null): ?string { - if (! sharp()->config()->get('breadcrumb.cache')) { + if (! sharp()->config()->get('breadcrumb.labels.cache')) { return null; } @@ -359,14 +372,14 @@ private function getCachedLabel(BreadcrumbItem $item, ?string $type = null): ?st private function storeCachedLabel(BreadcrumbItem $item, string $label, ?string $type = null): void { - if (! sharp()->config()->get('breadcrumb.cache')) { + if (! sharp()->config()->get('breadcrumb.labels.cache')) { return; } Cache::put( $this->breadcrumbCacheKey($item, $type), $label, - now()->addMinutes((int) sharp()->config()->get('breadcrumb.cache_duration')), + now()->addMinutes((int) sharp()->config()->get('breadcrumb.labels.cache_duration')), ); } @@ -418,6 +431,10 @@ private function buildBreadcrumbFromRequest(): void $depth = 0; if (count($segments) !== 0) { + if (! in_array($segments[0], ['s-list', 's-show', 's-dashboard'])) { + return; + } + $this->breadcrumbItems->add( (new BreadcrumbItem($segments[0], $segments[1]))->setDepth($depth++), ); @@ -449,6 +466,14 @@ private function buildBreadcrumbFromRequest(): void } } + private function getFakeRequestSegmentsFor(BreadcrumbItem $item): Collection + { + return $this->breadcrumbItems() + ->slice(0, $item->depth + 1) + ->values() + ->flatMap(fn (BreadcrumbItem $item) => explode('/', $item->toUri())); + } + protected function getSegmentsFromRequest(): Collection { if ($this->forcedSegments) { @@ -471,9 +496,16 @@ protected function getSegmentsFromRequest(): Collection return collect(request()->segments())->skip(2)->values(); } - public function forceRequestSegments(array|Collection $segments): void + public function forceRequestSegments(array|Collection $segments, ?Closure $callback = null): void { $this->breadcrumbItems = null; $this->forcedSegments = collect($segments)->values(); + + if ($callback) { + $callback(); + + $this->breadcrumbItems = null; + $this->forcedSegments = null; + } } } diff --git a/src/Http/Context/Util/BreadcrumbItem.php b/src/Http/Context/Util/BreadcrumbItem.php index d3bc74c73..ae4e16cf9 100644 --- a/src/Http/Context/Util/BreadcrumbItem.php +++ b/src/Http/Context/Util/BreadcrumbItem.php @@ -4,6 +4,7 @@ use Code16\Sharp\Utils\Entities\SharpEntityManager; use Code16\Sharp\Utils\Entities\ValueObjects\EntityKey; +use InvalidArgumentException; class BreadcrumbItem { @@ -16,6 +17,10 @@ public function __construct(string $type, string $key) { $this->type = $type; $this->key = $key; + + if (! in_array($type, ['s-list', 's-show', 's-form', 's-dashboard'])) { + throw new InvalidArgumentException("Invalid type [$type] for BreadcrumbItem"); + } } public function setDepth(int $depth): self diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php index 4ad29b6ca..62067be65 100644 --- a/src/Http/Middleware/HandleInertiaRequests.php +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -91,7 +91,7 @@ public function share(Request $request) 'sharp.auth.suggest_remember_me' => sharp()->config()->get('auth.suggest_remember_me'), 'sharp.custom_url_segment' => sharp()->config()->get('custom_url_segment'), 'sharp.display_sharp_version_in_title' => sharp()->config()->get('display_sharp_version_in_title'), - 'sharp.display_breadcrumb' => sharp()->config()->get('display_breadcrumb'), + 'sharp.breadcrumb.display' => sharp()->config()->get('breadcrumb.display'), 'sharp.name' => sharp()->config()->get('name'), 'sharp.theme.logo_height' => sharp()->config()->get('theme.logo_height'), ], diff --git a/src/Utils/Traits/HandleCustomBreadcrumb.php b/src/Utils/Traits/HandleCustomBreadcrumb.php index f082201e2..53fa2309c 100644 --- a/src/Utils/Traits/HandleCustomBreadcrumb.php +++ b/src/Utils/Traits/HandleCustomBreadcrumb.php @@ -22,6 +22,11 @@ public function configureBreadcrumbCustomLabelAttribute( return $this; } + public function getBreadcrumbAttribute(): ?string + { + return $this->breadcrumbAttribute; + } + public function getBreadcrumbCustomLabel(array $data): ?string { if (! $this->breadcrumbAttribute) { diff --git a/tests/Http/BreadcrumbTest.php b/tests/Http/BreadcrumbTest.php index ab3595459..9ad4c9137 100644 --- a/tests/Http/BreadcrumbTest.php +++ b/tests/Http/BreadcrumbTest.php @@ -108,7 +108,7 @@ ); }); -it('queries parent show labels when not cached', function () { +it('load parent show labels when not cached', function () { Cache::flush(); $show = new class() extends PersonShow @@ -151,9 +151,65 @@ public function find($id): array expect($show::$requestedIds)->toContain(1); }); -it('does not query parent show labels when disabled', function () { +it('set correct segment for loaded parent shows', function () { Cache::flush(); - sharp()->config()->configureBreadcrumb(queryShows: false); + + $show = new class() extends PersonShow + { + public static array $breadcrumbPaths = []; + + public function buildShowConfig(): void + { + $this->configureBreadcrumbCustomLabelAttribute('name'); + } + + public function find($id): array + { + $id = (int) $id; + self::$breadcrumbPaths[$id] = sharp()->context()->breadcrumb()->getCurrentPath(); + + return $this->transform([ + 'id' => $id, + 'name' => $id === 1 ? 'Marie Curie' : 'Albert Einstein', + ]); + } + }; + + fakeShowFor('person', $show); + + $this + ->get( + route('code16.sharp.show.show', [ + 'parentUri' => 's-list/person/s-show/person/1', + 'person', + 2, + ]) + ) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('breadcrumb.items.1.entityKey', 'person') + ->where('breadcrumb.items.1.url', route('code16.sharp.show.show', [ + 'parentUri' => 's-list/person', + 'person', + 1, + ])) + ->where('breadcrumb.items.2.entityKey', 'person') + ->where('breadcrumb.items.2.url', route('code16.sharp.show.show', [ + 'parentUri' => 's-list/person/s-show/person/1', + 'person', + 2, + ])) + ); + + expect($show::$breadcrumbPaths)->toEqual([ + 1 => 's-list/person/s-show/person/1', + 2 => 's-list/person/s-show/person/1/s-show/person/2', + ]); +}); + +it('does not load parent show labels when lazy loading', function () { + Cache::flush(); + sharp()->config()->enableBreadcrumbLabelsLazyLoading(); app(SharpEntityManager::class) ->entityFor('person') diff --git a/tests/Unit/Config/SharpConfigBuilderTest.php b/tests/Unit/Config/SharpConfigBuilderTest.php index 503de954f..ca92c3cd1 100644 --- a/tests/Unit/Config/SharpConfigBuilderTest.php +++ b/tests/Unit/Config/SharpConfigBuilderTest.php @@ -40,9 +40,17 @@ class WithEntityKeyEntity extends SharpEntity }); it('allows to configure breadcrumb options', function () { - sharp()->config()->configureBreadcrumb(queryShows: false, cache: false, cacheDuration: 42); + expect(sharp()->config()->get('breadcrumb.labels.lazy_loading'))->toBeFalse() + ->and(sharp()->config()->get('breadcrumb.labels.cache'))->toBeTrue() + ->and(sharp()->config()->get('breadcrumb.labels.cache_duration'))->toBe(30); - expect(sharp()->config()->get('breadcrumb.query_shows'))->toBeFalse() - ->and(sharp()->config()->get('breadcrumb.cache'))->toBeFalse() - ->and(sharp()->config()->get('breadcrumb.cache_duration'))->toBe(42); + sharp()->config()->enableBreadcrumbLabelsLazyLoading(); + sharp()->config()->configureBreadcrumbLabelsCache(false); + + expect(sharp()->config()->get('breadcrumb.labels.lazy_loading'))->toBeTrue() + ->and(sharp()->config()->get('breadcrumb.labels.cache'))->toBeFalse(); + + sharp()->config()->configureBreadcrumbLabelsCache(duration: 40); + + expect(sharp()->config()->get('breadcrumb.labels.cache_duration'))->toBe(40); });