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:
parent
6f19ce090d
commit
c86ea091b7
@ -46,6 +46,7 @@
|
|||||||
"@mui/material": "5.15.3",
|
"@mui/material": "5.15.3",
|
||||||
"@mui/x-date-pickers": "^7.0.0",
|
"@mui/x-date-pickers": "^7.0.0",
|
||||||
"@tanstack/react-table": "^8.10.7",
|
"@tanstack/react-table": "^8.10.7",
|
||||||
|
"@tanstack/react-virtual": "^3.11.3",
|
||||||
"@testing-library/dom": "10.4.0",
|
"@testing-library/dom": "10.4.0",
|
||||||
"@testing-library/jest-dom": "6.6.3",
|
"@testing-library/jest-dom": "6.6.3",
|
||||||
"@testing-library/react": "16.1.0",
|
"@testing-library/react": "16.1.0",
|
||||||
|
@ -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;
|
@ -9,6 +9,7 @@ import { UG_USERS_ID } from 'utils/testIds';
|
|||||||
import { caseInsensitiveSearch } from 'utils/search';
|
import { caseInsensitiveSearch } from 'utils/search';
|
||||||
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
||||||
import type { IServiceAccount } from 'interfaces/service-account';
|
import type { IServiceAccount } from 'interfaces/service-account';
|
||||||
|
import AutocompleteVirtual from './AutcompleteVirtual';
|
||||||
|
|
||||||
const StyledOption = styled('div')(({ theme }) => ({
|
const StyledOption = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -32,12 +33,14 @@ const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StrechedLi = styled('li')({ width: '100%' });
|
||||||
|
|
||||||
const renderOption = (
|
const renderOption = (
|
||||||
props: React.HTMLAttributes<HTMLLIElement>,
|
props: React.HTMLAttributes<HTMLLIElement>,
|
||||||
option: IUser,
|
option: IUser,
|
||||||
selected: boolean,
|
{ selected }: { selected: boolean },
|
||||||
) => (
|
) => (
|
||||||
<li {...props}>
|
<StrechedLi {...props} key={option.id}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
|
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
|
||||||
checkedIcon={<CheckBoxIcon fontSize='small' />}
|
checkedIcon={<CheckBoxIcon fontSize='small' />}
|
||||||
@ -52,7 +55,7 @@ const renderOption = (
|
|||||||
: option.email}
|
: option.email}
|
||||||
</span>
|
</span>
|
||||||
</StyledOption>
|
</StyledOption>
|
||||||
</li>
|
</StrechedLi>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTags = (value: IGroupUser[]) => (
|
const renderTags = (value: IGroupUser[]) => (
|
||||||
@ -76,73 +79,122 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
|
|||||||
users,
|
users,
|
||||||
setUsers,
|
setUsers,
|
||||||
}) => {
|
}) => {
|
||||||
const { users: usersAll } = useUsers();
|
const { users: usersAll, loading: isUsersLoading } = useUsers();
|
||||||
const { serviceAccounts } = useServiceAccounts();
|
const { serviceAccounts, loading: isServiceAccountsLoading } =
|
||||||
|
useServiceAccounts();
|
||||||
|
|
||||||
const options = [
|
const isLoading = isUsersLoading || isServiceAccountsLoading;
|
||||||
...usersAll
|
const options = isLoading
|
||||||
.map((user: IUser) => ({ ...user, type: 'USERS' }))
|
? []
|
||||||
.sort((a: IUser, b: IUser) => {
|
: [
|
||||||
const aName = a.name || a.username || '';
|
...usersAll
|
||||||
const bName = b.name || b.username || '';
|
.map((user: IUser) => ({ ...user, type: 'USERS' }))
|
||||||
return aName.localeCompare(bName);
|
.sort((a: IUser, b: IUser) => {
|
||||||
}),
|
const aName = a.name || a.username || '';
|
||||||
...serviceAccounts
|
const bName = b.name || b.username || '';
|
||||||
.map((serviceAccount: IServiceAccount) => ({
|
return aName.localeCompare(bName);
|
||||||
...serviceAccount,
|
}),
|
||||||
type: 'SERVICE ACCOUNTS',
|
...serviceAccounts
|
||||||
}))
|
.map((serviceAccount: IServiceAccount) => ({
|
||||||
.sort((a, b) => {
|
...serviceAccount,
|
||||||
const aName = a.name || a.username || '';
|
type: 'SERVICE ACCOUNTS',
|
||||||
const bName = b.name || b.username || '';
|
}))
|
||||||
return aName.localeCompare(bName);
|
.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 (
|
return (
|
||||||
<StyledGroupFormUsersSelect>
|
<StyledGroupFormUsersSelect>
|
||||||
<Autocomplete
|
{isLargeList ? (
|
||||||
data-testid={UG_USERS_ID}
|
<AutocompleteVirtual
|
||||||
size='small'
|
data-testid={UG_USERS_ID}
|
||||||
multiple
|
size='small'
|
||||||
limitTags={1}
|
limitTags={1}
|
||||||
openOnFocus
|
openOnFocus
|
||||||
disableCloseOnSelect
|
multiple
|
||||||
value={users as UserOption[]}
|
disableCloseOnSelect
|
||||||
onChange={(event, newValue, reason) => {
|
value={users as UserOption[]}
|
||||||
if (
|
onChange={(event, newValue, reason) => {
|
||||||
event.type === 'keydown' &&
|
if (
|
||||||
(event as React.KeyboardEvent).key === 'Backspace' &&
|
event.type === 'keydown' &&
|
||||||
reason === 'removeOption'
|
(event as React.KeyboardEvent).key ===
|
||||||
) {
|
'Backspace' &&
|
||||||
return;
|
reason === 'removeOption'
|
||||||
}
|
) {
|
||||||
setUsers(newValue);
|
return;
|
||||||
}}
|
}
|
||||||
groupBy={(option) => option.type}
|
setUsers(newValue);
|
||||||
options={options}
|
}}
|
||||||
renderOption={(props, option, { selected }) =>
|
options={options}
|
||||||
renderOption(props, option as UserOption, selected)
|
renderOption={renderOption}
|
||||||
}
|
filterOptions={(options, { inputValue }) =>
|
||||||
filterOptions={(options, { inputValue }) =>
|
options.filter(
|
||||||
options
|
|
||||||
.filter(
|
|
||||||
({ name, username, email }) =>
|
({ name, username, email }) =>
|
||||||
caseInsensitiveSearch(inputValue, email) ||
|
caseInsensitiveSearch(inputValue, email) ||
|
||||||
caseInsensitiveSearch(inputValue, name) ||
|
caseInsensitiveSearch(inputValue, name) ||
|
||||||
caseInsensitiveSearch(inputValue, username),
|
caseInsensitiveSearch(inputValue, username),
|
||||||
)
|
)
|
||||||
.slice(0, 100)
|
}
|
||||||
}
|
isOptionEqualToValue={(option, value) =>
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
option.id === value.id
|
||||||
getOptionLabel={(option: UserOption) =>
|
}
|
||||||
option.email || option.name || option.username || ''
|
getOptionLabel={(option: UserOption) =>
|
||||||
}
|
option.email || option.name || option.username || ''
|
||||||
renderInput={(params) => (
|
}
|
||||||
<TextField {...params} label='Select users' />
|
renderInput={(params) => (
|
||||||
)}
|
<TextField {...params} label='Select users' />
|
||||||
renderTags={(value) => renderTags(value)}
|
)}
|
||||||
/>
|
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>
|
</StyledGroupFormUsersSelect>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2768,6 +2768,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tanstack/table-core@npm:8.20.5":
|
||||||
version: 8.20.5
|
version: 8.20.5
|
||||||
resolution: "@tanstack/table-core@npm:8.20.5"
|
resolution: "@tanstack/table-core@npm:8.20.5"
|
||||||
@ -2775,6 +2787,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@testing-library/dom@npm:10.4.0":
|
||||||
version: 10.4.0
|
version: 10.4.0
|
||||||
resolution: "@testing-library/dom@npm:10.4.0"
|
resolution: "@testing-library/dom@npm:10.4.0"
|
||||||
@ -10165,6 +10184,7 @@ __metadata:
|
|||||||
"@mui/material": "npm:5.15.3"
|
"@mui/material": "npm:5.15.3"
|
||||||
"@mui/x-date-pickers": "npm:^7.0.0"
|
"@mui/x-date-pickers": "npm:^7.0.0"
|
||||||
"@tanstack/react-table": "npm:^8.10.7"
|
"@tanstack/react-table": "npm:^8.10.7"
|
||||||
|
"@tanstack/react-virtual": "npm:^3.11.3"
|
||||||
"@testing-library/dom": "npm:10.4.0"
|
"@testing-library/dom": "npm:10.4.0"
|
||||||
"@testing-library/jest-dom": "npm:6.6.3"
|
"@testing-library/jest-dom": "npm:6.6.3"
|
||||||
"@testing-library/react": "npm:16.1.0"
|
"@testing-library/react": "npm:16.1.0"
|
||||||
|
Loading…
Reference in New Issue
Block a user