feat(search): implement global navbar search for dashboards and datasets
This commit is contained in:
@@ -17,7 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { api } from "$lib/api.js";
|
||||||
import { activityStore } from "$lib/stores/activity.js";
|
import { activityStore } from "$lib/stores/activity.js";
|
||||||
import {
|
import {
|
||||||
taskDrawerStore,
|
taskDrawerStore,
|
||||||
@@ -41,6 +43,15 @@
|
|||||||
|
|
||||||
let showUserMenu = false;
|
let showUserMenu = false;
|
||||||
let isSearchFocused = false;
|
let isSearchFocused = false;
|
||||||
|
let searchQuery = "";
|
||||||
|
let showSearchDropdown = false;
|
||||||
|
let isSearchLoading = false;
|
||||||
|
let searchResults = [];
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
const SEARCH_MIN_LENGTH = 2;
|
||||||
|
const SEARCH_LIMIT = 5;
|
||||||
|
|
||||||
$: isExpanded = $sidebarStore?.isExpanded ?? true;
|
$: isExpanded = $sidebarStore?.isExpanded ?? true;
|
||||||
$: activeCount = $activityStore?.activeCount || 0;
|
$: activeCount = $activityStore?.activeCount || 0;
|
||||||
@@ -84,20 +95,23 @@
|
|||||||
|
|
||||||
function handleSearchFocus() {
|
function handleSearchFocus() {
|
||||||
isSearchFocused = true;
|
isSearchFocused = true;
|
||||||
|
showSearchDropdown = searchResults.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchBlur() {
|
function clearSearchState() {
|
||||||
isSearchFocused = false;
|
showSearchDropdown = false;
|
||||||
|
isSearchLoading = false;
|
||||||
|
searchResults = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDocumentClick(event) {
|
function handleDocumentClick(event) {
|
||||||
if (!event.target.closest(".user-menu-container")) {
|
if (!event.target.closest(".user-menu-container")) {
|
||||||
closeUserMenu();
|
closeUserMenu();
|
||||||
}
|
}
|
||||||
}
|
if (!event.target.closest(".global-search-container")) {
|
||||||
|
isSearchFocused = false;
|
||||||
if (typeof document !== "undefined") {
|
clearSearchState();
|
||||||
document.addEventListener("click", handleDocumentClick);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHamburgerClick(event) {
|
function handleHamburgerClick(event) {
|
||||||
@@ -107,10 +121,107 @@
|
|||||||
|
|
||||||
function handleGlobalEnvironmentChange(event) {
|
function handleGlobalEnvironmentChange(event) {
|
||||||
setSelectedEnvironment(event.target.value);
|
setSelectedEnvironment(event.target.value);
|
||||||
|
if (searchQuery.trim().length >= SEARCH_MIN_LENGTH) {
|
||||||
|
triggerSearch(searchQuery.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchResultItems(dashboardResponse, datasetResponse) {
|
||||||
|
const dashboards = (dashboardResponse?.dashboards || []).slice(
|
||||||
|
0,
|
||||||
|
SEARCH_LIMIT,
|
||||||
|
);
|
||||||
|
const datasets = (datasetResponse?.datasets || []).slice(0, SEARCH_LIMIT);
|
||||||
|
const dashboardItems = dashboards.map((dashboard) => ({
|
||||||
|
key: `dashboard-${dashboard.id}`,
|
||||||
|
type: "dashboard",
|
||||||
|
title: dashboard.title || dashboard.dashboard_title || `#${dashboard.id}`,
|
||||||
|
subtitle: `ID: ${dashboard.id}`,
|
||||||
|
href: `/dashboards/${dashboard.id}?env_id=${encodeURIComponent(globalSelectedEnvId)}`,
|
||||||
|
}));
|
||||||
|
const datasetItems = datasets.map((dataset) => ({
|
||||||
|
key: `dataset-${dataset.id}`,
|
||||||
|
type: "dataset",
|
||||||
|
title: dataset.table_name || `#${dataset.id}`,
|
||||||
|
subtitle: dataset.schema || "-",
|
||||||
|
href: `/datasets/${dataset.id}?env_id=${encodeURIComponent(globalSelectedEnvId)}`,
|
||||||
|
}));
|
||||||
|
return [...dashboardItems, ...datasetItems];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerSearch(query) {
|
||||||
|
const normalizedQuery = String(query || "").trim();
|
||||||
|
if (normalizedQuery.length < SEARCH_MIN_LENGTH || !globalSelectedEnvId) {
|
||||||
|
clearSearchState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSearchLoading = true;
|
||||||
|
showSearchDropdown = true;
|
||||||
|
try {
|
||||||
|
const [dashboardResponse, datasetResponse] = await Promise.all([
|
||||||
|
api.getDashboards(globalSelectedEnvId, {
|
||||||
|
search: normalizedQuery,
|
||||||
|
page: 1,
|
||||||
|
page_size: SEARCH_LIMIT,
|
||||||
|
}),
|
||||||
|
api.getDatasets(globalSelectedEnvId, {
|
||||||
|
search: normalizedQuery,
|
||||||
|
page: 1,
|
||||||
|
page_size: SEARCH_LIMIT,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
searchResults = buildSearchResultItems(dashboardResponse, datasetResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TopNavbar][Coherence:Failed] Global search failed", error);
|
||||||
|
searchResults = [];
|
||||||
|
} finally {
|
||||||
|
isSearchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchInput(event) {
|
||||||
|
searchQuery = event.target.value;
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
}
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
triggerSearch(searchQuery);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSearchResult(item) {
|
||||||
|
clearSearchState();
|
||||||
|
searchQuery = "";
|
||||||
|
isSearchFocused = false;
|
||||||
|
await goto(item.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearchKeydown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
clearSearchState();
|
||||||
|
searchQuery = "";
|
||||||
|
isSearchFocused = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && searchResults.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
await openSearchResult(searchResults[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await initializeEnvironmentContext();
|
await initializeEnvironmentContext();
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("click", handleDocumentClick);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.removeEventListener("click", handleDocumentClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -141,16 +252,42 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search placeholder (non-functional for now) -->
|
<!-- Global search -->
|
||||||
<div class="flex-1 max-w-xl mx-4 hidden md:block">
|
<div class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
||||||
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
|
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
|
||||||
placeholder={$t.common.search }
|
placeholder={$t.common.search }
|
||||||
|
value={searchQuery}
|
||||||
|
on:input={handleSearchInput}
|
||||||
on:focus={handleSearchFocus}
|
on:focus={handleSearchFocus}
|
||||||
on:blur={handleSearchBlur}
|
on:keydown={handleSearchKeydown}
|
||||||
/>
|
/>
|
||||||
|
{#if showSearchDropdown}
|
||||||
|
<div class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||||
|
{#if isSearchLoading}
|
||||||
|
<div class="px-4 py-3 text-sm text-slate-500">{$t.common?.loading || "Loading..."}</div>
|
||||||
|
{:else if searchResults.length === 0}
|
||||||
|
<div class="px-4 py-3 text-sm text-slate-500">{$t.common?.not_found || "No results found"}</div>
|
||||||
|
{:else}
|
||||||
|
{#each searchResults as result}
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-slate-50"
|
||||||
|
on:click={() => openSearchResult(result)}
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-medium text-slate-800">{result.title}</div>
|
||||||
|
<div class="truncate text-xs text-slate-500">{result.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-4 rounded border border-slate-200 px-2 py-0.5 text-xs text-slate-600">
|
||||||
|
{result.type === "dashboard" ? ($t.nav?.dashboards || "Dashboards") : ($t.nav?.datasets || "Datasets")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nav Actions -->
|
<!-- Nav Actions -->
|
||||||
|
|||||||
Reference in New Issue
Block a user