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:
2026-07-05 08:50:43 +03:00
parent ff60865183
commit bc3e288d0b
3 changed files with 389 additions and 286 deletions

View 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