/* eslint-disable no-underscore-dangle */ import * as Mousetrap from 'mousetrap' import { Controller } from '@hotwired/stimulus' import { Turbo } from '@hotwired/turbo-rails' import { autocomplete } from '@algolia/autocomplete-js' import { sanitize } from 'dompurify' import URI from 'urijs' import debouncePromise from '../helpers/debounce_promise' /** * The search controller is used in three places. * 1. Global search (on the top navbar) and can search through multiple resources. * 2. Resource search (on the Index page on top of the table panel) and will search one resource * 3. belongs_to field. This requires a bit more cleanup because the user will not navigate away from the page. * It will replace the id and label in some fields on the page and also needs a "clear" button which clears the information so the user can submit the form without a value. */ export default class extends Controller { static targets = [ 'autocomplete', 'button', 'hiddenId', 'visibleLabel', 'clearValue', 'clearButton', ] debouncedFetch = debouncePromise(fetch, this.searchDebounce) destroyMethod get dataset() { return this.autocompleteTarget.dataset } get searchDebounce() { return window.Avo.configuration.search_debounce } get translationKeys() { let keys try { keys = JSON.parse(this.dataset.translationKeys) } catch (error) { keys = {} } return keys } get isBelongsToSearch() { return this.dataset.viaAssociation === 'belongs_to' } get isHasManySearch() { return this.dataset.viaAssociation === 'has_many' } get isGlobalSearch() { return this.dataset.searchResource === 'global' } connect() { const that = this this.buttonTarget.onclick = () => this.showSearchPanel() this.clearValueTargets.forEach((target) => { if (target.getAttribute('value') && this.hasClearButtonTarget) { this.clearButtonTarget.classList.remove('hidden') } }) if (this.isGlobalSearch) { Mousetrap.bind(['command+k', 'ctrl+k'], () => this.showSearchPanel()) } // This line fixes a bug where the search box would be duplicated on back navigation. this.autocompleteTarget.innerHTML = '' const { destroy } = autocomplete({ container: this.autocompleteTarget, placeholder: this.translationKeys.placeholder, translations: { detachedCancelButtonText: this.translationKeys.cancel_button, }, autoFocus: true, openOnFocus: true, detachedMediaQuery: '', getSources: ({ query }) => { document.body.classList.add('search-loading') const endpoint = that.searchUrl(query) return that .debouncedFetch(endpoint) .then((response) => { document.body.classList.remove('search-loading') return response.json() }) .then((data) => Object.keys(data).map((resourceName) => that.addSource(resourceName, data[resourceName]))) }, }) // document.addEventListener('turbo:before-render', destroy) this.destroyMethod = destroy // When using search for belongs-to if (this.buttonTarget.dataset.shouldBeDisabled !== 'true') { this.buttonTarget.removeAttribute('disabled') } } disconnect() { // Don't leave open autocompletes around when disconnected. Otherwise it will still // be visible when navigating back to this page. if (this.destroyMethod) { this.destroyMethod() this.destroyMethod = null } } addSource(resourceName, data) { const that = this return { sourceId: resourceName, getItems: () => data.results, onSelect: that.handleOnSelect.bind(that), templates: { header() { return `${data.header.toUpperCase()} ${data.help}` }, item({ item, createElement }) { const children = [] if (item._avatar) { let classes switch (item._avatar_type) { default: case 'circle': classes = 'rounded-full' break case 'rounded': classes = 'rounded' break case 'square': classes = 'rounded-none' break } children.push( createElement('img', { src: item._avatar, alt: item._label, class: `flex-shrink-0 w-8 h-8 my-[2px] inline mr-2 ${classes}`, }), ) } const label = sanitize(item._label) const labelChildren = [ createElement( 'div', { dangerouslySetInnerHTML: { __html: label }, }, label, ), ] if (item._description) { const description = sanitize(item._description) labelChildren.push( createElement( 'div', { class: 'aa-ItemDescription', dangerouslySetInnerHTML: { __html: description }, }, description, ), ) } children.push(createElement('div', null, labelChildren)) return createElement( 'div', { class: 'flex', }, children, ) }, noResults() { return that.translationKeys.no_item_found.replace( '%{item}', resourceName, ) }, }, } } handleOnSelect({ item }) { if (this.isBelongsToSearch) { this.updateFieldAttribute(this.hiddenIdTarget, 'value', item._id) this.updateFieldAttribute(this.buttonTarget, 'value', this.removeHTMLTags(item._label)) document.querySelector('.aa-DetachedOverlay').remove() if (this.hasClearButtonTarget) { this.clearButtonTarget.classList.remove('hidden') } } else { Turbo.visit(item._url, { action: 'advance' }) } // On searchable belongs to the class `aa-Detached` remains on the body making it unscrollable document.body.classList.remove('aa-Detached') } searchUrl(query) { const url = URI() return url.segment([window.Avo.configuration.root_path, ...this.searchSegments()]) .search(this.searchParams(encodeURIComponent(query))) .readable().toString() } searchSegments() { let segments = [ 'avo_api', this.dataset.searchResource, 'search', ] if (this.isGlobalSearch) { segments = ['avo_api', 'search'] } return segments } searchParams(query) { let params = { ...Object.fromEntries(new URLSearchParams(window.location.search)), q: query, global: false, } if (this.isGlobalSearch) { params.global = true } if (this.isBelongsToSearch || this.isHasManySearch) { params = this.addAssociationParams(params) params = this.addReflectionParams(params) if (this.isBelongsToSearch) { params = { ...params, // eslint-disable-next-line camelcase via_parent_resource_id: this.dataset.viaParentResourceId, // eslint-disable-next-line camelcase via_parent_resource_class: this.dataset.viaParentResourceClass, // eslint-disable-next-line camelcase via_relation: this.dataset.viaRelation, } } } return params } addAssociationParams(params) { params = { ...params, // eslint-disable-next-line camelcase via_association: this.dataset.viaAssociation, // eslint-disable-next-line camelcase via_association_id: this.dataset.viaAssociationId, } return params } addReflectionParams(params) { params = { ...params, // eslint-disable-next-line camelcase via_reflection_class: this.dataset.viaReflectionClass, // eslint-disable-next-line camelcase via_reflection_id: this.dataset.viaReflectionId, // eslint-disable-next-line camelcase via_reflection_view: this.dataset.viaReflectionView, } return params } showSearchPanel() { this.autocompleteTarget.querySelector('button').click() } clearValue() { this.clearValueTargets.map((target) => this.updateFieldAttribute(target, 'value', '')) this.clearButtonTarget.classList.add('hidden') } // Private updateFieldAttribute(target, attribute, value) { target.setAttribute(attribute, value) target.dispatchEvent(new Event('input')) } removeHTMLTags(str) { const doc = new DOMParser().parseFromString(str, 'text/html') return doc.body.textContent || '' } }