1<template>
2 <div>
3 <div>
4 <LayoutHeader>
5 <template #left-header>
6 <Breadcrumbs
7 :items="[{ label: __('Search'), route: { name: 'SearchAgent' } }]"
8 />
9 </template>
10 </LayoutHeader>
11 <div class="mx-auto mt-6 max-w-4xl px-4 sm:px-5">
12 <div class="flex items-center gap-x-2 px-2.5">
13 <TextInput
14 ref="searchInput"
15 class="flex-1"
16 :placeholder="__('Search tickets, comments, and communications...')"
17 autocomplete="off"
18 :model-value="query"
19 @update:model-value="updateQuery"
20 @keydown="newSearch = true"
21 @keydown.enter="() => submit()"
22 >
23 <template #prefix>
24 <LucideSearch class="w-4 text-ink-gray-5" />
25 </template>
26 <template #suffix>
27 <div class="flex items-center">
28 <button
29 v-if="query"
30 @click="clearSearch"
31 class="p-1 size-6 grid place-content-center focus:outline-none focus:ring focus:ring-outline-gray-3 rounded-4"
32 >
33 <LucideX class="w-4 text-ink-gray-7" />
34 </button>
35 </div>
36 </template>
37 </TextInput>
38 </div>
39
40 <!-- Filter Panel -->
41 <div class="mt-2 px-2.5">
42 <div class="flex gap-2 items-center">
43 <!-- Team Filter -->
44 <SearchMultiSelect
45 :options="teamsFilterOptions"
46 :model-value="activeFilters.agent_group || []"
47 @update:model-value="
48 (values) => updateFilter('agent_group', values)
49 "
50 :placeholder="__('Team')"
51 :label="__('Team')"
52 />
53
54 <!-- Status Filter -->
55 <SearchMultiSelect
56 :options="statusFilterOptions"
57 :model-value="activeFilters.status || []"
58 @update:model-value="(values) => updateFilter('status', values)"
59 :placeholder="__('Status')"
60 :label="__('Status')"
61 />
62
63 <!-- Priority Filter -->
64 <SearchMultiSelect
65 :options="priorityFilterOptions"
66 :model-value="activeFilters.priority || []"
67 @update:model-value="(values) => updateFilter('priority', values)"
68 :placeholder="__('Priority')"
69 :label="__('Priority')"
70 />
71
72 <!-- Document Type Filter -->
73 <SearchMultiSelect
74 :options="doctypesFilterOptions"
75 :model-value="activeFilters.doctype || []"
76 @update:model-value="(values) => updateFilter('doctype', values)"
77 :placeholder="__('Type')"
78 :label="__('Type')"
79 />
80 <Button
81 v-if="hasActiveFilters()"
82 size="sm"
83 class="ms-auto text-ink-gray-5"
84 variant="gray-ghost"
85 @click="clearFilters"
86 :label="__('Clear all filters')"
87 />
88 </div>
89 </div>
90
91 <!-- Search Summary -->
92 <div
93 class="mt-2 px-2.5 text-sm flex items-center justify-between min-h-6"
94 >
95 <div>
96 <template v-if="search.error">
97 <ErrorMessage
98 :message="
99 search.error.type == 'OpendeskSearchIndexMissingError'
100 ? __(
101 'Search index does not exist. Please build the index first.'
102 )
103 : search.error
104 "
105 />
106 </template>
107 <template v-else-if="newSearch && query.length > 2">
108 <p class="text-ink-gray-6">{{ __("Press enter to search") }}</p>
109 </template>
110 <template v-else-if="search.loading">
111 <p class="text-ink-gray-6">{{ __("Searching...") }}</p>
112 </template>
113 <template v-else-if="searchResponse?.summary">
114 <div class="space-y-1">
115 <p class="text-ink-gray-6">
116 {{
117 __("{0} matches", [searchResponse.summary.filtered_matches])
118 }}
119 <span>
120 {{ __("({0}s)", [searchResponse.summary.duration]) }}
121 </span>
122 <span v-if="hasActiveFilters()">
123 •
124 {{
125 __("{0} filter(s) applied", [
126 Object.keys(
127 searchResponse.summary.applied_filters || {}
128 ).length,
129 ])
130 }}
131 </span>
132 </p>
133 <p
134 v-if="searchResponse.summary.corrected_query"
135 class="text-ink-gray-6"
136 >
137 <span class="text-ink-gray-5">{{ __("Searched for:") }}</span>
138 <span class="ms-1 font-medium text-primary">
139 {{ searchResponse.summary.corrected_query }}
140 </span>
141 </p>
142 </div>
143 </template>
144 </div>
145 </div>
146
147 <div class="mt-5">
148 <template v-for="item in searchResponse?.results" :key="item.id">
149 <router-link
150 :to="getItemRoute(item)"
151 class="flex gap-x-2 overflow-hidden rounded-4 px-2.5 py-3 hover:bg-surface-gray-2"
152 >
153 <div class="flex items-start gap-x-2">
154 <div class="flex-shrink-0">
155 <LucideTicket
156 v-if="item.doctype === 'OD Ticket'"
157 class="h-4 w-4 text-ink-gray-6"
158 />
159 <LucideMessageSquare
160 v-else-if="item.doctype === 'Comment'"
161 class="h-4 w-4 text-ink-gray-6"
162 />
163 <LucideMail
164 v-else-if="item.doctype === 'Communication'"
165 class="h-4 w-4 text-ink-gray-6"
166 />
167 </div>
168 <!-- <UserAvatar :name="item.raised_by || item.author || 'System'" /> -->
169 </div>
170 <div class="w-full">
171 <div class="flex items-center">
172 <div
173 v-if="item.title"
174 class="text-base-medium truncate max-w-[60%]"
175 v-html="item.title"
176 />
177 <div class="text-base-medium" v-else>
178 {{ item.name }}
179 </div>
180 <span class="px-1 leading-none text-sm text-ink-gray-5">
181 ·
182 {{
183 item.doctype == "Communication"
184 ? __("Email")
185 : item.doctype.replace("OD ", "")
186 }}
187 </span>
188 <span class="px-1 leading-none text-sm text-ink-gray-5">
189 · #{{ getTicketNumber(item) }}
190 </span>
191 <div class="ms-auto text-sm text-ink-gray-5">
192 {{ formatDate(item.modified) }}
193 </div>
194 </div>
195 <div
196 v-if="item.content"
197 class="mt-1 text-p-base text-ink-gray-6"
198 v-html="item.content"
199 ></div>
200 </div>
201 </router-link>
202 <div class="border-b mx-2"></div>
203 </template>
204 </div>
205 </div>
206 </div>
207 </div>
208</template>
209
210<script setup lang="ts">
211import { LayoutHeader } from "@/components";
212import SearchMultiSelect from "@/components/SearchMultiSelect.vue";
213import { useShortcut } from "@opendesk/shared/shortcuts";
214import { __ } from "@/translation";
215import {
216 Breadcrumbs,
217 createResource,
218 dayjs,
219 debounce,
220 ErrorMessage,
221 TextInput,
222} from "frappe-ui";
223import { computed, onMounted, ref, useTemplateRef, watch } from "vue";
224import { useRoute, useRouter } from "vue-router";
225// Icons
226
227// Type Definitions
228interface SearchSummary {
229 duration: number;
230 total_matches: number;
231 returned_matches: number;
232 filtered_matches: number;
233 corrected_words?: Record<string, string>;
234 corrected_query?: string;
235 applied_filters?: SearchFilters;
236}
237
238interface SearchResultItem {
239 id: string;
240 title: string;
241 content: string;
242 preview: string;
243 doctype: string;
244 name: string;
245 agent_group?: string;
246 status?: string;
247 priority?: string;
248 reference_ticket?: string;
249 reference_name?: string;
250 modified: number | string;
251 author?: string;
252 raised_by?: string;
253 score: number;
254}
255
256interface SearchResponse {
257 results: SearchResultItem[];
258 summary: SearchSummary;
259}
260
261interface SearchFilters {
262 agent_group?: string[];
263 status?: string[];
264 priority?: string[];
265 doctype?: string[];
266}
267
268interface FilterOption {
269 value: string;
270 label: string;
271 count?: number;
272}
273
274interface FilterOptions {
275 teams: Record<string, number>;
276 statuses: Record<string, number>;
277 priorities: Record<string, number>;
278 doctypes: Record<string, number>;
279}
280
281// Constants and Configuration
282const STORAGE_KEY_PREFIX = "opendesk_search:";
283
284// Reactive State
285const query = ref("");
286const searchResponse = ref<SearchResponse | null>(null);
287const newSearch = ref(true);
288const activeFilters = ref<SearchFilters>({});
289
290// Template Refs
291const searchInput = useTemplateRef<typeof TextInput>("searchInput");
292
293// Composables and External Data
294const router = useRouter();
295const route = useRoute();
296
297// API Calls Setup
298const search = createResource({
299 url: "opendesk.api.search.search",
300 makeParams() {
301 return {
302 query: query.value,
303 filters: JSON.stringify(activeFilters.value),
304 limit: 50,
305 };
306 },
307 onSuccess(response) {
308 searchResponse.value = response;
309 newSearch.value = false;
310 if (query.value) {
311 saveSearchState(query.value, response);
312 }
313 },
314});
315
316const filterOptions = createResource({
317 url: "opendesk.api.search.get_filter_options",
318 auto: true,
319});
320
321// Filter Options Computed Properties
322const teamsFilterOptions = computed(() => {
323 const options = filterOptions.data?.teams || {};
324 return Object.entries(options).map(([value]) => ({
325 value,
326 label: `${value}`,
327 // count,
328 }));
329});
330
331const statusFilterOptions = computed(() => {
332 const options = filterOptions.data?.statuses || {};
333 return Object.entries(options).map(([value]) => ({
334 value,
335 label: `${value}`,
336 // count,
337 }));
338});
339
340const priorityFilterOptions = computed(() => {
341 const options = filterOptions.data?.priorities || {};
342 return Object.entries(options).map(([value]) => ({
343 value,
344 label: `${value}`,
345 // count,
346 }));
347});
348
349const doctypesFilterOptions = computed(() => {
350 return [
351 { value: "OD Ticket", label: __("Tickets"), count: 0 },
352 { value: "Communication", label: __("Emails"), count: 0 },
353 { value: "Comment", label: __("Comments"), count: 0 },
354 ];
355});
356
357// Search Functions
358const debouncedSubmit = debounce(() => {
359 if (query.value.length > 2) {
360 search.submit();
361 }
362}, 500);
363
364function updateQuery(value: string) {
365 query.value = value;
366 newSearch.value = true;
367 debouncedSubmit();
368}
369
370function submit() {
371 if (query.value.length > 2) {
372 // Update the URL query parameter
373 router.replace({ name: "SearchAgent", query: { q: query.value } });
374 search.submit();
375 }
376}
377
378function clearSearch() {
379 query.value = "";
380 searchResponse.value = null;
381 newSearch.value = true;
382 clearStoredSearches();
383 // Clear the URL query parameter
384 router.replace({ name: "SearchAgent" });
385}
386
387function clearFilters() {
388 activeFilters.value = {};
389 if (query.value.length > 2) {
390 debouncedSubmit();
391 }
392}
393
394function hasActiveFilters() {
395 return Object.values(activeFilters.value).some(
396 (filter) => filter && filter.length > 0
397 );
398}
399
400function updateFilter(key: keyof SearchFilters, values: string[]) {
401 if (!values || values.length === 0) {
402 delete activeFilters.value[key];
403 } else {
404 activeFilters.value[key] = values;
405 }
406
407 if (query.value.length > 2) {
408 debouncedSubmit();
409 }
410}
411
412function getItemRoute(item: SearchResultItem) {
413 if (!item || !item.doctype || !item.name) {
414 return { name: "SearchAgent" };
415 }
416
417 if (item.doctype === "OD Ticket") {
418 return {
419 name: "TicketAgent",
420 params: { ticketId: item.name },
421 };
422 } else if (item.doctype === "Comment" && item.reference_ticket) {
423 return {
424 name: "TicketAgent",
425 params: { ticketId: item.reference_ticket },
426 hash: "#activity",
427 query: { highlight: `comment-${item.name}` },
428 };
429 } else if (item.doctype === "Communication" && item.reference_name) {
430 return {
431 name: "TicketAgent",
432 params: { ticketId: item.reference_name },
433 hash: "#activity",
434 query: { highlight: `communication-${item.name}` },
435 };
436 }
437 return { name: "SearchAgent" };
438}
439
440function getTicketNumber(item: SearchResultItem) {
441 if (!item || !item.doctype) {
442 return "";
443 }
444
445 if (item.doctype === "OD Ticket") {
446 return item.name || "";
447 } else if (item.doctype === "Comment") {
448 return item.reference_ticket || "";
449 } else if (item.doctype === "Communication") {
450 return item.reference_name || "";
451 }
452 return "";
453}
454
455function formatDate(date: number | string) {
456 if (!date) return "";
457 let FORMAT = "MMM D, YYYY hh:mm A";
458 try {
459 // Handle Unix timestamp (seconds) - use dayjs.unix()
460 if (typeof date === "number") {
461 return dayjs.unix(date).format(FORMAT);
462 }
463 // Handle string dates
464 return dayjs(date).format(FORMAT);
465 } catch (error) {
466 return "";
467 }
468}
469
470// Search State Persistence
471function getStorageKey(query: string) {
472 return `${STORAGE_KEY_PREFIX}${query}`;
473}
474
475function loadSearchState(searchQuery: string) {
476 const saved = localStorage.getItem(getStorageKey(searchQuery));
477 if (saved) {
478 const state = JSON.parse(saved);
479 // Check if stored result is less than 30 minutes old
480 if (Date.now() - state.timestamp < 30 * 60 * 1000) {
481 searchResponse.value = state.results;
482 // Restore active filters if they exist
483 if (state.filters) {
484 activeFilters.value = state.filters;
485 }
486 newSearch.value = false;
487 return true;
488 }
489 // Remove expired results
490 localStorage.removeItem(getStorageKey(searchQuery));
491 }
492 return false;
493}
494
495function saveSearchState(searchQuery: string, results: SearchResponse) {
496 const state = {
497 results,
498 filters: activeFilters.value,
499 timestamp: Date.now(),
500 };
501 localStorage.setItem(getStorageKey(searchQuery), JSON.stringify(state));
502}
503
504function clearStoredSearches() {
505 for (let i = 0; i < localStorage.length; i++) {
506 const key = localStorage.key(i);
507 if (key?.startsWith(STORAGE_KEY_PREFIX)) {
508 localStorage.removeItem(key);
509 }
510 }
511}
512
513// Watch for route query changes (browser back/forward navigation)
514watch(
515 () => route.query.q,
516 (newQuery) => {
517 if (newQuery && typeof newQuery === "string" && newQuery !== query.value) {
518 query.value = newQuery;
519 newSearch.value = false; // Prevent showing "Press enter to search"
520 search.submit();
521 } else if (!newQuery && query.value) {
522 query.value = "";
523 searchResponse.value = null;
524 newSearch.value = true;
525 }
526 }
527);
528
529// Lifecycle
530onMounted(() => {
531 const searchQuery = route.query.q as string;
532 if (searchQuery) {
533 query.value = searchQuery;
534 if (!loadSearchState(searchQuery)) {
535 submit();
536 }
537 } else {
538 clearStoredSearches();
539 }
540 // add a shortcut when presses "/" focus on this element
541 useShortcut("/", () => {
542 searchInput.value?.focus();
543 });
544});
545</script>
546