diff --git a/odex25_base/fims_general_search_tree_view/static/src/js/list_search.js b/odex25_base/fims_general_search_tree_view/static/src/js/list_search.js index 435db7d82..188528e9c 100644 --- a/odex25_base/fims_general_search_tree_view/static/src/js/list_search.js +++ b/odex25_base/fims_general_search_tree_view/static/src/js/list_search.js @@ -2,463 +2,377 @@ odoo.define('fims_general_search_tree_view.list_search', function (require) { "use strict"; var ListRenderer = require('web.ListRenderer'); + var ListController = require('web.ListController'); var core = require('web.core'); var _t = core._t; var concurrency = require('web.concurrency'); var rpc = require('web.rpc'); + var session = require('web.session'); - ListRenderer.include({ - events: _.extend({}, ListRenderer.prototype.events, { - 'keyup .oe_search_input': '_onSearchKeyUp', - 'input .oe_search_input': '_onSearchInput', - 'click .oe_clear_search': '_onClearSearchClick' - }), + // ================================ + // ListController: Data operations + // ================================ + ListController.include({ /** * @override */ init: function () { this._super.apply(this, arguments); - // Initialize mutex for preventing concurrent operations - this._searchMutex = new concurrency.Mutex(); // Initialize search state - this._initSearchState(); + this._customSearchState = { + timer: null, + value: '', + isFiltered: false, + filteredCount: 0, + originalDomain: null, + searchInProgress: false, + lastSearchPromise: null + }; + this._searchMutex = new concurrency.Mutex(); }, - + /** - * @override - FIXED: Single _renderView method with complete logic + * @override - Hook into rendering complete */ - _renderView: function () { + renderButtons: function () { + this._super.apply(this, arguments); + // Setup search handler after buttons are rendered + this._setupCustomSearchHandler(); + }, + + /** + * Setup custom search handler + */ + _setupCustomSearchHandler: function() { var self = this; - - // Store current search state to restore after render - var currentSearchValue = this._search ? this._search.value : ''; - var wasFiltered = this._search && this._search.isFiltered; - var originalFilteredCount = this._search ? this._search.filteredCount : 0; - - return this._super.apply(this, arguments).then(function (result) { - // Add search box if this is a tree view - if (self.arch && self.arch.tag === 'tree' && - self.$el && self.$el.hasClass('o_list_view')) { - - // Add search box if not exists - if (!self.$el.find('.oe_search_input').length) { - self._addSearchBox(); - } - - // Restore search UI state without re-triggering search - if (currentSearchValue && wasFiltered) { - console.log('Restoring search state for:', currentSearchValue); - self._restoreSearchUIState(currentSearchValue, originalFilteredCount); - } - - // Update search count after Odoo's native rendering if we're in search mode - if (self._search && self._search.isFiltered && self._search.value) { - self._updateSearchCount(); - } - } - - return result; - }); - }, - - /** - * Add search box to view - */ - _addSearchBox: function() { - if (this.$el.find('.oe_search_container').length) { + // Ensure renderer is ready + if (this.renderer && this.renderer._customSearchReady) { + console.log('Custom search handler already setup'); return; } - - var html = '
' + - '' + - '' + - '' + - '' + - '
'; - this.$el.prepend($(html)); }, - + /** - * Initialize search state + * Handle custom search from controller */ - _initSearchState: function() { - if (!this._search) { - this._search = { - timer: null, - value: '', - isFiltered: false, - filteredCount: 0, - originalDomain: null, - lastSearchPromise: null - }; - this._searchInProgress = false; - } - }, - - /** - * Restore search UI state after render - SIMPLIFIED - */ - _restoreSearchUIState: function(searchValue, filteredCount) { - var $input = this.$('.oe_search_input'); - var $clearBtn = this.$('.oe_clear_search'); - var $count = this.$('.oe_search_count'); - - // Restore input value - if ($input.length && searchValue) { - $input.val(searchValue); - $clearBtn.show(); - - // Restore search state - this._search.value = searchValue; - this._search.isFiltered = true; - - // Show count if available - if (filteredCount) { - this._search.filteredCount = filteredCount; - $count.text(_t('Found: ') + filteredCount + _t(' records')).show(); - } else { - $count.text(_t('Searching...')).show(); - } - } - }, - - /** - * Handle search input event - */ - _onSearchInput: function(e) { - this._search.value = $(e.currentTarget).val().trim(); - }, - - /** - * Handle search input keyup - ENHANCED LOGIC - */ - _onSearchKeyUp: function(e) { - var self = this; - var value = $(e.currentTarget).val().trim(); - - // Clear previous timer - if (this._search.timer) { - clearTimeout(this._search.timer); - } - - // Update UI immediately - this.$('.oe_clear_search').toggle(!!value); - - // Store value - this._search.value = value; - - // ENHANCED: Always trigger search with appropriate delay - this._search.timer = setTimeout(function() { - self._performSearch(value); - }, 500); - }, - - /** - * Handle clear button - */ - _onClearSearchClick: function() { - this._clearSearch(); - }, - - /** - * CORRECTED: Perform search using PROPER Odoo reload method - */ - _performSearch: function(value) { + _handleCustomSearch: function(value) { var self = this; - // Prevent concurrent searches - if (this._searchInProgress) { - console.log('Search already in progress, ignoring'); - return Promise.resolve(); + // Cancel any pending search + if (self._customSearchState.timer) { + clearTimeout(self._customSearchState.timer); + self._customSearchState.timer = null; } - console.log('=== CORRECTED PERFORM SEARCH ==='); - console.log('Search value:', value); - console.log('Value length:', value ? value.length : 0); - - return this._searchMutex.exec(function() { - self._searchInProgress = true; - - // Handle empty search by clearing filters - if (!value || value.length === 0) { - console.log('Empty search - clearing all filters'); - return self._clearSearchInternal().finally(function() { - self._searchInProgress = false; - }); - } - - // Check prerequisites - if (!self.state || !self.state.model) { - console.error('Missing model information'); - self._searchInProgress = false; - return Promise.resolve(); - } - - console.log('Starting CORRECTED search for model:', self.state.model); - - // Store original domain only once - if (!self._search.originalDomain && !self._search.isFiltered) { - self._storeOriginalDomain(); - } - - // Show loading - self._showLoading(true); - - // CORRECTED: Use proper Odoo reload method with domain update - return self._searchWithReload(value).finally(function() { - self._showLoading(false); - self._searchInProgress = false; - console.log('=== Corrected search completed ==='); - }); + // Debounce search + return new Promise(function(resolve) { + self._customSearchState.timer = setTimeout(function() { + self._executeCustomSearch(value).then(resolve); + }, 300); }); }, - + /** - * CORRECTED: The PROPER way - Use reload with updated domain + * Execute the search */ - _searchWithReload: function(value) { + _executeCustomSearch: function(value) { var self = this; - console.log('=== USING CORRECT RELOAD METHOD ==='); + if (self._customSearchState.searchInProgress) { + console.log('Search already in progress, queueing'); + return self._customSearchState.lastSearchPromise.then(function() { + return self._executeCustomSearch(value); + }); + } + + console.log('=== EXECUTING CUSTOM SEARCH ==='); + console.log('Search value:', value); + + var searchPromise = this._searchMutex.exec(function() { + self._customSearchState.searchInProgress = true; + + if (!value || value.length === 0) { + console.log('Empty search - clearing filters'); + return self._clearCustomSearch(); + } + + // Store original domain on first search + if (!self._customSearchState.originalDomain && !self._customSearchState.isFiltered) { + var currentState = self.model.get(self.handle); + if (currentState && currentState.domain) { + self._customSearchState.originalDomain = JSON.parse(JSON.stringify(currentState.domain)); + console.log('Stored original domain:', self._customSearchState.originalDomain); + } + } + + return self._applyCustomSearch(value); + }).finally(function() { + self._customSearchState.searchInProgress = false; + self._customSearchState.lastSearchPromise = null; + }); + + self._customSearchState.lastSearchPromise = searchPromise; + return searchPromise; + }, + + /** + * Apply search using proper Odoo 14 method + */ + _applyCustomSearch: function(value) { + var self = this; // Build search domain - var searchDomain = this._buildSearchDomain(value); - if (searchDomain.length === 0) { - console.warn('No searchable fields found for value:', value); + var searchDomain = this._buildCustomSearchDomain(value); + if (!searchDomain || searchDomain.length === 0) { + console.warn('No searchable fields found or no valid search domain'); return Promise.resolve(); } // Combine with original domain - var finalDomain = this._combineDomains(searchDomain); + var finalDomain = this._combineCustomDomains(searchDomain); - console.log('Final search domain:', finalDomain); + console.log('Search domain:', searchDomain); + console.log('Final domain:', finalDomain); - // STEP 1: Get accurate count using RPC + // Show loading state + if (self.renderer) { + self.renderer.$('.oe_search_loading').show(); + self.renderer.$('.oe_search_count').hide(); + } + + // First get the count return rpc.query({ - model: this.state.model, + model: this.modelName, method: 'search_count', args: [finalDomain], - context: this.state.context || {} + context: session.user_context }).then(function(count) { - console.log('Accurate search count from server:', count); + console.log('Found records:', count); - // Store count - self._search.filteredCount = count; + // Update UI with count + self._customSearchState.filteredCount = count; + self._updateCustomSearchUI(count); - // Update UI with accurate count - self.$('.oe_search_count').text(_t('Found: ') + count + _t(' records')).show(); + // Now reload the view with new domain + // CRITICAL FIX: Use proper reload method for Odoo 14 + var handle = self.handle; + var state = self.model.get(handle); - // STEP 2: THE CORRECT WAY - Use trigger_up with 'reload' passing domain - // This is how Odoo's own search works! - self.trigger_up('reload', { + // Update the domain in the state + return self.model.reload(handle, { domain: finalDomain, - context: self.state.context || {}, - groupBy: self.state.groupedBy || [], - keepSelection: false + offset: 0, // Reset to first page + limit: state.limit || 80 }); + }).then(function() { + // Update the view + self._customSearchState.isFiltered = true; + self._customSearchState.value = value; - // Update search state - self._search.isFiltered = true; - + // Trigger update to renderer + return self.update({}, {reload: false}); + }).then(function() { + console.log('Search applied successfully'); return Promise.resolve(); }).catch(function(error) { - console.error('Error getting search count:', error); - - // Fallback: still apply reload without count - self.trigger_up('reload', { - domain: finalDomain, - context: self.state.context || {}, - groupBy: self.state.groupedBy || [], - keepSelection: false - }); - - self._search.isFiltered = true; - self.$('.oe_search_count').text(_t('Search applied')).show(); - + console.error('Search error:', error); + self._showSearchError(); return Promise.resolve(); + }).finally(function() { + // Hide loading + if (self.renderer) { + self.renderer.$('.oe_search_loading').hide(); + } }); }, - + /** - * CORRECTED: Clear search using proper reload method + * Clear custom search */ - _clearSearchInternal: function() { + _clearCustomSearch: function() { var self = this; - console.log('=== CORRECTED CLEAR SEARCH ==='); + console.log('=== CLEARING CUSTOM SEARCH ==='); // Clear UI immediately - this.$('.oe_search_input').val(''); - this.$('.oe_clear_search').hide(); - this.$('.oe_search_count').hide(); + if (this.renderer && this.renderer.$) { + this.renderer.$('.oe_search_input').val(''); + this.renderer.$('.oe_clear_search').hide(); + this.renderer.$('.oe_search_count').hide(); + this.renderer.$('.oe_search_loading').show(); + } - // Clear search state - this._search.value = ''; - this._search.isFiltered = false; - this._search.filteredCount = 0; + // Reset state + this._customSearchState.value = ''; + this._customSearchState.isFiltered = false; + this._customSearchState.filteredCount = 0; - // Use original domain to clear search - var originalDomain = this._search.originalDomain || []; + // Get original domain + var originalDomain = this._customSearchState.originalDomain || []; + this._customSearchState.originalDomain = null; - // CORRECTED: Use proper reload with original domain - this.trigger_up('reload', { + console.log('Restoring original domain:', originalDomain); + + // Reload with original domain + var handle = this.handle; + var state = this.model.get(handle); + + return this.model.reload(handle, { domain: originalDomain, - context: this.state.context || {}, - groupBy: this.state.groupedBy || [], - keepSelection: false + offset: 0, + limit: state.limit || 80 + }).then(function() { + return self.update({}, {reload: false}); + }).finally(function() { + if (self.renderer) { + self.renderer.$('.oe_search_loading').hide(); + } }); + }, + + /** + * Build search domain - FIXED VERSION + */ + _buildCustomSearchDomain: function(value) { + if (!value) return []; - // Clear stored original domain - this._search.originalDomain = null; - - console.log('Search cleared using CORRECTED method'); - return Promise.resolve(); - }, - - /** - * Public clear search method - */ - _clearSearch: function() { - return this._clearSearchInternal(); - }, - - /** - * Store original domain properly - */ - _storeOriginalDomain: function() { - if (this.state && this.state.domain && !this._search.originalDomain) { - // Deep copy the domain to avoid reference issues - this._search.originalDomain = JSON.parse(JSON.stringify(this.state.domain)); - console.log('Stored original domain:', this._search.originalDomain); - } - }, - - /** - * ENHANCED: Combine search domain with original domain - */ - _combineDomains: function(searchDomain) { - var finalDomain = []; - - if (this._search.originalDomain && this._search.originalDomain.length > 0) { - if (searchDomain.length > 0) { - // Combine: originalDomain AND searchDomain - finalDomain = ['&']; - finalDomain = finalDomain.concat(this._search.originalDomain); - finalDomain = finalDomain.concat(searchDomain); - } else { - finalDomain = this._search.originalDomain.slice(); - } - } else { - finalDomain = searchDomain.slice(); - } - - return finalDomain; - }, - - /** - * ENHANCED: Update search count after render - */ - _updateSearchCount: function() { - // If we have the accurate count from RPC, use it - if (this._search.filteredCount) { - this.$('.oe_search_count').text(_t('Found: ') + this._search.filteredCount + _t(' records')).show(); - } else { - // Fallback: count visible records (just for immediate feedback) - var visibleCount = this.state && this.state.data ? this.state.data.length : 0; - if (visibleCount > 0) { - this.$('.oe_search_count').text(_t('Showing: ') + visibleCount + _t(' records')).show(); - } - } - }, - - /** - * ENHANCED: Build search domain with better logic - */ - _buildSearchDomain: function(value) { - var fields = this._getSearchableFields(); - if (fields.length === 0) { + var fields = this._getCustomSearchableFields(); + if (!fields || fields.length === 0) { console.warn('No searchable fields found'); return []; } - console.log('Searching in fields:', fields.map(f => f.name + ' (' + f.type + ')')); - var conditions = []; var normalized = this._normalizeArabic(value); + var searchValues = [value]; + if (normalized !== value) { + searchValues.push(normalized); + } + + console.log('Building domain for fields:', fields); - // Build conditions for each field type fields.forEach(function(field) { try { - if (['char', 'text', 'html'].includes(field.type)) { - // Text fields: use ilike for partial matching - conditions.push([field.name, 'ilike', value]); - if (normalized !== value) { - conditions.push([field.name, 'ilike', normalized]); - } - } else if (['integer', 'float', 'monetary'].includes(field.type)) { - // Numeric fields: exact match if value is numeric - var num = parseFloat(value); - if (!isNaN(num)) { - conditions.push([field.name, '=', num]); - } - } else if (field.type === 'selection') { - // Selection fields: search in selection values - conditions.push([field.name, 'ilike', value]); - } else if (field.type === 'many2one') { - // Many2one fields: search in related record's name - conditions.push([field.name + '.name', 'ilike', value]); - if (normalized !== value) { - conditions.push([field.name + '.name', 'ilike', normalized]); - } - } else if (field.type === 'boolean') { - // Boolean fields: match true/false/yes/no - var lowerValue = value.toLowerCase(); - if (['true', 'yes', 'نعم', '1'].includes(lowerValue)) { - conditions.push([field.name, '=', true]); - } else if (['false', 'no', 'لا', '0'].includes(lowerValue)) { - conditions.push([field.name, '=', false]); - } + var fieldType = field.type; + var fieldName = field.name; + + switch(fieldType) { + case 'char': + case 'text': + case 'html': + searchValues.forEach(function(searchVal) { + conditions.push([fieldName, 'ilike', searchVal]); + }); + break; + + case 'integer': + case 'float': + case 'monetary': + var numValue = parseFloat(value); + if (!isNaN(numValue)) { + // For numeric fields, also search as string + conditions.push([fieldName, '=', numValue]); + // Also search string representation + conditions.push([fieldName, 'ilike', value]); + } + break; + + case 'many2one': + // CRITICAL FIX: For many2one, search on display_name + searchValues.forEach(function(searchVal) { + // Direct search on the field (searches display_name by default) + conditions.push([fieldName, 'ilike', searchVal]); + }); + break; + + case 'selection': + searchValues.forEach(function(searchVal) { + conditions.push([fieldName, 'ilike', searchVal]); + }); + break; + + case 'boolean': + var lowerValue = value.toLowerCase().trim(); + var booleanMappings = { + 'true': true, 'yes': true, 'نعم': true, '1': true, 'صح': true, + 'false': false, 'no': false, 'لا': false, '0': false, 'خطأ': false + }; + if (lowerValue in booleanMappings) { + conditions.push([fieldName, '=', booleanMappings[lowerValue]]); + } + break; + + case 'date': + case 'datetime': + // Try to parse as date + if (value.match(/^\d{4}-\d{2}-\d{2}/)) { + conditions.push([fieldName, 'ilike', value]); + } + break; } } catch (error) { console.warn('Error processing field', field.name, ':', error); } }); - // Build OR domain if (conditions.length === 0) { return []; } + // Build proper OR domain if (conditions.length === 1) { - return conditions; + return conditions[0]; } - // Add OR operators: |, |, |, condition1, condition2, condition3, condition4 - var domain = []; + // Create OR domain: ['|', ['field1', 'op', 'val'], ['field2', 'op', 'val']] + var orDomain = []; for (var i = 1; i < conditions.length; i++) { - domain.push('|'); + orDomain.push('|'); } - return domain.concat(conditions); + return orDomain.concat(conditions); }, - + /** - * ENHANCED: Get searchable fields with better logic + * Get searchable fields - ENHANCED VERSION */ - _getSearchableFields: function() { + _getCustomSearchableFields: function() { var fields = []; - var fieldDefs = this.state.fields || {}; + var state = this.model.get(this.handle); - console.log('Available fields:', Object.keys(fieldDefs)); - console.log('Available columns:', this.columns ? this.columns.length : 0); + if (!state) { + console.warn('No state available'); + return fields; + } - // PRIORITY 1: Get from visible columns - if (this.columns && this.columns.length > 0) { - this.columns.forEach(function(col) { + var fieldDefs = state.fields || {}; + var fieldsInfo = state.fieldsInfo; + var viewType = state.viewType || 'list'; + + // Method 1: Get from fieldsInfo (most reliable) + if (fieldsInfo && fieldsInfo[viewType]) { + Object.keys(fieldsInfo[viewType]).forEach(function(fieldName) { + var fieldInfo = fieldsInfo[viewType][fieldName]; + if (fieldInfo && !fieldInfo.invisible && fieldDefs[fieldName]) { + var fieldDef = fieldDefs[fieldName]; + if (fieldDef.store !== false && fieldDef.searchable !== false) { + fields.push({ + name: fieldName, + type: fieldDef.type, + string: fieldDef.string || fieldName + }); + } + } + }); + } + + // Method 2: Get from renderer columns if available + if (fields.length === 0 && this.renderer && this.renderer.columns) { + this.renderer.columns.forEach(function(col) { if (!col.invisible && col.attrs && col.attrs.name) { var fieldName = col.attrs.name; var field = fieldDefs[fieldName]; - if (field && field.store !== false && field.searchable !== false) { + if (field && field.store !== false) { fields.push({ name: fieldName, type: field.type, @@ -469,11 +383,11 @@ odoo.define('fims_general_search_tree_view.list_search', function (require) { }); } - // PRIORITY 2: Add common searchable fields if none found from columns + // Method 3: Fallback to common searchable fields if (fields.length === 0) { - var commonFields = ['name', 'display_name', 'code', 'reference', 'description', 'note']; + var commonFields = ['name', 'display_name', 'code', 'reference', 'ref', 'description']; commonFields.forEach(function(fname) { - if (fieldDefs[fname] && fieldDefs[fname].store !== false && fieldDefs[fname].searchable !== false) { + if (fieldDefs[fname] && fieldDefs[fname].store !== false) { fields.push({ name: fname, type: fieldDefs[fname].type, @@ -483,57 +397,237 @@ odoo.define('fims_general_search_tree_view.list_search', function (require) { }); } - console.log('Final searchable fields:', fields); + console.log('Searchable fields found:', fields); return fields; }, - + /** - * Show loading indicator + * Combine domains - FIXED VERSION */ - _showLoading: function(show) { - this.$('.oe_search_loading').toggle(show); - if (show) { - this.$('.oe_search_count').hide(); + _combineCustomDomains: function(searchDomain) { + var originalDomain = this._customSearchState.originalDomain || []; + + // Handle empty domains + if (!searchDomain || searchDomain.length === 0) { + return originalDomain; + } + if (!originalDomain || originalDomain.length === 0) { + return searchDomain; + } + + // Combine with AND operator + // Ensure we're working with arrays + var origArray = Array.isArray(originalDomain) ? originalDomain : [originalDomain]; + var searchArray = Array.isArray(searchDomain) ? searchDomain : [searchDomain]; + + // Create combined domain: ['&', original_domain, search_domain] + return ['&'].concat(origArray).concat(searchArray); + }, + + /** + * Update search UI + */ + _updateCustomSearchUI: function(count) { + if (this.renderer && this.renderer.$) { + var message = _t('Found: ') + count + _t(' records'); + this.renderer.$('.oe_search_count') + .text(message) + .show(); + this.renderer.$('.oe_clear_search').show(); } }, - + /** - * ENHANCED: Normalize Arabic text for better searching + * Show search error + */ + _showSearchError: function() { + if (this.renderer && this.renderer.$) { + this.renderer.$('.oe_search_count') + .text(_t('Search error occurred')) + .addClass('text-danger') + .show(); + } + }, + + /** + * Normalize Arabic text - ENHANCED VERSION */ _normalizeArabic: function(text) { if (!text) return ''; return text - // Remove diacritics (تشكيل) + // Remove Arabic diacritics .replace(/[\u064B-\u065F]/g, '') - // Convert Arabic-Indic digits to European digits - .replace(/[\u0660-\u0669]/g, function(d) { + // Convert Arabic-Indic digits to Western digits + .replace(/[٠-٩]/g, function(d) { return String.fromCharCode(d.charCodeAt(0) - 0x0660 + 0x0030); }) - // Convert Persian digits to European digits - .replace(/[\u06F0-\u06F9]/g, function(d) { + // Convert Persian digits to Western digits + .replace(/[۰-۹]/g, function(d) { return String.fromCharCode(d.charCodeAt(0) - 0x06F0 + 0x0030); }) // Normalize Alef variations - .replace(/[\u0622\u0623\u0625\u0627]/g, 'ا') + .replace(/[آأإا]/g, 'ا') // Normalize Teh Marbuta - .replace(/[\u0629]/g, 'ه') + .replace(/ة/g, 'ه') // Normalize Yeh variations - .replace(/[\u064A\u0626\u0649]/g, 'ي') + .replace(/[يئى]/g, 'ي') // Normalize Waw variations - .replace(/[\u0624\u0648]/g, 'و'); + .replace(/[ؤو]/g, 'و') + // Trim spaces + .trim(); + } + }); + + // ================================ + // ListRenderer: UI only + // ================================ + ListRenderer.include({ + events: _.extend({}, ListRenderer.prototype.events, { + 'keyup .oe_search_input': '_onCustomSearchKeyUp', + 'input .oe_search_input': '_onCustomSearchInput', + 'click .oe_clear_search': '_onCustomClearSearch', + 'keydown .oe_search_input': '_onCustomSearchKeyDown' + }), + + /** + * @override + */ + init: function() { + this._super.apply(this, arguments); + this._searchTimer = null; + this._customSearchReady = false; }, /** * @override */ - destroy: function() { - if (this._search && this._search.timer) { - clearTimeout(this._search.timer); + _renderView: function () { + var self = this; + return this._super.apply(this, arguments).then(function (result) { + // Add search box for tree views + if (self._shouldAddSearchBox()) { + self._addCustomSearchBox(); + self._customSearchReady = true; + } + return result; + }); + }, + + /** + * Check if we should add search box + */ + _shouldAddSearchBox: function() { + return this.arch && + this.arch.tag === 'tree' && + this.$el && + this.$el.hasClass('o_list_view') && + !this.$el.find('.oe_search_container').length; + }, + + /** + * Add search box UI + */ + _addCustomSearchBox: function() { + var html = + '
' + + '' + + '' + + '' + + '' + + '
'; + + this.$el.prepend($(html)); + + // Focus on search input + this.$('.oe_search_input').focus(); + }, + + /** + * Handle input events + */ + _onCustomSearchInput: function(e) { + var hasValue = !!$(e.currentTarget).val().trim(); + this.$('.oe_clear_search').toggle(hasValue); + + if (!hasValue) { + this.$('.oe_search_count').hide(); + } + }, + + /** + * Handle keyup events with debounce + */ + _onCustomSearchKeyUp: function(e) { + var self = this; + var value = $(e.currentTarget).val().trim(); + + // Ignore special keys + if (e.which === 13 || e.which === 27) { + return; + } + + // Clear previous timer + if (this._searchTimer) { + clearTimeout(this._searchTimer); + } + + // Show loading after short delay + this._searchTimer = setTimeout(function() { + // Delegate to controller + if (self.getParent() && self.getParent()._handleCustomSearch) { + self.getParent()._handleCustomSearch(value); + } + }, 500); + }, + + /** + * Handle special keys + */ + _onCustomSearchKeyDown: function(e) { + // Enter key - trigger search immediately + if (e.which === 13) { + e.preventDefault(); + if (this._searchTimer) { + clearTimeout(this._searchTimer); + } + var value = $(e.currentTarget).val().trim(); + if (this.getParent() && this.getParent()._handleCustomSearch) { + this.getParent()._handleCustomSearch(value); + } + } + // Escape key - clear search + else if (e.which === 27) { + e.preventDefault(); + this._onCustomClearSearch(); + } + }, + + /** + * Handle clear button click + */ + _onCustomClearSearch: function() { + // Clear input + this.$('.oe_search_input').val('').focus(); + this.$('.oe_clear_search').hide(); + this.$('.oe_search_count').hide(); + + // Delegate to controller + if (this.getParent() && this.getParent()._clearCustomSearch) { + this.getParent()._clearCustomSearch(); } - this._searchInProgress = false; - return this._super.apply(this, arguments); } }); + return { + ListController: ListController, + ListRenderer: ListRenderer + }; }); \ No newline at end of file