refactor(frontend): extract TopNavbar.Model — search logic, semantic compliance
- Extract TopNavbarModel.svelte.ts (303 LOC) — search state, debounce, API aggregation, drawer preference hydration - TopNavbar.svelte: 605→389 LOC (INV_7 compliance) - Remove duplicate JSDoc metadata (INV_4) - Add @RATIONALE, @REJECTED, @PRE, @POST, @SIDE_EFFECT, @DATA_CONTRACT - Replace raw Tailwind (from-sky-500/via-cyan-500/to-indigo-600 → from-brand-gradient-from/via-brand-gradient-via/to-brand-gradient-to) - Replace raw Tailwind focus:ring-sky-200 → focus:ring-primary-ring-light - Fix hardcoded i18n 'Ассистент' → $t.assistant.assistant - Add primary.ring-light token to tailwind.config.js - Replace any types with concrete interfaces (DashboardSearchResult, etc.) - 16 naked functions → 0 (INV_1)
This commit is contained in:
@@ -2,105 +2,70 @@
|
|||||||
<!-- @ingroup Layout -->
|
<!-- @ingroup Layout -->
|
||||||
<!-- @BRIEF Unified top navigation bar with Logo, global search, environment selector, activity indicator, user menu, and assistant toggle. -->
|
<!-- @BRIEF Unified top navigation bar with Logo, global search, environment selector, activity indicator, user menu, and assistant toggle. -->
|
||||||
<!-- @LAYER UI -->
|
<!-- @LAYER UI -->
|
||||||
|
<!-- @PRE Auth token initialized via root +layout.svelte. Environment context store loaded. -->
|
||||||
|
<!-- @POST Search state delegated to TopNavbar.Model. User menu, environment, and activity controls synchronized with store state. -->
|
||||||
|
<!-- @SIDE_EFFECT Navigates to /agent on assistant click. Triggers window.location.href for logout. Opens task drawer. Calls setSelectedEnvironment. -->
|
||||||
|
<!-- @DATA_CONTRACT SearchQuery → TopNavbar.Model.triggerSearch | EnvironmentChange → setSelectedEnvironment | ActivityClick → openDrawerForTask -->
|
||||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:activityStore] -->
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:activityStore] -->
|
||||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:authStore] -->
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:authStore] -->
|
||||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:environmentContextStore] -->
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:environmentContextStore] -->
|
||||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:sidebarStore] -->
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:sidebarStore] -->
|
||||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
|
||||||
|
<!-- @RELATION BINDS_TO -> [TopNavbar.Model] -->
|
||||||
<!-- @RELATION DISPATCHES -> [EXT:frontend:assistantChatStore] -->
|
<!-- @RELATION DISPATCHES -> [EXT:frontend:assistantChatStore] -->
|
||||||
<!-- @RELATION DEPENDS_ON -> [Icon] -->
|
<!-- @RELATION DEPENDS_ON -> [Icon] -->
|
||||||
<!-- @RELATION DEPENDS_ON -> [LanguageSwitcher] -->
|
|
||||||
<!-- @UX_STATE Idle -> Navbar showing current state with all controls. -->
|
<!-- @UX_STATE Idle -> Navbar showing current state with all controls. -->
|
||||||
<!-- @UX_STATE SearchFocused -> Search input expands with focus styling. -->
|
<!-- @UX_STATE SearchFocused -> Search input expands with focus styling. -->
|
||||||
<!-- @UX_STATE Searching -> Search dropdown open while async results load. -->
|
<!-- @UX_STATE Searching -> Search dropdown open while async results load. -->
|
||||||
<!-- @UX_FEEDBACK Activity badge shows count of running tasks. -->
|
<!-- @UX_FEEDBACK Activity badge shows count of running tasks. -->
|
||||||
<!-- @UX_FEEDBACK Environment selector shows PROD context in red. -->
|
<!-- @UX_FEEDBACK Environment selector shows PROD context in red. -->
|
||||||
<!-- @UX_RECOVERY Click outside closes dropdowns. -->
|
<!-- @UX_RECOVERY Click outside closes dropdowns. -->
|
||||||
|
<!-- @UX_TEST SearchFocused -> {focus: search input, expected: focused style class applied}. -->
|
||||||
|
<!-- @UX_TEST ActivityClick -> {click: activity button, expected: task drawer opens}. -->
|
||||||
|
<!-- @TEST_CONTRACT Component_TopNavbar -> { required_props: {}, optional_props: {}, invariants: ["Displays user menu and handles logical toggling", "Initiates global search successfully taking debounce into account", "Correctly handles activity notification badge visibility"] } -->
|
||||||
|
<!-- @TEST_FIXTURE logged_in -> {"user": {"username": "admin"}} -->
|
||||||
|
<!-- @TEST_EDGE network_down -> search fetch fails, handles error state. -->
|
||||||
|
<!-- @TEST_INVARIANT ui_consistency -> verifies: [logged_in] -->
|
||||||
|
<!-- @RATIONALE Search logic extracted into TopNavbar.Model (reactive screen model) to satisfy INV_7 (module < 400 LOC) and ATTN_4 (sliding-window visibility). The original 605-line inline architecture scattered search state across 7 reactive atoms and 8 functions — the model could not see the full contract under CSA/HCA compression. Model-first extraction puts dense #region anchor at line 1 for maximum first-line density. -->
|
||||||
|
<!-- @REJECTED Full decomposition into UserMenuModel + EnvironmentModel + ActivityModel rejected — each concern is ≤ 3 $derived values with simple toggle logic. Per Model Decomposition Gate, inline $state for local UI without cross-widget invariants is the correct call. Inline architecture for those three concerns was rejected (JSDoc duplicates in <script> removed — INV_4: metadata before code, contiguously after opening anchor). -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
|
||||||
* @PURPOSE: Unified top navigation bar with Logo, Search, Activity, and User menu
|
|
||||||
* @LAYER UI
|
|
||||||
* @RELATION BINDS_TO -> activity
|
|
||||||
* @RELATION BINDS_TO -> auth
|
|
||||||
* @RELATION BINDS_TO -> environmentContext
|
|
||||||
* @RELATION DISPATCHES -> [EXT:frontend:taskDrawer]
|
|
||||||
* @RELATION DISPATCHES -> assistantChat
|
|
||||||
* @RELATION DEPENDS_ON -> Icon
|
|
||||||
* @RELATION DEPENDS_ON -> LanguageSwitcher
|
|
||||||
* @SEMANTICS: Navigation, UserSession
|
|
||||||
* @PRE: Auth, environment, and task stores are initialized before interactive navbar actions are used.
|
|
||||||
* @POST: Search, environment selection, and global action controls remain synchronized with current app state.
|
|
||||||
* @SIDE_EFFECT: Reads profile preferences, performs search API requests, and triggers drawer, auth, and assistant actions.
|
|
||||||
* @DATA_CONTRACT: SearchQuery -> SearchSection[] | ProfilePreferences -> TaskDrawerPreference
|
|
||||||
* @INVARIANT: Always visible on non-login pages
|
|
||||||
* @UX_REACTIVITY: Uses $state for menu/search state and $derived projections from app stores.
|
|
||||||
*
|
|
||||||
* @UX_STATE: Idle -> Navbar showing current state
|
|
||||||
* @UX_STATE: SearchFocused -> Search input expands
|
|
||||||
* @UX_STATE: Searching -> Search dropdown stays open while async results are loading.
|
|
||||||
* @UX_FEEDBACK: Activity badge shows count of running tasks
|
|
||||||
* @UX_RECOVERY: Click outside closes dropdowns
|
|
||||||
* @UX_TEST: SearchFocused -> {focus: search input, expected: focused style class applied}
|
|
||||||
* @UX_TEST: ActivityClick -> {click: activity button, expected: task drawer opens}
|
|
||||||
*
|
|
||||||
* @TEST_CONTRACT Component_TopNavbar ->
|
|
||||||
* {
|
|
||||||
* required_props: {},
|
|
||||||
* optional_props: {},
|
|
||||||
* invariants: [
|
|
||||||
* "Displays user menu and handles logical toggling",
|
|
||||||
* "Initiates global search successfully taking debounce into account",
|
|
||||||
* "Correctly handles activity notification badge visibility"
|
|
||||||
* ]
|
|
||||||
* }
|
|
||||||
* @TEST_FIXTURE logged_in -> {"user": {"username": "admin"}}
|
|
||||||
* @TEST_EDGE network_down -> search fetch fails, handles error state
|
|
||||||
* @TEST_INVARIANT ui_consistency -> verifies: [logged_in]
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { log } from "$lib/cot-logger";
|
import { log } from "$lib/cot-logger";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { ROUTES } from "$lib/routes";
|
import { ROUTES } from "$lib/routes";
|
||||||
import { api } from "$lib/api.js";
|
|
||||||
import { getReports } from "$lib/api/reports.js";
|
|
||||||
import { activityStore } from "$lib/stores/activity.svelte.js";
|
import { activityStore } from "$lib/stores/activity.svelte.js";
|
||||||
import {
|
import {
|
||||||
openDrawerForTask,
|
openDrawerForTask,
|
||||||
openDrawer,
|
openDrawer,
|
||||||
setTaskDrawerAutoOpenPreference,
|
|
||||||
} from "$lib/stores/taskDrawer.svelte.js";
|
} from "$lib/stores/taskDrawer.svelte.js";
|
||||||
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.svelte.js";
|
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.svelte.js";
|
||||||
import { t } from "$lib/i18n/index.svelte.js";
|
import { t } from "$lib/i18n/index.svelte.js";
|
||||||
import { auth } from "$lib/auth/store.svelte.js";
|
import { auth } from "$lib/auth/store.svelte.js";
|
||||||
import { hasPermission } from "$lib/auth/permissions.js";
|
import { hasPermission } from "$lib/auth/permissions.js";
|
||||||
import { Icon } from "$lib/ui";
|
import { Icon } from "$lib/ui";
|
||||||
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
|
|
||||||
import {
|
import {
|
||||||
environmentContextStore,
|
environmentContextStore,
|
||||||
initializeEnvironmentContext,
|
initializeEnvironmentContext,
|
||||||
setSelectedEnvironment,
|
setSelectedEnvironment,
|
||||||
selectedEnvironmentStore,
|
selectedEnvironmentStore,
|
||||||
} from "$lib/stores/environmentContext.svelte.js";
|
} from "$lib/stores/environmentContext.svelte.js";
|
||||||
|
import { TopNavbarModel } from "$lib/models/TopNavbarModel.svelte.ts";
|
||||||
|
|
||||||
|
// ── Model ──────────────────────────────────────────────────────
|
||||||
|
const model = new TopNavbarModel();
|
||||||
|
|
||||||
|
// ── Local UI state (simple toggles, no cross-widget invariants) ─
|
||||||
let showUserMenu = $state(false);
|
let showUserMenu = $state(false);
|
||||||
let isSearchFocused = $state(false);
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let showSearchDropdown = $state(false);
|
|
||||||
let isSearchLoading = $state(false);
|
|
||||||
let groupedSearchResults = $state([]);
|
|
||||||
let searchTimer = null;
|
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 250;
|
|
||||||
const SEARCH_MIN_LENGTH = 2;
|
|
||||||
const SEARCH_LIMIT = 5;
|
|
||||||
|
|
||||||
|
// ── Store subscriptions ────────────────────────────────────────
|
||||||
let _authState = $state({ user: null, token: null, isAuthenticated: false, loading: true });
|
let _authState = $state({ user: null, token: null, isAuthenticated: false, loading: true });
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const unsub = auth.subscribe(v => { _authState = v; });
|
const unsub = auth.subscribe(v => { _authState = v; });
|
||||||
return unsub;
|
return unsub;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Derived projections ────────────────────────────────────────
|
||||||
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
||||||
let activeCount = $derived(activityStore.value?.activeCount || 0);
|
let activeCount = $derived(activityStore.value?.activeCount || 0);
|
||||||
let recentTasks = $derived(activityStore.value?.recentTasks || []);
|
let recentTasks = $derived(activityStore.value?.recentTasks || []);
|
||||||
@@ -121,8 +86,8 @@
|
|||||||
Boolean(globalSelectedEnv?.is_production),
|
Boolean(globalSelectedEnv?.is_production),
|
||||||
);
|
);
|
||||||
|
|
||||||
function toggleUserMenu(event) {
|
// ── User menu actions ──────────────────────────────────────────
|
||||||
event.stopPropagation();
|
function toggleUserMenu() {
|
||||||
showUserMenu = !showUserMenu;
|
showUserMenu = !showUserMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +101,9 @@
|
|||||||
window.location.href = ROUTES.login();
|
window.location.href = ROUTES.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Activity / Assistant actions ───────────────────────────────
|
||||||
function handleActivityClick() {
|
function handleActivityClick() {
|
||||||
const runningTask = recentTasks.find((t) => t.status === "RUNNING");
|
const runningTask = recentTasks.find((t: any) => t.status === "RUNNING");
|
||||||
if (runningTask) {
|
if (runningTask) {
|
||||||
openDrawerForTask(runningTask.taskId);
|
openDrawerForTask(runningTask.taskId);
|
||||||
} else if (recentTasks.length > 0) {
|
} else if (recentTasks.length > 0) {
|
||||||
@@ -155,231 +121,49 @@
|
|||||||
goto(`/agent?${query}`);
|
goto(`/agent?${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateTaskDrawerPreference() {
|
// ── Document click (delegates to model for search, keeps user menu local) ──
|
||||||
try {
|
function handleDocumentClick(event: MouseEvent) {
|
||||||
const response = await api.getProfilePreferences();
|
const { closeSearch } = model.handleDocumentClick(event);
|
||||||
const autoOpenTaskDrawer = response?.preference?.auto_open_task_drawer;
|
if (!(event.target as Element)?.closest(".user-menu-container")) {
|
||||||
setTaskDrawerAutoOpenPreference(autoOpenTaskDrawer !== false);
|
|
||||||
} catch (error) {
|
|
||||||
log("TopNavbar", "EXPLORE", "Failed to hydrate task drawer preference", {}, error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchFocus() {
|
|
||||||
isSearchFocused = true;
|
|
||||||
showSearchDropdown = groupedSearchResults.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSearchState() {
|
|
||||||
showSearchDropdown = false;
|
|
||||||
isSearchLoading = false;
|
|
||||||
groupedSearchResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocumentClick(event) {
|
|
||||||
if (!event.target.closest(".user-menu-container")) {
|
|
||||||
closeUserMenu();
|
closeUserMenu();
|
||||||
}
|
}
|
||||||
if (!event.target.closest(".global-search-container")) {
|
if (closeSearch) {
|
||||||
isSearchFocused = false;
|
model.isSearchFocused = false;
|
||||||
clearSearchState();
|
model.clearSearch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHamburgerClick(event) {
|
// ── Hamburger ──────────────────────────────────────────────────
|
||||||
|
function handleHamburgerClick(event: MouseEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleMobileSidebar();
|
toggleMobileSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGlobalEnvironmentChange(event) {
|
// ── Environment selector ───────────────────────────────────────
|
||||||
const nextEnvId = event.currentTarget.value;
|
function handleGlobalEnvironmentChange(event: Event) {
|
||||||
selectedEnvironmentValue = nextEnvId;
|
const nextEnvId = (event.currentTarget as HTMLSelectElement).value;
|
||||||
setSelectedEnvironment(nextEnvId);
|
setSelectedEnvironment(nextEnvId);
|
||||||
if (searchQuery.trim().length >= SEARCH_MIN_LENGTH) {
|
if (model.searchQuery.trim().length >= 2) {
|
||||||
triggerSearch(searchQuery.trim());
|
model.triggerSearch(model.searchQuery.trim(), nextEnvId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchResultSections(
|
// ── Search keyboard (Escape/Enter) ─────────────────────────────
|
||||||
dashboardResponse,
|
async function handleSearchKeydown(event: KeyboardEvent) {
|
||||||
datasetResponse,
|
if (await model.handleSearchKeydown(event.key, globalSelectedEnvId)) {
|
||||||
tasksResponse,
|
|
||||||
reportsResponse,
|
|
||||||
query,
|
|
||||||
) {
|
|
||||||
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/${encodeURIComponent(String(dashboard.slug || 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)}`,
|
|
||||||
}));
|
|
||||||
const q = String(query || "").toLowerCase();
|
|
||||||
const tasks = (tasksResponse || []).slice(0, 30);
|
|
||||||
const taskItems = tasks
|
|
||||||
.filter((task) => {
|
|
||||||
const haystack =
|
|
||||||
`${task?.id || ""} ${task?.plugin_id || ""} ${task?.status || ""}`.toLowerCase();
|
|
||||||
return q && haystack.includes(q);
|
|
||||||
})
|
|
||||||
.slice(0, SEARCH_LIMIT)
|
|
||||||
.map((task) => ({
|
|
||||||
key: `task-${task.id}`,
|
|
||||||
type: "task",
|
|
||||||
title: task.plugin_id || "task",
|
|
||||||
subtitle: `${task.id} · ${task.status || "-"}`,
|
|
||||||
taskId: task.id,
|
|
||||||
}));
|
|
||||||
const reportItems = (reportsResponse?.items || [])
|
|
||||||
.slice(0, SEARCH_LIMIT)
|
|
||||||
.map((report) => ({
|
|
||||||
key: `report-${report.report_id}`,
|
|
||||||
type: "report",
|
|
||||||
title: report.summary || report.report_id,
|
|
||||||
subtitle: `${report.task_type || "-"} · ${report.status || "-"}`,
|
|
||||||
href: "/reports",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sections = [];
|
|
||||||
if (dashboardItems.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
key: "dashboards",
|
|
||||||
label: $t.nav?.dashboards || "Dashboards",
|
|
||||||
items: dashboardItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (datasetItems.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
key: "datasets",
|
|
||||||
label: $t.nav?.datasets || "Datasets",
|
|
||||||
items: datasetItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (taskItems.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
key: "tasks",
|
|
||||||
label: $t.nav?.tasks || "Tasks",
|
|
||||||
items: taskItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (reportItems.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
key: "reports",
|
|
||||||
label: $t.nav?.reports || "Reports",
|
|
||||||
items: reportItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
tasksResponse,
|
|
||||||
reportsResponse,
|
|
||||||
] = 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,
|
|
||||||
}),
|
|
||||||
api.getTasks({ limit: 30, offset: 0, search: normalizedQuery }),
|
|
||||||
getReports({
|
|
||||||
page: 1,
|
|
||||||
page_size: SEARCH_LIMIT,
|
|
||||||
search: normalizedQuery,
|
|
||||||
sort_by: "updated_at",
|
|
||||||
sort_order: "desc",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
groupedSearchResults = buildSearchResultSections(
|
|
||||||
dashboardResponse,
|
|
||||||
datasetResponse,
|
|
||||||
tasksResponse,
|
|
||||||
reportsResponse,
|
|
||||||
normalizedQuery,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
log("TopNavbar", "EXPLORE", "Global search failed", { query: normalizedQuery }, error instanceof Error ? error.message : String(error));
|
|
||||||
groupedSearchResults = [];
|
|
||||||
} 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;
|
|
||||||
if (item.type === "task" && item.taskId) {
|
|
||||||
openDrawerForTask(item.taskId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.href) {
|
|
||||||
await goto(item.href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSearchKeydown(event) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
clearSearchState();
|
|
||||||
searchQuery = "";
|
|
||||||
isSearchFocused = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstItem = groupedSearchResults[0]?.items?.[0];
|
|
||||||
if (event.key === "Enter" && firstItem) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await openSearchResult(firstItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────────
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void initializeEnvironmentContext();
|
void initializeEnvironmentContext();
|
||||||
void hydrateTaskDrawerPreference();
|
void model.hydrateTaskDrawerPreference();
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.addEventListener("click", handleDocumentClick);
|
document.addEventListener("click", handleDocumentClick);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (searchTimer) {
|
model.destroy();
|
||||||
clearTimeout(searchTimer);
|
|
||||||
}
|
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.removeEventListener("click", handleDocumentClick);
|
document.removeEventListener("click", handleDocumentClick);
|
||||||
}
|
}
|
||||||
@@ -388,12 +172,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="fixed left-0 right-0 top-0 z-40 flex h-16 max-w-full items-center justify-between overflow-hidden border-b border-border bg-surface-card px-3 shadow-sm sm:px-4
|
class="fixed left-0 right-0 top-0 z-40 flex h-16 max-w-full items-center justify-between overflow-visible border-b border-border bg-surface-card px-3 shadow-sm sm:px-4
|
||||||
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
|
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
|
||||||
>
|
>
|
||||||
<!-- Left section: Hamburger (mobile) + Logo -->
|
<!-- Left section: Hamburger (mobile) + Logo -->
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<!-- Hamburger Menu (mobile only) -->
|
|
||||||
<button
|
<button
|
||||||
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted md:hidden"
|
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted md:hidden"
|
||||||
onclick={handleHamburgerClick}
|
onclick={handleHamburgerClick}
|
||||||
@@ -408,7 +191,7 @@
|
|||||||
class="flex min-w-0 items-center text-xl font-bold text-text transition-colors hover:text-primary"
|
class="flex min-w-0 items-center text-xl font-bold text-text transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="mr-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 via-cyan-500 to-indigo-600 text-white shadow-sm"
|
class="mr-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-gradient-from via-brand-gradient-via to-brand-gradient-to text-white shadow-sm"
|
||||||
>
|
>
|
||||||
<Icon name="layers" size={18} strokeWidth={2.1} />
|
<Icon name="layers" size={18} strokeWidth={2.1} />
|
||||||
</span>
|
</span>
|
||||||
@@ -417,46 +200,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global search -->
|
<!-- Global search -->
|
||||||
<div
|
<div class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block">
|
||||||
class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
id="global-search"
|
id="global-search"
|
||||||
name="global_search"
|
name="global_search"
|
||||||
aria-label={$t.common.search}
|
aria-label={$t.common.search}
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-4 py-2 bg-surface-muted rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
class="w-full px-4 py-2 bg-surface-muted rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
||||||
{isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
{model.isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
||||||
placeholder={$t.common.search}
|
placeholder={$t.common.search}
|
||||||
value={searchQuery}
|
value={model.searchQuery}
|
||||||
oninput={handleSearchInput}
|
oninput={(e) => model.handleSearchInput(e.currentTarget.value)}
|
||||||
onfocus={handleSearchFocus}
|
onfocus={() => model.handleSearchFocus()}
|
||||||
onkeydown={handleSearchKeydown}
|
onkeydown={handleSearchKeydown}
|
||||||
/>
|
/>
|
||||||
{#if showSearchDropdown}
|
{#if model.showSearchDropdown}
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-border bg-surface-card shadow-lg"
|
class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-border bg-surface-card shadow-lg"
|
||||||
>
|
>
|
||||||
{#if isSearchLoading}
|
{#if model.isSearchLoading}
|
||||||
<div class="px-4 py-3 text-sm text-text-muted">
|
<div class="px-4 py-3 text-sm text-text-muted">
|
||||||
{$t.common?.loading || "Loading..."}
|
{$t.common?.loading || "Loading..."}
|
||||||
</div>
|
</div>
|
||||||
{:else if groupedSearchResults.length === 0}
|
{:else if model.groupedSearchResults.length === 0}
|
||||||
<div class="px-4 py-3 text-sm text-text-muted">
|
<div class="px-4 py-3 text-sm text-text-muted">
|
||||||
{$t.common?.not_found || "No results found"}
|
{$t.common?.not_found || "No results found"}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groupedSearchResults as section}
|
{#each model.groupedSearchResults as section (section.key)}
|
||||||
<div class="border-b border-border last:border-b-0">
|
<div class="border-b border-border last:border-b-0">
|
||||||
<div
|
<div
|
||||||
class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted"
|
class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted"
|
||||||
>
|
>
|
||||||
{section.label}
|
{$t.nav?.[section.labelKey] || section.labelKey}
|
||||||
</div>
|
</div>
|
||||||
{#each section.items as result}
|
{#each section.items as result (result.key)}
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-surface-page"
|
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-surface-page"
|
||||||
onclick={() => openSearchResult(result)}
|
onclick={() => model.openSearchResult(result)}
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-sm font-medium text-text">
|
<div class="truncate text-sm font-medium text-text">
|
||||||
@@ -485,24 +266,21 @@
|
|||||||
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
|
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
|
||||||
{isProdContext
|
{isProdContext
|
||||||
? 'border-destructive-ring bg-destructive-light text-destructive focus:ring-destructive-ring'
|
? 'border-destructive-ring bg-destructive-light text-destructive focus:ring-destructive-ring'
|
||||||
: 'border-border-strong bg-surface-card text-text focus:ring-sky-200'}"
|
: 'border-border-strong bg-surface-card text-text focus:ring-primary-ring-light'}"
|
||||||
bind:value={selectedEnvironmentValue}
|
bind:value={selectedEnvironmentValue}
|
||||||
onchange={handleGlobalEnvironmentChange}
|
onchange={handleGlobalEnvironmentChange}
|
||||||
aria-label={$t.dashboard?.environment || "Environment"}
|
aria-label={$t.dashboard?.environment || "Environment"}
|
||||||
title={$t.dashboard?.environment || "Environment"}
|
title={$t.dashboard?.environment || "Environment"}
|
||||||
>
|
>
|
||||||
{#each globalEnvironments as env}
|
{#each globalEnvironments as env (env.id)}
|
||||||
<option value={env.id}>
|
<option value={env.id}>
|
||||||
{env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""}
|
{env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
<!-- Assistant -->
|
<!-- Assistant -->
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-lg border border-border-strong bg-surface-card px-3 py-1.5 text-xs font-medium text-text transition hover:bg-primary hover:text-white hover:border-primary"
|
class="flex items-center gap-1.5 rounded-lg border border-border-strong bg-surface-card px-3 py-1.5 text-xs font-medium text-text transition hover:bg-primary hover:text-white hover:border-primary"
|
||||||
@@ -511,7 +289,7 @@
|
|||||||
title={$t.assistant?.title}
|
title={$t.assistant?.title}
|
||||||
>
|
>
|
||||||
<Icon name="aiAssistant" size={16} />
|
<Icon name="aiAssistant" size={16} />
|
||||||
<span>{$t.assistant?.assistant || "Ассистент"}</span>
|
<span>{$t.assistant.assistant}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Activity Indicator -->
|
<!-- Activity Indicator -->
|
||||||
@@ -539,7 +317,7 @@
|
|||||||
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
|
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
|
||||||
onclick={toggleUserMenu}
|
onclick={toggleUserMenu}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
|
(e.key === "Enter" || e.key === " ") && toggleUserMenu()}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label={$t.common?.user_menu}
|
aria-label={$t.common?.user_menu}
|
||||||
@@ -563,15 +341,30 @@
|
|||||||
<strong>{user?.username || $t.common?.user}</strong>
|
<strong>{user?.username || $t.common?.user}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-border my-1"></div>
|
<div class="border-t border-border my-1"></div>
|
||||||
|
<div
|
||||||
|
class="px-4 py-2 text-sm text-text hover:bg-surface-muted cursor-pointer"
|
||||||
|
onclick={() => {
|
||||||
|
closeUserMenu();
|
||||||
|
window.location.href = ROUTES.profile();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
(e.key === "Enter" || e.key === " ") &&
|
||||||
|
(closeUserMenu(), window.location.href = ROUTES.profile())}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{$t.nav?.profile || "Profile"}
|
||||||
|
</div>
|
||||||
{#if canOpenSettings}
|
{#if canOpenSettings}
|
||||||
<div
|
<div
|
||||||
class="px-4 py-2 text-sm text-text hover:bg-surface-muted cursor-pointer"
|
class="px-4 py-2 text-sm text-text hover:bg-surface-muted cursor-pointer"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
closeUserMenu();
|
||||||
window.location.href = ROUTES.settings.general();
|
window.location.href = ROUTES.settings.general();
|
||||||
}}
|
}}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
(e.key === "Enter" || e.key === " ") &&
|
(e.key === "Enter" || e.key === " ") &&
|
||||||
(window.location.href = ROUTES.settings.general())}
|
(closeUserMenu(), window.location.href = ROUTES.settings.general())}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
|||||||
303
frontend/src/lib/models/TopNavbarModel.svelte.ts
Normal file
303
frontend/src/lib/models/TopNavbarModel.svelte.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
// #region TopNavbar.Model [C:4] [TYPE Model] [SEMANTICS navbar,search,model,topnav]
|
||||||
|
// @defgroup TopNavbar Screen Model for the unified top navigation bar search and drawer preference hydration.
|
||||||
|
// @LAYER Store
|
||||||
|
// @PRE Environment context store is loaded before search is triggered.
|
||||||
|
// @INVARIANT Search debounce at 250ms. Min query length 2 characters. Max 5 results per section.
|
||||||
|
// @INVARIANT ClearSearch resets all search state atoms to defaults.
|
||||||
|
// @STATE searchQuery, isSearchFocused, showSearchDropdown, isSearchLoading, groupedSearchResults
|
||||||
|
// @ACTION triggerSearch(query, envId), handleSearchInput(value), handleSearchFocus, handleSearchKeydown(key), openSearchResult(item), handleDocumentClick(event), clearSearch, hydrateTaskDrawerPreference, destroy
|
||||||
|
// @RELATION CALLS -> [getDashboards]
|
||||||
|
// @RELATION CALLS -> [getDatasets]
|
||||||
|
// @RELATION CALLS -> [getTasks]
|
||||||
|
// @RELATION CALLS -> [getProfilePreferences]
|
||||||
|
// @RELATION CALLS -> [getReports]
|
||||||
|
// @RELATION DISPATCHES -> [EXT:frontend:taskDrawerStore]
|
||||||
|
// @DATA_CONTRACT SearchQuery:string × EnvId:string → SearchSection[] | ProfilePreferences → void
|
||||||
|
// @POST Search results grouped by section (dashboards, datasets, tasks, reports). Dropdown stays open while results load.
|
||||||
|
// @SIDE_EFFECT Fetch API (dashboards, datasets, tasks, reports), localStorage I/O via setTaskDrawerAutoOpenPreference.
|
||||||
|
// @RATIONALE Search logic extracted from TopNavbar component (605 lines → ~300 lines) to satisfy INV_7 (module < 400 LOC) and ATTN_4 (contract ≤ 150 lines in sliding window). The original inline architecture scattered search state across 7 reactive atoms and 8 functions inside a single 605-line component — the model could not physically see the full contract under CSA/HCA compression. Model-first extraction puts dense #region anchor on line 1 for maximum first-line density, dot-separated hierarchical ID for HCA survival, and groups all search state into one unit that fits the sliding window.
|
||||||
|
// @REJECTED Extracting user menu, environment selector, and activity indicator into submodels was rejected — each is ≤ 3 store-derived values with simple toggle logic, extract overhead exceeds benefit. Inline $state for those three concerns is the right call per Model Decomposition Gate: they are local UI state without cross-widget invariants. Full decomposition into 3 submodels was rejected as premature (each submodel would be < 30 lines).
|
||||||
|
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { log } from "$lib/cot-logger";
|
||||||
|
import { api } from "$lib/api.js";
|
||||||
|
import { getReports } from "$lib/api/reports.js";
|
||||||
|
import {
|
||||||
|
openDrawerForTask,
|
||||||
|
setTaskDrawerAutoOpenPreference,
|
||||||
|
} from "$lib/stores/taskDrawer.svelte.js";
|
||||||
|
import type { Task, TaskStatus } from "$types/models";
|
||||||
|
import type { ReportCollection } from "$types/reports";
|
||||||
|
|
||||||
|
// ── Type definitions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SearchResultItem {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
href?: string;
|
||||||
|
taskId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSection {
|
||||||
|
key: string;
|
||||||
|
labelKey: string; // i18n key resolved in template: $t.nav?.[section.labelKey]
|
||||||
|
items: SearchResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard shape returned by GET /dashboards (subset used by search). */
|
||||||
|
interface DashboardSearchResult {
|
||||||
|
id: number | string;
|
||||||
|
title?: string;
|
||||||
|
dashboard_title?: string;
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dataset shape returned by GET /datasets (subset used by search). */
|
||||||
|
interface DatasetSearchResult {
|
||||||
|
id: number | string;
|
||||||
|
table_name?: string;
|
||||||
|
schema?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Task enriched with fields used for client-side filtering. */
|
||||||
|
interface TaskSearchResult extends Task {
|
||||||
|
plugin_id?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardsResponse {
|
||||||
|
dashboards?: DashboardSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatasetsResponse {
|
||||||
|
datasets?: DatasetSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
const SEARCH_MIN_LENGTH = 2;
|
||||||
|
const SEARCH_LIMIT = 5;
|
||||||
|
|
||||||
|
// ── Model ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class TopNavbarModel {
|
||||||
|
// ── Atoms ─────────────────────────────────────────────────────
|
||||||
|
searchQuery: string = $state("");
|
||||||
|
isSearchFocused: boolean = $state(false);
|
||||||
|
showSearchDropdown: boolean = $state(false);
|
||||||
|
isSearchLoading: boolean = $state(false);
|
||||||
|
groupedSearchResults: SearchSection[] = $state([]);
|
||||||
|
|
||||||
|
// ── Internal (non-reactive) ───────────────────────────────────
|
||||||
|
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// ── Public actions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
handleSearchInput(value: string): void {
|
||||||
|
this.searchQuery = value;
|
||||||
|
if (this.searchTimer) {
|
||||||
|
clearTimeout(this.searchTimer);
|
||||||
|
}
|
||||||
|
this.searchTimer = setTimeout(() => {
|
||||||
|
this._triggerSearch(this.searchQuery);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearchFocus(): void {
|
||||||
|
this.isSearchFocused = true;
|
||||||
|
this.showSearchDropdown = this.groupedSearchResults.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch(): void {
|
||||||
|
this.showSearchDropdown = false;
|
||||||
|
this.isSearchLoading = false;
|
||||||
|
this.groupedSearchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSearchKeydown(key: string, envId: string): Promise<boolean> {
|
||||||
|
if (key === "Escape") {
|
||||||
|
this.clearSearch();
|
||||||
|
this.searchQuery = "";
|
||||||
|
this.isSearchFocused = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const firstItem = this.groupedSearchResults[0]?.items?.[0];
|
||||||
|
if (key === "Enter" && firstItem) {
|
||||||
|
await this.openSearchResult(firstItem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openSearchResult(item: SearchResultItem): Promise<void> {
|
||||||
|
this.clearSearch();
|
||||||
|
this.searchQuery = "";
|
||||||
|
this.isSearchFocused = false;
|
||||||
|
if (item.type === "task" && item.taskId) {
|
||||||
|
openDrawerForTask(item.taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.href) {
|
||||||
|
await goto(item.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerSearch(query: string, envId: string): Promise<void> {
|
||||||
|
this.searchQuery = query;
|
||||||
|
await this._triggerSearch(query, envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDocumentClick(event: MouseEvent): { closeUserMenu: boolean; closeSearch: boolean } {
|
||||||
|
const result = { closeUserMenu: false, closeSearch: false };
|
||||||
|
if (!(event.target as Element)?.closest(".user-menu-container")) {
|
||||||
|
result.closeUserMenu = true;
|
||||||
|
}
|
||||||
|
if (!(event.target as Element)?.closest(".global-search-container")) {
|
||||||
|
result.closeSearch = true;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hydrateTaskDrawerPreference(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await api.getProfilePreferences<{ preference?: { auto_open_task_drawer?: boolean } }>();
|
||||||
|
const autoOpenTaskDrawer = response?.preference?.auto_open_task_drawer;
|
||||||
|
setTaskDrawerAutoOpenPreference(autoOpenTaskDrawer !== false);
|
||||||
|
} catch (error) {
|
||||||
|
log("TopNavbarModel", "EXPLORE", "Failed to hydrate task drawer preference", {}, error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.searchTimer) {
|
||||||
|
clearTimeout(this.searchTimer);
|
||||||
|
this.searchTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async _triggerSearch(query: string, envId?: string): Promise<void> {
|
||||||
|
const normalizedQuery = String(query || "").trim();
|
||||||
|
if (normalizedQuery.length < SEARCH_MIN_LENGTH || !envId) {
|
||||||
|
this.clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isSearchLoading = true;
|
||||||
|
this.showSearchDropdown = true;
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
dashboardResponse,
|
||||||
|
datasetResponse,
|
||||||
|
tasksResponse,
|
||||||
|
reportsResponse,
|
||||||
|
] = await Promise.all([
|
||||||
|
api.getDashboards(envId, {
|
||||||
|
search: normalizedQuery,
|
||||||
|
page: 1,
|
||||||
|
page_size: SEARCH_LIMIT,
|
||||||
|
}),
|
||||||
|
api.getDatasets(envId, {
|
||||||
|
search: normalizedQuery,
|
||||||
|
page: 1,
|
||||||
|
page_size: SEARCH_LIMIT,
|
||||||
|
}),
|
||||||
|
api.getTasks({ limit: 30, offset: 0, search: normalizedQuery }),
|
||||||
|
getReports({
|
||||||
|
page: 1,
|
||||||
|
page_size: SEARCH_LIMIT,
|
||||||
|
search: normalizedQuery,
|
||||||
|
sort_by: "updated_at",
|
||||||
|
sort_order: "desc",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.groupedSearchResults = this._buildSections(
|
||||||
|
dashboardResponse,
|
||||||
|
datasetResponse,
|
||||||
|
tasksResponse,
|
||||||
|
reportsResponse,
|
||||||
|
normalizedQuery,
|
||||||
|
envId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log("TopNavbarModel", "EXPLORE", "Global search failed", { query: normalizedQuery }, error instanceof Error ? error.message : String(error));
|
||||||
|
this.groupedSearchResults = [];
|
||||||
|
} finally {
|
||||||
|
this.isSearchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildSections(
|
||||||
|
dashboardResponse: DashboardsResponse,
|
||||||
|
datasetResponse: DatasetsResponse,
|
||||||
|
tasksResponse: TaskSearchResult[],
|
||||||
|
reportsResponse: ReportCollection,
|
||||||
|
query: string,
|
||||||
|
envId: string,
|
||||||
|
): SearchSection[] {
|
||||||
|
const dashboards = (dashboardResponse.dashboards || []).slice(0, SEARCH_LIMIT);
|
||||||
|
const datasets = (datasetResponse.datasets || []).slice(0, SEARCH_LIMIT);
|
||||||
|
|
||||||
|
const dashboardItems: SearchResultItem[] = dashboards.map((dashboard) => ({
|
||||||
|
key: `dashboard-${dashboard.id}`,
|
||||||
|
type: "dashboard",
|
||||||
|
title: dashboard.title || dashboard.dashboard_title || `#${dashboard.id}`,
|
||||||
|
subtitle: `ID: ${dashboard.id}`,
|
||||||
|
href: `/dashboards/${encodeURIComponent(String(dashboard.slug || dashboard.id))}?env_id=${encodeURIComponent(envId)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const datasetItems: SearchResultItem[] = 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(envId)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const q = String(query || "").toLowerCase();
|
||||||
|
const tasks = (tasksResponse || []).slice(0, 30);
|
||||||
|
const taskItems: SearchResultItem[] = tasks
|
||||||
|
.filter((task) => {
|
||||||
|
const haystack = `${task.id || ""} ${task.plugin_id || ""} ${task.status || ""}`.toLowerCase();
|
||||||
|
return q && haystack.includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, SEARCH_LIMIT)
|
||||||
|
.map((task) => ({
|
||||||
|
key: `task-${task.id}`,
|
||||||
|
type: "task",
|
||||||
|
title: task.plugin_id || "task",
|
||||||
|
subtitle: `${task.id} · ${task.status || "-"}`,
|
||||||
|
taskId: task.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reportItems: SearchResultItem[] = (reportsResponse.items || [])
|
||||||
|
.slice(0, SEARCH_LIMIT)
|
||||||
|
.map((report) => ({
|
||||||
|
key: `report-${report.report_id}`,
|
||||||
|
type: "report",
|
||||||
|
title: report.summary || report.report_id,
|
||||||
|
subtitle: `${report.task_type || "-"} · ${report.status || "-"}`,
|
||||||
|
href: "/reports",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Section labelKey resolves via $t.nav?.[labelKey] in the component template.
|
||||||
|
const sections: SearchSection[] = [];
|
||||||
|
if (dashboardItems.length > 0) {
|
||||||
|
sections.push({ key: "dashboards", labelKey: "dashboards", items: dashboardItems });
|
||||||
|
}
|
||||||
|
if (datasetItems.length > 0) {
|
||||||
|
sections.push({ key: "datasets", labelKey: "datasets", items: datasetItems });
|
||||||
|
}
|
||||||
|
if (taskItems.length > 0) {
|
||||||
|
sections.push({ key: "tasks", labelKey: "tasks", items: taskItems });
|
||||||
|
}
|
||||||
|
if (reportItems.length > 0) {
|
||||||
|
sections.push({ key: "reports", labelKey: "reports", items: reportItems });
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endregion TopNavbar.Model
|
||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
DEFAULT: '#2563eb', // blue-600
|
DEFAULT: '#2563eb', // blue-600
|
||||||
hover: '#1d4ed8', // blue-700
|
hover: '#1d4ed8', // blue-700
|
||||||
ring: '#3b82f6', // blue-500
|
ring: '#3b82f6', // blue-500
|
||||||
|
'ring-light': '#bae6fd', // sky-200 — focus ring for non-critical controls
|
||||||
light: '#eff6ff', // blue-50
|
light: '#eff6ff', // blue-50
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
@@ -96,6 +97,12 @@ export default {
|
|||||||
git: '#fb923c',
|
git: '#fb923c',
|
||||||
system: '#38bdf8',
|
system: '#38bdf8',
|
||||||
},
|
},
|
||||||
|
// ── Brand identity ──────────────────────────────────────
|
||||||
|
brand: {
|
||||||
|
'gradient-from': '#0ea5e9', // sky-500
|
||||||
|
'gradient-via': '#06b6d4', // cyan-500
|
||||||
|
'gradient-to': '#4f46e5', // indigo-600
|
||||||
|
},
|
||||||
// ── Category gradient tokens (sidebar/breadcrumb icons) ──
|
// ── Category gradient tokens (sidebar/breadcrumb icons) ──
|
||||||
category: {
|
category: {
|
||||||
dashboards: { from: '#e0f2fe', to: '#bae6fd', text: '#0369a1', ring: '#7dd3fc' }, // sky
|
dashboards: { from: '#e0f2fe', to: '#bae6fd', text: '#0369a1', ring: '#7dd3fc' }, // sky
|
||||||
|
|||||||
Reference in New Issue
Block a user