m22_helpdesk_select.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. /** @odoo-module **/
  2. import { _t } from "@web/core/l10n/translation";
  3. import publicWidget from "@web/legacy/js/public/public_widget";
  4. /**
  5. * M22 Helpdesk Select Enhancement Widget
  6. *
  7. * Transforms standard select dropdowns in helpdesk forms into modern
  8. * searchable selects using vanilla JavaScript + Tailwind CSS.
  9. * No external dependencies - pure modern JavaScript.
  10. */
  11. publicWidget.registry.M22HelpdeskSelect = publicWidget.Widget.extend({
  12. selector: '#helpdesk_ticket_form, form[id*="helpdesk"], form[data-model_name="helpdesk.ticket"]',
  13. /**
  14. * @override
  15. */
  16. start: function () {
  17. const self = this;
  18. return this._super.apply(this, arguments).then(function () {
  19. // Wait a bit for form fields to be fully rendered
  20. setTimeout(function () {
  21. self._initializeSelects();
  22. }, 300);
  23. });
  24. },
  25. /**
  26. * @override
  27. */
  28. destroy: function () {
  29. // Cleanup click outside handlers
  30. const comboboxes = this.el.querySelectorAll('.m22-combobox-wrapper');
  31. comboboxes.forEach(wrapper => {
  32. if (wrapper._clickOutsideHandler) {
  33. document.removeEventListener('click', wrapper._clickOutsideHandler);
  34. delete wrapper._clickOutsideHandler;
  35. }
  36. });
  37. this._super.apply(this, arguments);
  38. },
  39. //--------------------------------------------------------------------------
  40. // Private
  41. //--------------------------------------------------------------------------
  42. /**
  43. * Initialize combobox on all select elements in the form
  44. * @private
  45. */
  46. _initializeSelects: function () {
  47. // Find all select elements with class s_website_form_input (many2one fields)
  48. // Exclude selects that are already initialized
  49. const selects = this.el.querySelectorAll('select.s_website_form_input:not(.m22-combobox-processed)');
  50. if (!selects.length) {
  51. return;
  52. }
  53. selects.forEach(select => {
  54. try {
  55. select.classList.add('m22-combobox-processed');
  56. this._transformSelectToCombobox(select);
  57. } catch (error) {
  58. console.error(_t('Error initializing combobox:'), error);
  59. }
  60. });
  61. },
  62. /**
  63. * Transform a select element into a modern combobox
  64. * @param {HTMLElement} select
  65. * @private
  66. */
  67. _transformSelectToCombobox: function (select) {
  68. // Get options from select
  69. const options = Array.from(select.options).map(opt => ({
  70. value: opt.value,
  71. label: opt.textContent.trim(),
  72. disabled: opt.disabled
  73. })).filter(opt => opt.value || opt.label);
  74. // Get current value
  75. const currentValue = select.value;
  76. const currentOption = options.find(opt => opt.value === currentValue);
  77. const placeholder = select.querySelector('option[disabled]')?.textContent?.trim() || _t('Select an option...');
  78. const searchPlaceholder = _t('Search...');
  79. const noResultsText = _t('No results found');
  80. // Create wrapper div
  81. const wrapper = document.createElement('div');
  82. wrapper.className = 'relative m22-combobox-wrapper';
  83. // Create state object
  84. const state = {
  85. open: false,
  86. search: '',
  87. selected: currentOption || null,
  88. options: options.filter(opt => !opt.disabled),
  89. placeholder: placeholder,
  90. searchPlaceholder: searchPlaceholder,
  91. noResultsText: noResultsText,
  92. get filteredOptions() {
  93. if (!this.search) return this.options;
  94. const query = this.search.toLowerCase();
  95. return this.options.filter(opt =>
  96. opt.label.toLowerCase().includes(query)
  97. );
  98. },
  99. get hasResults() {
  100. return this.filteredOptions.length > 0;
  101. }
  102. };
  103. // Build HTML structure with Tailwind classes
  104. wrapper.innerHTML = `
  105. <!-- Hidden original select for form submission -->
  106. <select name="${select.name}" id="${select.id}" class="hidden" ${select.required ? 'required' : ''}>
  107. ${options.map(opt =>
  108. `<option value="${opt.value}" ${opt.value === currentValue ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}>${opt.label}</option>`
  109. ).join('')}
  110. </select>
  111. <!-- Combobox button -->
  112. <button
  113. type="button"
  114. class="m22-combobox-button w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-left shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm ${state.selected ? 'text-gray-900' : 'text-gray-500'}"
  115. >
  116. <span class="m22-combobox-placeholder block truncate ${state.selected ? 'hidden' : ''}">${placeholder}</span>
  117. <span class="m22-combobox-value block truncate ${!state.selected ? 'hidden' : ''}">${state.selected ? state.selected.label : ''}</span>
  118. <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
  119. <svg class="m22-combobox-arrow h-5 w-5 text-gray-400 transition-transform" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
  120. <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
  121. </svg>
  122. </span>
  123. </button>
  124. <!-- Dropdown panel -->
  125. <div
  126. class="m22-combobox-dropdown absolute z-[9999] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm hidden"
  127. >
  128. <!-- Search input -->
  129. <div class="sticky top-0 z-10 bg-white px-3 py-2 border-b border-gray-200">
  130. <input
  131. type="text"
  132. class="m22-combobox-search w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
  133. placeholder="${searchPlaceholder}"
  134. />
  135. </div>
  136. <!-- Options list -->
  137. <div class="m22-combobox-options">
  138. ${state.options.map(opt => `
  139. <div
  140. class="m22-combobox-option relative cursor-pointer select-none px-4 py-2 text-gray-900 hover:bg-blue-50 transition-colors ${state.selected && state.selected.value === opt.value ? 'bg-blue-100' : ''}"
  141. data-value="${opt.value}"
  142. >
  143. <span class="block truncate">${this._escapeHtml(opt.label)}</span>
  144. ${state.selected && state.selected.value === opt.value ? `
  145. <span class="absolute inset-y-0 right-0 flex items-center pr-4 text-blue-600">
  146. <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
  147. <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
  148. </svg>
  149. </span>
  150. ` : ''}
  151. </div>
  152. `).join('')}
  153. </div>
  154. <!-- No results -->
  155. <div class="m22-combobox-no-results px-4 py-2 text-sm text-gray-500 hidden">
  156. ${noResultsText}
  157. </div>
  158. </div>
  159. `;
  160. // Get references to elements
  161. const button = wrapper.querySelector('.m22-combobox-button');
  162. const dropdown = wrapper.querySelector('.m22-combobox-dropdown');
  163. const searchInput = wrapper.querySelector('.m22-combobox-search');
  164. const optionsContainer = wrapper.querySelector('.m22-combobox-options');
  165. const noResults = wrapper.querySelector('.m22-combobox-no-results');
  166. const placeholderSpan = wrapper.querySelector('.m22-combobox-placeholder');
  167. const valueSpan = wrapper.querySelector('.m22-combobox-value');
  168. const arrow = wrapper.querySelector('.m22-combobox-arrow');
  169. const hiddenSelect = wrapper.querySelector('select');
  170. // Methods
  171. const updateDisplay = () => {
  172. if (state.selected) {
  173. placeholderSpan.classList.add('hidden');
  174. valueSpan.textContent = state.selected.label;
  175. valueSpan.classList.remove('hidden');
  176. button.classList.remove('text-gray-500');
  177. button.classList.add('text-gray-900');
  178. } else {
  179. placeholderSpan.classList.remove('hidden');
  180. valueSpan.classList.add('hidden');
  181. button.classList.remove('text-gray-900');
  182. button.classList.add('text-gray-500');
  183. }
  184. };
  185. const renderOptions = () => {
  186. const filtered = state.filteredOptions;
  187. if (filtered.length === 0 && state.search) {
  188. optionsContainer.classList.add('hidden');
  189. noResults.classList.remove('hidden');
  190. } else {
  191. optionsContainer.classList.remove('hidden');
  192. noResults.classList.add('hidden');
  193. // Update options highlighting
  194. optionsContainer.querySelectorAll('.m22-combobox-option').forEach(optionEl => {
  195. const optionValue = optionEl.dataset.value;
  196. const isSelected = state.selected && state.selected.value === optionValue;
  197. optionEl.classList.toggle('bg-blue-100', isSelected);
  198. // Update checkmark
  199. const checkmark = optionEl.querySelector('.absolute');
  200. if (isSelected && !checkmark) {
  201. const check = document.createElement('span');
  202. check.className = 'absolute inset-y-0 right-0 flex items-center pr-4 text-blue-600';
  203. check.innerHTML = `
  204. <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
  205. <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
  206. </svg>
  207. `;
  208. optionEl.appendChild(check);
  209. } else if (!isSelected && checkmark) {
  210. checkmark.remove();
  211. }
  212. // Show/hide based on search
  213. const option = state.options.find(o => o.value === optionValue);
  214. if (option) {
  215. const matches = !state.search || option.label.toLowerCase().includes(state.search.toLowerCase());
  216. optionEl.style.display = matches ? '' : 'none';
  217. }
  218. });
  219. }
  220. };
  221. const selectOption = (option) => {
  222. state.selected = option;
  223. state.search = '';
  224. state.open = false;
  225. // Update hidden select
  226. hiddenSelect.value = option.value;
  227. // Update display
  228. updateDisplay();
  229. renderOptions();
  230. closeDropdown();
  231. // Trigger change event
  232. const event = new Event('change', { bubbles: true });
  233. hiddenSelect.dispatchEvent(event);
  234. };
  235. const openDropdown = () => {
  236. state.open = true;
  237. dropdown.classList.remove('hidden');
  238. arrow.classList.add('rotate-180');
  239. searchInput.focus();
  240. renderOptions();
  241. };
  242. const closeDropdown = () => {
  243. state.open = false;
  244. dropdown.classList.add('hidden');
  245. arrow.classList.remove('rotate-180');
  246. state.search = '';
  247. searchInput.value = '';
  248. renderOptions();
  249. };
  250. // Event listeners
  251. button.addEventListener('click', (e) => {
  252. e.stopPropagation();
  253. if (state.open) {
  254. closeDropdown();
  255. } else {
  256. openDropdown();
  257. }
  258. });
  259. searchInput.addEventListener('input', (e) => {
  260. state.search = e.target.value;
  261. renderOptions();
  262. });
  263. searchInput.addEventListener('keydown', (e) => {
  264. if (e.key === 'Escape') {
  265. closeDropdown();
  266. } else if (e.key === 'Enter' && state.filteredOptions.length > 0) {
  267. e.preventDefault();
  268. selectOption(state.filteredOptions[0]);
  269. }
  270. });
  271. optionsContainer.addEventListener('click', (e) => {
  272. const optionEl = e.target.closest('.m22-combobox-option');
  273. if (optionEl) {
  274. const value = optionEl.dataset.value;
  275. const option = state.options.find(o => o.value === value);
  276. if (option) {
  277. selectOption(option);
  278. }
  279. }
  280. });
  281. // Click outside handler
  282. const clickOutsideHandler = (e) => {
  283. if (state.open && !wrapper.contains(e.target)) {
  284. closeDropdown();
  285. }
  286. };
  287. document.addEventListener('click', clickOutsideHandler);
  288. wrapper._clickOutsideHandler = clickOutsideHandler;
  289. // Keyboard navigation
  290. optionsContainer.addEventListener('keydown', (e) => {
  291. if (e.key === 'Escape') {
  292. closeDropdown();
  293. button.focus();
  294. }
  295. });
  296. // Replace select with wrapper
  297. select.parentNode.insertBefore(wrapper, select);
  298. select.style.display = 'none';
  299. wrapper.appendChild(select);
  300. // Initial render
  301. updateDisplay();
  302. renderOptions();
  303. },
  304. /**
  305. * Escape HTML to prevent XSS
  306. * @param {string} text
  307. * @returns {string}
  308. * @private
  309. */
  310. _escapeHtml: function(text) {
  311. const div = document.createElement('div');
  312. div.textContent = text;
  313. return div.innerHTML;
  314. }
  315. });