import { Popup } from "../components/popup/popup"; import { SelectBox } from "../components/selectbox"; import { GroupModel } from "../models/group-model"; import { OptionModel } from "../models/option-model"; import { LifecycleState } from "../types/core/base/lifecycle.type"; import { MixedItem } from "../types/core/base/mixed-adapter.type"; import { AjaxConfig, NormalizedAjaxItem, PaginationState, ParseResponseResult, } from "../types/core/search-controller.type"; import { Libs } from "../utils/libs"; import { Lifecycle } from "./base/lifecycle"; import { ModelManager } from "./model-manager"; /** * Search orchestration layer for Selective's Select UI. * * This controller bridges **user-driven search input** (keyword changes / infinite scroll) * to either: * - **Local filtering**: toggling model visibility flags in-memory, or * - **Remote search (AJAX)**: fetching, normalizing, and applying results back into the backing * native `` (DOM mutation) and keep selection when requested. * - Coordinate transient UI states via {@link Popup} (loading indicator). * * ### Lifecycle (Strict FSM) * - Constructed with references to the native `` children (`innerHTML`, `appendChild`, dataset, selected state). * - UI: calls `popup.showLoading()` / `popup.hideLoading()` when a popup is attached. * * @extends Lifecycle * @see {@link ModelManager} * @see {@link AjaxConfig} * @see {@link Popup} */ export class SearchController extends Lifecycle { /** Backing native `` and an existing {@link ModelManager}. * Immediately transitions lifecycle `NEW → INITIALIZED` via {@link Lifecycle.init}. * * @param {HTMLSelectElement} selectElement - Native select element acting as the authoritative data source/target. * @param {ModelManager} modelManager - Manager responsible for model resources and rendering refresh. * @param {SelectBox} selectBox - SelectBox handle used by configured AJAX data builders. */ public constructor( selectElement: HTMLSelectElement, modelManager: ModelManager, selectBox: SelectBox, ) { super(); this.initialize(selectElement, modelManager, selectBox); } /** * Captures dependencies and starts the controller lifecycle. * Intended to be called only from the constructor. * * @param {HTMLSelectElement} selectElement - Native select element. * @param {ModelManager} modelManager - Model manager. * @param {SelectBox} selectBox - SelectBox handle. * @returns {void} */ private initialize( selectElement: HTMLSelectElement, modelManager: ModelManager, selectBox: SelectBox, ): void { this.select = selectElement; this.modelManager = modelManager; this.selectBox = selectBox; this.init(); } /** * Indicates whether remote (AJAX) search is configured. * * @returns {boolean} `true` when {@link AjaxConfig} is present; otherwise `false`. */ public isAjax(): boolean { return !!this.ajaxConfig; } /** * Loads specific option rows by their values from the server (AJAX-only). * * ### Behavior * - Uses `ajaxConfig.dataByValues(values[])` when provided; otherwise builds a default payload: * `{ values: "...", load_by_values: "1", ...ajaxConfig.data }`. * - Supports GET/POST according to `ajaxConfig.method` (defaults to GET). * - Normalizes the response via {@link parseResponse}. * - Calls {@link Lifecycle.update} to mark an internal update. * * @param {string | string[]} values - One value or a list of values to fetch. * @returns {Promise<{ success: boolean; items: NormalizedAjaxItem[]; message?: string }>} * Resolves with normalized items on success. * * @remarks * - When AJAX is not configured, resolves with `{ success: false, ... }`. * - This method does not mutate the `` options * and those missing. * * @param {string[]} values - Values to check. * @returns {{ existing: string[]; missing: string[] }} Partitioned result. */ public checkMissingValues(values: string[]): { existing: string[]; missing: string[]; } { const allOptions = Array.from(this.select.options); const existingValues = allOptions.map((opt) => opt.value); const existing = values.filter((v) => existingValues.includes(v)); const missing = values.filter((v) => !existingValues.includes(v)); return { existing, missing }; } /** * Configures AJAX settings used for remote searching and pagination. * Setting `null` disables AJAX mode and causes {@link search} to use local filtering. * * @param {AjaxConfig} config - AJAX configuration (endpoint, method, data builders, keepSelected, ...). * @returns {void} */ public setAjax(config?: AjaxConfig): void { this.ajaxConfig = config; } /** * Attaches a popup instance so the controller can reflect transient UI states * during remote operations (e.g., loading indicator). * * @param {Popup} popupInstance - Popup used to show results and loading state. * @returns {void} */ public setPopup(popupInstance: Popup): void { this.popup = popupInstance; } /** * Returns a shallow snapshot of the current pagination state. * * @returns {PaginationState} State snapshot (defensive copy). */ public getPaginationState(): PaginationState { return { ...this.paginationState }; } /** * Resets pagination counters while preserving whether pagination is enabled. * Clears page/totals/loading flags and the current keyword. * * @returns {void} */ public resetPagination(): void { this.paginationState = { currentPage: 0, totalPages: 1, hasMore: false, isLoading: false, currentKeyword: "", isPaginationEnabled: this.paginationState.isPaginationEnabled, }; } /** * Clears the current keyword and restores visibility for all option models (local reset). * * ### Notes * - No network requests are made. * - This mutates `OptionModel.visible` for the current model set exposed by {@link ModelManager#getResources}. * * @returns {void} */ public clear(): void { this.paginationState.currentKeyword = ""; const { modelList } = this.modelManager.getResources(); const flatOptions: OptionModel[] = []; for (const m of modelList) { if (m instanceof OptionModel) flatOptions.push(m); else if (m instanceof GroupModel && Array.isArray(m.items)) flatOptions.push(...m.items); } flatOptions.forEach((opt) => { opt.visible = true; }); } /** * Performs a search using the configured strategy. * - If {@link AjaxConfig} is present, executes {@link ajaxSearch}. * - Otherwise performs local filtering via {@link localSearch}. * * @param {string} keyword - Search term. * @param {boolean} [append=false] - AJAX mode only: append results (next page) instead of replacing. * @returns {Promise} Implementation-specific result object from the underlying strategy. */ public async search( keyword: string, append: boolean = false, ): Promise { if (this.ajaxConfig) return this.ajaxSearch(keyword, append); return this.localSearch(keyword); } /** * Loads the next page in AJAX mode when pagination is enabled and available. * * ### Guards (no-ops with error result) * - AJAX must be configured. * - Must not already be loading. * - Pagination must be enabled and `hasMore` must be true. * * @returns {Promise} Result of the paginated request, or an error object when not applicable. */ public async loadMore(): Promise { if (!this.ajaxConfig) return { success: false, message: "Ajax not enabled" }; if (this.paginationState.isLoading) return { success: false, message: "Already loading" }; if (!this.paginationState.isPaginationEnabled) return { success: false, message: "Pagination not enabled" }; if (!this.paginationState.hasMore) return { success: false, message: "No more data" }; this.paginationState.currentPage++; return this.ajaxSearch(this.paginationState.currentKeyword, true); } /** * Executes an in-memory search by normalizing the keyword and toggling each option's visibility. * * ### Matching * - Keyword is lowercased and de-accented via {@link Libs.string2normalize}. * - Each option uses `OptionModel.textToFind` for matching. * * ### Side effects * - Mutates `OptionModel.visible`. * - Calls {@link Lifecycle.update}. * * @param {string} keyword - Keyword to filter against local options. * @returns {Promise<{ success: boolean; hasResults: boolean; isEmpty: boolean }>} * Summary result for UI consumers. */ private async localSearch( keyword: string, ): Promise<{ success: boolean; hasResults: boolean; isEmpty: boolean }> { if (this.compareSearchTrigger(keyword)) this.paginationState.currentKeyword = keyword; const lower = String(keyword ?? "").toLowerCase(); const lowerNA = Libs.string2normalize(lower); const { modelList } = this.modelManager.getResources(); const flatOptions: OptionModel[] = []; for (const m of modelList) { if (m instanceof OptionModel) flatOptions.push(m); else if (m instanceof GroupModel && Array.isArray(m.items)) flatOptions.push(...m.items); } let hasVisibleItems = false; flatOptions.forEach((opt) => { const isVisible = lower === "" || opt.textToFind.includes(lowerNA); opt.visible = isVisible; if (isVisible) hasVisibleItems = true; }); this.update(); return { success: true, hasResults: hasVisibleItems, isEmpty: flatOptions.length === 0, }; } /** * Determines whether the given keyword differs from the currently tracked keyword. * Used to decide whether a new search "session" should reset pagination. * * @param {string} keyword - Candidate keyword. * @returns {boolean} `true` if keyword differs from `paginationState.currentKeyword`. */ public compareSearchTrigger(keyword: string): boolean { return keyword !== this.paginationState.currentKeyword; } /** * Executes an AJAX-based search with optional appending (pagination). * * ### Behavior * - If keyword changed (see {@link compareSearchTrigger}), pagination is reset and `append` is forced to `false`. * - Aborts any in-flight request and starts a new one via {@link AbortController}. * - Shows/hides loading UI on the attached {@link Popup} if present. * - Supports GET/POST based on {@link AjaxConfig.method}; payload is built from {@link AjaxConfig.data}. * - Normalizes server response via {@link parseResponse}. * - Applies items to the underlying `` element. * * ### Behavior * - Optionally preserves existing selection values (`keepSelected`). * - Clears existing options when `append === false`. * - Accepts either normalized items or raw DOM nodes (`HTMLOptionElement` / `HTMLOptGroupElement`). * - Populates `dataset` for `data` payload fields on generated nodes. * * ### DOM side effects * - Mutates `