Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide search-as-you-type within the options of ui.select #272

Closed
falkoschindler opened this issue Jan 20, 2023 · 19 comments
Closed

Provide search-as-you-type within the options of ui.select #272

falkoschindler opened this issue Jan 20, 2023 · 19 comments
Assignees
Labels
enhancement New feature or request
Milestone

Comments

@falkoschindler
Copy link
Contributor

As discussed in #267 it would be nice to be able to search within the options of ui.select. Or maybe it's rather an auto-suggest extension of ui.input.

@falkoschindler falkoschindler added the enhancement New feature or request label Jan 20, 2023
@falkoschindler falkoschindler added this to the v1.1.4 milestone Jan 20, 2023
@falkoschindler
Copy link
Contributor Author

Yes, QSelect offers what we are looking for:
https://quasar.dev/vue-components/select#filtering-and-autocomplete

@falkoschindler falkoschindler self-assigned this Jan 20, 2023
@falkoschindler falkoschindler added the help wanted Extra attention is needed label Jan 23, 2023
@falkoschindler
Copy link
Contributor Author

So far I couldn't get ui.select working with the use-input prop.

@falkoschindler falkoschindler modified the milestones: v1.1.4, v1.1.5 Jan 23, 2023
@rodja rodja modified the milestones: v1.1.5, v1.1.6 Jan 28, 2023
@rodja
Copy link
Member

rodja commented Jan 29, 2023

I just pushed an example of an search-as-you-type example which uses the public API of https://www.thecocktaildb.com to search for cocktails:

search-cocktails_small.mp4

This is a very different thing from what @falkoschindler wanted to achieve: filtering values of a ui.select element. Maybe we should rename this issue to "filtering on a ui.select element"?

@falkoschindler
Copy link
Contributor Author

@rodja Sure, you can update search results on every input value change. But as explained in my first comment this issue is about searching within the options of ui.select.

@falkoschindler falkoschindler changed the title Provide search-as-you-type mode for ui.input or ui.select Provide search-as-you-type within the options of ui.select Jan 29, 2023
@falkoschindler falkoschindler removed their assignment Jan 30, 2023
@rodja rodja modified the milestones: v1.1.6, Later Feb 10, 2023
@AL-THE-BOT-FATHER
Copy link

AL-THE-BOT-FATHER commented Feb 22, 2023

@rodja Sure, you can update search results on every input value change. But as explained in my first comment this issue is about searching within the options of ui.select.

Lookin at the Quasar docs they provide the following....

     <q-select
        filled
        v-model="model"
        use-input
        input-debounce="0"
        label="Simple filter"
        :options="options"
        @filter="filterFn"
        style="width: 250px"
      >
        <template v-slot:no-option>
          <q-item>
            <q-item-section class="text-grey">
              No results
            </q-item-section>
          </q-item>
        </template>
      </q-select>

You prob also have to pass those other props.

@AL-THE-BOT-FATHER
Copy link

AL-THE-BOT-FATHER commented Feb 22, 2023

@rodja Sure, you can update search results on every input value change. But as explained in my first comment this issue is about searching within the options of ui.select.

I feel like I'm pretty close to figuring it out.
The select.on(type='filter', handler=handleFilter, throttle=3) returns a dict with the filter args which could possible be used to match the options in the select box.

I know how to update the options
For example, the code below.... when you click the button it updates the options.

from nicegui import ui

select = ui.select(options=[1,2,3])

def change():
    select.options = [4,5,6]
    select.update()

button = ui.button(on_click=change)
ui.run()

Maybe someone else can take a look at the code below.

import re
from nicegui import ui

stringOptions = ['Google', 'Facebook', 'Twitter', 'Apple', 'Oracle', "Goodyear"]

def search_strings(query, string_list):
    escaped_query = re.escape(query)
    pattern = re.compile(r"\b" + escaped_query + r"\w*\b", re.IGNORECASE)
    matches = [string for string in string_list if pattern.search(string)]
    return matches

async def handleFilter(event):
    val = event['args']
    val = val.lower()
    print(val)
    if val == '' or val == None:
        print(stringOptions)
        select.options = stringOptions
        select.update()
    else:
        filtered = search_strings(val, stringOptions)
        print(filtered)
        select.options = filtered
        select.update()

