Skip to content

Commit

Permalink
Fix: the browser hanging while searching for constraint items (#78)
Browse files Browse the repository at this point in the history
When a user types in a dropdown, the filtering logic caused the UI to
lock up. This degraded UX massively. The issue lied with the use of
fuzzy searching.

But fuzzy searching isn't really valuable for our case. Instead, a
contextual search is more appropriate. This commit uses `flexSearch` to
conduct contextual searches.

Closes: #76
Closes: #77

Squashed commits:
* Render a customized menu list for the select dropdown
* Render a virtualized dropdown list
* Keep the main popup open after selecting an item
* Scroll the item into view is the use scrolls past it with the keyboard
* Render the entire list of available item values
* Perf: improve searching in constraint inputs with flexSearch
* Increase the amount of filter results is returned
JM-Mendez authored Jul 10, 2020
1 parent c818701 commit 42190ab
Showing 7 changed files with 165 additions and 52 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -41,14 +41,14 @@
"@xstate/immer": "^0.1.0",
"@xstate/react": "^0.8.1",
"babel-plugin-emotion": "^10.0.33",
"fuse.js": "^6.4.0",
"flexsearch": "^0.6.32",
"imjs": "^4.0.0",
"immer": "^7.0.5",
"nanoid": "^3.1.10",
"nanoid-dictionary": "^3.0.0",
"object-hash": "^2.0.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-window": "^1.8.5",
"recharts": "^1.8.5",
"underscore.string": "^3.3.5",
"xstate": "latest"
@@ -67,6 +67,7 @@
"@testing-library/react": "^10.4.3",
"@testing-library/user-event": "^12.0.11",
"@types/dotenv": "^8.2.0",
"@types/react-window": "^1.8.2",
"@typescript-eslint/eslint-plugin": "2.x",
"@typescript-eslint/parser": "2.x",
"@xstate/test": "^0.4.0",
1 change: 1 addition & 0 deletions src/components/Constraints/CheckboxPopup.stories.jsx
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@ export const Playground = () => {
const machine = createConstraintMachine({
id: 'checkbox',
constraintItemsQuery: {},
// @ts-ignore
}).withContext({
selectedValues: [],
availableValues: organismSummary.results,
139 changes: 102 additions & 37 deletions src/components/Constraints/SelectPopup.jsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,107 @@
import { Button, Divider, FormGroup, H4, MenuItem } from '@blueprintjs/core'
import { Button, Classes, Divider, FormGroup, H4, Menu, MenuItem } from '@blueprintjs/core'
import { IconNames } from '@blueprintjs/icons'
import { Suggest } from '@blueprintjs/select'
import Fuse from 'fuse.js'
import React, { useEffect, useRef, useState } from 'react'
import { FixedSizeList as List } from 'react-window'
import { ADD_CONSTRAINT, REMOVE_CONSTRAINT } from 'src/actionConstants'
import { generateId } from 'src/generateId'

import { useServiceContext } from '../../machineBus'
import { NoValuesProvided } from './NoValuesProvided'

/**
* Renders the menu item for the drop down available menu items
*/
const itemRenderer = (item, props) => {
const ConstraintItem = ({ index, style, data }) => {
const { filteredItems, activeItem, handleItemSelect, infoText } = data

if (index === 0) {
return <MenuItem disabled={true} text={infoText} />
}

// subtract 1 because we're adding an informative menu item before all items
const name = filteredItems[index - 1].name

return (
<MenuItem
key={item.name}
text={item.name}
active={props.modifiers.active}
onClick={props.handleClick}
shouldDismissPopover={false}
key={name}
text={name}
style={style}
active={name === activeItem.name}
onClick={() => handleItemSelect({ name })}
/>
)
}

const VirtualizedMenu = ({
filteredItems,
itemsParentRef,
query,
activeItem,
handleItemSelect,
}) => {
const listRef = useRef(null)

const isPlural = filteredItems.length > 1 ? 's' : ''
const infoText =
query === ''
? `Showing ${filteredItems.length} Item${isPlural}`
: `Found ${filteredItems.length} item${isPlural} matching "${query}"`

useEffect(() => {
if (listRef?.current) {
const itemLocation = filteredItems.findIndex((item) => item.name === activeItem.name)
// add one to offset the menu description item
listRef.current.scrollToItem(itemLocation + 1)
}
}, [activeItem, filteredItems])

const ulWrapper = ({ children, style }) => {
return (
<Menu style={style} ulRef={itemsParentRef}>
{children}
</Menu>
)
}

return (
<List
ref={listRef}
height={Math.min(200, (filteredItems.length + 1) * 30)}
itemSize={30}
width={300}
// add 1 because we're adding an informative menu item before all items
itemCount={filteredItems.length + 1}
innerElementType={ulWrapper}
className={Classes.MENU}
style={{ listStyle: 'none' }}
itemData={{
filteredItems,
activeItem,
handleItemSelect,
infoText,
}}
>
{ConstraintItem}
</List>
)
}

const renderMenu = (handleItemSelect) => ({ filteredItems, itemsParentRef, query, activeItem }) => (
<VirtualizedMenu
filteredItems={filteredItems}
itemsParentRef={itemsParentRef}
query={query}
activeItem={activeItem}
handleItemSelect={handleItemSelect}
/>
)

export const SelectPopup = ({
nonIdealTitle = undefined,
nonIdealDescription = undefined,
label = '',
}) => {
const [uniqueId] = useState(() => `selectPopup-${generateId()}`)
const [state, send] = useServiceContext('constraints')
const { availableValues, selectedValues } = state.context

const fuse = useRef(new Fuse([]))

useEffect(() => {
fuse.current = new Fuse(availableValues, {
keys: ['item'],
useExtendedSearch: true,
})
}, [availableValues])
const { availableValues, selectedValues, searchIndex } = state.context

if (availableValues.length === 0) {
return <NoValuesProvided title={nonIdealTitle} description={nonIdealDescription} />
@@ -50,29 +111,33 @@ export const SelectPopup = ({
// the value directly to the added constraints list when clicked, so we reset the input here
const renderInputValue = () => ''

const handleItemSelect = ({ name }) => {
send({ type: ADD_CONSTRAINT, constraint: name })
}

const handleButtonClick = (constraint) => () => {
send({ type: REMOVE_CONSTRAINT, constraint })
}

const filterQuery = (query, items) => {
if (query === '') {
return items.filter((i) => !selectedValues.includes(i.name))
}

const fuseResults = fuse.current.search(query)
return fuseResults.flatMap((r) => {
if (selectedValues.includes(r.item.item)) {
// flexSearch's default result limit is set 1000, so we set it to the length of all items
const results = searchIndex.search(query, availableValues.length)

return results.flatMap((value) => {
if (selectedValues.includes(value)) {
return []
}

return [{ name: r.item.item, count: r.item.count }]
const item = items.find((it) => it.name === value)

return [{ name: item.name, count: item.count }]
})
}

const handleItemSelect = ({ name }) => {
send({ type: ADD_CONSTRAINT, constraint: name })
}

const handleButtonClick = (constraint) => () => {
send({ type: REMOVE_CONSTRAINT, constraint })
}

return (
<div>
{selectedValues.length > 0 && (
@@ -117,12 +182,12 @@ export const SelectPopup = ({
id={`selectPopup-${uniqueId}`}
items={availableValues.map((i) => ({ name: i.item, count: i.count }))}
inputValueRenderer={renderInputValue}
itemListPredicate={filterQuery}
fill={true}
onItemSelect={handleItemSelect}
resetOnSelect={true}
noResults={<MenuItem disabled={true} text="No results match your entry" />}
itemRenderer={itemRenderer}
itemListRenderer={renderMenu(handleItemSelect)}
onItemSelect={handleItemSelect}
itemListPredicate={filterQuery}
popoverProps={{ captureDismiss: true }}
/>
</FormGroup>
</div>
1 change: 1 addition & 0 deletions src/components/Constraints/SelectPopup.stories.jsx
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@ export const Playground = () => (
machine={createConstraintMachine({
id: 'select',
constraintItemsQuery: {},
// @ts-ignore
}).withContext({
selectedValues: [],
availableValues: mockResults,
24 changes: 22 additions & 2 deletions src/components/Constraints/createConstraintMachine.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assign } from '@xstate/immer'
import FlexSearch from 'flexsearch'
import { fetchSummary } from 'src/fetchSummary'
import { sendToBus } from 'src/machineBus'
import { formatConstraintPath } from 'src/utils'
@@ -30,11 +31,13 @@ export const createConstraintMachine = ({
id,
initial,
context: {
type: id,
constraintPath: path,
selectedValues: [],
availableValues: [],
classView: '',
constraintItemsQuery,
searchIndex: null,
},
on: {
[LOCK_ALL_CONSTRAINTS]: 'constraintLimitReached',
@@ -115,6 +118,24 @@ export const createConstraintMachine = ({
// @ts-ignore
ctx.availableValues = data.items
ctx.classView = data.classView

if (ctx.type === 'select') {
// prebuild search index for the dropdown select menu
// @ts-ignore
const searchIndex = new FlexSearch({
encode: 'advanced',
tokenize: 'reverse',
suggest: true,
cache: true,
})

data.items.forEach((item) => {
// @ts-ignore
searchIndex.add(item.item, item.item)
})

ctx.searchIndex = searchIndex
}
}),
applyConstraint: ({ classView, constraintPath, selectedValues, availableValues }) => {
const query = {
@@ -166,8 +187,7 @@ export const createConstraintMachine = ({

return {
classView,
// fixme: return all results after menu has been virtualized
items: summary.results.slice(0, 20),
items: summary.results,
}
},
},
2 changes: 2 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -45,6 +45,8 @@ export interface ConstraintMachineContext {
constraintPath: string
classView: string
constraintItemsQuery: { [key: string]: any }
searchIndex?: any
type: ConstraintMachineTypes
}

export type ConstraintEvents = EventObject &
Loading

0 comments on commit 42190ab

Please sign in to comment.