Skip to content

Commit 4631fe1

Browse files
authored
Merge pull request #632 from CyanFox/v4-develop
2 parents 8c862fc + 6a1fe2e commit 4631fe1

31 files changed

+4534
-1316
lines changed

.github/workflows/fix-php-code-style-issues.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ name: Fix PHP code style issues
22

33
on:
44
push:
5+
branches:
6+
- v4
57
paths:
68
- '**.php'
79

.github/workflows/phpstan.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ name: PHPStan
22

33
on:
44
push:
5+
branches:
6+
- v4
57
pull_request:
68
branches:
7-
- v4-develop
89
- v4
910
workflow_dispatch:
1011

app/Livewire/SpotlightSearch.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace App\Livewire;
4+
5+
use Livewire\Attributes\On;
6+
use Livewire\Component;
7+
8+
class SpotlightSearch extends Component
9+
{
10+
public $searchTerm = '';
11+
public $isOpen = false;
12+
public $selectedIndex = 0;
13+
public $results = [];
14+
public $staticItems = [];
15+
16+
protected $listeners = [
17+
'openSpotlight' => 'open',
18+
'closeSpotlight' => 'close',
19+
];
20+
21+
public function mount()
22+
{
23+
$this->loadStaticItems();
24+
}
25+
26+
public function updatedSearchTerm()
27+
{
28+
if (empty($this->searchTerm)) {
29+
$this->loadStaticItems();
30+
$this->selectedIndex = null;
31+
} else {
32+
$this->search();
33+
$this->selectedIndex = 0;
34+
}
35+
}
36+
37+
public function loadStaticItems()
38+
{
39+
$spotlightService = app('spotlight');
40+
$this->staticItems = $spotlightService->getStaticItems();
41+
$this->results = [];
42+
}
43+
44+
public function search()
45+
{
46+
if (empty($this->searchTerm)) {
47+
$this->loadStaticItems();
48+
return;
49+
}
50+
51+
$spotlightService = app('spotlight');
52+
$this->results = $spotlightService->search($this->searchTerm);
53+
$this->staticItems = [];
54+
}
55+
56+
public function selectNextResult()
57+
{
58+
$items = $this->getDisplayItems();
59+
if (count($items) > 0) {
60+
if ($this->selectedIndex === null) {
61+
$this->selectedIndex = 0;
62+
} else {
63+
$this->selectedIndex = ($this->selectedIndex + 1) % count($items);
64+
}
65+
}
66+
}
67+
68+
public function selectPreviousResult()
69+
{
70+
$items = $this->getDisplayItems();
71+
if (count($items) > 0) {
72+
if ($this->selectedIndex === null) {
73+
$this->selectedIndex = count($items) - 1;
74+
} else {
75+
$this->selectedIndex = ($this->selectedIndex - 1 + count($items)) % count($items);
76+
}
77+
}
78+
}
79+
80+
public function selectResult($index = null)
81+
{
82+
$index = $index ?? $this->selectedIndex;
83+
$items = $this->getDisplayItems();
84+
85+
if (isset($items[$index])) {
86+
$result = $items[$index];
87+
$this->close();
88+
89+
$this->redirect($result['url']);
90+
}
91+
}
92+
93+
#[On('open-spotlight')]
94+
public function open()
95+
{
96+
$this->isOpen = true;
97+
$this->searchTerm = '';
98+
$this->loadStaticItems();
99+
$this->selectedIndex = null;
100+
101+
$this->dispatch('spotlight-opened');
102+
}
103+
104+
#[On('close-spotlight')]
105+
public function close()
106+
{
107+
$this->isOpen = false;
108+
$this->dispatch('spotlight-closed');
109+
}
110+
111+
public function getDisplayItems()
112+
{
113+
$items = empty($this->searchTerm) ? $this->staticItems : $this->results;
114+
return array_slice($items, 0, settings('internal.spotlight.results_limit', config('settings.spotlight_result_limit', 10)));
115+
}
116+
117+
public function render()
118+
{
119+
if (empty($this->searchTerm) && $this->isOpen) {
120+
$this->loadStaticItems();
121+
}
122+
123+
return view('livewire.spotlight-search');
124+
}
125+
}

