Skip to content

Commit 946c8aa

Browse files
committed
Make DropDown more performant for many options
Attempts to improve performance of the DropDown component for scenarios where a dropdown has a lot (~10000) options to choose from. Improvements should be most apparent in the access control tabs in case of many roles, and the series table filter in case of many series. Introduces virtualization to our DropDown, using react-window. Unfortunately forces us to define fixed heights for our options, which may cause trouble for long labels. Makes auto-translation of option labels optional, as it can be very expensive and thus should be avoided if not needed. Switch Select for AsyncSelect. This allows us to potentially cache option calculations, further reducing computation time. Memoize the getFilters selector. Should prevent constant rerenders in certain cases.
1 parent 68569f8 commit 946c8aa

11 files changed

Lines changed: 139 additions & 18 deletions

File tree

package-lock.json

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"react-router": "^7.6.0",
3636
"react-select": "^5.10.1",
3737
"react-textarea-autosize": "^8.5.9",
38+
"react-window": "^1.8.11",
3839
"redux": "^5.0.1",
3940
"redux-persist": "^6.0.0",
4041
"redux-thunk": "^3.1.0",
@@ -65,6 +66,7 @@
6566
"@types/lodash": "^4.17.16",
6667
"@types/node": "^22.15.18",
6768
"@types/react-dom": "^19.1.5",
69+
"@types/react-window": "^1.8.8",
6870
"@types/uuid": "^10.0.0",
6971
"@vitejs/plugin-react-swc": "^3.9.0",
7072
"eslint": "^9.26.0",

