diff --git a/frontend/package.json b/frontend/package.json index f51ad52f13..10d9a7ce46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "@mui/material": "5.15.3", "@mui/x-date-pickers": "^7.0.0", "@tanstack/react-table": "^8.10.7", + "@tanstack/react-virtual": "^3.11.3", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/AutcompleteVirtual.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/AutcompleteVirtual.tsx new file mode 100644 index 0000000000..770aeb1b04 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/AutcompleteVirtual.tsx @@ -0,0 +1,84 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import { + Children, + type HTMLAttributes, + type ReactElement, + cloneElement, + forwardRef, + useRef, +} from 'react'; +import { Autocomplete, type AutocompleteProps } from '@mui/material'; + +const ListboxComponent = forwardRef< + HTMLDivElement, + HTMLAttributes +>(function ListboxComponent(props, ref) { + const { children, ...other } = props; + const parentRef = useRef(null); + const items = Children.toArray(children); + + const rowVirtualizer = useVirtualizer({ + count: Children.count(children), + getScrollElement: () => parentRef.current, + estimateSize: () => 56, + overscan: 3, + }); + + return ( +
+
+
    + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const element = items[virtualRow.index] as ReactElement; + + if (!element) { + return null; + } + + const inlineStyle = { + position: 'absolute', + left: 0, + width: '100%', + top: `${virtualRow.start}px`, + }; + + return cloneElement(element, { + ref: rowVirtualizer.measureElement, + key: virtualRow.key, + 'data-index': virtualRow.index, + style: inlineStyle, + }); + })} +
