Skip to content

Commit 3a71cd0

Browse files
committed
Add global quick search across projects, requests, and submittals
1 parent 42847d1 commit 3a71cd0

4 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\DrawingRequest;
6+
use App\Models\DrawingSubmittal;
7+
use App\Models\Project;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
11+
class GlobalSearchController extends Controller
12+
{
13+
public function __invoke(Request $request): JsonResponse
14+
{
15+
$query = trim((string) $request->string('q'));
16+
17+
if ($query === '') {
18+
return response()->json(['data' => []]);
19+
}
20+
21+
$projects = Project::query()
22+
->with('customer:id,name')
23+
->where(function ($q) use ($query): void {
24+
$q->where('project_number', 'like', "%{$query}%")
25+
->orWhere('name', 'like', "%{$query}%");
26+
})
27+
->orderByDesc('updated_at')
28+
->limit(5)
29+
->get()
30+
->map(fn (Project $project): array => [
31+
'type' => 'project',
32+
'title' => $project->project_number.' · '.$project->name,
33+
'subtitle' => $project->customer?->name,
34+
'url' => route('projects.show', $project),
35+
]);
36+
37+
$requests = DrawingRequest::query()
38+
->with('project:id,project_number,name')
39+
->where(function ($q) use ($query): void {
40+
$q->where('request_number', 'like', "%{$query}%")
41+
->orWhere('title', 'like', "%{$query}%")
42+
->orWhere('job_number', 'like', "%{$query}%");
43+
})
44+
->orderByDesc('updated_at')
45+
->limit(5)
46+
->get()
47+
->map(fn (DrawingRequest $drawingRequest): array => [
48+
'type' => 'drawing_request',
49+
'title' => $drawingRequest->request_number.' · '.$drawingRequest->title,
50+
'subtitle' => $drawingRequest->project?->project_number,
51+
'url' => route('drawing-requests.show', $drawingRequest),
52+
]);
53+
54+
$submittals = DrawingSubmittal::query()
55+
->with('project:id,project_number,name')
56+
->where(function ($q) use ($query): void {
57+
$q->where('submittal_number', 'like', "%{$query}%")
58+
->orWhere('revision', 'like', "%{$query}%");
59+
})
60+
->orderByDesc('updated_at')
61+
->limit(5)
62+
->get()
63+
->map(fn (DrawingSubmittal $submittal): array => [
64+
'type' => 'submittal',
65+
'title' => $submittal->submittal_number.' · Rev '.$submittal->revision,
66+
'subtitle' => $submittal->project?->project_number,
67+
'url' => route('submittals.show', $submittal),
68+
]);
69+
70+
return response()->json([
71+
'data' => $projects
72+
->concat($requests)
73+
->concat($submittals)
74+
->take(12)
75+
->values(),
76+
]);
77+
}
78+
}

