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:
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
|
||||
Reference in New Issue
Block a user