select = ui.select(options=stringOptions, label="Test").props('''
    use-input
    hide-selected
    fill-input
    input-debounce="0"
    style="width: 250px; padding-bottom: 32px"
''')
select.on(type='filter', handler=handleFilter, throttle=3)

ui.run()

@falkoschindler
Copy link
Contributor Author

@Allen-Taylor Very interesting! You found some props I wasn't aware of and that seem to be important for combining an input with a dropdown menu.

I boiled it down to the following code:

#!/usr/bin/env python3
import re
from nicegui import ui

names = ['Google', 'Facebook', 'Twitter', 'Apple', 'Oracle', 'Goodyear']

def on_filter(event: dict) -> None:
    select.options = [n for n in names if re.search(event['args'], n, re.IGNORECASE)]
    select.update()

select = ui.select(names).props('use-input hide-selected fill-input').on('filter', handler=on_filter)

ui.run()

Without .on(...) the interaction looks ok, but - of course - the options are not filtered. But with .on() the dropdown arrow turns into a spinner and the dropdown remains closed.

@AL-THE-BOT-FATHER
Copy link

AL-THE-BOT-FATHER commented Feb 23, 2023

class QSelectWithFilter(jp.QSelect):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.initial_options = kwargs.get('options', [])
        self.use_input = True
        self.allowed_events = ['input', 'remove', 'add', 'new_value', 'filter', 'filter_abort', 'keyup', 'focus', 'blur']
        self.append('keyup', self.filter_function)

    def filter_function(self, msg):
        self.options = list(filter(lambda x: msg.value.lower() in str(x).lower(), self.initial_options))

This is the JustPy example which should be similar.
I'm not able import the classes for some reason.

@falkoschindler
Copy link
Contributor Author

@Allen-Taylor Interesting! Where did you find QSelectWithFilter? Or is it your implementation?

JustPy seems to have the same problem:

My issue is when I use the filter event, the dropdown no longer populates. Instead, I get a spinning "loading" icon.
https://stackoverflow.com/questions/71213157/justpy-qselect-doesnt-display-values-in-dropdown-when-using-filter-event

@falkoschindler
Copy link
Contributor Author

falkoschindler commented Feb 23, 2023

Meanwhile I managed to get one step further: Using the 'input-value' event instead of 'filter' I can successfully narrow down the list of options. There are, however, still some issues within ui.select, probably related to the _msg_to_value conversion:

def on_filter(event: dict) -> None:
    select.options = [n for n in names if not event['args'] or re.search(event['args'], option, re.IGNORECASE)]
    select.update()

select = ui.select(names).props('use-input hide-selected fill-input').on('input-value', handler=on_filter)

@AL-THE-BOT-FATHER
Copy link

AL-THE-BOT-FATHER commented Feb 24, 2023

This code lets me sort the drop down.

import re
from nicegui import ui

options = ['United States',
           'China',
           'Japan',
           'Germany',
           'United Kingdom',
           'France',
           'Brazil',
           'India',
           'Italy',
           'Canada']

def search_strings(query, string_list):
    escaped_query = re.escape(query)
    pattern = re.compile(r"\b" + escaped_query + r"\w*\b", re.IGNORECASE)
    matches = [string for string in string_list if pattern.search(string)]
    return matches

def handle_filter(event):
    val = event['args']
    if val:
        filtered = search_strings(val, options)
        if filtered:
            select.options = filtered
        else:
            select.options = options
    else:
        select.options = options
    select.update()

with ui.row().classes('q-gutter-md row'):
    select = ui.select(options=options, label="Please select a country:").props(
        ''' use-input hide-selected fill-input stack-label ''') \
        .style("width: 250px; padding-bottom: 32px")
    select.on(type='input-value', handler=handle_filter)

ui.run()

However, keyboard mashing on the input I get this error.

    return self._values[msg['args']['value']]
           ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
IndexError: list index out of range

Also, with the using the up and down arrows on the keyboard messes with the filter.

@falkoschindler
Copy link
Contributor Author