resources/js/Layouts/AppLayout.vue

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ const showNotifications = ref(false);
1919
const notifications = ref([]);
2020
const unreadCount = ref(user.value?.notification_unread_count ?? 0);
2121
const isLoadingNotifications = ref(false);
22+
const globalSearchQuery = ref('');
23+
const globalSearchResults = ref([]);
24+
const showGlobalSearchResults = ref(false);
25+
const isGlobalSearchLoading = ref(false);
2226
let notificationsPoller = null;
27+
let globalSearchDebounce = null;
2328
2429
function applyTheme(nextTheme) {
2530
const root = document.documentElement;
@@ -107,6 +112,58 @@ function toggleNotifications() {
107112
}
108113
}
109114
115+
async function runGlobalSearch() {
116+
const query = globalSearchQuery.value.trim();
117+
118+
if (query.length < 2) {
119+
globalSearchResults.value = [];
120+
showGlobalSearchResults.value = false;
121+
return;
122+
}
123+
124+
isGlobalSearchLoading.value = true;
125+
126+
try {
127+
const response = await window.axios.get(route('search.global'), {
128+
params: { q: query },
129+
});
130+
131+
globalSearchResults.value = response.data?.data ?? [];
132+
showGlobalSearchResults.value = true;
133+
} catch {
134+
globalSearchResults.value = [];
135+
showGlobalSearchResults.value = true;
136+
} finally {
137+
isGlobalSearchLoading.value = false;
138+
}
139+
}
140+
141+
function onGlobalSearchInput() {
142+
if (globalSearchDebounce !== null) {
143+
clearTimeout(globalSearchDebounce);
144+
}
145+
146+
globalSearchDebounce = setTimeout(() => {
147+
runGlobalSearch();
148+
}, 220);
149+
}
150+
151+
function openGlobalSearchResult(result) {
152+
globalSearchQuery.value = '';
153+
globalSearchResults.value = [];
154+
showGlobalSearchResults.value = false;
155+
156+
if (result?.url) {
157+
router.visit(result.url);
158+
}
159+
}
160+
161+
function hideGlobalSearchResults() {
162+
setTimeout(() => {
163+
showGlobalSearchResults.value = false;
164+
}, 120);
165+
}
166+
110167
function logout() {
111168
router.post(route('logout'));
112169
}
@@ -132,6 +189,10 @@ onBeforeUnmount(() => {
132189
if (notificationsPoller !== null) {
133190
clearInterval(notificationsPoller);
134191
}
192+
193+
if (globalSearchDebounce !== null) {
194+
clearTimeout(globalSearchDebounce);
195+
}
135196
});
136197
137198
watch(theme, (nextTheme) => {
@@ -188,6 +249,47 @@ watch(theme, (nextTheme) => {
188249
189250
<!-- User Menu -->
190251
<div class="hidden sm:flex sm:items-center sm:space-x-4">
252+
<div class="relative">
253+
<input
254+
v-model="globalSearchQuery"
255+
type="search"
256+
placeholder="Search #, project, request, submittal..."
257+
class="w-80 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
258+
@input="onGlobalSearchInput"
259+
@focus="onGlobalSearchInput"
260+
@blur="hideGlobalSearchResults"
261+
/>
262+
263+
<div
264+
v-if="showGlobalSearchResults"
265+
class="absolute right-0 z-50 mt-2 w-[28rem] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900"
266+
>
267+
<div
268+
v-if="isGlobalSearchLoading"
269+
class="px-4 py-3 text-sm text-gray-500 dark:text-slate-300"
270+
>
271+
Searching...
272+
</div>
273+
<div
274+
v-else-if="globalSearchResults.length === 0"
275+
class="px-4 py-3 text-sm text-gray-500 dark:text-slate-300"
276+
>
277+
No matches found.
278+
</div>
279+
<button
280+
v-for="result in globalSearchResults"
281+
:key="`${result.type}-${result.url}`"
282+
type="button"
283+
class="block w-full border-b border-gray-100 px-4 py-3 text-left last:border-b-0 hover:bg-gray-50 dark:border-slate-800 dark:hover:bg-slate-800"
284+
@mousedown.prevent="openGlobalSearchResult(result)"
285+
>
286+
<p class="text-sm font-medium text-gray-900 dark:text-slate-100">{{ result.title }}</p>
287+
<p class="mt-1 text-xs text-gray-500 dark:text-slate-400">
288+
{{ result.type.replace('_', ' ') }}<span v-if="result.subtitle"> · {{ result.subtitle }}</span>
289+
</p>
290+
</button>
291+
</div>
292+
</div>
191293
<div class="relative">
192294
<button
193295
type="button"

routes/web.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Http\Controllers\DashboardController;
1010
use App\Http\Controllers\DrawingRequestController;
1111
use App\Http\Controllers\FabQueueController;
12+
use App\Http\Controllers\GlobalSearchController;
1213
use App\Http\Controllers\NotificationController;
1314
use App\Http\Controllers\ProfileController;
1415
use App\Http\Controllers\ProjectAttachmentController;
@@ -38,6 +39,7 @@
3839
Route::put('/profile/avatar', [ProfileController::class, 'updateAvatar'])->name('profile.avatar.update');
3940
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password.update');
4041
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
42+
Route::get('/search/global', GlobalSearchController::class)->name('search.global');
4143
Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('notifications.read-all');
4244
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read');
4345

tests/Feature/GlobalSearchTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\Customer;
6+
use App\Models\DrawingRequest;
7+
use App\Models\DrawingSubmittal;
8+
use App\Models\Project;
9+
use App\Models\User;
10+
use Illuminate\Foundation\Testing\RefreshDatabase;
11+
use Tests\TestCase;
12+
13+
class GlobalSearchTest extends TestCase
14+
{
15+
use RefreshDatabase;
16+
17+
public function test_global_search_returns_project_request_and_submittal_matches(): void
18+
{
19+
$user = User::factory()->create();
20+
21+
$customer = Customer::create([
22+
'name' => 'Search Customer',
23+
'active' => true,
24+
]);
25+
26+
$project = Project::create([
27+
'project_number' => 'FIND-100',
28+
'name' => 'Find Me Project',
29+
'customer_id' => $customer->id,
30+
'status' => 'active',
31+
]);
32+
33+
$request = DrawingRequest::create([
34+
'request_number' => 'DR-2026-7777',
35+
'project_id' => $project->id,
36+
'customer_id' => $customer->id,
37+
'requested_by_user_id' => $user->id,
38+
'assigned_to_user_id' => $user->id,
39+
'title' => 'Find Request',
40+
'priority' => 'normal',
41+
'drawing_type' => 'structural',
42+
'requested_date' => now()->toDateString(),
43+
'status' => 'pending',
44+
]);
45+
46+
$submittal = DrawingSubmittal::create([
47+
'submittal_number' => 'SUB-2026-7777',
48+
'drawing_request_id' => $request->id,
49+
'project_id' => $project->id,
50+
'customer_id' => $customer->id,
51+
'revision' => 'A',
52+
'submitted_by_user_id' => $user->id,
53+
'purpose' => 'for_approval',
54+
'drawing_discipline' => 'commercial_structural',
55+
'status' => 'draft',
56+
]);
57+
58+
$response = $this->actingAs($user)
59+
->getJson(route('search.global', ['q' => '7777']));
60+
61+
$response->assertOk();
62+
$response->assertJsonStructure([
63+
'data' => [
64+
['type', 'title', 'subtitle', 'url'],
65+
],
66+
]);
67+
68+
$payload = $response->json('data');
69+
70+
$this->assertTrue(collect($payload)->contains(fn ($row) => ($row['url'] ?? '') === route('drawing-requests.show', $request)));
71+
$this->assertTrue(collect($payload)->contains(fn ($row) => ($row['url'] ?? '') === route('submittals.show', $submittal)));
72+
}
73+
74+
public function test_global_search_returns_empty_for_short_query(): void
75+
{
76+
$user = User::factory()->create();
77+
78+
$response = $this->actingAs($user)
79+
->getJson(route('search.global', ['q' => ' ']));
80+
81+
$response->assertOk();
82+
$response->assertExactJson([
83+
'data' => [],
84+
]);
85+
}
86+
}

0 commit comments

Comments
 (0)