algolia / vue-instantsearch

👀 Algolia components for building search UIs with Vue.js
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/vue
MIT License
853 stars 157 forks source link

Allow preservation of user specified high/low range value when out of bounds #882

Closed frostydustpile closed 1 year ago

frostydustpile commented 3 years ago

Allow Range Input to accept and maintain state of a minimum below the lowest in range, and/or a maximum higher than highest in product range.

What is your use case for such a feature

We use the Range Input for a price range selector. Unfortunately, it puts the burden of understanding the the high and low price boundaries on the customer. We also combine the information in the "selected filter" so that it reads more naturally (no mathematical syntax). In other words, we show the Selected Filter as a single $50 to $150 instead of the >= $50 and <= $150 pair.

Example:

Given:

When:

Then:

The fact that the >=$25 does nothing to reduce the product set is logically sensible from a programmer's standpoint. Why apply it if it does nothing? However, the disappearance of the low range and absence of confirmation via the selected filters can be disorienting to a customer who "thought" they were trying to restrict within a range. They may try again. Even when they see the number disappear (which they may not have explicitly noticed at first), they may not understand why and feel like they're doing something wrong, or get frustrated with the system not accepting their input.

What is your proposal

Include an unbounded widget parameter to the RangeInput.vue component that would allow the developer to override the default behavior and specify that the bounds need NOT be enforced (they would be by default to preserve present behavior & backwards compatibility):

// vue-instantsearch/src/components/RangeInput.vue
// Some care would need to be taken to allow the input fields to accept unbounded values
<template>
...
<input
    type="number"
    :class="[suit('input'), suit('input', 'min')]"
    :step="step"
    :min="unbounded ? false : state.range.min" // <== Turn off min/max for unbounded
    :max="unbounded ? false : state.range.max" // <== Turn off min/max for unbounded
    :placeholder="state.range.min" // <== might want to do something here too...
    :value="values.min"
    @change="minInput = $event.currentTarget.value"
/>

// And the logic would need to recognize the unbounded prop

export default {
    name: 'RangeInput',
    ...
    props: {
        ...
        unbounded: { // <== Define new prop with backwards compatible default value
            type: Boolean,
            required: false,
            default: false,
        },
    },
    ...
    computed: {
        widgetParams: {
            return {
                attribute: this.attribute,
                min: this.min,
                max: this.max,
                precision: this.precision,
                unbounded: this.unbounded, // <== Add to widgetParams that the connector will use
            };
        },
        ....
    },

Then the connector would need to be updated to reflect this, which I think is pretty simple:

// instantsearch.js/es/connectors/range/connectRange.js
...
export default function connectRange(renderFn) {
  var unmountFn = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : noop;
  checkRendering(renderFn, withUsage());
  return function () {
    var widgetParams = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
    var attribute = widgetParams.attribute,
        minBound = widgetParams.min,
        maxBound = widgetParams.max,
        unbounded = widgetParams.unbounded, // <== capture the unbounded state
...
      var isGreaterThanCurrentRange = isValidMinCurrentRange && currentRangeMin <= newNextMin;
      var isMinValid = isResetNewNextMin 
        || isValidNewNextMin
        && (
          unbounded ||!isValidMinCurrentRange || isGreaterThanCurrentRange // <== Allow too low when unbounded
        );
      ...
      var isMaxValid = isResetNewNextMax 
        || isValidNewNextMax
        && (
          unbounded || !isValidMaxCurrentRange || isLowerThanRange // <== Allow too high when unbounded
        );
      ...

At this point, enabling the unbounded version of the Range Input would be as easy for the developer as adding the unbounded attribute to the component:

<AisRangeInput :attribute="price.or.whatever.field" unbounded />

What is the version you are using?

Other notes

I tried going down the route of making a custom widget and custom connector, but the documentation discourages this and prefers a feature request. 😄 Thus, this feature request. If there is anything in the way of a good example of creating a custom widget/connector for Vue, I'd love to see it.

Haroenv commented 3 years ago

I think since you've already looked so much into the details, you can create your forked version without issue, although I'd love to see what you create in a sandbox / repo or something like that. An example of a full custom widget would be for example ais-state-results here

I think a better name for this parameter would be enforceRange, although we should discuss with the team whether this makes sense this way or something more custom.

Thanks for opening an issue!

frostydustpile commented 3 years ago

Ha! My variable name went through a lot of variations during my experiments. I'm pretty sure at once point in time or another enforceRange was one of them. 😄

Since this involves changes in two repositories (vue-instantsearch and instantsearch.js) - I wasn't sure how the fork process would go. Would I just fork them both and provide references from one to the other?

Haroenv commented 3 years ago

Before jumping to making a PR, we'll discuss this with the team so we can find possibly a different solution. I'll get back to you once we've discussed it, but otherwise it's just making two separate PRs, yes.

frostydustpile commented 3 years ago

@Haroenv - was the ais-state-results "here" supposed to be a link to a fully custom widget?

Haroenv commented 3 years ago

yes, that was meant to be https://github.com/algolia/vue-instantsearch/blob/master/src/components/StateResults.vue @frostydustpile.