Skip to content

Commit 42190ab

Browse files
authored
Fix: the browser hanging while searching for constraint items (#78)
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
1 parent c818701 commit 42190ab

File tree

7 files changed

+165
-52
lines changed

7 files changed

+165
-52
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@
4141
"@xstate/immer": "^0.1.0",
4242
"@xstate/react": "^0.8.1",
4343
"babel-plugin-emotion": "^10.0.33",
44-
"fuse.js": "^6.4.0",
44+
"flexsearch": "^0.6.32",
4545
"imjs": "^4.0.0",
4646
"immer": "^7.0.5",
4747
"nanoid": "^3.1.10",
4848
"nanoid-dictionary": "^3.0.0",
49-
"object-hash": "^2.0.3",
5049
"react": "^16.13.1",
5150
"react-dom": "^16.13.1",
51+
"react-window": "^1.8.5",
5252
"recharts": "^1.8.5",
5353
"underscore.string": "^3.3.5",
5454
"xstate": "latest"
@@ -67,6 +67,7 @@
6767
"@testing-library/react": "^10.4.3",
6868
"@testing-library/user-event": "^12.0.11",
6969
"@types/dotenv": "^8.2.0",
70+
"@types/react-window": "^1.8.2",
7071
"@typescript-eslint/eslint-plugin": "2.x",
7172
"@typescript-eslint/parser": "2.x",
7273
"@xstate/test": "^0.4.0",

src/components/Constraints/CheckboxPopup.stories.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const Playground = () => {
6868
const machine = createConstraintMachine({
6969
id: 'checkbox',
7070
constraintItemsQuery: {},
71+
// @ts-ignore
7172
}).withContext({
7273
selectedValues: [],
7374
availableValues: organismSummary.results,

src/components/Constraints/SelectPopup.jsx

Lines changed: 102 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,107 @@
1-
import { Button, Divider, FormGroup, H4, MenuItem } from '@blueprintjs/core'
1+
import { Button, Classes, Divider, FormGroup, H4, Menu, MenuItem } from '@blueprintjs/core'
22
import { IconNames } from '@blueprintjs/icons'
33
import { Suggest } from '@blueprintjs/select'
4-
import Fuse from 'fuse.js'
54
import React, { useEffect, useRef, useState } from 'react'
5+
import { FixedSizeList as List } from 'react-window'
66
import { ADD_CONSTRAINT, REMOVE_CONSTRAINT } from 'src/actionConstants'
77
import { generateId } from 'src/generateId'
88

99
import { useServiceContext } from '../../machineBus'
1010
import { NoValuesProvided } from './NoValuesProvided'
1111

12-
/**
13-
* Renders the menu item for the drop down available menu items
14-
*/
15-
const itemRenderer = (item, props) => {
12+
const ConstraintItem = ({ index, style, data }) => {
13+
const { filteredItems, activeItem, handleItemSelect, infoText } = data
14+
15+
if (index === 0) {
16+
return <MenuItem disabled={true} text={infoText} />
17+
}
18+
19+
// subtract 1 because we're adding an informative menu item before all items
20+
const name = filteredItems[index - 1].name
21+
1622
return (
1723
<MenuItem
18-
key={item.name}
19-
text={item.name}
20-
active={props.modifiers.active}
21-
onClick={props.handleClick}
22-
shouldDismissPopover={false}
24+
key={name}
25+
text={name}
26+
style={style}
27+
active={name === activeItem.name}
28+
onClick={() => handleItemSelect({ name })}
2329
/>
2430
)
2531
}
2632

33+
const VirtualizedMenu = ({
34+
filteredItems,
35+
itemsParentRef,
36+
query,
37+
activeItem,
38+
handleItemSelect,
39+
}) => {
40+
const listRef = useRef(null)
41+
42+
const isPlural = filteredItems.length > 1 ? 's' : ''
43+
const infoText =
44+
query === ''
45+
? `Showing ${filteredItems.length} Item${isPlural}`
46+
: `Found ${filteredItems.length} item${isPlural} matching "${query}"`
47+
48+
useEffect(() => {
49+
if (listRef?.current) {
50+
const itemLocation = filteredItems.findIndex((item) => item.name === activeItem.name)
51+
// add one to offset the menu description item
52+
listRef.current.scrollToItem(itemLocation + 1)
53+
}
54+
}, [activeItem, filteredItems])
55+
56+
const ulWrapper = ({ children, style }) => {
57+
return (
58+
<Menu style={style} ulRef={itemsParentRef}>
59+
{children}
60+
</Menu>
61+
)
62+
}
63+
64+
return (
65+
<List
66+
ref={listRef}
67+
height={Math.min(200, (filteredItems.length + 1) * 30)}
68+
itemSize={30}
69+
width={300}
70+
// add 1 because we're adding an informative menu item before all items
71+
itemCount={filteredItems.length + 1}
72+
innerElementType={ulWrapper}
73+
className={Classes.MENU}
74+
style={{ listStyle: 'none' }}
75+
itemData={{
76+
filteredItems,
77+
activeItem,
78+
handleItemSelect,
79+
infoText,
80+
}}
81+
>
82+
{ConstraintItem}
83+
</List>
84+
)
85+
}
86+
87+
const renderMenu = (handleItemSelect) => ({ filteredItems, itemsParentRef, query, activeItem }) => (
88+
<VirtualizedMenu
89+
filteredItems={filteredItems}
90+
itemsParentRef={itemsParentRef}
91+
query={query}
92+
activeItem={activeItem}
93+
handleItemSelect={handleItemSelect}
94+
/>
95+
)
96+
2797
export const SelectPopup = ({
2898
nonIdealTitle = undefined,
2999
nonIdealDescription = undefined,
30100
label = '',
31101
}) => {
32102
const [uniqueId] = useState(() => `selectPopup-${generateId()}`)
33103
const [state, send] = useServiceContext('constraints')
34-
const { availableValues, selectedValues } = state.context
35-
36-
const fuse = useRef(new Fuse([]))
37-
38-
useEffect(() => {
39-
fuse.current = new Fuse(availableValues, {
40-
keys: ['item'],
41-
useExtendedSearch: true,
42-
})
43-
}, [availableValues])
104+
const { availableValues, selectedValues, searchIndex } = state.context
44105

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

53-
const handleItemSelect = ({ name }) => {
54-
send({ type: ADD_CONSTRAINT, constraint: name })
55-
}
56-
57-
const handleButtonClick = (constraint) => () => {
58-
send({ type: REMOVE_CONSTRAINT, constraint })
59-
}
60-
61114
const filterQuery = (query, items) => {
62115
if (query === '') {
63116
return items.filter((i) => !selectedValues.includes(i.name))
64117
}
65118

66-
const fuseResults = fuse.current.search(query)
67-
return fuseResults.flatMap((r) => {
68-
if (selectedValues.includes(r.item.item)) {
119+
// flexSearch's default result limit is set 1000, so we set it to the length of all items
120+
const results = searchIndex.search(query, availableValues.length)
121+
122+
return results.flatMap((value) => {
123+
if (selectedValues.includes(value)) {
69124
return []
70125
}
71126

72-
return [{ name: r.item.item, count: r.item.count }]
127+
const item = items.find((it) => it.name === value)
128+
129+
return [{ name: item.name, count: item.count }]
73130
})
74131
}
75132

133+
const handleItemSelect = ({ name }) => {
134+
send({ type: ADD_CONSTRAINT, constraint: name })
135+
}
136+
137+
const handleButtonClick = (constraint) => () => {
138+
send({ type: REMOVE_CONSTRAINT, constraint })
139+
}
140+
76141
return (
77142
<div>
78143
{selectedValues.length > 0 && (
@@ -117,12 +182,12 @@ export const SelectPopup = ({
117182
id={`selectPopup-${uniqueId}`}
118183
items={availableValues.map((i) => ({ name: i.item, count: i.count }))}
119184
inputValueRenderer={renderInputValue}
120-
itemListPredicate={filterQuery}
121185
fill={true}
122-
onItemSelect={handleItemSelect}
123186
resetOnSelect={true}
124-
noResults={<MenuItem disabled={true} text="No results match your entry" />}
125-
itemRenderer={itemRenderer}
187+
itemListRenderer={renderMenu(handleItemSelect)}
188+
onItemSelect={handleItemSelect}
189+
itemListPredicate={filterQuery}
190+
popoverProps={{ captureDismiss: true }}
126191
/>
127192
</FormGroup>
128193
</div>

src/components/Constraints/SelectPopup.stories.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const Playground = () => (
6868
machine={createConstraintMachine({
6969
id: 'select',
7070
constraintItemsQuery: {},
71+
// @ts-ignore
7172
}).withContext({
7273
selectedValues: [],
7374
availableValues: mockResults,

src/components/Constraints/createConstraintMachine.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assign } from '@xstate/immer'
2+
import FlexSearch from 'flexsearch'
23
import { fetchSummary } from 'src/fetchSummary'
34
import { sendToBus } from 'src/machineBus'
45
import { formatConstraintPath } from 'src/utils'
@@ -30,11 +31,13 @@ export const createConstraintMachine = ({
3031
id,
3132
initial,
3233
context: {
34+
type: id,
3335
constraintPath: path,
3436
selectedValues: [],
3537
availableValues: [],
3638
classView: '',
3739
constraintItemsQuery,
40+
searchIndex: null,
3841
},
3942
on: {
4043
[LOCK_ALL_CONSTRAINTS]: 'constraintLimitReached',
@@ -115,6 +118,24 @@ export const createConstraintMachine = ({
115118
// @ts-ignore
116119
ctx.availableValues = data.items
117120
ctx.classView = data.classView
121+
122+
if (ctx.type === 'select') {
123+
// prebuild search index for the dropdown select menu
124+
// @ts-ignore
125+
const searchIndex = new FlexSearch({
126+
encode: 'advanced',
127+
tokenize: 'reverse',
128+
suggest: true,
129+
cache: true,
130+
})
131+
132+
data.items.forEach((item) => {
133+
// @ts-ignore
134+
searchIndex.add(item.item, item.item)
135+
})
136+
137+
ctx.searchIndex = searchIndex
138+
}
118139
}),
119140
applyConstraint: ({ classView, constraintPath, selectedValues, availableValues }) => {
120141
const query = {
@@ -166,8 +187,7 @@ export const createConstraintMachine = ({
166187

167188
return {
168189
classView,
169-
// fixme: return all results after menu has been virtualized
170-
items: summary.results.slice(0, 20),
190+
items: summary.results,
171191
}
172192
},
173193
},

src/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface ConstraintMachineContext {
4545
constraintPath: string
4646
classView: string
4747
constraintItemsQuery: { [key: string]: any }
48+
searchIndex?: any
49+
type: ConstraintMachineTypes
4850
}
4951

5052
export type ConstraintEvents = EventObject &

0 commit comments

Comments
 (0)