From ba78e103e0ff6b838641187310576f7b7d3b7ce9 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Tue, 16 Jun 2026 23:51:16 +0200 Subject: [PATCH 01/28] feat(global-search): add wire value objects Co-Authored-By: Claude Opus 4.8 (1M context) --- src/GlobalSearch/SearchCategory.php | 32 ++++++++++++++++++++ src/GlobalSearch/SearchPagination.php | 36 +++++++++++++++++++++++ src/GlobalSearch/SearchQuery.php | 15 ++++++++++ src/GlobalSearch/SearchResult.php | 23 +++++++++++++++ src/GlobalSearch/SearchResultItem.php | 31 +++++++++++++++++++ src/GlobalSearch/SearchResults.php | 13 ++++++++ tests/Unit/GlobalSearch/WireShapeTest.php | 36 +++++++++++++++++++++++ 7 files changed, 186 insertions(+) create mode 100644 src/GlobalSearch/SearchCategory.php create mode 100644 src/GlobalSearch/SearchPagination.php create mode 100644 src/GlobalSearch/SearchQuery.php create mode 100644 src/GlobalSearch/SearchResult.php create mode 100644 src/GlobalSearch/SearchResultItem.php create mode 100644 src/GlobalSearch/SearchResults.php create mode 100644 tests/Unit/GlobalSearch/WireShapeTest.php diff --git a/src/GlobalSearch/SearchCategory.php b/src/GlobalSearch/SearchCategory.php new file mode 100644 index 00000000..c242f2d6 --- /dev/null +++ b/src/GlobalSearch/SearchCategory.php @@ -0,0 +1,32 @@ +name, $this->label, $this->icon, $count); + } + + /** @return array{name:string,label:string,icon:?string,count:?int} */ + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'label' => $this->label, + 'icon' => $this->icon, + 'count' => $this->count, + ]; + } +} diff --git a/src/GlobalSearch/SearchPagination.php b/src/GlobalSearch/SearchPagination.php new file mode 100644 index 00000000..ecdb46e7 --- /dev/null +++ b/src/GlobalSearch/SearchPagination.php @@ -0,0 +1,36 @@ + $this->page, + 'perPage' => $this->perPage, + 'total' => $this->total, + 'hasMore' => $this->hasMore, + 'nextPage' => $this->nextPage, + ]; + } +} diff --git a/src/GlobalSearch/SearchQuery.php b/src/GlobalSearch/SearchQuery.php new file mode 100644 index 00000000..2861a481 --- /dev/null +++ b/src/GlobalSearch/SearchQuery.php @@ -0,0 +1,15 @@ +} */ + public function jsonSerialize(): array + { + return [ + 'category' => ['name' => $this->category], + 'item' => $this->item->jsonSerialize(), + ]; + } +} diff --git a/src/GlobalSearch/SearchResultItem.php b/src/GlobalSearch/SearchResultItem.php new file mode 100644 index 00000000..80c3522d --- /dev/null +++ b/src/GlobalSearch/SearchResultItem.php @@ -0,0 +1,31 @@ + $this->id, + 'title' => $this->title, + 'subtitle' => $this->subtitle, + 'additionalInfo' => $this->additionalInfo, + 'link' => $this->link, + 'badge' => $this->badge, + ]; + } +} diff --git a/src/GlobalSearch/SearchResults.php b/src/GlobalSearch/SearchResults.php new file mode 100644 index 00000000..2c61e70a --- /dev/null +++ b/src/GlobalSearch/SearchResults.php @@ -0,0 +1,13 @@ + $rows */ + public function __construct( + public array $rows, + public int $total, + ) {} +} diff --git a/tests/Unit/GlobalSearch/WireShapeTest.php b/tests/Unit/GlobalSearch/WireShapeTest.php new file mode 100644 index 00000000..7fa74f86 --- /dev/null +++ b/tests/Unit/GlobalSearch/WireShapeTest.php @@ -0,0 +1,36 @@ +jsonSerialize())->toBe([ + 'category' => ['name' => 'products'], + 'item' => [ + 'id' => '42', 'title' => 'Widget X', 'subtitle' => 'SKU-42', + 'additionalInfo' => '€19.99', 'link' => '/products/42', 'badge' => 'new', + ], + ]); +}); + +test('category count is null until stamped', function () { + $category = new SearchCategory('products', 'Products', 'package'); + expect($category->jsonSerialize()['count'])->toBeNull(); + expect($category->withCount(96)->jsonSerialize())->toBe([ + 'name' => 'products', 'label' => 'Products', 'icon' => 'package', 'count' => 96, + ]); +}); + +test('pagination reports the next page when more remain', function () { + expect((new SearchPagination(1, 20, 96, true, 2))->jsonSerialize())->toBe([ + 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2, + ]); +}); From 32a3f2590bcfa346c03902cb492dce95c70d4e95 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Tue, 16 Jun 2026 23:53:07 +0200 Subject: [PATCH 02/28] test(global-search): cover SearchPagination::forPage boundaries Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Unit/GlobalSearch/WireShapeTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Unit/GlobalSearch/WireShapeTest.php b/tests/Unit/GlobalSearch/WireShapeTest.php index 7fa74f86..5a1df51a 100644 --- a/tests/Unit/GlobalSearch/WireShapeTest.php +++ b/tests/Unit/GlobalSearch/WireShapeTest.php @@ -34,3 +34,17 @@ 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2, ]); }); + +test('SearchPagination::forPage calculates pagination with items remaining', function () { + $pagination = SearchPagination::forPage(1, 20, 96); + expect($pagination->jsonSerialize())->toBe([ + 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2, + ]); +}); + +test('SearchPagination::forPage handles exact last page boundary', function () { + $pagination = SearchPagination::forPage(5, 20, 100); + expect($pagination->jsonSerialize())->toBe([ + 'page' => 5, 'perPage' => 20, 'total' => 100, 'hasMore' => false, 'nextPage' => null, + ]); +}); From 313b70746c911cab4ecbd2b11fdf422e2e81cfc3 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Tue, 16 Jun 2026 23:54:28 +0200 Subject: [PATCH 03/28] feat(global-search): add provider and history contracts Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Contracts/SearchHistoryRecorder.php | 16 +++++++++++++ .../Contracts/SearchResultProvider.php | 23 +++++++++++++++++++ .../NullSearchHistoryRecorder.php | 20 ++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/GlobalSearch/Contracts/SearchHistoryRecorder.php create mode 100644 src/GlobalSearch/Contracts/SearchResultProvider.php create mode 100644 src/GlobalSearch/NullSearchHistoryRecorder.php diff --git a/src/GlobalSearch/Contracts/SearchHistoryRecorder.php b/src/GlobalSearch/Contracts/SearchHistoryRecorder.php new file mode 100644 index 00000000..b0f2b4a4 --- /dev/null +++ b/src/GlobalSearch/Contracts/SearchHistoryRecorder.php @@ -0,0 +1,16 @@ + */ + public function recent(Request $request, int $limit): array; +} diff --git a/src/GlobalSearch/Contracts/SearchResultProvider.php b/src/GlobalSearch/Contracts/SearchResultProvider.php new file mode 100644 index 00000000..c2618793 --- /dev/null +++ b/src/GlobalSearch/Contracts/SearchResultProvider.php @@ -0,0 +1,23 @@ + Date: Tue, 16 Jun 2026 23:54:40 +0200 Subject: [PATCH 04/28] test(global-search): add null history recorder test Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NullSearchHistoryRecorderTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/Unit/GlobalSearch/NullSearchHistoryRecorderTest.php diff --git a/tests/Unit/GlobalSearch/NullSearchHistoryRecorderTest.php b/tests/Unit/GlobalSearch/NullSearchHistoryRecorderTest.php new file mode 100644 index 00000000..36751ec5 --- /dev/null +++ b/tests/Unit/GlobalSearch/NullSearchHistoryRecorderTest.php @@ -0,0 +1,15 @@ +record(new Request, $result))->toBeFalse(); + expect($recorder->recent(new Request, 10))->toBe([]); +}); From 7ae4e7226733fdb87c9c21a8946432e72d306616 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Tue, 16 Jun 2026 23:57:26 +0200 Subject: [PATCH 05/28] feat(global-search): discover providers via AsSearchProvider Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Attributes/AsSearchProvider.php | 7 ++ src/Core/Discovery/DiscoveryKinds.php | 2 + tests/Feature/GlobalSearch/DiscoveryTest.php | 14 ++++ .../DiscoveredProductsSearchProvider.php | 79 +++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 src/Attributes/AsSearchProvider.php create mode 100644 tests/Feature/GlobalSearch/DiscoveryTest.php create mode 100644 tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php diff --git a/src/Attributes/AsSearchProvider.php b/src/Attributes/AsSearchProvider.php new file mode 100644 index 00000000..9b4517c7 --- /dev/null +++ b/src/Attributes/AsSearchProvider.php @@ -0,0 +1,7 @@ + AsFragment::class, 'remote-sources' => AsRemoteSource::class, 'layouts' => AsLayout::class, + 'global-search' => AsSearchProvider::class, ]; public const string PAGE_ATTRIBUTE = AsPage::class; diff --git a/tests/Feature/GlobalSearch/DiscoveryTest.php b/tests/Feature/GlobalSearch/DiscoveryTest.php new file mode 100644 index 00000000..e6ee3703 --- /dev/null +++ b/tests/Feature/GlobalSearch/DiscoveryTest.php @@ -0,0 +1,14 @@ +forGroup('global-search'); + + expect($group)->toHaveKey('products'); + expect($group['products'])->toBe(DiscoveredProductsSearchProvider::class); +}); diff --git a/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php b/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php new file mode 100644 index 00000000..663626ac --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php @@ -0,0 +1,79 @@ + */ + public static array $rows = []; + + public static bool $authorized = true; + + public function authorize(Request $request): bool + { + return self::$authorized; + } + + public function category(): SearchCategory + { + return new SearchCategory('products', 'Products', 'package'); + } + + public function count(SearchQuery $query): int + { + return count($this->matching($query->query)); + } + + public function search(SearchQuery $query): SearchResults + { + $matching = $this->matching($query->query); + $offset = ($query->page - 1) * $query->perPage; + $slice = array_slice($matching, $offset, $query->perPage); + + $rows = array_map( + fn (array $row): SearchResult => new SearchResult('products', new SearchResultItem( + id: $row['id'], title: $row['title'], link: '/products/'.$row['id'], + )), + $slice, + ); + + return new SearchResults($rows, count($matching)); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + foreach (self::$rows as $row) { + if ($row['id'] === $id) { + return new SearchResult('products', new SearchResultItem( + id: $row['id'], title: $row['title'], link: '/products/'.$row['id'], + )); + } + } + + return null; + } + + /** @return array */ + private function matching(string $query): array + { + if ($query === '') { + return self::$rows; + } + + return array_values(array_filter( + self::$rows, + fn (array $row): bool => str_contains(strtolower($row['title']), strtolower($query)), + )); + } +} From 8bd5711a2ef1462ae0380ddd2a98df481d2b1d42 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:00:52 +0200 Subject: [PATCH 06/28] feat(global-search): add provider registry and bindings Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SearchResultProviderRegistry.php | 77 +++++++++++++++++++ src/LatticeServiceProvider.php | 5 ++ tests/Feature/GlobalSearch/RegistryTest.php | 28 +++++++ 3 files changed, 110 insertions(+) create mode 100644 src/GlobalSearch/SearchResultProviderRegistry.php create mode 100644 tests/Feature/GlobalSearch/RegistryTest.php diff --git a/src/GlobalSearch/SearchResultProviderRegistry.php b/src/GlobalSearch/SearchResultProviderRegistry.php new file mode 100644 index 00000000..9f97ef02 --- /dev/null +++ b/src/GlobalSearch/SearchResultProviderRegistry.php @@ -0,0 +1,77 @@ +> */ + private array $registered = []; + + public function __construct( + private readonly Container $container, + private readonly DiscoveryManifest $manifest, + ) {} + + /** + * @param class-string|array> $providers + */ + public function register(string|array $providers): void + { + foreach ((array) $providers as $provider) { + $this->registered[$this->keyFor($provider)] = $provider; + } + } + + /** @return array */ + public function all(): array + { + /** @var array> $discovered */ + $discovered = $this->manifest->forGroup('global-search'); + + return array_map( + fn (string $class): SearchResultProvider => $this->container->make($class), + array_merge($discovered, $this->registered), + ); + } + + public function forCategory(string $name): ?SearchResultProvider + { + return $this->all()[$name] ?? null; + } + + /** @return array */ + public function authorized(Request $request): array + { + return array_filter( + $this->all(), + fn (SearchResultProvider $provider): bool => $provider->authorize($request), + ); + } + + /** + * @param class-string $provider + */ + private function keyFor(string $provider): string + { + if (! is_subclass_of($provider, SearchResultProvider::class) && ! in_array(SearchResultProvider::class, class_implements($provider) ?: [], true)) { + throw new InvalidArgumentException("[{$provider}] must implement ".SearchResultProvider::class.'.'); + } + + $attribute = Attributes::get($provider, AsSearchProvider::class); + + if (! $attribute instanceof AsSearchProvider) { + throw new InvalidArgumentException("[{$provider}] is missing the #[AsSearchProvider] attribute."); + } + + return $attribute->key; + } +} diff --git a/src/LatticeServiceProvider.php b/src/LatticeServiceProvider.php index e3144d4a..918c8def 100644 --- a/src/LatticeServiceProvider.php +++ b/src/LatticeServiceProvider.php @@ -27,6 +27,9 @@ use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\FormRegistry; use Lattice\Lattice\Fragments\FragmentRegistry; +use Lattice\Lattice\GlobalSearch\Contracts\SearchHistoryRecorder; +use Lattice\Lattice\GlobalSearch\NullSearchHistoryRecorder; +use Lattice\Lattice\GlobalSearch\SearchResultProviderRegistry; use Lattice\Lattice\Http\Middleware\SetLocale; use Lattice\Lattice\Http\PageRegistry; use Lattice\Lattice\Layouts\LayoutRegistry; @@ -68,6 +71,8 @@ public function packageRegistered(): void $this->app->singleton(BulkActionRegistry::class); $this->app->singleton(PageRegistry::class); $this->app->singleton(RemoteSourceRegistry::class); + $this->app->singleton(SearchResultProviderRegistry::class); + $this->app->bind(SearchHistoryRecorder::class, NullSearchHistoryRecorder::class); $this->app->singleton(ComponentReferenceSigner::class); $this->app->alias(ComponentReferenceSigner::class, SignsComponentReferences::class); $this->app->singleton(LatticeRegistry::class); diff --git a/tests/Feature/GlobalSearch/RegistryTest.php b/tests/Feature/GlobalSearch/RegistryTest.php new file mode 100644 index 00000000..8ec89092 --- /dev/null +++ b/tests/Feature/GlobalSearch/RegistryTest.php @@ -0,0 +1,28 @@ +forCategory('products'))->toBeInstanceOf(SearchResultProvider::class); + expect($registry->forCategory('missing'))->toBeNull(); + expect(array_keys($registry->all()))->toContain('products'); +}); + +test('the registry filters unauthorized providers', function () { + DiscoveredProductsSearchProvider::$authorized = false; + + $authorized = app(SearchResultProviderRegistry::class)->authorized(new Request); + + expect($authorized)->not->toHaveKey('products'); +}); From acb0493e4b6c08fb6707474d1452e3d641c73726 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:02:44 +0200 Subject: [PATCH 07/28] refactor(global-search): simplify provider interface guard Co-Authored-By: Claude Opus 4.8 (1M context) --- src/GlobalSearch/SearchResultProviderRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GlobalSearch/SearchResultProviderRegistry.php b/src/GlobalSearch/SearchResultProviderRegistry.php index 9f97ef02..8e07ad48 100644 --- a/src/GlobalSearch/SearchResultProviderRegistry.php +++ b/src/GlobalSearch/SearchResultProviderRegistry.php @@ -62,7 +62,7 @@ public function authorized(Request $request): array */ private function keyFor(string $provider): string { - if (! is_subclass_of($provider, SearchResultProvider::class) && ! in_array(SearchResultProvider::class, class_implements($provider) ?: [], true)) { + if (! is_subclass_of($provider, SearchResultProvider::class)) { throw new InvalidArgumentException("[{$provider}] must implement ".SearchResultProvider::class.'.'); } From 2d0663097fe49bc4d8ed8cca678600d00a44bdba Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:04:21 +0200 Subject: [PATCH 08/28] feat(global-search): expose provider registry on the facade Co-Authored-By: Claude Opus 4.8 (1M context) --- src/LatticeRegistry.php | 16 ++++++++++++++++ tests/Feature/GlobalSearch/FacadeTest.php | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/Feature/GlobalSearch/FacadeTest.php diff --git a/src/LatticeRegistry.php b/src/LatticeRegistry.php index 3fe8a9a0..d47d1ebf 100644 --- a/src/LatticeRegistry.php +++ b/src/LatticeRegistry.php @@ -13,6 +13,8 @@ use Lattice\Lattice\Forms\FormRegistry; use Lattice\Lattice\Fragments\FragmentDefinition; use Lattice\Lattice\Fragments\FragmentRegistry; +use Lattice\Lattice\GlobalSearch\Contracts\SearchResultProvider; +use Lattice\Lattice\GlobalSearch\SearchResultProviderRegistry; use Lattice\Lattice\Http\PageRegistry; use Lattice\Lattice\Layouts\LayoutDefinition; use Lattice\Lattice\Layouts\LayoutRegistry; @@ -32,6 +34,7 @@ public function __construct( private PageRegistry $pages, private TableRegistry $tables, private RemoteSourceRegistry $remoteSources, + private SearchResultProviderRegistry $searchProviders, ) {} /** @@ -120,4 +123,17 @@ public function remoteSourceRegistry(): RemoteSourceRegistry { return $this->remoteSources; } + + /** + * @param class-string|array> $providers + */ + public function searchProviders(string|array $providers): void + { + $this->searchProviders->register($providers); + } + + public function searchProviderRegistry(): SearchResultProviderRegistry + { + return $this->searchProviders; + } } diff --git a/tests/Feature/GlobalSearch/FacadeTest.php b/tests/Feature/GlobalSearch/FacadeTest.php new file mode 100644 index 00000000..d9f80c67 --- /dev/null +++ b/tests/Feature/GlobalSearch/FacadeTest.php @@ -0,0 +1,17 @@ + []]); + app(SearchResultProviderRegistry::class); // ensure singleton built before manifest clear + app(DiscoveryManifest::class)->clear(); + + Lattice::searchProviders([DiscoveredProductsSearchProvider::class]); + + expect(Lattice::searchProviderRegistry()->forCategory('products'))->not->toBeNull(); +}); From f0f90bb33ab46fc89abab0834a4c916e4ccfbc80 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:07:45 +0200 Subject: [PATCH 09/28] feat(global-search): add search endpoint Co-Authored-By: Claude Opus 4.8 (1M context) --- config/lattice.php | 5 + routes/web.php | 9 ++ .../Controllers/GlobalSearchController.php | 109 ++++++++++++++++++ src/Http/Requests/RecordSelectionRequest.php | 23 ++++ src/Http/Requests/SearchRequest.php | 59 ++++++++++ .../GlobalSearch/SearchEndpointTest.php | 62 ++++++++++ 6 files changed, 267 insertions(+) create mode 100644 src/Http/Controllers/GlobalSearchController.php create mode 100644 src/Http/Requests/RecordSelectionRequest.php create mode 100644 src/Http/Requests/SearchRequest.php create mode 100644 tests/Feature/GlobalSearch/SearchEndpointTest.php diff --git a/config/lattice.php b/config/lattice.php index a6b68000..d9830a01 100644 --- a/config/lattice.php +++ b/config/lattice.php @@ -41,6 +41,11 @@ 'middleware' => ['web', 'auth'], ], + 'global-search' => [ + 'endpoint' => 'lattice/global-search', + 'middleware' => ['web', 'auth'], + ], + 'actions' => [ 'endpoint' => 'lattice/actions/{action}', 'middleware' => ['web', 'auth'], diff --git a/routes/web.php b/routes/web.php index bd43e5bd..53aa5ebf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use Lattice\Lattice\Http\Controllers\BulkActionController; use Lattice\Lattice\Http\Controllers\FormController; use Lattice\Lattice\Http\Controllers\FragmentController; +use Lattice\Lattice\Http\Controllers\GlobalSearchController; use Lattice\Lattice\Http\Controllers\RemoteSourceTokenController; use Lattice\Lattice\Http\Controllers\TableController; @@ -38,3 +39,11 @@ ->match(['post', 'put', 'patch', 'delete'], config('lattice.bulk-actions.endpoint', 'lattice/bulk-actions/{bulkAction}'), BulkActionController::class) ->where('bulkAction', '.*') ->name('lattice.bulk-actions.handle'); + +Route::middleware(config('lattice.global-search.middleware', ['web', 'auth'])) + ->get(config('lattice.global-search.endpoint', 'lattice/global-search'), [GlobalSearchController::class, 'search']) + ->name('lattice.global-search.search'); + +Route::middleware(config('lattice.global-search.middleware', ['web', 'auth'])) + ->post(config('lattice.global-search.endpoint', 'lattice/global-search'), [GlobalSearchController::class, 'record']) + ->name('lattice.global-search.record'); diff --git a/src/Http/Controllers/GlobalSearchController.php b/src/Http/Controllers/GlobalSearchController.php new file mode 100644 index 00000000..3d0dfcfc --- /dev/null +++ b/src/Http/Controllers/GlobalSearchController.php @@ -0,0 +1,109 @@ +providers->authorized($request); + + if ($request->wantsRecent()) { + return response()->json([ + 'data' => $this->history->recent($request, $request->perPage()), + 'categories' => $this->categories($request, $authorized), + 'pagination' => SearchPagination::forPage(1, $request->perPage(), 0), + 'state' => $this->state($request, null), + ]); + } + + $categories = $this->categories($request, $authorized); + $active = $this->activeCategory($request, $categories); + $provider = $active !== null ? ($authorized[$active] ?? null) : null; + + $results = $provider instanceof SearchResultProvider + ? $provider->search(new SearchQuery($request->queryString(), $active, $request->page(), $request->perPage(), app()->getLocale())) + : new SearchResults([], 0); + + return response()->json([ + 'data' => $results->rows, + 'categories' => $categories, + 'pagination' => SearchPagination::forPage($request->page(), $request->perPage(), $results->total), + 'state' => $this->state($request, $active), + ]); + } + + public function record(RecordSelectionRequest $request): JsonResponse + { + // Implemented in Task 7. + return response()->json(['data' => null, 'state' => ['recorded' => false]]); + } + + /** + * @param array $authorized + * @return array + */ + private function categories(SearchRequest $request, array $authorized): array + { + return array_values(array_map(function (SearchResultProvider $provider) use ($request): SearchCategory { + $category = $provider->category(); + + if (! $request->wantsCounts()) { + return $category; + } + + return $category->withCount($provider->count( + new SearchQuery($request->queryString(), $category->name, 1, $request->perPage(), app()->getLocale()), + )); + }, $authorized)); + } + + /** + * @param array $categories + */ + private function activeCategory(SearchRequest $request, array $categories): ?string + { + $names = array_map(fn (SearchCategory $category): string => $category->name, $categories); + + $requested = $request->category(); + if ($requested !== null && in_array($requested, $names, true)) { + return $requested; + } + + if ($request->wantsCounts() && $categories !== []) { + usort($categories, fn (SearchCategory $a, SearchCategory $b): int => ($b->count ?? 0) <=> ($a->count ?? 0)); + + return $categories[0]->name; + } + + return $names[0] ?? null; + } + + /** @return array{query:string,category:?string,perPage:int,countsIncluded:bool} */ + private function state(SearchRequest $request, ?string $active): array + { + return [ + 'query' => $request->queryString(), + 'category' => $active, + 'perPage' => $request->perPage(), + 'countsIncluded' => $request->wantsCounts(), + ]; + } +} diff --git a/src/Http/Requests/RecordSelectionRequest.php b/src/Http/Requests/RecordSelectionRequest.php new file mode 100644 index 00000000..5d799373 --- /dev/null +++ b/src/Http/Requests/RecordSelectionRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'category' => ['required', 'string', 'max:255'], + 'id' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/src/Http/Requests/SearchRequest.php b/src/Http/Requests/SearchRequest.php new file mode 100644 index 00000000..5d379570 --- /dev/null +++ b/src/Http/Requests/SearchRequest.php @@ -0,0 +1,59 @@ + */ + public function rules(): array + { + return [ + 'query' => ['nullable', 'string', 'max:255'], + 'category' => ['nullable', 'string', 'max:255'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'counts' => ['nullable', 'boolean'], + 'recent' => ['nullable', 'boolean'], + ]; + } + + public function queryString(): string + { + return (string) $this->input('query', ''); + } + + public function page(): int + { + return (int) $this->input('page', 1); + } + + public function perPage(): int + { + return (int) $this->input('per_page', 20); + } + + public function wantsCounts(): bool + { + return $this->boolean('counts'); + } + + public function wantsRecent(): bool + { + return $this->boolean('recent'); + } + + public function category(): ?string + { + $category = $this->input('category'); + + return is_string($category) && $category !== '' ? $category : null; + } +} diff --git a/tests/Feature/GlobalSearch/SearchEndpointTest.php b/tests/Feature/GlobalSearch/SearchEndpointTest.php new file mode 100644 index 00000000..20137cad --- /dev/null +++ b/tests/Feature/GlobalSearch/SearchEndpointTest.php @@ -0,0 +1,62 @@ + ['id' => (string) $n, 'title' => "Widget {$n}"], + range(1, 25), + ); + discoverFixtures(); + actingAs(UserFactory::new()->create()); +}); + +test('config exposes the default endpoint and middleware', function () { + expect(config('lattice.global-search.endpoint'))->toBe('lattice/global-search'); + expect(config('lattice.global-search.middleware'))->toBe(['web', 'auth']); +}); + +test('search returns the closed response envelope', function () { + $response = getJson('/lattice/global-search?query=Widget&category=products&per_page=10'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [['category' => ['name'], 'item' => ['id', 'title', 'subtitle', 'additionalInfo', 'link', 'badge']]], + 'categories' => [['name', 'label', 'icon', 'count']], + 'pagination' => ['page', 'perPage', 'total', 'hasMore', 'nextPage'], + 'state' => ['query', 'category', 'perPage', 'countsIncluded'], + ]); + + expect($response->json('pagination.total'))->toBe(25) + ->and($response->json('pagination.hasMore'))->toBeTrue() + ->and($response->json('pagination.nextPage'))->toBe(2) + ->and($response->json('data'))->toHaveCount(10) + ->and($response->json('data.0.category.name'))->toBe('products') + ->and($response->json('state.query'))->toBe('Widget'); +}); + +test('counts are present only when requested', function () { + expect(getJson('/lattice/global-search?query=Widget&category=products')->json('categories.0.count'))->toBeNull(); + expect(getJson('/lattice/global-search?query=Widget&category=products&counts=1')->json('categories.0.count'))->toBe(25); + expect(getJson('/lattice/global-search?query=Widget&category=products')->json('state.countsIncluded'))->toBeFalse(); +}); + +test('unauthorized providers are excluded', function () { + DiscoveredProductsSearchProvider::$authorized = false; + + $response = getJson('/lattice/global-search?query=Widget'); + + expect($response->json('categories'))->toBe([]) + ->and($response->json('data'))->toBe([]); +}); + +test('search validates pagination parameters', function () { + getJson('/lattice/global-search?per_page=999')->assertStatus(422); + getJson('/lattice/global-search?page=0')->assertStatus(422); +}); From 14ce100947ebfd74ffd0c621c2170bc65d04e43a Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:11:03 +0200 Subject: [PATCH 10/28] feat(global-search): record selections by re-resolving through providers Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/GlobalSearchController.php | 19 +++++++- .../GlobalSearch/RecordEndpointTest.php | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/GlobalSearch/RecordEndpointTest.php diff --git a/src/Http/Controllers/GlobalSearchController.php b/src/Http/Controllers/GlobalSearchController.php index 3d0dfcfc..74527c32 100644 --- a/src/Http/Controllers/GlobalSearchController.php +++ b/src/Http/Controllers/GlobalSearchController.php @@ -52,8 +52,23 @@ public function search(SearchRequest $request): JsonResponse public function record(RecordSelectionRequest $request): JsonResponse { - // Implemented in Task 7. - return response()->json(['data' => null, 'state' => ['recorded' => false]]); + /** @var string $category */ + $category = $request->validated('category'); + /** @var string $id */ + $id = $request->validated('id'); + + $provider = $this->providers->forCategory($category); + + abort_if($provider === null, 404); + abort_unless($provider->authorize($request), 403); + + $result = $provider->resolve($id, $request); + $recorded = $result !== null && $this->history->record($request, $result); + + return response()->json([ + 'data' => $result, + 'state' => ['recorded' => $recorded], + ]); } /** diff --git a/tests/Feature/GlobalSearch/RecordEndpointTest.php b/tests/Feature/GlobalSearch/RecordEndpointTest.php new file mode 100644 index 00000000..4bbbd554 --- /dev/null +++ b/tests/Feature/GlobalSearch/RecordEndpointTest.php @@ -0,0 +1,46 @@ + '7', 'title' => 'Canonical Widget']]; + discoverFixtures(); + actingAs(UserFactory::new()->create()); +}); + +test('record re-resolves through the provider and ignores client payload', function () { + $response = postJson('/lattice/global-search', [ + 'category' => 'products', + 'id' => '7', + 'item' => ['title' => 'TAMPERED', 'link' => 'https://evil.example/x'], + ]); + + $response->assertOk(); + expect($response->json('data.item.title'))->toBe('Canonical Widget') + ->and($response->json('data.item.link'))->toBe('/products/7') + ->and($response->json('state.recorded'))->toBeFalse(); +}); + +test('record validates the payload', function () { + postJson('/lattice/global-search', [])->assertStatus(422); +}); + +test('record 404s an unknown category and 403s an unauthorized one', function () { + postJson('/lattice/global-search', ['category' => 'missing', 'id' => '7'])->assertStatus(404); + + DiscoveredProductsSearchProvider::$authorized = false; + postJson('/lattice/global-search', ['category' => 'products', 'id' => '7'])->assertStatus(403); +}); + +test('recent returns an empty list with the default recorder', function () { + actingAs(UserFactory::new()->create()); + $response = \Pest\Laravel\getJson('/lattice/global-search?recent=1'); + $response->assertOk(); + expect($response->json('data'))->toBe([]); +}); From 23543e262a215dc994c75f720690d18a22f82336 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:14:07 +0200 Subject: [PATCH 11/28] feat(global-search): add server-driven component nodes Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/js/types/generated.ts | 43 +++++++++++++ src/Core/Components/GlobalSearch.php | 60 +++++++++++++++++++ .../Components/GlobalSearchCategories.php | 15 +++++ src/Core/Components/GlobalSearchInput.php | 15 +++++ src/Core/Components/GlobalSearchPreview.php | 15 +++++ src/Core/Components/GlobalSearchRecent.php | 15 +++++ src/Core/Components/GlobalSearchResults.php | 15 +++++ .../GlobalSearch/ComponentWireShapeTest.php | 33 ++++++++++ 8 files changed, 211 insertions(+) create mode 100644 src/Core/Components/GlobalSearch.php create mode 100644 src/Core/Components/GlobalSearchCategories.php create mode 100644 src/Core/Components/GlobalSearchInput.php create mode 100644 src/Core/Components/GlobalSearchPreview.php create mode 100644 src/Core/Components/GlobalSearchRecent.php create mode 100644 src/Core/Components/GlobalSearchResults.php create mode 100644 tests/Unit/GlobalSearch/ComponentWireShapeTest.php diff --git a/resources/js/types/generated.ts b/resources/js/types/generated.ts index 38fc480f..b3906caa 100644 --- a/resources/js/types/generated.ts +++ b/resources/js/types/generated.ts @@ -328,6 +328,37 @@ export type CoreNode = props: FloatingPanel; schema?: Node[]; } + | { + type: "global-search.categories"; + key?: string; + props: GlobalSearchCategories; + } + | { + type: "global-search.input"; + key?: string; + props: GlobalSearchInput; + } + | { + type: "global-search.preview"; + key?: string; + props: GlobalSearchPreview; + } + | { + type: "global-search.recent"; + key?: string; + props: GlobalSearchRecent; + } + | { + type: "global-search.results"; + key?: string; + props: GlobalSearchResults; + } + | { + type: "global-search.root"; + key?: string; + props: GlobalSearch; + schema?: Node[]; + } | { type: "grid"; key?: string; @@ -653,6 +684,18 @@ export type FragmentNode = { schema?: Node[]; }; export type Gap = "none" | "xs" | "sm" | "md" | "lg" | "xl"; +export type GlobalSearch = { + endpoint: string | null; + perPage: number; + placeholder: string | null; + shortcut: boolean; + title: string | null; +}; +export type GlobalSearchCategories = Record; +export type GlobalSearchInput = Record; +export type GlobalSearchPreview = Record; +export type GlobalSearchRecent = Record; +export type GlobalSearchResults = Record; export type Grid = { columns: number | null; }; diff --git a/src/Core/Components/GlobalSearch.php b/src/Core/Components/GlobalSearch.php new file mode 100644 index 00000000..1e554dce --- /dev/null +++ b/src/Core/Components/GlobalSearch.php @@ -0,0 +1,60 @@ +endpoint = $endpoint; + + return $this; + } + + public function placeholder(string $placeholder): static + { + $this->placeholder = $placeholder; + + return $this; + } + + public function title(string $title): static + { + $this->title = $title; + + return $this; + } + + public function shortcut(bool $shortcut = true): static + { + $this->shortcut = $shortcut; + + return $this; + } + + public function perPage(int $perPage): static + { + $this->perPage = $perPage; + + return $this; + } +} diff --git a/src/Core/Components/GlobalSearchCategories.php b/src/Core/Components/GlobalSearchCategories.php new file mode 100644 index 00000000..f45c9af5 --- /dev/null +++ b/src/Core/Components/GlobalSearchCategories.php @@ -0,0 +1,15 @@ +endpoint('/lattice/global-search') + ->placeholder('Search…') + ->title('Search') + ->schema([ + GlobalSearchInput::make(), + GlobalSearchResults::make(), + ]), + ); + + expect($node)->toMatchArray([ + 'type' => 'global-search.root', + 'props' => [ + 'endpoint' => '/lattice/global-search', + 'placeholder' => 'Search…', + 'title' => 'Search', + 'shortcut' => true, + 'perPage' => 20, + ], + ]); + + expect($node['schema'][0]['type'])->toBe('global-search.input'); + expect($node['schema'][1]['type'])->toBe('global-search.results'); +}); From 7a223a25eb83db51a705fcd72a9fdf995c3605c7 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:31:55 +0200 Subject: [PATCH 12/28] feat(global-search): add hook, types, and context Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/js/global-search/context.tsx | 18 ++ resources/js/global-search/types.ts | 41 +++++ .../global-search/use-global-search.test.ts | 96 ++++++++++ .../js/global-search/use-global-search.ts | 167 ++++++++++++++++++ resources/js/test/setup.ts | 9 +- 5 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 resources/js/global-search/context.tsx create mode 100644 resources/js/global-search/types.ts create mode 100644 resources/js/global-search/use-global-search.test.ts create mode 100644 resources/js/global-search/use-global-search.ts diff --git a/resources/js/global-search/context.tsx b/resources/js/global-search/context.tsx new file mode 100644 index 00000000..f47ab250 --- /dev/null +++ b/resources/js/global-search/context.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { UseGlobalSearchReturn } from "./types"; + +const GlobalSearchContext = createContext(null); + +export function GlobalSearchProvider({ value, children }: { value: UseGlobalSearchReturn; children: ReactNode }) { + return {children}; +} + +export function useGlobalSearchContext(): UseGlobalSearchReturn { + const context = useContext(GlobalSearchContext); + + if (context === null) { + throw new Error("useGlobalSearchContext must be used within a root."); + } + + return context; +} diff --git a/resources/js/global-search/types.ts b/resources/js/global-search/types.ts new file mode 100644 index 00000000..fe5a7772 --- /dev/null +++ b/resources/js/global-search/types.ts @@ -0,0 +1,41 @@ +export const GLOBAL_SEARCH_DEBOUNCE_MS = 250; + +export type SearchResult = { + category: { name: string }; + item: { + id: string; + title: string; + subtitle: string | null; + additionalInfo: string | null; + link: string; + badge: string | null; + }; +}; + +export type SearchCategory = { name: string; label: string; icon: string | null; count: number | null }; +export type SearchPagination = { page: number; perPage: number; total: number; hasMore: boolean; nextPage: number | null }; + +export type SearchState = { query: string; category: string | null; perPage: number; countsIncluded: boolean }; +export type SearchResponse = { data: SearchResult[]; categories: SearchCategory[]; pagination: SearchPagination; state: SearchState }; +export type RecordResponse = { data: SearchResult | null; state: { recorded: boolean } }; + +export type GlobalSearchStatus = "idle" | "loading" | "success" | "error"; +export type UseGlobalSearchOptions = { endpoint: string; perPage?: number }; + +export type UseGlobalSearchReturn = { + query: string; + setQuery: (value: string) => void; + categories: SearchCategory[]; + activeCategory: string | null; + setCategory: (name: string | null) => void; + results: SearchResult[]; + recent: SearchResult[]; + pagination: SearchPagination | null; + status: GlobalSearchStatus; + error: string | null; + focusedId: string | null; + setFocusedId: (id: string | null) => void; + loadMore: () => void; + openResult: (result: SearchResult) => void; + refreshRecent: () => void; +}; diff --git a/resources/js/global-search/use-global-search.test.ts b/resources/js/global-search/use-global-search.test.ts new file mode 100644 index 00000000..ac8d39d2 --- /dev/null +++ b/resources/js/global-search/use-global-search.test.ts @@ -0,0 +1,96 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { router } from "@inertiajs/react"; +import { GLOBAL_SEARCH_DEBOUNCE_MS } from "./types"; +import { useGlobalSearch } from "./use-global-search"; + +vi.mock("@inertiajs/react", () => ({ router: { visit: vi.fn() } })); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +const page1 = { + data: [{ category: { name: "products" }, item: { id: "1", title: "Widget 1", subtitle: null, additionalInfo: null, link: "/products/1", badge: null } }], + categories: [{ name: "products", label: "Products", icon: "package", count: null }], + pagination: { page: 1, perPage: 1, total: 2, hasMore: true, nextPage: 2 }, + state: { query: "wid", category: "products", perPage: 1, countsIncluded: false }, +}; +const page2 = { + data: [{ category: { name: "products" }, item: { id: "2", title: "Widget 2", subtitle: null, additionalInfo: null, link: "/products/2", badge: null } }], + categories: page1.categories, + pagination: { page: 2, perPage: 1, total: 2, hasMore: false, nextPage: null }, + state: { query: "wid", category: "products", perPage: 1, countsIncluded: false }, +}; + +describe("useGlobalSearch", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); + + it("debounces the query then fetches results", async () => { + const fetchMock = vi.fn(async () => jsonResponse(page1)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useGlobalSearch({ endpoint: "/lattice/global-search", perPage: 1 })); + act(() => result.current.setQuery("wid")); + + expect(fetchMock).not.toHaveBeenCalled(); // still within debounce window + await act(async () => { await vi.advanceTimersByTimeAsync(GLOBAL_SEARCH_DEBOUNCE_MS); }); + + await waitFor(() => expect(result.current.results).toHaveLength(1)); + expect(result.current.results[0]?.item.id).toBe("1"); + }); + + it("appends the next page on loadMore", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(page2)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useGlobalSearch({ endpoint: "/lattice/global-search", perPage: 1 })); + act(() => result.current.setQuery("wid")); + await act(async () => { await vi.advanceTimersByTimeAsync(GLOBAL_SEARCH_DEBOUNCE_MS); }); + await waitFor(() => expect(result.current.results).toHaveLength(1)); + + act(() => result.current.loadMore()); + await waitFor(() => expect(result.current.results).toHaveLength(2)); + expect(result.current.results[1]?.item.id).toBe("2"); + }); + + it("ignores a stale response whose echoed query no longer matches", async () => { + const fetchMock = vi.fn(async (_url, init) => { + const url = String(_url); + // The first (stale) request resolves last but echoes the old query. + return jsonResponse(url.includes("query=old") ? page1 : page2); + }); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useGlobalSearch({ endpoint: "/lattice/global-search", perPage: 1 })); + act(() => result.current.setQuery("old")); + await act(async () => { await vi.advanceTimersByTimeAsync(GLOBAL_SEARCH_DEBOUNCE_MS); }); + act(() => result.current.setQuery("new")); + await act(async () => { await vi.advanceTimersByTimeAsync(GLOBAL_SEARCH_DEBOUNCE_MS); }); + + await waitFor(() => expect(result.current.results[0]?.item.id).toBe("2")); + }); + + it("records the selection over POST then navigates to the re-resolved link", async () => { + vi.useRealTimers(); + const recordResponse = { + data: { category: { name: "products" }, item: { id: "9", title: "Canonical", subtitle: null, additionalInfo: null, link: "/products/9", badge: null } }, + state: { recorded: false }, + }; + const fetchMock = vi.fn(async () => jsonResponse(recordResponse)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useGlobalSearch({ endpoint: "/lattice/global-search", perPage: 1 })); + // The client-supplied link is deliberately wrong; navigation must use the re-resolved row. + const clicked = { category: { name: "products" }, item: { id: "9", title: "Client Title", subtitle: null, additionalInfo: null, link: "/client/9", badge: null } }; + + result.current.openResult(clicked); + + await waitFor(() => expect(router.visit).toHaveBeenCalledWith("/products/9")); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + }); +}); diff --git a/resources/js/global-search/use-global-search.ts b/resources/js/global-search/use-global-search.ts new file mode 100644 index 00000000..c521d49b --- /dev/null +++ b/resources/js/global-search/use-global-search.ts @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { router } from "@inertiajs/react"; +import { apiJson } from "@lattice-php/lattice/core/api"; +import { + GLOBAL_SEARCH_DEBOUNCE_MS, + type RecordResponse, + type SearchCategory, + type SearchPagination, + type SearchResponse, + type SearchResult, + type UseGlobalSearchOptions, + type UseGlobalSearchReturn, +} from "./types"; + +function buildUrl(endpoint: string, params: Record): string { + const search = new URLSearchParams(params).toString(); + + return search === "" ? endpoint : `${endpoint}?${search}`; +} + +export function useGlobalSearch({ endpoint, perPage = 20 }: UseGlobalSearchOptions): UseGlobalSearchReturn { + const [query, setQueryState] = useState(""); + const [categories, setCategories] = useState([]); + const [activeCategory, setActiveCategory] = useState(null); + const [results, setResults] = useState([]); + const [recent, setRecent] = useState([]); + const [pagination, setPagination] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [focusedId, setFocusedId] = useState(null); + + const abortRef = useRef(null); + const requestIdRef = useRef(0); + const debounceRef = useRef | null>(null); + + const run = useCallback( + async (nextQuery: string, category: string | null, page: number, append: boolean): Promise => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const requestId = (requestIdRef.current += 1); + + setStatus("loading"); + setError(null); + + try { + const params: Record = { query: nextQuery, page: String(page), per_page: String(perPage), counts: "1" }; + if (category !== null) { + params.category = category; + } + + const payload = await apiJson(buildUrl(endpoint, params), { signal: controller.signal }); + + // Stale-response guard: drop anything superseded by a newer request. + if (requestId !== requestIdRef.current) { + return; + } + + setCategories(payload.categories); + setActiveCategory(payload.state.category); + setPagination(payload.pagination); + setResults((current) => (append ? [...current, ...payload.data] : payload.data)); + setFocusedId((current) => current ?? payload.data[0]?.item.id ?? null); + setStatus("success"); + } catch (caught) { + if (caught instanceof DOMException && caught.name === "AbortError") { + return; + } + + setError(caught instanceof Error ? caught.message : String(caught)); + setStatus("error"); + } + }, + [endpoint, perPage], + ); + + const setQuery = useCallback( + (value: string): void => { + setQueryState(value); + setFocusedId(null); + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(async () => { + await run(value, activeCategory, 1, false); + }, GLOBAL_SEARCH_DEBOUNCE_MS); + }, + [activeCategory, run], + ); + + const setCategory = useCallback( + (name: string | null): void => { + setActiveCategory(name); + setFocusedId(null); + void run(query, name, 1, false); + }, + [query, run], + ); + + const loadMore = useCallback((): void => { + if (pagination?.nextPage == null || status === "loading") { + return; + } + + void run(query, activeCategory, pagination.nextPage, true); + }, [activeCategory, pagination, query, run, status]); + + const refreshRecent = useCallback(async (): Promise => { + try { + const payload = await apiJson(buildUrl(endpoint, { recent: "1", per_page: String(perPage) })); + setRecent(payload.data); + } catch { + setRecent([]); + } + }, [endpoint, perPage]); + + const openResult = useCallback( + async (result: SearchResult): Promise => { + let target = result; + + try { + const payload = await apiJson(endpoint, { + method: "POST", + body: JSON.stringify({ category: result.category.name, id: result.item.id }), + throwOnError: false, + }); + if (payload.data) { + target = payload.data; + } + } catch { + // Navigation should still proceed with the row we already have. + } + + router.visit(target.item.link); + }, + [endpoint], + ); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, []); + + return { + query, + setQuery, + categories, + activeCategory, + setCategory, + results, + recent, + pagination, + status, + error, + focusedId, + setFocusedId, + loadMore, + openResult: (result) => void openResult(result), + refreshRecent: () => void refreshRecent(), + }; +} diff --git a/resources/js/test/setup.ts b/resources/js/test/setup.ts index 6be848fa..1b5127de 100644 --- a/resources/js/test/setup.ts +++ b/resources/js/test/setup.ts @@ -1,6 +1,13 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, configure } from "@testing-library/react"; -import { afterEach } from "vitest"; +import { afterEach, vi } from "vitest"; + +// Allow @testing-library/dom's `jestFakeTimersAreEnabled()` to detect vitest +// fake timers. Without this, `waitFor` uses a fake `setTimeout(resolve, 0)` +// inside its async wrapper that never fires when fake timers are active. +// See: https://github.com/testing-library/dom-testing-library/blob/main/src/helpers.ts +// @ts-expect-error — jest is not declared as a global in this project +globalThis.jest = vi; configure({ testIdAttribute: "data-test" }); From a85e97447ced03218c069415a1b0f22d9c79de1f Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:37:49 +0200 Subject: [PATCH 13/28] feat(global-search): add results slot with keyboard nav and infinite loading Co-Authored-By: Claude Opus 4.8 (1M context) --- .../global-search/components/result-row.tsx | 46 +++++++++++ .../global-search/components/results.test.tsx | 49 ++++++++++++ .../js/global-search/components/results.tsx | 78 +++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 resources/js/global-search/components/result-row.tsx create mode 100644 resources/js/global-search/components/results.test.tsx create mode 100644 resources/js/global-search/components/results.tsx diff --git a/resources/js/global-search/components/result-row.tsx b/resources/js/global-search/components/result-row.tsx new file mode 100644 index 00000000..18b15a9b --- /dev/null +++ b/resources/js/global-search/components/result-row.tsx @@ -0,0 +1,46 @@ +import { Icon } from "@lattice-php/lattice/icons"; +import { cn } from "@lattice-php/lattice/lib/utils"; +import type { SearchResult } from "../types"; + +function secondary(item: SearchResult["item"]): string { + return [item.subtitle, item.additionalInfo].filter((value): value is string => Boolean(value)).join(" · "); +} + +export function ResultRow({ + result, + focused, + onOpen, + onFocus, +}: { + result: SearchResult; + focused: boolean; + onOpen: () => void; + onFocus: () => void; +}) { + const detail = secondary(result.item); + + return ( + + ); +} diff --git a/resources/js/global-search/components/results.test.tsx b/resources/js/global-search/components/results.test.tsx new file mode 100644 index 00000000..f405f9d0 --- /dev/null +++ b/resources/js/global-search/components/results.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { GlobalSearchProvider } from "../context"; +import type { SearchResult, UseGlobalSearchReturn } from "../types"; +import GlobalSearchResults from "./results"; + +function row(id: string, title: string): SearchResult { + return { category: { name: "products" }, item: { id, title, subtitle: "SKU-" + id, additionalInfo: null, link: "/products/" + id, badge: null } }; +} + +function harness(overrides: Partial = {}) { + const setFocusedId = vi.fn(); + const openResult = vi.fn(); + const loadMore = vi.fn(); + const value: UseGlobalSearchReturn = { + query: "wid", setQuery: vi.fn(), categories: [], activeCategory: "products", setCategory: vi.fn(), + results: [row("1", "Widget 1"), row("2", "Widget 2")], recent: [], pagination: { page: 1, perPage: 20, total: 2, hasMore: false, nextPage: null }, + status: "success", error: null, focusedId: "1", setFocusedId, openResult, loadMore, refreshRecent: vi.fn(), + ...overrides, + }; + + render({null}); + + return { setFocusedId, openResult, loadMore }; +} + +describe("GlobalSearchResults", () => { + it("renders rows with title and joined subtitle", () => { + harness(); + expect(screen.getByText("Widget 1")).toBeInTheDocument(); + expect(screen.getByText("SKU-1")).toBeInTheDocument(); + }); + + it("moves focus with arrow keys and opens on Enter", () => { + const { setFocusedId, openResult } = harness(); + const list = screen.getByRole("listbox"); + + fireEvent.keyDown(list, { key: "ArrowDown" }); + expect(setFocusedId).toHaveBeenCalledWith("2"); + + fireEvent.keyDown(list, { key: "Enter" }); + expect(openResult).toHaveBeenCalledWith(expect.objectContaining({ item: expect.objectContaining({ id: "1" }) })); + }); + + it("shows the empty state when there are no results", () => { + harness({ results: [], status: "success" }); + expect(screen.getByText(/no results/i)).toBeInTheDocument(); + }); +}); diff --git a/resources/js/global-search/components/results.tsx b/resources/js/global-search/components/results.tsx new file mode 100644 index 00000000..81bbb266 --- /dev/null +++ b/resources/js/global-search/components/results.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, type KeyboardEvent } from "react"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useGlobalSearchContext } from "../context"; +import { ResultRow } from "./result-row"; + +const GlobalSearchResults: RendererComponent<"global-search.results"> = () => { + const { results, focusedId, setFocusedId, openResult, loadMore, status, pagination } = useGlobalSearchContext(); + const { t } = useT("lattice"); + const sentinelRef = useRef(null); + + useEffect(() => { + const node = sentinelRef.current; + if (!node || pagination?.hasMore !== true) { + return; + } + + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + loadMore(); + } + }); + observer.observe(node); + + return () => observer.disconnect(); + }, [loadMore, pagination?.hasMore]); + + function onKeyDown(event: KeyboardEvent): void { + if (results.length === 0) { + return; + } + + const index = Math.max(0, results.findIndex((result) => result.item.id === focusedId)); + + if (event.key === "ArrowDown") { + event.preventDefault(); + setFocusedId(results[Math.min(results.length - 1, index + 1)]?.item.id ?? null); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setFocusedId(results[Math.max(0, index - 1)]?.item.id ?? null); + } else if (event.key === "Enter") { + event.preventDefault(); + const focused = results[index]; + if (focused) { + openResult(focused); + } + } + } + + if (status === "error") { + return
{t("globalSearch.error", "Something went wrong.")}
; + } + + if (status === "loading" && results.length === 0) { + return
{t("globalSearch.loading", "Searching…")}
; + } + + if (results.length === 0) { + return
{t("globalSearch.empty", "No results found.")}
; + } + + return ( +
+ {results.map((result) => ( + setFocusedId(result.item.id)} + onOpen={() => openResult(result)} + result={result} + /> + ))} + + ); +}; + +export default GlobalSearchResults; From ea1fb7896a1afee2809856ceac7037c5d3c9514e Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:41:29 +0200 Subject: [PATCH 14/28] feat(global-search): add input, categories, recent, and preview slots Co-Authored-By: Claude Opus 4.8 (1M context) --- .../global-search/components/categories.tsx | 33 ++++++++++++ .../js/global-search/components/input.tsx | 25 ++++++++++ .../js/global-search/components/preview.tsx | 30 +++++++++++ .../js/global-search/components/recent.tsx | 32 ++++++++++++ .../global-search/components/slots.test.tsx | 50 +++++++++++++++++++ 5 files changed, 170 insertions(+) create mode 100644 resources/js/global-search/components/categories.tsx create mode 100644 resources/js/global-search/components/input.tsx create mode 100644 resources/js/global-search/components/preview.tsx create mode 100644 resources/js/global-search/components/recent.tsx create mode 100644 resources/js/global-search/components/slots.test.tsx diff --git a/resources/js/global-search/components/categories.tsx b/resources/js/global-search/components/categories.tsx new file mode 100644 index 00000000..093563fe --- /dev/null +++ b/resources/js/global-search/components/categories.tsx @@ -0,0 +1,33 @@ +import { Icon } from "@lattice-php/lattice/icons"; +import { cn } from "@lattice-php/lattice/lib/utils"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useGlobalSearchContext } from "../context"; + +const GlobalSearchCategories: RendererComponent<"global-search.categories"> = () => { + const { categories, activeCategory, setCategory } = useGlobalSearchContext(); + + return ( +
+ {categories.map((category) => ( + + ))} +
+ ); +}; + +export default GlobalSearchCategories; diff --git a/resources/js/global-search/components/input.tsx b/resources/js/global-search/components/input.tsx new file mode 100644 index 00000000..b8c332bf --- /dev/null +++ b/resources/js/global-search/components/input.tsx @@ -0,0 +1,25 @@ +import { Icon } from "@lattice-php/lattice/icons"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useGlobalSearchContext } from "../context"; + +const GlobalSearchInput: RendererComponent<"global-search.input"> = () => { + const { query, setQuery } = useGlobalSearchContext(); + const { t } = useT("lattice"); + + return ( +
+
+ ); +}; + +export default GlobalSearchInput; diff --git a/resources/js/global-search/components/preview.tsx b/resources/js/global-search/components/preview.tsx new file mode 100644 index 00000000..7ba79178 --- /dev/null +++ b/resources/js/global-search/components/preview.tsx @@ -0,0 +1,30 @@ +import { Button } from "@lattice-php/lattice/core/components/button"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useGlobalSearchContext } from "../context"; + +const GlobalSearchPreview: RendererComponent<"global-search.preview"> = () => { + const { results, recent, focusedId, openResult } = useGlobalSearchContext(); + const { t } = useT("lattice"); + + const focused = [...results, ...recent].find((result) => result.item.id === focusedId) ?? null; + + if (focused === null) { + return
{t("globalSearch.previewEmpty", "Select a result to preview.")}
; + } + + const detail = [focused.item.subtitle, focused.item.additionalInfo].filter(Boolean).join(" · "); + + return ( +
+
+ {focused.item.title} + {detail !== "" ? {detail} : null} + {focused.item.badge ? {focused.item.badge} : null} +
+ +
+ ); +}; + +export default GlobalSearchPreview; diff --git a/resources/js/global-search/components/recent.tsx b/resources/js/global-search/components/recent.tsx new file mode 100644 index 00000000..67f509a0 --- /dev/null +++ b/resources/js/global-search/components/recent.tsx @@ -0,0 +1,32 @@ +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useGlobalSearchContext } from "../context"; +import { ResultRow } from "./result-row"; + +const GlobalSearchRecent: RendererComponent<"global-search.recent"> = () => { + const { query, recent, focusedId, setFocusedId, openResult } = useGlobalSearchContext(); + const { t } = useT("lattice"); + + if (query.trim() !== "" || recent.length === 0) { + return null; + } + + return ( +
+ + {t("globalSearch.recent", "Recent")} + + {recent.map((result) => ( + setFocusedId(result.item.id)} + onOpen={() => openResult(result)} + result={result} + /> + ))} +
+ ); +}; + +export default GlobalSearchRecent; diff --git a/resources/js/global-search/components/slots.test.tsx b/resources/js/global-search/components/slots.test.tsx new file mode 100644 index 00000000..404f53cb --- /dev/null +++ b/resources/js/global-search/components/slots.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { GlobalSearchProvider } from "../context"; +import type { SearchResult, UseGlobalSearchReturn } from "../types"; +import GlobalSearchCategories from "./categories"; +import GlobalSearchInput from "./input"; +import GlobalSearchRecent from "./recent"; + +function row(id: string): SearchResult { + return { category: { name: "products" }, item: { id, title: "Widget " + id, subtitle: null, additionalInfo: null, link: "/p/" + id, badge: null } }; +} + +function value(overrides: Partial = {}): UseGlobalSearchReturn { + return { + query: "", setQuery: vi.fn(), categories: [{ name: "products", label: "Products", icon: "package", count: 3 }], + activeCategory: "products", setCategory: vi.fn(), results: [], recent: [], pagination: null, + status: "idle", error: null, focusedId: null, setFocusedId: vi.fn(), loadMore: vi.fn(), openResult: vi.fn(), refreshRecent: vi.fn(), + ...overrides, + }; +} + +function node(type: string) { + return { type, props: {} } as never; +} + +describe("global-search slots", () => { + it("input forwards typing to setQuery", () => { + const v = value(); + render({null}); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "wid" } }); + expect(v.setQuery).toHaveBeenCalledWith("wid"); + }); + + it("categories render label + count and select on click", () => { + const v = value(); + render({null}); + fireEvent.click(screen.getByRole("button", { name: /Products/ })); + expect(v.setCategory).toHaveBeenCalledWith("products"); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("recent hides while a query is active", () => { + const { container } = render( + + {null} + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); From e4f2046b71aaec72948d35a7b468bd651833abb2 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:43:32 +0200 Subject: [PATCH 15/28] fix(global-search): use aria-pressed on category filter buttons Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/js/global-search/components/categories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/global-search/components/categories.tsx b/resources/js/global-search/components/categories.tsx index 093563fe..84e40325 100644 --- a/resources/js/global-search/components/categories.tsx +++ b/resources/js/global-search/components/categories.tsx @@ -11,7 +11,7 @@ const GlobalSearchCategories: RendererComponent<"global-search.categories"> = () {categories.map((category) => ( + + + {composed} + + + + ); +}; + +export default GlobalSearch; From 57d5829cb4124d5168c57ee026c502667842e338 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:49:53 +0200 Subject: [PATCH 17/28] feat(global-search): register plugin and export public API Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 8 +++++ resources/js/global-search/index.test.ts | 17 +++++++++++ resources/js/global-search/index.ts | 38 ++++++++++++++++++++++++ resources/js/index.ts | 17 +++++++++++ resources/js/registry.ts | 2 ++ vite.config.ts | 2 ++ 6 files changed, 84 insertions(+) create mode 100644 resources/js/global-search/index.test.ts create mode 100644 resources/js/global-search/index.ts diff --git a/package.json b/package.json index 0b46245e..41c31f58 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,14 @@ "types": "./dist/chat/*.d.ts", "import": "./dist/chat/*.js" }, + "./global-search": { + "types": "./dist/global-search/index.d.ts", + "import": "./dist/global-search/index.js" + }, + "./global-search/*": { + "types": "./dist/global-search/*.d.ts", + "import": "./dist/global-search/*.js" + }, "./clipboard": { "types": "./dist/clipboard/index.d.ts", "import": "./dist/clipboard/index.js" diff --git a/resources/js/global-search/index.test.ts b/resources/js/global-search/index.test.ts new file mode 100644 index 00000000..347969df --- /dev/null +++ b/resources/js/global-search/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { globalSearchPlugin } from "./index"; + +describe("globalSearchPlugin", () => { + it("registers every global-search wire type", () => { + expect(Object.keys(globalSearchPlugin.components ?? {})).toEqual( + expect.arrayContaining([ + "global-search.root", + "global-search.input", + "global-search.categories", + "global-search.results", + "global-search.recent", + "global-search.preview", + ]), + ); + }); +}); diff --git a/resources/js/global-search/index.ts b/resources/js/global-search/index.ts new file mode 100644 index 00000000..4a28a252 --- /dev/null +++ b/resources/js/global-search/index.ts @@ -0,0 +1,38 @@ +import { createPlugin, eagerComponent } from "@lattice-php/lattice/core/registry"; +import GlobalSearch from "./components/global-search"; +import GlobalSearchCategories from "./components/categories"; +import GlobalSearchInput from "./components/input"; +import GlobalSearchPreview from "./components/preview"; +import GlobalSearchRecent from "./components/recent"; +import GlobalSearchResults from "./components/results"; + +export const globalSearchPlugin = createPlugin({ + name: "global-search", + components: { + "global-search.root": eagerComponent(GlobalSearch), + "global-search.input": eagerComponent(GlobalSearchInput), + "global-search.categories": eagerComponent(GlobalSearchCategories), + "global-search.results": eagerComponent(GlobalSearchResults), + "global-search.recent": eagerComponent(GlobalSearchRecent), + "global-search.preview": eagerComponent(GlobalSearchPreview), + }, +}); + +export { default as GlobalSearch } from "./components/global-search"; +export { default as GlobalSearchInput } from "./components/input"; +export { default as GlobalSearchCategories } from "./components/categories"; +export { default as GlobalSearchResults } from "./components/results"; +export { default as GlobalSearchRecent } from "./components/recent"; +export { default as GlobalSearchPreview } from "./components/preview"; +export { GlobalSearchProvider, useGlobalSearchContext } from "./context"; +export { useGlobalSearch } from "./use-global-search"; +export type { + SearchResult, + SearchCategory, + SearchPagination, + SearchResponse, + RecordResponse, + GlobalSearchStatus, + UseGlobalSearchOptions, + UseGlobalSearchReturn, +} from "./types"; diff --git a/resources/js/index.ts b/resources/js/index.ts index 309d2235..10d6fc63 100644 --- a/resources/js/index.ts +++ b/resources/js/index.ts @@ -1,6 +1,23 @@ export { registry } from "./registry"; export { useChat } from "./chat/use-chat"; export { chatPlugin } from "./chat"; +export { useGlobalSearch } from "./global-search/use-global-search"; +export { + GlobalSearch, + GlobalSearchInput, + GlobalSearchCategories, + GlobalSearchResults, + GlobalSearchRecent, + GlobalSearchPreview, + globalSearchPlugin, +} from "./global-search"; +export type { + SearchResult, + SearchCategory, + SearchPagination, + UseGlobalSearchOptions, + UseGlobalSearchReturn, +} from "./global-search/types"; export { getActionEffects, isActionEffect, diff --git a/resources/js/registry.ts b/resources/js/registry.ts index b24c5e7d..7e0ae618 100644 --- a/resources/js/registry.ts +++ b/resources/js/registry.ts @@ -3,6 +3,7 @@ import { actionComponents } from "./action"; import { chatPlugin } from "./chat"; import { coreComponents } from "./core/components"; import { formComponents } from "./form"; +import { globalSearchPlugin } from "./global-search"; import { remoteComponents } from "./remote"; import { layoutComponents } from "./layout/components"; import { tableComponents } from "./table"; @@ -14,5 +15,6 @@ export const registry = createRegistry( layoutComponents, tableComponents, chatPlugin, + globalSearchPlugin, remoteComponents, ); diff --git a/vite.config.ts b/vite.config.ts index c52c69db..751325b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,8 @@ const latticeIcons = [ "align-center", "align-justify", "align-left", "align-right", "bold", "code", "columns-3", "heading-1", "heading-2", "heading-3", "highlighter", "italic", "list", "list-ordered", "quote", "rows-3", "smile", "strikethrough", "table", "underline", + // Global-search category icons + "package", "receipt", "users", ]; function libraryEntries(): string[] { From 8f2167347209a825c40962a44ebaebe4581f8773 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:54:39 +0200 Subject: [PATCH 18/28] feat(global-search): add workbench providers, recorder, and layout wiring Co-Authored-By: Claude Opus 4.8 (1M context) --- lang/de/lattice.php | 11 +++ lang/en/lattice.php | 11 +++ .../GlobalSearch/WorkbenchProviderTest.php | 22 ++++++ workbench/app/Layouts/AppLayout.php | 4 + .../Providers/WorkbenchServiceProvider.php | 8 ++ .../Search/BusinessPartnerSearchProvider.php | 75 ++++++++++++++++++ .../app/Search/ProductSearchProvider.php | 77 +++++++++++++++++++ .../app/Search/SalesOrderSearchProvider.php | 75 ++++++++++++++++++ .../Search/SessionSearchHistoryRecorder.php | 39 ++++++++++ workbench/lang/de/workbench.php | 7 ++ workbench/lang/en/workbench.php | 7 ++ 11 files changed, 336 insertions(+) create mode 100644 tests/Feature/GlobalSearch/WorkbenchProviderTest.php create mode 100644 workbench/app/Search/BusinessPartnerSearchProvider.php create mode 100644 workbench/app/Search/ProductSearchProvider.php create mode 100644 workbench/app/Search/SalesOrderSearchProvider.php create mode 100644 workbench/app/Search/SessionSearchHistoryRecorder.php diff --git a/lang/de/lattice.php b/lang/de/lattice.php index 28c34805..67e71e21 100644 --- a/lang/de/lattice.php +++ b/lang/de/lattice.php @@ -110,6 +110,17 @@ 'send' => 'Senden', 'input-label' => 'Nachrichteneingabe', ], + 'globalSearch' => [ + 'placeholder' => 'Suchen…', + 'title' => 'Suche', + 'results' => 'Ergebnisse', + 'recent' => 'Zuletzt', + 'open' => 'Öffnen', + 'empty' => 'Keine Ergebnisse gefunden.', + 'loading' => 'Suche läuft…', + 'error' => 'Etwas ist schiefgelaufen.', + 'previewEmpty' => 'Ergebnis auswählen, um eine Vorschau anzuzeigen.', + ], 'remote' => [ 'data-list' => [ 'loading' => 'Lädt...', diff --git a/lang/en/lattice.php b/lang/en/lattice.php index 1cb8698a..45bde173 100644 --- a/lang/en/lattice.php +++ b/lang/en/lattice.php @@ -110,6 +110,17 @@ 'send' => 'Send', 'input-label' => 'Message input', ], + 'globalSearch' => [ + 'placeholder' => 'Search…', + 'title' => 'Search', + 'results' => 'Results', + 'recent' => 'Recent', + 'open' => 'Open', + 'empty' => 'No results found.', + 'loading' => 'Searching…', + 'error' => 'Something went wrong.', + 'previewEmpty' => 'Select a result to preview.', + ], 'remote' => [ 'data-list' => [ 'loading' => 'Loading...', diff --git a/tests/Feature/GlobalSearch/WorkbenchProviderTest.php b/tests/Feature/GlobalSearch/WorkbenchProviderTest.php new file mode 100644 index 00000000..3cf853da --- /dev/null +++ b/tests/Feature/GlobalSearch/WorkbenchProviderTest.php @@ -0,0 +1,22 @@ +create(['name' => 'Aurora Lamp', 'sku' => 'LAMP-1', 'status' => 'active']); + Product::factory()->create(['name' => 'Borealis Chair', 'sku' => 'CHAIR-1', 'status' => 'active']); + + actingAs(UserFactory::new()->create()); + + $response = getJson('/lattice/global-search?query=Aurora&category=products&counts=1'); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1) + ->and($response->json('data.0.item.title'))->toContain('Aurora') + ->and($response->json('data.0.category.name'))->toBe('products'); +}); diff --git a/workbench/app/Layouts/AppLayout.php b/workbench/app/Layouts/AppLayout.php index cd696846..5aa1b361 100644 --- a/workbench/app/Layouts/AppLayout.php +++ b/workbench/app/Layouts/AppLayout.php @@ -10,6 +10,7 @@ use Lattice\Lattice\Core\Components\Badge; use Lattice\Lattice\Core\Components\ChatBox; use Lattice\Lattice\Core\Components\FloatingPanel; +use Lattice\Lattice\Core\Components\GlobalSearch; use Lattice\Lattice\Core\Components\RawBlock; use Lattice\Lattice\Core\Components\Stack; use Lattice\Lattice\Core\Enums\Align; @@ -59,6 +60,9 @@ public function schema(PageSchema $schema, Request $request): PageSchema Stack::make('app-main') ->width(Width::Fill) ->schema([ + GlobalSearch::make('global-search') + ->placeholder(__('workbench.global-search.placeholder')) + ->title(__('workbench.global-search.title')), Breadcrumbs::make(), Outlet::make(), RawBlock::make('chat-scroll-clearance')->html(''), diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index f5bfaa81..22a36122 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -14,8 +14,10 @@ use Laravel\Boost\Install\SkillComposer; use Laravel\Boost\Support\Config; use Laravel\Roster\Roster; +use Lattice\Lattice\GlobalSearch\Contracts\SearchHistoryRecorder; use Lattice\Lattice\Support\TypeScript\TypeScriptProfile; use Workbench\App\Models\User; +use Workbench\App\Search\SessionSearchHistoryRecorder; use Workbench\App\Support\BoostConfig; use Workbench\App\Support\BoostGuidelineComposer; use Workbench\App\Support\BoostSkillComposer; @@ -40,6 +42,12 @@ public function register(): void // Rebind so lattice:typescript regenerates the package's own built-in types. $this->app->bind(TypeScriptProfile::class, BaseProfile::class); + if (! $this->app->runningUnitTests()) { + $this->app->bind( + SearchHistoryRecorder::class, + SessionSearchHistoryRecorder::class, + ); + } $this->useWorkbenchDatabase(); $this->readBoostConfigFromPackageRoot(); $this->serveLatticeTranslations(); diff --git a/workbench/app/Search/BusinessPartnerSearchProvider.php b/workbench/app/Search/BusinessPartnerSearchProvider.php new file mode 100644 index 00000000..face91de --- /dev/null +++ b/workbench/app/Search/BusinessPartnerSearchProvider.php @@ -0,0 +1,75 @@ +query($query->query)->count(); + } + + public function search(SearchQuery $query): SearchResults + { + $builder = $this->query($query->query); + $total = $builder->count(); + + $rows = $builder + ->forPage($query->page, $query->perPage) + ->get() + ->map(fn (BusinessPartner $partner): SearchResult => $this->toResult($partner)) + ->all(); + + return new SearchResults($rows, $total); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + $partner = BusinessPartner::query()->find($id); + + return $partner === null ? null : $this->toResult($partner); + } + + /** @return Builder */ + private function query(string $term): Builder + { + return BusinessPartner::query() + ->when($term !== '', function (Builder $builder) use ($term): void { + $builder->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%"); + }) + ->orderBy('name'); + } + + private function toResult(BusinessPartner $partner): SearchResult + { + return new SearchResult('business-partners', new SearchResultItem( + id: (string) $partner->getKey(), + title: $partner->name, + link: '/business-partners/'.$partner->getKey(), + subtitle: $partner->email, + )); + } +} diff --git a/workbench/app/Search/ProductSearchProvider.php b/workbench/app/Search/ProductSearchProvider.php new file mode 100644 index 00000000..bba06ffa --- /dev/null +++ b/workbench/app/Search/ProductSearchProvider.php @@ -0,0 +1,77 @@ +query($query->query)->count(); + } + + public function search(SearchQuery $query): SearchResults + { + $builder = $this->query($query->query); + $total = $builder->count(); + + $products = $builder + ->forPage($query->page, $query->perPage) + ->get(); + + $rows = $products->map(fn (Product $product): SearchResult => $this->toResult($product))->all(); + + return new SearchResults($rows, $total); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + $product = Product::query()->find($id); + + return $product === null ? null : $this->toResult($product); + } + + /** @return Builder */ + private function query(string $term): Builder + { + return Product::query() + ->when($term !== '', function ($builder) use ($term): void { + $builder->where('name', 'like', "%{$term}%")->orWhere('sku', 'like', "%{$term}%"); + }) + ->orderBy('name'); + } + + private function toResult(Product $product): SearchResult + { + return new SearchResult('products', new SearchResultItem( + id: (string) $product->getKey(), + title: $product->name, + link: '/products/'.$product->getKey(), + subtitle: $product->sku, + additionalInfo: null, + badge: $product->status === 'active' ? null : $product->status, + )); + } +} diff --git a/workbench/app/Search/SalesOrderSearchProvider.php b/workbench/app/Search/SalesOrderSearchProvider.php new file mode 100644 index 00000000..a266e6ea --- /dev/null +++ b/workbench/app/Search/SalesOrderSearchProvider.php @@ -0,0 +1,75 @@ +query($query->query)->count(); + } + + public function search(SearchQuery $query): SearchResults + { + $builder = $this->query($query->query); + $total = $builder->count(); + + $rows = $builder + ->with('businessPartner') + ->forPage($query->page, $query->perPage) + ->get() + ->map(fn (SalesOrder $order): SearchResult => $this->toResult($order)) + ->all(); + + return new SearchResults($rows, $total); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + $order = SalesOrder::query()->with('businessPartner')->find($id); + + return $order === null ? null : $this->toResult($order); + } + + /** @return Builder */ + private function query(string $term): Builder + { + return SalesOrder::query() + ->when($term !== '', fn (Builder $builder) => $builder->where('number', 'like', "%{$term}%")) + ->orderByDesc('id'); + } + + private function toResult(SalesOrder $order): SearchResult + { + return new SearchResult('sales-orders', new SearchResultItem( + id: (string) $order->getKey(), + title: $order->number, + link: '/sales-orders/'.$order->getKey(), + subtitle: $order->businessPartner?->name, + badge: $order->status->value, + )); + } +} diff --git a/workbench/app/Search/SessionSearchHistoryRecorder.php b/workbench/app/Search/SessionSearchHistoryRecorder.php new file mode 100644 index 00000000..0a5251fd --- /dev/null +++ b/workbench/app/Search/SessionSearchHistoryRecorder.php @@ -0,0 +1,39 @@ +session()->get(self::KEY, [])) + ->reject(fn (array $row): bool => $row['category'] === $result->category && $row['item']['id'] === $result->item->id) + ->prepend($result->jsonSerialize()) + ->take(10) + ->values() + ->all(); + + $request->session()->put(self::KEY, $recent); + + return true; + } + + public function recent(Request $request, int $limit): array + { + return collect($request->session()->get(self::KEY, [])) + ->take($limit) + ->map(fn (array $row): SearchResult => new SearchResult($row['category']['name'], new SearchResultItem( + id: $row['item']['id'], title: $row['item']['title'], link: $row['item']['link'], + subtitle: $row['item']['subtitle'] ?? null, additionalInfo: $row['item']['additionalInfo'] ?? null, badge: $row['item']['badge'] ?? null, + ))) + ->all(); + } +} diff --git a/workbench/lang/de/workbench.php b/workbench/lang/de/workbench.php index 42710674..4bec5d7f 100644 --- a/workbench/lang/de/workbench.php +++ b/workbench/lang/de/workbench.php @@ -2,6 +2,13 @@ declare(strict_types=1); return [ + 'global-search' => [ + 'placeholder' => 'Produkte, Partner, Aufträge suchen…', + 'title' => 'Suche', + 'products' => 'Produkte', + 'business-partners' => 'Geschäftspartner', + 'sales-orders' => 'Aufträge', + ], 'assistant' => [ 'placeholder' => 'Frag den Assistenten…', 'title' => 'Assistent', diff --git a/workbench/lang/en/workbench.php b/workbench/lang/en/workbench.php index ba804e05..408208e1 100644 --- a/workbench/lang/en/workbench.php +++ b/workbench/lang/en/workbench.php @@ -2,6 +2,13 @@ declare(strict_types=1); return [ + 'global-search' => [ + 'placeholder' => 'Search products, partners, orders…', + 'title' => 'Search', + 'products' => 'Products', + 'business-partners' => 'Business partners', + 'sales-orders' => 'Sales orders', + ], 'assistant' => [ 'placeholder' => 'Ask the assistant…', 'title' => 'Assistant', From a90812513d46cabbdab21207fb305c90041dff86 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 00:59:10 +0200 Subject: [PATCH 19/28] fix(global-search): correct recent dedup and bind workbench recorder unconditionally Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GlobalSearch/RecordEndpointTest.php | 3 ++ .../GlobalSearch/SessionRecorderTest.php | 42 +++++++++++++++++++ .../Providers/WorkbenchServiceProvider.php | 10 ++--- .../Search/SessionSearchHistoryRecorder.php | 2 +- 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/GlobalSearch/SessionRecorderTest.php diff --git a/tests/Feature/GlobalSearch/RecordEndpointTest.php b/tests/Feature/GlobalSearch/RecordEndpointTest.php index 4bbbd554..fb6b41c6 100644 --- a/tests/Feature/GlobalSearch/RecordEndpointTest.php +++ b/tests/Feature/GlobalSearch/RecordEndpointTest.php @@ -1,6 +1,8 @@ '7', 'title' => 'Canonical Widget']]; discoverFixtures(); + app()->bind(SearchHistoryRecorder::class, NullSearchHistoryRecorder::class); actingAs(UserFactory::new()->create()); }); diff --git a/tests/Feature/GlobalSearch/SessionRecorderTest.php b/tests/Feature/GlobalSearch/SessionRecorderTest.php new file mode 100644 index 00000000..def4f58a --- /dev/null +++ b/tests/Feature/GlobalSearch/SessionRecorderTest.php @@ -0,0 +1,42 @@ +setLaravelSession(new Store('test', new ArraySessionHandler(120))); + + return $request; +} + +test('records a selection and reads it back', function () { + $request = recorderRequest(); + $recorder = new SessionSearchHistoryRecorder; + $result = new SearchResult('products', new SearchResultItem(id: '1', title: 'Widget', link: '/products/1')); + + expect($recorder->record($request, $result))->toBeTrue(); + + $recent = $recorder->recent($request, 10); + expect($recent)->toHaveCount(1) + ->and($recent[0])->toBeInstanceOf(SearchResult::class) + ->and($recent[0]->item->id)->toBe('1') + ->and($recent[0]->category)->toBe('products'); +}); + +test('recording the same selection twice dedupes to one entry', function () { + $request = recorderRequest(); + $recorder = new SessionSearchHistoryRecorder; + $result = new SearchResult('products', new SearchResultItem(id: '1', title: 'Widget', link: '/products/1')); + + $recorder->record($request, $result); + $recorder->record($request, $result); + + expect($recorder->recent($request, 10))->toHaveCount(1); +}); diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 22a36122..8c44a35f 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -42,12 +42,10 @@ public function register(): void // Rebind so lattice:typescript regenerates the package's own built-in types. $this->app->bind(TypeScriptProfile::class, BaseProfile::class); - if (! $this->app->runningUnitTests()) { - $this->app->bind( - SearchHistoryRecorder::class, - SessionSearchHistoryRecorder::class, - ); - } + $this->app->bind( + SearchHistoryRecorder::class, + SessionSearchHistoryRecorder::class, + ); $this->useWorkbenchDatabase(); $this->readBoostConfigFromPackageRoot(); $this->serveLatticeTranslations(); diff --git a/workbench/app/Search/SessionSearchHistoryRecorder.php b/workbench/app/Search/SessionSearchHistoryRecorder.php index 0a5251fd..7e620ebf 100644 --- a/workbench/app/Search/SessionSearchHistoryRecorder.php +++ b/workbench/app/Search/SessionSearchHistoryRecorder.php @@ -15,7 +15,7 @@ final class SessionSearchHistoryRecorder implements SearchHistoryRecorder public function record(Request $request, SearchResult $result): bool { $recent = collect($request->session()->get(self::KEY, [])) - ->reject(fn (array $row): bool => $row['category'] === $result->category && $row['item']['id'] === $result->item->id) + ->reject(fn (array $row): bool => $row['category']['name'] === $result->category && $row['item']['id'] === $result->item->id) ->prepend($result->jsonSerialize()) ->take(10) ->values() From 62592e78669eadd791bb5f8d3985b74cd86d1026 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 01:03:06 +0200 Subject: [PATCH 20/28] fix(global-search): satisfy phpstan in session recorder and recorder test Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Feature/GlobalSearch/SessionRecorderTest.php | 5 ++--- workbench/app/Search/SessionSearchHistoryRecorder.php | 9 +++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/Feature/GlobalSearch/SessionRecorderTest.php b/tests/Feature/GlobalSearch/SessionRecorderTest.php index def4f58a..01c907bd 100644 --- a/tests/Feature/GlobalSearch/SessionRecorderTest.php +++ b/tests/Feature/GlobalSearch/SessionRecorderTest.php @@ -24,9 +24,8 @@ function recorderRequest(): Request expect($recorder->record($request, $result))->toBeTrue(); $recent = $recorder->recent($request, 10); - expect($recent)->toHaveCount(1) - ->and($recent[0])->toBeInstanceOf(SearchResult::class) - ->and($recent[0]->item->id)->toBe('1') + expect($recent)->toHaveCount(1); + expect($recent[0]->item->id)->toBe('1') ->and($recent[0]->category)->toBe('products'); }); diff --git a/workbench/app/Search/SessionSearchHistoryRecorder.php b/workbench/app/Search/SessionSearchHistoryRecorder.php index 7e620ebf..5f0664dc 100644 --- a/workbench/app/Search/SessionSearchHistoryRecorder.php +++ b/workbench/app/Search/SessionSearchHistoryRecorder.php @@ -14,7 +14,9 @@ final class SessionSearchHistoryRecorder implements SearchHistoryRecorder public function record(Request $request, SearchResult $result): bool { - $recent = collect($request->session()->get(self::KEY, [])) + /** @var list $stored */ + $stored = $request->session()->get(self::KEY, []); + $recent = collect($stored) ->reject(fn (array $row): bool => $row['category']['name'] === $result->category && $row['item']['id'] === $result->item->id) ->prepend($result->jsonSerialize()) ->take(10) @@ -28,7 +30,10 @@ public function record(Request $request, SearchResult $result): bool public function recent(Request $request, int $limit): array { - return collect($request->session()->get(self::KEY, [])) + /** @var list $stored */ + $stored = $request->session()->get(self::KEY, []); + + return collect($stored) ->take($limit) ->map(fn (array $row): SearchResult => new SearchResult($row['category']['name'], new SearchResultItem( id: $row['item']['id'], title: $row['item']['title'], link: $row['item']['link'], From abe6ffe01f8e44ddb90e041ab57a2ec36a9661db Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 01:05:03 +0200 Subject: [PATCH 21/28] test(global-search): update workbench layout assertions for the search box Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Feature/Http/PageTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Http/PageTest.php b/tests/Feature/Http/PageTest.php index 7967a8e9..c102c601 100644 --- a/tests/Feature/Http/PageTest.php +++ b/tests/Feature/Http/PageTest.php @@ -120,8 +120,9 @@ ->where('lattice.layout.schema.0.schema.0.schema.0.schema.0.schema.1.schema.1.schema.2.props.href', '/builder-table') ->where('lattice.layout.schema.0.schema.0.schema.0.schema.1.schema.0.type', 'menu-item') ->where('lattice.layout.schema.0.schema.0.schema.0.schema.1.schema.0.props.label', 'Log out') - ->where('lattice.layout.schema.0.schema.1.schema.0.type', 'breadcrumbs') - ->where('lattice.layout.schema.0.schema.1.schema.1.type', 'outlet') + ->where('lattice.layout.schema.0.schema.1.schema.0.type', 'global-search.root') + ->where('lattice.layout.schema.0.schema.1.schema.1.type', 'breadcrumbs') + ->where('lattice.layout.schema.0.schema.1.schema.2.type', 'outlet') ->where('lattice.layout.schema.1.type', 'floating-panel') ->where('lattice.layout.schema.1.key', 'locale-switcher-panel') ->where('lattice.layout.schema.1.props.label', 'Language') From 4b6da6402432969597f5faa7ebaee23bc26ce9f6 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 17 Jun 2026 01:07:51 +0200 Subject: [PATCH 22/28] fix(global-search): satisfy frontend lint and type-coverage Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 265 +----------------- .../global-search/components/categories.tsx | 12 +- .../components/global-search.test.tsx | 27 +- .../components/global-search.tsx | 14 +- .../js/global-search/components/input.tsx | 1 + .../js/global-search/components/preview.tsx | 16 +- .../global-search/components/result-row.tsx | 20 +- .../global-search/components/results.test.tsx | 48 +++- .../js/global-search/components/results.tsx | 32 ++- .../global-search/components/slots.test.tsx | 44 ++- resources/js/global-search/context.tsx | 8 +- resources/js/global-search/types.ts | 29 +- .../global-search/use-global-search.test.ts | 98 +++++-- .../js/global-search/use-global-search.ts | 27 +- 14 files changed, 316 insertions(+), 325 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec2c76f2..661a1b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lattice-php/lattice", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lattice-php/lattice", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@lattice-php/vite-svg-sprite": "^0.2.1", @@ -279,9 +279,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -299,9 +296,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -319,9 +313,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -339,9 +330,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2484,9 +2472,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2504,9 +2489,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2524,9 +2506,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2544,9 +2523,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2564,9 +2540,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2584,9 +2557,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2604,9 +2574,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2624,9 +2591,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2644,9 +2608,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2670,9 +2631,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2696,9 +2654,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2722,9 +2677,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2748,9 +2700,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2774,9 +2723,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2800,9 +2746,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2826,9 +2769,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3615,9 +3555,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3635,9 +3572,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3655,9 +3589,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3675,9 +3606,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3695,9 +3623,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3715,9 +3640,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3735,9 +3657,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3755,9 +3674,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3981,9 +3897,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3998,9 +3911,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4015,9 +3925,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4032,9 +3939,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4049,9 +3953,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4066,9 +3967,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4083,9 +3981,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4100,9 +3995,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4308,9 +4200,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4328,9 +4217,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4348,9 +4234,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4368,9 +4251,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4388,9 +4268,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4408,9 +4285,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4428,9 +4302,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4448,9 +4319,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4655,9 +4523,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4675,9 +4540,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4695,9 +4557,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4715,9 +4574,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4735,9 +4591,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4755,9 +4608,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4775,9 +4625,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4795,9 +4642,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5781,9 +5625,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5801,9 +5642,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5821,9 +5659,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5841,9 +5676,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5861,9 +5693,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5881,9 +5710,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6103,9 +5929,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6120,9 +5943,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6137,9 +5957,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6154,9 +5971,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6171,9 +5985,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6188,9 +5999,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6205,9 +6013,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6222,9 +6027,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6239,9 +6041,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6256,9 +6055,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6273,9 +6069,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6290,9 +6083,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6307,9 +6097,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6883,9 +6670,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6903,9 +6687,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6923,9 +6704,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6943,9 +6721,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11586,9 +11361,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11610,9 +11382,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11634,9 +11403,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11658,9 +11424,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14244,9 +14007,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14264,9 +14024,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -14284,9 +14041,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14304,9 +14058,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14324,9 +14075,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -14344,9 +14092,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14364,9 +14109,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14384,9 +14126,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/resources/js/global-search/components/categories.tsx b/resources/js/global-search/components/categories.tsx index 84e40325..cdd21179 100644 --- a/resources/js/global-search/components/categories.tsx +++ b/resources/js/global-search/components/categories.tsx @@ -14,16 +14,22 @@ const GlobalSearchCategories: RendererComponent<"global-search.categories"> = () aria-pressed={category.name === activeCategory} className={cn( "flex items-center justify-between gap-2 rounded-lt-sm px-3 py-2 text-left text-sm", - category.name === activeCategory ? "bg-lt-muted text-lt-fg" : "text-lt-muted-fg hover:bg-lt-muted/60", + category.name === activeCategory + ? "bg-lt-muted text-lt-fg" + : "text-lt-muted-fg hover:bg-lt-muted/60", )} onClick={() => setCategory(category.name)} type="button" > - {category.icon ? - {category.count !== null ? {category.count} : null} + {category.count !== null ? ( + {category.count} + ) : null} ))}
diff --git a/resources/js/global-search/components/global-search.test.tsx b/resources/js/global-search/components/global-search.test.tsx index 5849a7eb..d4f608bf 100644 --- a/resources/js/global-search/components/global-search.test.tsx +++ b/resources/js/global-search/components/global-search.test.tsx @@ -5,14 +5,37 @@ import GlobalSearch from "./global-search"; afterEach(() => vi.unstubAllGlobals()); function renderRoot() { - const node = { type: "global-search.root", props: { endpoint: "/lattice/global-search", placeholder: "Search…", title: "Search", shortcut: true, perPage: 20 } } as never; + const node = { + type: "global-search.root", + props: { + endpoint: "/lattice/global-search", + placeholder: "Search…", + title: "Search", + shortcut: true, + perPage: 20, + }, + } as never; return render({null}); } describe("GlobalSearch root", () => { it("opens on Cmd+K and shows the default composition", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ data: [], categories: [], pagination: { page: 1, perPage: 20, total: 0, hasMore: false, nextPage: null }, state: { query: "", category: null, perPage: 20, countsIncluded: true } }), { status: 200 }))); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + data: [], + categories: [], + pagination: { page: 1, perPage: 20, total: 0, hasMore: false, nextPage: null }, + state: { query: "", category: null, perPage: 20, countsIncluded: true }, + }), + { status: 200 }, + ), + ), + ); renderRoot(); fireEvent.keyDown(window, { key: "k", metaKey: true }); diff --git a/resources/js/global-search/components/global-search.tsx b/resources/js/global-search/components/global-search.tsx index ad65a034..12f4c699 100644 --- a/resources/js/global-search/components/global-search.tsx +++ b/resources/js/global-search/components/global-search.tsx @@ -47,7 +47,10 @@ const GlobalSearch: RendererComponent<"global-search.root"> = ({ node, children const { endpoint, placeholder, title, shortcut, perPage } = node.props; const { t } = useT("lattice"); const [open, setOpen] = useState(false); - const search = useGlobalSearch({ endpoint: endpoint ?? "/lattice/global-search", perPage: perPage ?? 20 }); + const search = useGlobalSearch({ + endpoint: endpoint ?? "/lattice/global-search", + perPage: perPage ?? 20, + }); useEffect(() => { if (shortcut === false) { @@ -86,11 +89,16 @@ const GlobalSearch: RendererComponent<"global-search.root"> = ({ node, children type="button" >