app/Providers/AppServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Providers;
44

5+
use App\Services\SpotlightService;
56
use App\Services\ViewIntegrationService;
67
use Filament\Notifications\Livewire\Notifications;
78
use Filament\Notifications\Notification;
@@ -22,6 +23,10 @@ public function register(): void
2223
$this->app->singleton(ViewIntegrationService::class, function () {
2324
return new ViewIntegrationService;
2425
});
26+
27+
$this->app->singleton('spotlight', function ($app) {
28+
return new SpotlightService();
29+
});
2530
}
2631

2732
public function boot(): void

app/Services/SpotlightService.php

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use InvalidArgumentException;
6+
7+
class SpotlightService
8+
{
9+
protected array $registeredModels = [];
10+
protected array $manualItems = [];
11+
protected array $staticItems = [];
12+
13+
public function registerModel(string $modelClass): void
14+
{
15+
if (!in_array($modelClass, $this->registeredModels)) {
16+
$this->registeredModels[] = $modelClass;
17+
}
18+
}
19+
20+
public function addItem(array $item): void
21+
{
22+
if (!isset($item['title']) || !isset($item['url'])) {
23+
throw new InvalidArgumentException('Spotlight items must have at least a title and URL');
24+
}
25+
26+
$item['description'] = $item['description'] ?? '';
27+
$item['icon'] = $item['icon'] ?? 'link';
28+
$item['id'] = $item['id'] ?? 'manual-' . count($this->manualItems);
29+
$item['model_type'] = $item['model_type'] ?? 'manual';
30+
$item['module'] = $item['module'] ?? null;
31+
$item['permissions'] = $item['permissions'] ?? null;
32+
33+
if (isset($item['module']) && is_string($item['module']) && $this->isTranslationKey($item['module'])) {
34+
$item['raw_module'] = $item['module'];
35+
$item['module'] = __($item['module']);
36+
}
37+
38+
$this->manualItems[] = $item;
39+
}
40+
41+
public function addStaticItem(array $item): void
42+
{
43+
if (!isset($item['title']) || !isset($item['url'])) {
44+
throw new InvalidArgumentException('Spotlight items must have at least a title and URL');
45+
}
46+
47+
$item['icon'] = $item['icon'] ?? 'link';
48+
$item['id'] = $item['id'] ?? 'static-' . count($this->staticItems);
49+
$item['permissions'] = $item['permissions'] ?? null;
50+
$item['module'] = $item['module'] ?? null;
51+
52+
if (isset($item['module']) && is_string($item['module']) && $this->isTranslationKey($item['module'])) {
53+
$item['raw_module'] = $item['module'];
54+
$item['module'] = __($item['module']);
55+
}
56+
57+
if (is_string($item['title']) && $this->isTranslationKey($item['title'])) {
58+
$item['raw_title'] = $item['title'];
59+
$item['title'] = __($item['title']);
60+
}
61+
62+
if (isset($item['description']) && is_string($item['description']) && $this->isTranslationKey($item['description'])) {
63+
$item['raw_description'] = $item['description'];
64+
$item['description'] = __($item['description']);
65+
}
66+
67+
$this->staticItems[] = $item;
68+
}
69+
70+
protected function isTranslationKey(string $text): bool
71+
{
72+
return str_contains($text, '::') ||
73+
preg_match('/^[a-z0-9_]+\.[a-z0-9_.]+$/i', $text);
74+
}
75+
76+
protected function refreshTranslations(array &$item): void
77+
{
78+
if (isset($item['raw_module'])) {
79+
$item['module'] = __($item['raw_module']);
80+
}
81+
82+
if (isset($item['raw_title'])) {
83+
$item['title'] = __($item['raw_title']);
84+
}
85+
86+
if (isset($item['raw_description'])) {
87+
$item['description'] = __($item['raw_description']);
88+
}
89+
}
90+
91+
public function getRegisteredModels(): array
92+
{
93+
return $this->registeredModels;
94+
}
95+
96+
public function getManualItems(): array
97+
{
98+
$items = $this->manualItems;
99+
100+
foreach ($items as &$item) {
101+
$this->refreshTranslations($item);
102+
}
103+
104+
return $items;
105+
}
106+
107+
public function getStaticItems(): array
108+
{
109+
$items = array_filter($this->staticItems, [$this, 'userCanViewItem']);
110+
111+
foreach ($items as &$item) {
112+
$this->refreshTranslations($item);
113+
}
114+
115+
return $items;
116+
}
117+
118+
protected function userCanViewItem(array $item): bool
119+
{
120+
if (empty($item['permissions'])) {
121+
return true;
122+
}
123+
124+
$user = auth()->user();
125+
if (!$user) {
126+
return false;
127+
}
128+
129+
if (is_array($item['permissions'])) {
130+
foreach ($item['permissions'] as $permission) {
131+
if ($user->can($permission)) { // @phpstan-ignore-line
132+
return true;
133+
}
134+
}
135+
} else {
136+
return $user->can($item['permissions']); // @phpstan-ignore-line
137+
}
138+
139+
return false;
140+
}
141+
142+
public function search(string $term): array
143+
{
144+
if (empty($term)) {
145+
return [];
146+
}
147+
148+
$results = [];
149+
$searchTerm = strtolower($term);
150+
151+
foreach ($this->registeredModels as $modelClass) {
152+
$model = new $modelClass;
153+
$modelResults = $modelClass::spotlightSearch($term)
154+
->get()
155+
->filter(function ($item) {
156+
return $item->userCanViewInSpotlight();
157+
})
158+
->map(function ($item) {
159+
return $item->toSpotlightResult();
160+
});
161+
162+
$results = array_merge($results, $modelResults->toArray());
163+
}
164+
165+
$manualItems = $this->getManualItems();
166+
167+
foreach ($manualItems as $item) {
168+
$matchFound = false;
169+
170+
if (str_contains(strtolower($item['title']), $searchTerm)) {
171+
$matchFound = true;
172+
}
173+
174+
if (!empty($item['description']) && str_contains(strtolower($item['description']), $searchTerm)) {
175+
$matchFound = true;
176+
}
177+
178+
if (!empty($item['module']) && str_contains(strtolower($item['module']), $searchTerm)) {
179+
$matchFound = true;
180+
}
181+
182+
if ($matchFound && $this->userCanViewItem($item)) {
183+
$results[] = $item;
184+
}
185+
}
186+
187+
usort($results, function ($a, $b) use ($searchTerm) {
188+
$aTitle = strtolower($a['title']);
189+
$bTitle = strtolower($b['title']);
190+
191+
if ($aTitle === $searchTerm && $bTitle !== $searchTerm) {
192+
return -1;
193+
}
194+
if ($bTitle === $searchTerm && $aTitle !== $searchTerm) {
195+
return 1;
196+
}
197+
198+
if (str_starts_with($aTitle, $searchTerm) && !str_starts_with($bTitle, $searchTerm)) {
199+
return -1;
200+
}
201+
if (str_starts_with($bTitle, $searchTerm) && !str_starts_with($aTitle, $searchTerm)) {
202+
return 1;
203+
}
204+
205+
$aModule = strtolower($a['module'] ?? '');
206+
$bModule = strtolower($b['module'] ?? '');
207+
if ($aModule === $searchTerm && $bModule !== $searchTerm) {
208+
return -1;
209+
}
210+
if ($bModule === $searchTerm && $aModule !== $searchTerm) {
211+
return 1;
212+
}
213+
214+
return strcmp($aTitle, $bTitle);
215+
});
216+
217+
return array_slice($results, 0);
218+
}
219+
}

0 commit comments

Comments
 (0)