Skip to content
16 changes: 16 additions & 0 deletions resources/lang/en/search.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

return [
'title' => 'Search',
'short_placeholder' => 'Search',
'placeholder' => 'Search admin content',
'min_length' => 'Enter at least two characters.',
'empty' => 'No results found.',
'no_results' => 'No results',
'view_all' => 'View all results',
'types' => [
'page' => 'Page',
'user' => 'User',
'setting' => 'Setting',
],
];
8 changes: 4 additions & 4 deletions resources/views/search/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
type="search"
wire:model.live.debounce.250ms="query"
@focus="open = true"
:placeholder="__('Search')"
aria-label="{{ __('Search') }}"
:placeholder="__('lazy-admin::search.short_placeholder')"
aria-label="{{ __('lazy-admin::search.short_placeholder') }}"
class="w-72"
minlength="2"
maxlength="100"
Expand Down Expand Up @@ -32,15 +32,15 @@ class="h-auto w-full justify-start px-3 py-2 text-left"
</span>
</x-lazy-btn>
@empty
<x-lazy-empty-state :title="__('No results')" class="border-0 p-3 shadow-none" />
<x-lazy-empty-state :title="__('lazy-admin::search.no_results')" class="border-0 p-3 shadow-none" />
@endforelse

<x-lazy-btn
:href="route(trim((string) config('lazy.admin.route.name', 'admin.'), '.').'.search', ['q' => $query])"
ghost
sm
block
:label="__('View all results')"
:label="__('lazy-admin::search.view_all')"
class="mt-1"
/>
</x-lazy-card>
Expand Down
8 changes: 4 additions & 4 deletions resources/views/search/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
wire:model.live.debounce.250ms="query"
minlength="2"
maxlength="100"
:placeholder="__('Search admin content')"
aria-label="{{ __('Search admin content') }}"
:placeholder="__('lazy-admin::search.placeholder')"
aria-label="{{ __('lazy-admin::search.placeholder') }}"
autofocus
/>
<div wire:loading wire:target="query" class="mt-2 text-sm opacity-60">
Expand All @@ -15,9 +15,9 @@
</div>

@if(mb_strlen($query) < 2)
<x-lazy-alert :message="__('Enter at least two characters.')" />
<x-lazy-alert :message="__('lazy-admin::search.min_length')" />
@elseif($results === [])
<x-lazy-empty-state :title="__('No results found.')" />
<x-lazy-empty-state :title="__('lazy-admin::search.empty')" />
@else
<div class="flex flex-col gap-1 rounded-2xl border border-base-300 bg-base-100 p-2">
@foreach($results as $result)
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Livewire/Search/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Livewire\Component;
use Step2dev\LazyAdmin\Search\SearchRegistry;

#[Layout('lazy::livewire-layout', ['title' => 'Search'])]
#[Layout('lazy::livewire-layout', ['title' => 'lazy-admin::search.title'])]
class Page extends Component
{
#[Url(as: 'q', history: true)]
Expand Down
14 changes: 10 additions & 4 deletions src/LazyAdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ public function packageBooted(): void
: $page->slug,
'description' => $page->path(),
'url' => route($prefix.'.page.index', ['search' => $page->slug]),
'type' => __('Page'),
'type' => __('lazy-admin::search.types.page'),
];
}

Expand All @@ -342,6 +342,10 @@ public function packageBooted(): void
return [];
}

$showRoute = $prefix.'.user.show';
$indexRoute = $prefix.'.user.index';
$hasShowRoute = RouteFacade::has($showRoute);