+
+
+ ); +}); + +type TProps = Omit< + AutocompleteProps, + 'autoHighlight' | 'disableListWrap' | 'ListboxComponent' | 'groupBy' +>; + +function AutocompleteVirtual( + props: TProps, +) { + const { getOptionLabel, className, ...restAutocompleteProps } = props; + + return ( + + ); +} + +export default AutocompleteVirtual; diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx index d615791232..1f48099e13 100644 --- a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx @@ -9,6 +9,7 @@ import { UG_USERS_ID } from 'utils/testIds'; import { caseInsensitiveSearch } from 'utils/search'; import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; import type { IServiceAccount } from 'interfaces/service-account'; +import AutocompleteVirtual from './AutcompleteVirtual'; const StyledOption = styled('div')(({ theme }) => ({ display: 'flex', @@ -32,12 +33,14 @@ const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({ }, })); +const StrechedLi = styled('li')({ width: '100%' }); + const renderOption = ( props: React.HTMLAttributes, option: IUser, - selected: boolean, + { selected }: { selected: boolean }, ) => ( -
  • + } checkedIcon={} @@ -52,7 +55,7 @@ const renderOption = ( : option.email} -
  • + ); const renderTags = (value: IGroupUser[]) => ( @@ -76,73 +79,122 @@ export const GroupFormUsersSelect: VFC = ({ users, setUsers, }) => { - const { users: usersAll } = useUsers(); - const { serviceAccounts } = useServiceAccounts(); + const { users: usersAll, loading: isUsersLoading } = useUsers(); + const { serviceAccounts, loading: isServiceAccountsLoading } = + useServiceAccounts(); - const options = [ - ...usersAll - .map((user: IUser) => ({ ...user, type: 'USERS' })) - .sort((a: IUser, b: IUser) => { - const aName = a.name || a.username || ''; - const bName = b.name || b.username || ''; - return aName.localeCompare(bName); - }), - ...serviceAccounts - .map((serviceAccount: IServiceAccount) => ({ - ...serviceAccount, - type: 'SERVICE ACCOUNTS', - })) - .sort((a, b) => { - const aName = a.name || a.username || ''; - const bName = b.name || b.username || ''; - return aName.localeCompare(bName); - }), - ]; + const isLoading = isUsersLoading || isServiceAccountsLoading; + const options = isLoading + ? [] + : [ + ...usersAll + .map((user: IUser) => ({ ...user, type: 'USERS' })) + .sort((a: IUser, b: IUser) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + }), + ...serviceAccounts + .map((serviceAccount: IServiceAccount) => ({ + ...serviceAccount, + type: 'SERVICE ACCOUNTS', + })) + .sort((a, b) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + }), + ]; + + const isLargeList = options.length > 200; return ( - { - if ( - event.type === 'keydown' && - (event as React.KeyboardEvent).key === 'Backspace' && - reason === 'removeOption' - ) { - return; - } - setUsers(newValue); - }} - groupBy={(option) => option.type} - options={options} - renderOption={(props, option, { selected }) => - renderOption(props, option as UserOption, selected) - } - filterOptions={(options, { inputValue }) => - options - .filter( + {isLargeList ? ( + { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === + 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setUsers(newValue); + }} + options={options} + renderOption={renderOption} + filterOptions={(options, { inputValue }) => + options.filter( ({ name, username, email }) => caseInsensitiveSearch(inputValue, email) || caseInsensitiveSearch(inputValue, name) || caseInsensitiveSearch(inputValue, username), ) - .slice(0, 100) - } - isOptionEqualToValue={(option, value) => option.id === value.id} - getOptionLabel={(option: UserOption) => - option.email || option.name || option.username || '' - } - renderInput={(params) => ( - - )} - renderTags={(value) => renderTags(value)} - /> + } + isOptionEqualToValue={(option, value) => + option.id === value.id + } + getOptionLabel={(option: UserOption) => + option.email || option.name || option.username || '' + } + renderInput={(params) => ( + + )} + renderTags={(value) => renderTags(value)} + /> + ) : ( + { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === + 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setUsers(newValue); + }} + groupBy={(option) => option.type} + options={options} + renderOption={renderOption} + filterOptions={(options, { inputValue }) => + options.filter( + ({ name, username, email }) => + caseInsensitiveSearch(inputValue, email) || + caseInsensitiveSearch(inputValue, name) || + caseInsensitiveSearch(inputValue, username), + ) + } + isOptionEqualToValue={(option, value) => + option.id === value.id + } + getOptionLabel={(option: UserOption) => + option.email || option.name || option.username || '' + } + renderInput={(params) => ( + + )} + renderTags={(value) => renderTags(value)} + noOptionsText={isLoading ? 'Loading…' : 'No options'} + /> + )} ); }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1e2cbbb95c..a86a7f25c3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2768,6 +2768,18 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.11.3": + version: 3.11.3 + resolution: "@tanstack/react-virtual@npm:3.11.3" + dependencies: + "@tanstack/virtual-core": "npm:3.11.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/9718379045ecda92d2c59f3c25699c703d98509ea569d7bfb0dbf78f1e0f46f0023d7a5d3a373fb7cdd4507286c1cf47648b5c0f4b1bdb85b2d2a6c26814b884 + languageName: node + linkType: hard + "@tanstack/table-core@npm:8.20.5": version: 8.20.5 resolution: "@tanstack/table-core@npm:8.20.5" @@ -2775,6 +2787,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/virtual-core@npm:3.11.3": + version: 3.11.3 + resolution: "@tanstack/virtual-core@npm:3.11.3" + checksum: 10c0/94701b8d2da9167c8b4ba36bdaff22019b8ebb19224c357c1af16cbc375b39076ecc021a8c9581001607afc921d0a843019cb27168999065dda511c445a1a335 + languageName: node + linkType: hard + "@testing-library/dom@npm:10.4.0": version: 10.4.0 resolution: "@testing-library/dom@npm:10.4.0" @@ -10165,6 +10184,7 @@ __metadata: "@mui/material": "npm:5.15.3" "@mui/x-date-pickers": "npm:^7.0.0" "@tanstack/react-table": "npm:^8.10.7" + "@tanstack/react-virtual": "npm:^3.11.3" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:6.6.3" "@testing-library/react": "npm:16.1.0"