/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import { KTSelectConfigInterface, KTSelectOption as KTSelectOptionData, } from './config'; /** * KTSelectRemote class * Handles fetching remote data for the KTSelect component */ export class KTSelectRemote { private _config: KTSelectConfigInterface; private _isLoading: boolean = false; private _hasError: boolean = false; private _errorMessage: string = ''; private _currentPage: number = 1; private _totalPages: number = 1; private _lastQuery: string = ''; private _element: HTMLElement | null = null; private _isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } /** * Constructor * @param config KTSelect configuration * @param element The select element */ constructor(config: KTSelectConfigInterface, element?: HTMLElement) { this._config = config; this._element = element || null; } /** * Fetch data from remote URL * @param query Optional search query * @param page Page number for pagination * @returns Promise with fetched items */ public fetchData( query?: string, page: number = 1, ): Promise { this._isLoading = true; this._hasError = false; this._errorMessage = ''; this._lastQuery = query || ''; this._currentPage = page; const url = this._buildUrl(query, page); // Dispatch search start event this._dispatchEvent('remoteSearchStart'); return fetch(url) .then((response: Response): Promise => { if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } return response.json(); }) .then((data: unknown): KTSelectOptionData[] => { // Process the data return this._processData(data); }) .catch((error: Error): KTSelectOptionData[] => { console.error('Error fetching remote data:', error); this._hasError = true; this._errorMessage = this._config.remoteErrorMessage || 'Failed to load data'; return []; }) .finally((): void => { this._isLoading = false; // Dispatch search end event this._dispatchEvent('remoteSearchEnd'); }); } /** * Dispatch custom events to notify about search state changes * @param eventName Name of the event to dispatch */ private _dispatchEvent(eventName: string): void { if (!this._element) return; const event = new CustomEvent(`ktselect.${eventName}`, { bubbles: true, detail: { query: this._lastQuery, isLoading: this._isLoading, hasError: this._hasError, errorMessage: this._errorMessage, }, }); this._element.dispatchEvent(event); } /** * Build the URL for the API request * @param query Search query * @param page Page number * @returns Fully formed URL */ private _buildUrl(query?: string, page: number = 1): string { let url = this._config.dataUrl; if (!url) { console.error('No URL specified for remote data'); return ''; } // Add parameters const params = new URLSearchParams(); // Add search parameter if provided if (query && this._config.searchParam) { params.append(this._config.searchParam, query); } // Add pagination parameters if enabled if (this._config.pagination) { const limitParam = this._config.paginationLimitParam || 'limit'; const pageParam = this._config.paginationPageParam || 'page'; const limit = this._config.paginationLimit || 10; params.append(limitParam, limit.toString()); params.append(pageParam, page.toString()); } // Append parameters to URL if there are any const paramsString = params.toString(); if (paramsString) { url += (url.includes('?') ? '&' : '?') + paramsString; } return url; } /** * Process the API response data * @param data API response data * @returns Array of KTSelectOptionData */ private _processData(data: unknown): KTSelectOptionData[] { try { let processedData: unknown = data; const dataRecord = this._isRecord(data) ? data : null; // Extract data from the API property if specified if ( this._config.apiDataProperty && dataRecord && this._config.apiDataProperty in dataRecord ) { // If pagination metadata is available, extract it if (this._config.pagination) { if (typeof dataRecord.total_pages === 'number') { this._totalPages = dataRecord.total_pages; } if (typeof dataRecord.total === 'number') { this._totalPages = Math.ceil( dataRecord.total / (this._config.paginationLimit || 10), ); } } processedData = dataRecord[this._config.apiDataProperty]; } // Ensure data is an array if (!Array.isArray(processedData)) { console.warn('Remote data is not an array:', processedData); return []; } // Map data to KTSelectOptionData format const mappedData = processedData.map( (item: unknown): KTSelectOptionData => { const mappedItem = this._mapItemToOption(item); // Add logging to trace data path extraction if ( this._config.dataValueField && this._config.dataValueField.includes('.') ) { // For nested paths, verify extraction worked const parts = this._config.dataValueField.split('.'); let nestedValue: unknown = item; // Try to navigate to the value manually for verification for (const part of parts) { if ( nestedValue && typeof nestedValue === 'object' && part in nestedValue ) { nestedValue = (nestedValue as Record)[part]; } else { nestedValue = null; break; } } // If we found a value, verify it matches what was extracted if (nestedValue !== null && nestedValue !== undefined) { const expectedValue = String(nestedValue); if (mappedItem.id !== expectedValue && expectedValue) { console.warn( `Value mismatch! Path: ${this._config.dataValueField}, Expected: ${expectedValue}, Got: ${mappedItem.id}`, ); } } } return mappedItem; }, ); return mappedData; } catch (error) { console.error('Error processing remote data:', error); this._hasError = true; this._errorMessage = 'Error processing data'; return []; } } /** * Map a data item to KTSelectOptionData format * @param item Data item from API * @returns KTSelectOptionData object */ private _mapItemToOption(item: unknown): KTSelectOptionData { // Get the field mapping from config with fallbacks for common field names const valueField = this._config.dataValueField || 'id'; const labelField = this._config.dataFieldText || 'title'; // Extract values using improved getValue function const getValue = (obj: unknown, path: string): unknown => { if (!path || !obj) return null; try { // Handle dot notation to access nested properties const parts = path.split('.'); let result: unknown = obj; for (const part of parts) { if ( result === null || result === undefined || typeof result !== 'object' ) { return null; } result = (result as Record)[part]; } return result; } catch (error) { console.error(`Error extracting path ${path}:`, error); return null; } }; // Get ID and ensure it's a string let id = getValue(item, valueField); const itemRecord = this._isRecord(item) ? item : {}; if (id === null || id === undefined) { // Try common fallback fields for ID const fallbackFields = ['id', 'value', 'key', 'pk']; for (const field of fallbackFields) { if (itemRecord[field] !== null && itemRecord[field] !== undefined) { id = String(itemRecord[field]); break; } } } else { id = String(id); } // If still no ID, generate one if (!id) { id = `option-${Math.random().toString(36).substr(2, 9)}`; } // Get label with proper fallbacks let title = getValue(item, labelField); if (!title) { // Try common fallback fields for title const fallbackFields = [ 'name', 'title', 'label', 'text', 'displayName', 'description', ]; for (const field of fallbackFields) { if (itemRecord[field] !== null && itemRecord[field] !== undefined) { title = String(itemRecord[field]); break; } } } else { title = String(title); } // If still no title, use ID as fallback if (!title) { title = `Option ${id}`; } // Create the option object with consistent structure const result: KTSelectOptionData = { id: String(id), title: String(title), selected: Boolean(itemRecord.selected), disabled: Boolean(itemRecord.disabled), }; return result; } /** * Load the next page of results * @returns Promise with fetched items */ public loadNextPage(): Promise { if (this._currentPage < this._totalPages) { return this.fetchData(this._lastQuery, this._currentPage + 1); } return Promise.resolve([]); } /** * Check if there are more pages available * @returns Boolean indicating if more pages exist */ public hasMorePages(): boolean { return this._currentPage < this._totalPages; } /** * Get loading state * @returns Boolean indicating if data is loading */ public isLoading(): boolean { return this._isLoading; } /** * Get error state * @returns Boolean indicating if there was an error */ public hasError(): boolean { return this._hasError; } /** * Get error message * @returns Error message */ public getErrorMessage(): string { return this._errorMessage; } /** * Reset the remote data state */ public reset(): void { this._isLoading = false; this._hasError = false; this._errorMessage = ''; this._currentPage = 1; this._totalPages = 1; this._lastQuery = ''; } /** * Set the select element for event dispatching * @param element The select element */ public setElement(element: HTMLElement): void { this._element = element; } }