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 -->
|
||||
<!-- @BRIEF Unified top navigation bar with Logo, global search, environment selector, activity indicator, user menu, and assistant toggle. -->
|
||||
<!-- @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:authStore] -->
|
||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:environmentContextStore] -->
|
||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:sidebarStore] -->
|
||||
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
|
||||
<!-- @RELATION BINDS_TO -> [TopNavbar.Model] -->
|
||||
<!-- @RELATION DISPATCHES -> [EXT:frontend:assistantChatStore] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [Icon] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [LanguageSwitcher] -->
|
||||
<!-- @UX_STATE Idle -> Navbar showing current state with all controls. -->
|
||||
<!-- @UX_STATE SearchFocused -> Search input expands with focus styling. -->
|
||||
<!-- @UX_STATE Searching -> Search dropdown open while async results load. -->
|
||||
<!-- @UX_FEEDBACK Activity badge shows count of running tasks. -->
|
||||
<!-- @UX_FEEDBACK Environment selector shows PROD context in red. -->
|
||||
<!-- @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">
|
||||
/**
|
||||
* @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 { log } from "$lib/cot-logger";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
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 {
|
||||
openDrawerForTask,
|
||||
openDrawer,
|
||||
setTaskDrawerAutoOpenPreference,
|
||||
} from "$lib/stores/taskDrawer.svelte.js";
|
||||
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.svelte.js";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { auth } from "$lib/auth/store.svelte.js";
|
||||
import { hasPermission } from "$lib/auth/permissions.js";
|
||||
import { Icon } from "$lib/ui";
|
||||
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
|
||||
import {
|
||||
environmentContextStore,
|
||||
initializeEnvironmentContext,
|
||||
setSelectedEnvironment,
|
||||
selectedEnvironmentStore,
|
||||
} 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 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 });
|
||||
onMount(() => {
|
||||
const unsub = auth.subscribe(v => { _authState = v; });
|
||||
return unsub;
|
||||
});
|
||||
|
||||
// ── Derived projections ────────────────────────────────────────
|
||||
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
||||
let activeCount = $derived(activityStore.value?.activeCount || 0);
|
||||
let recentTasks = $derived(activityStore.value?.recentTasks || []);
|
||||
@@ -121,8 +86,8 @@
|
||||
Boolean(globalSelectedEnv?.is_production),
|
||||
);
|
||||
|
||||
function toggleUserMenu(event) {
|
||||
event.stopPropagation();
|
||||
// ── User menu actions ──────────────────────────────────────────
|
||||
function toggleUserMenu() {
|
||||
showUserMenu = !showUserMenu;
|
||||
}
|
||||
|
||||
@@ -136,8 +101,9 @@
|
||||
window.location.href = ROUTES.login();
|
||||
}
|
||||
|
||||
// ── Activity / Assistant actions ───────────────────────────────
|
||||
function handleActivityClick() {
|
||||
const runningTask = recentTasks.find((t) => t.status === "RUNNING");
|
||||
const runningTask = recentTasks.find((t: any) => t.status === "RUNNING");
|
||||
if (runningTask) {
|
||||
openDrawerForTask(runningTask.taskId);
|
||||
} else if (recentTasks.length > 0) {
|
||||
@@ -155,231 +121,49 @@
|
||||
goto(`/agent?${query}`);
|
||||
}
|
||||
|
||||
async function hydrateTaskDrawerPreference() {
|
||||
try {
|
||||
const response = await api.getProfilePreferences();
|
||||
const autoOpenTaskDrawer = response?.preference?.auto_open_task_drawer;
|
||||
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")) {
|
||||
// ── Document click (delegates to model for search, keeps user menu local) ──
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
const { closeSearch } = model.handleDocumentClick(event);
|
||||
if (!(event.target as Element)?.closest(".user-menu-container")) {
|
||||
closeUserMenu();
|
||||
}
|
||||
if (!event.target.closest(".global-search-container")) {
|
||||
isSearchFocused = false;
|
||||
clearSearchState();
|
||||
if (closeSearch) {
|
||||
model.isSearchFocused = false;
|
||||
model.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function handleHamburgerClick(event) {
|
||||
// ── Hamburger ──────────────────────────────────────────────────
|
||||
function handleHamburgerClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
|
||||
function handleGlobalEnvironmentChange(event) {
|
||||
const nextEnvId = event.currentTarget.value;
|
||||
selectedEnvironmentValue = nextEnvId;
|
||||
// ── Environment selector ───────────────────────────────────────
|
||||
function handleGlobalEnvironmentChange(event: Event) {
|
||||
const nextEnvId = (event.currentTarget as HTMLSelectElement).value;
|
||||
setSelectedEnvironment(nextEnvId);
|
||||
if (searchQuery.trim().length >= SEARCH_MIN_LENGTH) {
|
||||
triggerSearch(searchQuery.trim());
|
||||
if (model.searchQuery.trim().length >= 2) {
|
||||
model.triggerSearch(model.searchQuery.trim(), nextEnvId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSearchResultSections(
|
||||
dashboardResponse,
|
||||
datasetResponse,
|
||||
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) {
|
||||
// ── Search keyboard (Escape/Enter) ─────────────────────────────
|
||||
async function handleSearchKeydown(event: KeyboardEvent) {
|
||||
if (await model.handleSearchKeydown(event.key, globalSelectedEnvId)) {
|
||||
event.preventDefault();
|
||||
await openSearchResult(firstItem);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
void initializeEnvironmentContext();
|
||||
void hydrateTaskDrawerPreference();
|
||||
void model.hydrateTaskDrawerPreference();
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
}
|
||||
return () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
model.destroy();
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
}
|
||||
@@ -388,12 +172,11 @@
|
||||
</script>
|
||||
|
||||
<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'}"
|
||||
>
|
||||
<!-- Left section: Hamburger (mobile) + Logo -->
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<!-- Hamburger Menu (mobile only) -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted md:hidden"
|
||||
onclick={handleHamburgerClick}
|
||||
@@ -408,7 +191,7 @@
|
||||
class="flex min-w-0 items-center text-xl font-bold text-text transition-colors hover:text-primary"
|
||||
>
|
||||
<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} />
|
||||
</span>
|
||||
@@ -417,46 +200,44 @@
|
||||
</div>
|
||||
|
||||
<!-- Global search -->
|
||||
<div
|
||||
class="global-search-container relative 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
|
||||
id="global-search"
|
||||
name="global_search"
|
||||
aria-label={$t.common.search}
|
||||
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
|
||||
{isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
||||
{model.isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
||||
placeholder={$t.common.search}
|
||||
value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
onfocus={handleSearchFocus}
|
||||
value={model.searchQuery}
|
||||
oninput={(e) => model.handleSearchInput(e.currentTarget.value)}
|
||||
onfocus={() => model.handleSearchFocus()}
|
||||
onkeydown={handleSearchKeydown}
|
||||
/>
|
||||
{#if showSearchDropdown}
|
||||
{#if model.showSearchDropdown}
|
||||
<div
|
||||
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">
|
||||
{$t.common?.loading || "Loading..."}
|
||||
</div>
|
||||
{:else if groupedSearchResults.length === 0}
|
||||
{:else if model.groupedSearchResults.length === 0}
|
||||
<div class="px-4 py-3 text-sm text-text-muted">
|
||||
{$t.common?.not_found || "No results found"}
|
||||
</div>
|
||||
{:else}
|
||||
{#each groupedSearchResults as section}
|
||||
{#each model.groupedSearchResults as section (section.key)}
|
||||
<div class="border-b border-border last:border-b-0">
|
||||
<div
|
||||
class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted"
|
||||
>
|
||||
{section.label}
|
||||
{$t.nav?.[section.labelKey] || section.labelKey}
|
||||
</div>
|
||||
{#each section.items as result}
|
||||
{#each section.items as result (result.key)}
|
||||
<button
|
||||
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="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
|
||||
{isProdContext
|
||||
? '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}
|
||||
onchange={handleGlobalEnvironmentChange}
|
||||
aria-label={$t.dashboard?.environment || "Environment"}
|
||||
title={$t.dashboard?.environment || "Environment"}
|
||||
>
|
||||
{#each globalEnvironments as env}
|
||||
{#each globalEnvironments as env (env.id)}
|
||||
<option value={env.id}>
|
||||
{env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- Assistant -->
|
||||
<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"
|
||||
@@ -511,7 +289,7 @@
|
||||
title={$t.assistant?.title}
|
||||
>
|
||||
<Icon name="aiAssistant" size={16} />
|
||||
<span>{$t.assistant?.assistant || "Ассистент"}</span>
|
||||
<span>{$t.assistant.assistant}</span>
|
||||
</button>
|
||||
|
||||
<!-- 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"
|
||||
onclick={toggleUserMenu}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
|
||||
(e.key === "Enter" || e.key === " ") && toggleUserMenu()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={$t.common?.user_menu}
|
||||
@@ -563,15 +341,30 @@
|
||||
<strong>{user?.username || $t.common?.user}</strong>
|
||||
</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}
|
||||
<div
|
||||
class="px-4 py-2 text-sm text-text hover:bg-surface-muted cursor-pointer"
|
||||
onclick={() => {
|
||||
closeUserMenu();
|
||||
window.location.href = ROUTES.settings.general();
|
||||
}}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
(window.location.href = ROUTES.settings.general())}
|
||||
(closeUserMenu(), window.location.href = ROUTES.settings.general())}
|
||||
role="button"
|
||||
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
|
||||
hover: '#1d4ed8', // blue-700
|
||||
ring: '#3b82f6', // blue-500
|
||||
'ring-light': '#bae6fd', // sky-200 — focus ring for non-critical controls
|
||||
light: '#eff6ff', // blue-50
|
||||
},
|
||||
secondary: {
|
||||
@@ -96,6 +97,12 @@ export default {
|
||||
git: '#fb923c',
|
||||
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: {
|
||||
dashboards: { from: '#e0f2fe', to: '#bae6fd', text: '#0369a1', ring: '#7dd3fc' }, // sky
|
||||
|
||||
Reference in New Issue
Block a user