1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

feat: virtual autocomplete (#9181)

This commit is contained in:
Mateusz Kwasniewski 2025-02-03 16:07:24 +01:00 committed by GitHub
parent 6f19ce090d
commit c86ea091b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 218 additions and 61 deletions

View File

@ -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",

View File

@ -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<HTMLElement>
>(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 (
<div ref={ref}>
<div ref={parentRef} {...other}>
<ul
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{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,
});
})}
</ul>
</div>
</div>
);
});
type TProps<T, M extends boolean | undefined> = Omit<
AutocompleteProps<T, M, boolean, false>,
'autoHighlight' | 'disableListWrap' | 'ListboxComponent' | 'groupBy'
>;
function AutocompleteVirtual<T, M extends boolean | undefined>(
props: TProps<T, M>,
) {
const { getOptionLabel, className, ...restAutocompleteProps } = props;
return (
<Autocomplete
{...restAutocompleteProps}
disableListWrap
getOptionLabel={getOptionLabel}
ListboxComponent={ListboxComponent}
/>
);
}
export default AutocompleteVirtual;

View File

@ -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<HTMLLIElement>,
option: IUser,
selected: boolean,
{ selected }: { selected: boolean },
) => (
<li {...props}>
<StrechedLi {...props} key={option.id}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
checkedIcon={<CheckBoxIcon fontSize='small' />}
@ -52,7 +55,7 @@ const renderOption = (
: option.email}
</span>
</StyledOption>
</li>
</StrechedLi>
);
const renderTags = (value: IGroupUser[]) => (
@ -76,73 +79,122 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
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 (
<StyledGroupFormUsersSelect>
<Autocomplete
data-testid={UG_USERS_ID}
size='small'
multiple
limitTags={1}
openOnFocus
disableCloseOnSelect
value={users as UserOption[]}
onChange={(event, newValue, reason) => {
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 ? (
<AutocompleteVirtual
data-testid={UG_USERS_ID}
size='small'
limitTags={1}
openOnFocus
multiple
disableCloseOnSelect
value={users as UserOption[]}
onChange={(event, newValue, reason) => {
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) => (
<TextField {...params} label='Select users' />
)}
renderTags={(value) => renderTags(value)}
/>
}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
getOptionLabel={(option: UserOption) =>
option.email || option.name || option.username || ''
}
renderInput={(params) => (
<TextField {...params} label='Select users' />
)}
renderTags={(value) => renderTags(value)}
/>
) : (
<Autocomplete
data-testid={UG_USERS_ID}
size='small'
limitTags={1}
openOnFocus
multiple
disableCloseOnSelect
value={users as UserOption[]}
onChange={(event, newValue, reason) => {
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) => (
<TextField {...params} label='Select users' />
)}
renderTags={(value) => renderTags(value)}
noOptionsText={isLoading ? 'Loading…' : 'No options'}
/>
)}
</StyledGroupFormUsersSelect>
);
};

View File

@ -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"