Yes, the internals of ui.select expect the value to be one of the pre-defined options. Like every other ChoiceElement it creates a kind of lookup table [{'value': index, 'label': option}, ...] for communicating with the frontend. Now that the user can type anything, the value can't be mapped to an index and this model does not hold anymore.

We can either make choice elements robust against arbitrary values, or implement our search-as-you-type input selection as a completely new UI element.

@AL-THE-BOT-FATHER
Copy link

You could have something like ui.select(options[...], filter=True)

@AL-THE-BOT-FATHER
Copy link

AL-THE-BOT-FATHER commented Feb 25, 2023

Got it working with custom vue component.

image

main.py

from select_filter import Select_Filter
from nicegui import ui

options = ['United States',
           'China',
           'Japan',
           'Germany',
           'United Kingdom',
           'France',
           'Brazil',
           'India',
           'Italy',
           'Canada']

with ui.card():
    qsel = Select_Filter(options=options).style("width: 250px")

ui.run()

select_filter.js

export default {
  props: ['options'],
  template: `
    <q-select
      :model-value="model"
      use-input
      hide-selected
      fill-input
      input-debounce="0"
      :options="options"
      @filter="filterFn"
      @input-value="setModel"
    >
      <template v-slot:no-option>
        <q-item>
          <q-item-section>
            No results
          </q-item-section>
        </q-item>
      </template>
    </q-select>

  `,
  setup(props) {
    const stringOptions = Vue.ref(props.options)
    const model = Vue.ref(null)
    const options = Vue.ref(stringOptions.value)
    const filterFn = (val, update, abort) => {
      update(() => {
        const needle = val.toLocaleLowerCase()
        options.value = stringOptions.value.filter(v => v.toLocaleLowerCase().indexOf(needle) > -1)
      })
    }
    const setModel = (val) => {
      model.value = val
    }
    Vue.watch(() => props.options, (newVal) => {
      stringOptions.value = newVal
      options.value = stringOptions.value
    })
    return {
      model,
      options,
      filterFn,
      setModel
    }
  },
};

select_filter.py

from typing import List

from nicegui.dependencies import register_component
from nicegui.element import Element

register_component('select_filter', __file__, 'select_filter.js')

class Select_Filter(Element):

    def __init__(self, options: List[str]) -> None:
        super().__init__('select_filter')
        self._props['options'] = options

@falkoschindler falkoschindler self-assigned this Feb 27, 2023
@falkoschindler
Copy link
Contributor Author

Yes, a custom component is definitely an option.
But I'll look into it once again and try to combine ui.select with the input field. Maybe I just have to remove the value-to-index conversion.

@falkoschindler falkoschindler removed the help wanted Extra attention is needed label Feb 27, 2023
@falkoschindler
Copy link
Contributor Author

That's really difficult... I got it somehow working on the search-as-you-type branch. But the UX is pretty bad: When the user types the first letters, the list is filtered correctly. But when the user tries to navigate the list with cursor keys, the list immediately collapses to a single option. This is because the highlighting causes an input-value event with the highlighted option as a parameter, the filter is evaluated and only a single option matches.

In contrast, @Allen-Taylor's approach works quite nicely. It doesn't use the input-value but the filter event. This doesn't seem to be possible without a custom JS component, because the filter event expects an update function to be called, which doesn't happen with our generic event registration.

So maybe adding a dedicated ui.select_filter element is the way to go.

@falkoschindler
Copy link
Contributor Author

Or can we let the existing ui.select use Allen's select_filter as a wrapper for q-select?
It would only need to add filtering and pass all other props, events and slots to q-select.

@falkoschindler
Copy link
Contributor Author

This is starting to look very promising! I managed to wrap q-select with a custom component similar to the one from here: #272 (comment). But I could even simplify quite a bit. And it's still possible to use all props, slots, and events QSelect offers. Some more testing and documentation, and we might be able to release it pretty soon.

falkoschindler added a commit that referenced this issue Mar 17, 2023
@falkoschindler falkoschindler modified the milestones: Later, v1.2.1, v1.2.2 Mar 20, 2023
falkoschindler added a commit that referenced this issue Mar 24, 2023
falkoschindler added a commit that referenced this issue Mar 24, 2023
@falkoschindler
Copy link
Contributor Author

Ok, this feature is finally complete. I just merged it onto main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants