Autocomplete checkbox (mutli-select)

Context:
I’ve been using an autocomplete package and it’s just not got the functionality that i’m needing.

The idea is to have a reusable component that loads in a list of item that has a checkbox input, you select the option(s) you need, then submit the form:

<div {{ stimulus_controller('autocomplete-select', {
    // path to load list of items [in this case users]
    url: path('_user_search')
}) }}>

     // display the autocomplete results
    <div data-autocomplete-select-target="autocompleteResult"></div>

</div>

_user_search end point loads in the list of users

{% for user in users %}
    <div class="list-group-item">
        <input class="form-check-input" type="checkbox" role="option" data-id="{{ user.id }}"/>
    </div>
{% endfor %}

autocomplete-select_controller.js

export default class extends Controller {

  static targets = ['textInput', 'autocompleteResult', 'selected', 'itemCheckbox', 'input']

  static values = {
    url: String,
    option: String
  }

  connect () {
    console.log('autocom select connected')
    this.textInputTarget.addEventListener('keyup', (e) => {
      axios.get(this.urlValue, {
        params:{
          q: this.textInputTarget.value
        }
      }).then((response) => {
        this.autocompleteResultTarget.innerHTML = response.data
        let options = document.querySelectorAll('[role="option"]')

        options.forEach((option)=>{
          option.addEventListener('click', (e) => {
            console.log(option.getAttribute('data-id'))
          })
        })
      })
    })
  }
}

Problem:
Whenever I select an option, and then search for another, of course the data is reloaded and the checked checkbox is lost.

Any suggestions of how I can refactor this to keep the checked checkboxes even on reload. I was looking a saving state in the documentation, but I’m unsure how to implement it in this case.

I’ll be creating a public package, once this is resolved.

Have you tried looking at Values API.

What you would want to do is that when an option is clicked(check/uncheck)ed. When checked, you would push the data-id of the option, when unchecked, you would filter out that.

  options.forEach((option)=>{
    option.addEventListener('click', (e) => {
      if(this.selectedOptionsValue.includes(option.dataset.id)) {
           this.selectOptionsValue = this.selectedOptionsValue.filter(id => id !== option.dataset.id)
      } else {
          this.selectedOptionsValue = [...this.selectedOptionsValue, option.dataset.id]
      }
    })
  })

Are you using Turbo(streams) by any chance?

Thanks for the reply and awesome suggestion, but this wouldn’t work because the data value doesn’t exist until after the axios get request, so this options.forEach would have to been inside the then() on the get request, therefore will change all the data each time. maybe I’m missing something?

yes, I’m using turbo streams.

Not necessarily, the value persists across axios requests since the controller does not get disconnected, it just replaces it’s inner content. Maybe i’m missing something here?

Why not use Turbo streams and replace the partial with a replace action. Using rails/requestjs-rails (github.com).

not sure what you mean with the stream, I’m using php symofny not rails.

So I’ve refactored a bit based on your suggestion.

I’ve remove the checkbox inputs from the partial as they didn’t seems to helping the situation. The toggle event is triggered by the data action click->autocomplete-select#toggle
but now I’m unsure of how to select the clicked option and then remove it from the response of subsequent axios requests.

// autocomplete-select_controller.js

import {Controller} from '@hotwired/stimulus'
import axios from 'axios'

export default class extends Controller
{

    static targets = ['textInput', 'autocompleteResult', 'input']

    static values = {
        url: String,
    }

    connect() {
        this.textInputTarget.addEventListener('keyup', (e) => {
            axios.get(this.urlValue, {
                params: {
                    q: this.textInputTarget.value
                }
            }).then((response) => {
                this.autocompleteResultTarget.innerHTML = response.data
            })

        })
    }

    toggle (event) {
       // toggle select/unselect of item 
    }

}

_autocomplete-list.html.twig

{% for user in users %}
    <a class="list-group-item" data-action="click->autocomplete-select#toggle" data-autocomplete-select-target="option"  data-autocomplete-select-id-param="{{ user.id }}" >
        <div class="d-flex justify-content-between align-items-center">
            <div>
                <img class="rounded-circle border me-2" src="{{ user.avatar }}" height="30"/>
                {{ user.fullName }}
            </div>
        </div>
    </a>
{% else %}
    <div class="list-group-item text-center text-muted">{{ 'no-results-found'|trans }}</div>
{% endfor %}

You might want to add another parameter, that is a JSON array, of all the selected IDS.

     static values = {
        url: String,
        selectedOptions: Array,
    }

    toggle ({ currentTarget }) {
       if(this.selectedOptionsValue.includes(currentTarget.dataset.id)) {
         this.selectOptionsValue = this.selectedOptionsValue.filter(id => id !== currentTarget.dataset.id)
       } else {
           this.selectedOptionsValue = [...this.selectedOptionsValue, currentTarget.dataset.id]
       }
    }

The, inside connect, you send it as a query param.

    connect() {
        this.textInputTarget.addEventListener('keyup', (e) => {
            axios.get(this.urlValue, {
                params: {
                    q: this.textInputTarget.value,
                    selectedIds: JSON.stringify(this.selectedOptionsValue)
                }
            }).then((response) => {
                this.autocompleteResultTarget.innerHTML = response.data
            })
        })
    }

Then, in your server. You would filter out any items with the selectedIds.

Another question comes to the mind, how would you recheck them again once the input is cleared?.

You can loop over the items and see if they are inside the selectedIds, and check them

   connect() {
        this.textInputTarget.addEventListener('keyup', (e) => {
            axios.get(this.urlValue, {
                params: {
                    q: this.textInputTarget.value,
                    selectedIds: JSON.stringify(this.selectedOptionsValue)
                }
            }).then((response) => {
                this.autocompleteResultTarget.innerHTML = response.data
            }).then(() => {
               this.element.querySelectorAll(`[data-autocomplete-select-id-param]`)
                      .forEach(option => {
                            if(this.selectedOptionsValue.includes(option.dataset.autocompleteSelectIdParam)) {
                              // check the option, since it was checked previously
                            }
                       })
            })
        })
    }

Another better approach would be to do that in the server. If the user->id is inside the selectedIds query param. Check it, else don’t.

Hope this helps out

1 Like