src/components/events/partials/ModalTabsAndPages/NewAccessPage.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ const NewAccessPage = <T extends RequiredFormProps>({
242242
formik.values.acls
243243
)
244244
)}
245+
fetchOptions={() =>
246+
formatAclRolesForDropdown(
247+
filterRoles(
248+
roles,
249+
formik.values.acls
250+
)
251+
)
252+
}
245253
required={true}
246254
handleChange={(element) => {
247255
if (element) {
@@ -260,6 +268,8 @@ const NewAccessPage = <T extends RequiredFormProps>({
260268
user
261269
)
262270
}
271+
skipTranslate
272+
optionHeight={35}
263273
customCSS={{ width: 360, optionPaddingTop: 5 }}
264274
/>
265275
</td>

src/components/shared/DateTimeCell.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { renderValidDate } from "../../utils/dateUtils";
77
import { IconButton } from "../shared/IconButton";
88
import { ParseKeys } from "i18next";
99
import { AsyncThunk } from "@reduxjs/toolkit";
10+
import { Resource } from "../../slices/tableSlice";
1011

1112
/**
1213
* This component renders the start date cells of events in the table view
@@ -19,7 +20,7 @@ const DateTimeCell = ({
1920
loadResourceIntoTable,
2021
tooltipText,
2122
}: {
22-
resource: string
23+
resource: Resource
2324
date: string
2425
filterName: string
2526
fetchResource: AsyncThunk<any, void, any>

src/components/shared/DropDown.tsx

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
dropDownSpacingTheme,
55
dropDownStyle,
66
} from "../../utils/componentStyles";
7-
import Select, { GroupBase, Props, SelectInstance } from "react-select";
8-
import CreatableSelect from "react-select/creatable";
7+
import { GroupBase, MenuListProps, Props, SelectInstance, createFilter } from "react-select";
98
import { isJson } from "../../utils/utils";
109
import { ParseKeys } from "i18next";
10+
import { FixedSizeList, ListChildComponentProps } from "react-window";
11+
import AsyncSelect from 'react-select/async';
12+
import AsyncCreatableSelect from 'react-select/async-creatable';
1113

1214
export type DropDownOption = {
1315
label: string,
@@ -34,7 +36,10 @@ const DropDown = <T, >({
3436
disabled = false,
3537
menuIsOpen = undefined,
3638
handleMenuIsOpen = undefined,
39+
skipTranslate = false,
40+
optionHeight = 25,
3741
customCSS,
42+
fetchOptions,
3843
}: {
3944
ref?: React.RefObject<SelectInstance<any, boolean, GroupBase<any>> | null>
4045
value: T
@@ -51,12 +56,15 @@ const DropDown = <T, >({
5156
disabled?: boolean,
5257
menuIsOpen?: boolean,
5358
handleMenuIsOpen?: (open: boolean) => void,
59+
skipTranslate?: boolean,
60+
optionHeight?: number,
5461
customCSS?: {
5562
isMetadataStyle?: boolean,
5663
width?: number | string,
5764
optionPaddingTop?: number,
5865
optionLineHeight?: string
59-
}
66+
},
67+
fetchOptions?: () => { label: string, value: string}[]
6068
}) => {
6169
const { t } = useTranslation();
6270

@@ -84,8 +92,11 @@ const DropDown = <T, >({
8492
filterText: string,
8593
required: boolean,
8694
) => {
87-
// Translate?
88-
unformattedOptions = unformattedOptions.map(option => ({...option, label: t(option.label as ParseKeys)}))
95+
// Translate
96+
// Translating is expensive, skip it if it is not required
97+
if (!skipTranslate) {
98+
unformattedOptions = unformattedOptions.map(option => ({...option, label: t(option.label as ParseKeys)}))
99+
}
89100

90101
// Filter
91102
filterText = filterText.toLowerCase();
@@ -119,6 +130,41 @@ const DropDown = <T, >({
119130
return unformattedOptions;
120131
};
121132

133+
const itemHeight = optionHeight;
134+
/**
135+
* Custom component for list virtualization
136+
*/
137+
const MenuList = (props: MenuListProps<DropDownOption, false>) => {
138+
const { options, children, maxHeight, getValue } = props
139+
140+
console.log("Menu List render")
141+
142+
return Array.isArray(children) ? (
143+
<div style={{ paddingTop: 4 }}>
144+
<FixedSizeList
145+
height={maxHeight < (children.length * itemHeight) ? maxHeight : children.length * itemHeight}
146+
itemCount={children.length}
147+
itemSize={itemHeight}
148+
overscanCount={4}
149+
width="100%"
150+
>
151+
{({ index, style }: ListChildComponentProps) => <div style={{ ...style }}>{children[index]}</div>}
152+
</FixedSizeList>
153+
</div>
154+
) : null
155+
}
156+
157+
const loadOptions = (
158+
inputValue: string,
159+
callback: (options: DropDownOption[]) => void
160+
) => {
161+
callback(formatOptions(
162+
fetchOptions ? fetchOptions() : options,
163+
searchText,
164+
required,
165+
));
166+
};
167+
122168

123169
let commonProps: Props = {
124170
tabIndex: tabIndex,
@@ -129,11 +175,6 @@ const DropDown = <T, >({
129175
isSearchable: true,
130176
value: { value: value, label: text === "" ? placeholder : text },
131177
inputValue: searchText,
132-
options: formatOptions(
133-
options,
134-
searchText,
135-
required,
136-
),
137178
placeholder: placeholder,
138179
onInputChange: (value: string) => setSearch(value),
139180
onChange: (element) => handleChange(element as {value: T, label: string}),
@@ -142,18 +183,36 @@ const DropDown = <T, >({
142183
onMenuClose: () => openMenu(false),
143184
isDisabled: disabled,
144185
openMenuOnFocus: openMenuOnFocus,
186+
187+
//@ts-expect-error: React-Select typing does not account for the typing of option it itself requires
188+
components: { MenuList },
189+
filterOption: createFilter({ ignoreAccents: false }), // To improve performance on filtering
145190
};
146191

147192
return creatable ? (
148-
<CreatableSelect
193+
<AsyncCreatableSelect
149194
ref={selectRef}
150195
{...commonProps}
196+
cacheOptions
197+
defaultOptions={formatOptions(
198+
options,
199+
searchText,
200+
required,
201+
)}
202+
loadOptions={loadOptions}
151203
/>
152204
) : (
153-
<Select
205+
<AsyncSelect
154206
ref={selectRef}
155207
{...commonProps}
156208
noOptionsMessage={() => t("SELECT_NO_MATCHING_RESULTS")}
209+
cacheOptions
210+
defaultOptions={formatOptions(
211+
options,
212+
searchText,
213+
required,
214+
)}
215+
loadOptions={loadOptions}
157216
/>
158217
);
159218
};

src/components/shared/MultiValueCell.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AppThunk, useAppDispatch, useAppSelector } from "../../store";
55
import { IconButton } from "../shared/IconButton";
66
import { AsyncThunk } from "@reduxjs/toolkit";
77
import { ParseKeys } from "i18next";
8+
import { Resource } from "../../slices/tableSlice";
89

910
/**
1011
* This component renders the presenters cells of events in the table view
@@ -17,7 +18,7 @@ const MultiValueCell = ({
1718
loadResourceIntoTable,
1819
tooltipText,
1920
}: {
20-
resource: string
21+
resource: Resource
2122
values: string[]
2223
filterName: string
2324
fetchResource: AsyncThunk<any, void, any>

src/components/shared/TableFilterProfiles.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { availableHotkeys } from "../../configs/hotkeysConfig";
1818
import { AsyncThunk } from "@reduxjs/toolkit";
1919
import ButtonLikeAnchor from "./ButtonLikeAnchor";
2020
import { ParseKeys } from "i18next";
21+
import { Resource } from "../../slices/tableSlice";
2122

2223
/**
2324
* This component renders the table filter profiles in the upper right corner when clicked on settings icon of the
@@ -34,7 +35,7 @@ const TableFiltersProfiles = ({
3435
setFilterSettings: (_: boolean) => void,
3536
loadResource: AsyncThunk<any, void, any>,
3637
loadResourceIntoTable: () => AppThunk,
37-
resource: string,
38+
resource: Resource,
3839
}) => {
3940
const dispatch = useAppDispatch();
4041

src/components/shared/TableFilters.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import DropDown from "./DropDown";
3131
import { AsyncThunk } from "@reduxjs/toolkit";
3232
import ButtonLikeAnchor from "./ButtonLikeAnchor";
3333
import { ParseKeys } from "i18next";
34+
import { Resource } from "../../slices/tableSlice";
3435

3536
/**
3637
* This component renders the table filters in the upper right corner of the table
@@ -42,7 +43,7 @@ const TableFilters = ({
4243
}: {
4344
loadResource: AsyncThunk<any, void, any>,
4445
loadResourceIntoTable: () => AppThunk,
45-
resource: string,
46+
resource: Resource,
4647
}) => {
4748
const { t } = useTranslation();
4849
const dispatch = useAppDispatch();
@@ -448,6 +449,7 @@ const FilterSwitch = ({
448449
openMenuOnFocus
449450
menuIsOpen={openSecondFilterMenu}
450451
handleMenuIsOpen={setOpenSecondFilterMenu}
452+
skipTranslate={!filter.translatable}
451453
customCSS={{ width: 200, optionPaddingTop: 5 }}
452454
/>
453455
</div>

src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,8 @@ const ResourceDetailsAccessPolicyTab = ({
498498
user
499499
)
500500
}
501+
skipTranslate
502+
optionHeight={35}
501503
customCSS={{ width: 360, optionPaddingTop: 5 }}
502504
/>
503505
) : (

src/components/users/partials/wizard/AclAccessPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ const AclAccessPage = <T extends RequiredFormProps>({
228228
"USERS.ACLS.NEW.ACCESS.ROLES.LABEL"
229229
)}
230230
disabled={!isAccess}
231+
skipTranslate
232+
optionHeight={35}
231233
customCSS={{ width: 360, optionPaddingTop: 5 }}
232234
/>
233235
</td>

0 commit comments

Comments
 (0)