algolia / instantsearch

⚡️ Libraries for building performant and instant search and recommend experiences with Algolia. Compatible with JavaScript, TypeScript, React and Vue.
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/
MIT License
3.68k stars 515 forks source link

Re-usable Select Field Widget #1022

Closed JonnyBR12 closed 8 years ago

JonnyBR12 commented 8 years ago

Hey all, been working through this post (https://github.com/algolia/instantsearch.js/issues/868) for a while now but not getting any where. Has anybody implemented a custom widget that creates a select field? My question is pretty much identical to this.

I've read through both instantsearch docs and the helper docs but just not getting it. Can anyone guide me on creating this? The search form needs 6 select fields, each has 'all' a the top, must retain values so the user can then change options.

Any help would be great!

pixelastic commented 8 years ago

Hello,

It has been a recurring demand recently. Our menu widget does not let users easily use a select to switch in between the different menu values, while this is an obvious UI choice.

I'll have a go at building a custom widget that does what you're asking, and then we'll use it as a basis to discuss a potential API to include it into the core.

I'll keep you informed.

JonnyBR12 commented 8 years ago

Great, thanks @pixelastic. That would be excellent!

pixelastic commented 8 years ago

So I gave it a try and came up with the following:

2016-05-19_14h54m05

This is a custom widget that does pretty much the same behavior than the menu widget, but with a different markup. I actually copy-pasted a lot of code from the original menu widget but changed the HTML and did with vanilla JS without React. It has less features than an official widgets (like no BEM CSS classes on the elements), but should be a good enough place to start.

You would call it like this:

search.addWidget(
  instantsearch.widgets.customSelectorWidget({
    container: '#custom-selector-widget',
    attributeName: 'species',
    limit: 10,
    title: 'All'
  })
);

It uses the same container, attributeName and limit options than the menuwidget, but I also added the title one. This will be the name of the first value of the dropdown, used to reset it.

And here is the complete, annotated, code of the widget. Note that I'm using a bit of ES6 syntax in here (for defining default options, fat arrow method definition, string interpolation, etc). If this is an issue for you, I can post an ES5-compatible version.

// Extending the instantsearch.widgets namespace, like regular widgets
instantsearch.widgets.customSelectorWidget = function customSelectorWidget({
  container,
  attributeName,
  limit = 10,
  title = 'All'
}) {
  // container should be a CSS selector, so we convert it into a DOM element
  container = document.querySelector(container);
  // We'll internally keep track of the <select> element
  let selectElement = null;
  // We'll also need to keep track of the current selected value
  let currentlySelectedValue = null;

  return {
    // Method called at startup, to configure the Algolia settings
    getConfiguration() {
      // This is a copy-paste of the menu widget
      // We use a hierarchicalFacet and not a classical facet because we want to
      // keep the list of all possible values displayed at all times. With
      // a classical facet we would only have the list of possible values for
      // the current filters (which would not make any sense here)
      return {
        hierarchicalFacets: [{
          name: attributeName,
          attributes: [attributeName]
        }]
      };
    },

    // Called on the first instantsearch search
    init({helper}) {
      // We add an (empty) <select> to the specified container
      container.innerHTML = `<select></select>`;
      selectElement = container.querySelector('select');

      // We listen to the change event on that <select>
      selectElement.addEventListener('change', () => {
        // Finding the newly selected value
        let selectedIndex = selectElement.selectedIndex;
        let selectedValue = selectElement.options[selectedIndex].value;

        // If we select the first, default value, it's like we "unchecked" the
        // currently selected value
        if (selectedValue === '__EMPTY__') {
          selectedValue = currentlySelectedValue;
        }
        // We toggle on/off on the selected value
        helper.toggleRefinement(attributeName, selectedValue);
        // And we actually start the search (to force a re-render of results)
        helper.search();

        // We keep track of what the currently selected value is
        currentlySelectedValue = selectedValue;
      });
    },

    // Called whenever we receive new results from Algolia
    render({results}) {
      // We get the list of possible values for this search and sort them in the
      // same order everytime (most popular first)
      let sortBy = ['count:desc', 'name:asc'];
      let facetValues = results.getFacetValues(attributeName, {sortBy}).data;
      // We only keep the X first elements, as defined on instanciation
      facetValues = facetValues.slice(0, limit);

      // Then we add that to the <select>
      let innerOptions = [`<option value="__EMPTY__">${title}</option>`];
      facetValues.forEach((facetValue) => {
        // We mark the current one as selected
        let selected = facetValue.isRefined ? 'selected="selected"' : '';
        innerOptions.push(`<option value="${facetValue.name}" ${selected}>${facetValue.name}</option>`);
      });

      // Update the rendering
      selectElement.innerHTML = innerOptions.join('\n');
    }
  }
};

I must say that it helped a lot that I had a good understanding of the instantsearch internals. This is definitely not a trivial widget to implement out of the box and should find its way into the core. I'll post an API proposal about that shortly.

Is this what you were looking for?

JonnyBR12 commented 8 years ago

Thanks a lot for the super quick response and code. 👍 I've been working through it but getting a few issues so assuming at this stage it's the ES6 version. Would it be possible to post the ES5 compatible version please?

pixelastic commented 8 years ago

No problem, here is an ES5 compatible version:

// Extending the instantsearch.widgets namespace, like regular widgets
instantsearch.widgets.customSelectorWidget = function customSelectorWidget(options) {
  var container = options.container;
  var attributeName = options.attributeName;
  var limit = options.limit || 10;
  var title = options.title || 'All';

  // container should be a CSS selector, so we convert it into a DOM element
  container = document.querySelector(container);
  // We'll internally keep track of the <select> element
  var selectElement = null;
  // We'll also need to keep track of the current selected value
  var currentlySelectedValue = null;

  return {
    // Method called at startup, to configure the Algolia settings
    getConfiguration: function getConfiguration() {
      // This is a copy-paste of the menu widget
      // We use a hierarchicalFacet and not a classical facet because we want to
      // keep the list of all possible values displayed at all times. With
      // a classical facet we would only have the list of possible values for
      // the current filters (which would not make any sense here)
      return {
        hierarchicalFacets: [{
          name: attributeName,
          attributes: [attributeName]
        }]
      };
    },

    // Called on the first instantsearch search
    init: function init(options) {
      var helper = options.helper;
      // We add an (empty) <select> to the specified container
      container.innerHTML = '<select></select>';
      selectElement = container.querySelector('select');

      // We listen to the change event on that <select>
      selectElement.addEventListener('change', function() {
        // Finding the newly selected value
        var selectedIndex = selectElement.selectedIndex;
        var selectedValue = selectElement.options[selectedIndex].value;

        // If we select the first, default value, it's like we "unchecked" the
        // currently selected value
        if (selectedValue === '__EMPTY__') {
          selectedValue = currentlySelectedValue;
        }
        // We toggle on/off on the selected value
        helper.toggleRefinement(attributeName, selectedValue);
        // And we actually start the search (to force a re-render of results)
        helper.search();

        // We keep track of what the currently selected value is
        currentlySelectedValue = selectedValue;
      });
    },

    // Called whenever we receive new results from Algolia
    render: function render(options) {
      var results = options.results;
      // We get the list of possible values for this search and sort them in the
      // same order everytime (most popular first)
      var sortBy = ['count:desc', 'name:asc'];
      var facetValues = results.getFacetValues(attributeName, {sortBy: sortBy}).data;
      // We only keep the X first elements, as defined on instanciation
      facetValues = facetValues.slice(0, limit);

      // Then we add that to the <select>
      var innerOptions = ['<option value="__EMPTY__">' + title + '</option>'];
      facetValues.forEach(function(facetValue) {
        // We mark the current one as selected
        var selected = facetValue.isRefined ? 'selected="selected"' : '';
        innerOptions.push('<option value="' + facetValue.name + '" ' + selected + '>' + facetValue.name + '</option>');
      });

      // Update the rendering
      selectElement.innerHTML = innerOptions.join('\n');
    }
  }
};

I think I removed every ES6-only features.

JonnyBR12 commented 8 years ago

Massive thanks @pixelastic - just managed to get it all set-up and almost sorted.

Just a quick question though, I've got an ion rangeslider. On page loads, the slider works great, so do the select fields - however, if I move the rangeslider after using of the select fields, I get "facetValues is null" and the search breaks.

Apologies for the constant questions. Looking through the code and after reading the docs, it kinda makes sense so learning loads too, plus, I'm sure it'll help many others so thanks again.

pixelastic commented 8 years ago

No problem for the question, we'd like to have instantsearch.js as easy to use as possible, so any way to improve it great :)

Would you have a test page or a repo where I could reproduce your issue?

JonnyBR12 commented 8 years ago

@pixelastic , just sent you a quick email with a dev link on it. I created a repo but thought I'd send you the actual page in action.

Thanks again!

pixelastic commented 8 years ago

Thanks for the email and test repo. I think I managed to reproduce and fix the possible issue.

What you have to keep in mind is that the possible values that are displayed in the various select depends on other filters. For example if you apply a specific price range, there might not be anymore new or used cars in that range, so the new or used values in the select dropdown will not show.

With a set of very narrow filters that actually do not match any results, you can end up with empty lists on the select. It was actually that edge case that my quickly-coded example wasn't catching. It was trying to do stuff on an empty list and failing.

I "fixed" the code like this:

    // Called whenever we receive new results from Algolia
    render: function render(options) {
      var results = options.results;
      // We get the list of possible values for this search and sort them in the
      // same order everytime (most popular first)
      var sortBy = ['count:desc', 'name:asc'];
      var innerOptions = ['<option value="__EMPTY__">' + title + '</option>'];
      var facetValues = results.getFacetValues(attributeName, {sortBy: sortBy}).data;

      if (facetValues && facetValues.length) {
        facetValues = facetValues.slice(0, limit);
        // Then we add that to the <select>
        facetValues.forEach(function(facetValue) {
          // We mark the current one as selected
          var selected = facetValue.isRefined ? 'selected="selected"' : '';
          innerOptions.push('<option value="' + facetValue.name + '" ' + selected + '>' + facetValue.name + '</option>');
        });
      }

      // Update the rendering
      selectElement.innerHTML = innerOptions.join('\n');
    }

This should fix your issue, but there might be other edge-cases lurking around :). I'd really like to move this code into a real widget in the future, so let's continue to iterate on it to see if it still breaks.

