app/javascript/js/controllers/search_controller.js
/* 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',
]
static values = {
extraParams: Object,
}
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: '',
onStateChange({ prevState, state }) {
// If is closed and was open clear query value
if (!state.isOpen && prevState.isOpen) {
state.query = ''
}
},
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 && !item._error) {
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,
...this.extraParamsValue,
}
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 || ''
}
}