return $model::query()
->where(fn ($builder) => $builder
->where('name', 'like', '%'.$query.'%')
Expand All @@ -351,8 +355,10 @@ public function packageBooted(): void
->map(fn ($user): array => [
'title' => (string) $user->getAttribute('name'),
'description' => (string) $user->getAttribute('email'),
'url' => route($prefix.'.user.show', $user),
'type' => __('User'),
'url' => $hasShowRoute
? route($showRoute, $user->getKey())
: route($indexRoute),
'type' => __('lazy-admin::search.types.user'),
])
->all();
},
Expand All @@ -372,7 +378,7 @@ public function packageBooted(): void
'title' => $section['label'],
'description' => $section['description'],
'url' => route($prefix.'.setting.index', ['section' => $section['id']]),
'type' => __('Setting'),
'type' => __('lazy-admin::search.types.setting'),
])
->values()
->all();
Expand Down
63 changes: 58 additions & 5 deletions src/Search/SearchRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Step2dev\LazyAdmin\Search;

use Closure;
use Stringable;

class SearchRegistry
{
Expand Down Expand Up @@ -36,23 +37,75 @@ public function search(string $query, ?object $user, int $limitPerProvider = 8):
$items = ($definition['provider'])($query, $limitPerProvider);

foreach ($items as $item) {
if (! is_array($item) || empty($item['title']) || empty($item['url'])) {
if (! is_array($item)) {
continue;
}

$title = $this->stringValue($item['title'] ?? null);
$url = $this->stringValue($item['url'] ?? null);

if ($title === null || $url === null) {
continue;
}

$results[] = [
'provider' => $definition['id'],
'title' => (string) $item['title'],
'url' => (string) $item['url'],
'description' => isset($item['description']) ? (string) $item['description'] : null,
'type' => isset($item['type']) ? (string) $item['type'] : null,
'title' => $title,
'url' => $url,
'description' => $this->stringValue($item['description'] ?? null),
'type' => $this->stringValue($item['type'] ?? null),
];
}
}

return $results;
}

private function stringValue(mixed $value): ?string
{
if (is_string($value)) {
$value = trim($value);

return $value !== '' ? $value : null;
}

if (is_int($value) || is_float($value)) {
return (string) $value;
}

if ($value instanceof Stringable) {
$value = trim((string) $value);

return $value !== '' ? $value : null;
}

if (! is_array($value)) {
return null;
}

$locales = array_values(array_unique(array_filter([
app()->getLocale(),
config('app.fallback_locale'),
'en',
], 'is_string')));

foreach ($locales as $locale) {
$candidate = $value[$locale] ?? null;

if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}

foreach ($value as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}

return null;
}

private function allowed(?object $user, ?string $permission): bool
{
if ($permission === null || ! config('lazy.admin.permissions.enforce', true)) {
Expand Down
39 changes: 39 additions & 0 deletions tests/Feature/AdminPlatformTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,45 @@
->and($results[0]['url'])->toBe('/orders/1');
});

it('normalizes localized array values returned by search providers', function (): void {
app()->setLocale('uk');
config()->set('app.fallback_locale', 'en');

$registry = new SearchRegistry;

$registry->register(
id: 'localized',
provider: fn (string $query, int $limit): array => [[
'title' => ['uk' => 'Сторінка', 'en' => 'Page'],
'url' => '/pages/1',
'description' => ['uk' => 'Опис', 'en' => 'Description'],
'type' => ['uk' => 'Сторінка', 'en' => 'Page'],
]],
);

$results = $registry->search('page', null);

expect($results)
->toHaveCount(1)
->and($results[0]['title'])->toBe('Сторінка')
->and($results[0]['description'])->toBe('Опис')
->and($results[0]['type'])->toBe('Сторінка');
});

it('skips malformed search results instead of casting arrays blindly', function (): void {
$registry = new SearchRegistry;

$registry->register(
id: 'malformed',
provider: fn (string $query, int $limit): array => [[
'title' => ['nested' => ['value']],
'url' => '/pages/1',
]],
);

expect($registry->search('page', null))->toBe([]);
});

it('allows modules to register settings sections', function (): void {
$registry = new SettingsRegistry;

Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/HeaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@

expect($html)
->toContain('menu-toggle')
->toContain('placeholder="'.__('Search').'"')
->toContain('placeholder="'.__('lazy-admin::search.short_placeholder').'"')
->toContain(config('lazy.admin.logo', '/main.svg'));
});
Loading