JonnyBR12 commented 8 years ago

Ah, completely makes sense. After looking at it further I understand more about it so thanks for that.

I suppose one way around it would be to possible update the ion range slider on change of a select (or checkbox, input etc)? I've had a look into updating these and someone raised an issue a couple of weeks back here - https://github.com/IonDen/ion.rangeSlider/issues/351

Do you think this would be possible?

vvo commented 8 years ago

@pixelastic Could you publish this as a new widget here: https://github.com/instantsearch that would be awesome

pixelastic commented 8 years ago

@JonnyTurnerUK The rangeSlider should update automatically whenever new results are returned from Algolia (and a new query is triggered everytime a select is changed), to it should already work. Is your issue linked to this one https://github.com/instantsearch/instantsearch-ion.rangeSlider/issues/6?

@vvo: Yes, that's the plan. I wanted to resolve all the potential problems raised in this thread first, as a kind of beta-test, and then I'll package it into its own widget for everyone to enjoy :)

vvo commented 8 years ago

I think we can close this issue and move the discussion to other issues if needed. There's no more support needed here.

JonnyBR12 commented 8 years ago

@pixelastic Apologies, think I posted the wrong link earlier. They've updated the ion range slider and all works perfect. BIG thanks to everyone for all the help on this. Much appreciated!

Maaark commented 8 years ago

@pixelastic Is it possible to make the available options sync with other filters?

So if I apply a filter (for example price) and a