Skip to content

Commit b1c1b74

Browse files
feat: [UIE-10361] - Improve UpdateDelegateDrawer & EntitiesSelect UI (#13468)
* Update Delegation UI * Entities * test * Added changeset: Improve UpdateDelegateDrawer & EntitiesSelect UI * Address feedback + copy changes * test fix * e2e small fix
1 parent 9a62982 commit b1c1b74

8 files changed

Lines changed: 227 additions & 79 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Changed
3+
---
4+
5+
Improve UpdateDelegateDrawer & EntitiesSelect UI ([#13468](https://github.com/linode/manager/pull/13468))

packages/manager/src/features/Account/SwitchAccountDrawer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export const SwitchAccountDrawer = (props: Props) => {
230230
)}
231231
{childAccounts &&
232232
childAccounts.length === 0 &&
233+
isIAMDelegationEnabled &&
233234
!Object.prototype.hasOwnProperty.call(filter, 'company') ? (
234235
<Box alignItems="center" display="flex" flexDirection="column" mt={8}>
235236
<NoResultsState />

packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx

Lines changed: 120 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@ import {
33
useAllAccountUsersQuery,
44
useUpdateChildAccountDelegatesQuery,
55
} from '@linode/queries';
6-
import { ActionsPanel, Autocomplete, Notice, Typography } from '@linode/ui';
6+
import {
7+
ActionsPanel,
8+
Autocomplete,
9+
CloseIcon,
10+
IconButton,
11+
Notice,
12+
Paper,
13+
Stack,
14+
Typography,
15+
} from '@linode/ui';
716
import { useDebouncedValue } from '@linode/utilities';
817
import { useTheme } from '@mui/material';
918
import { enqueueSnackbar } from 'notistack';
@@ -57,6 +66,8 @@ export const UpdateDelegationForm = ({
5766
const { data, error, fetchNextPage, hasNextPage, isFetching } =
5867
useAccountUsersInfiniteQuery(apiFilter);
5968

69+
const totalUserCount = data?.pages[0]?.results ?? 0;
70+
6071
const {
6172
data: allUsers,
6273
isFetching: isFetchingAllUsers,
@@ -65,26 +76,6 @@ export const UpdateDelegationForm = ({
6576
user_type: 'parent',
6677
});
6778

68-
const users =
69-
allUserSelected && allUsers
70-
? allUsers.map((user) => ({
71-
label: user.username,
72-
value: user.username,
73-
}))
74-
: (data?.pages.flatMap((page) => {
75-
return page.data.map((user) => ({
76-
label: user.username,
77-
value: user.username,
78-
}));
79-
}) ?? []);
80-
81-
const isSearching =
82-
inputValue.length > 0 && debouncedInputValue !== inputValue;
83-
84-
const isLoadingOptions = isFetching || isFetchingAllUsers;
85-
86-
const showNoOptionsText = !isLoadingOptions && !isSearching;
87-
8879
const isSelectAllFetching = allUserSelected && isFetchingAllUsers;
8980

9081
const { mutateAsync: updateDelegates } =
@@ -103,8 +94,35 @@ export const UpdateDelegationForm = ({
10394
reset,
10495
setError,
10596
setValue,
97+
watch,
10698
} = form;
10799

100+
const selectedUsers = watch('users');
101+
102+
const users =
103+
allUserSelected && allUsers
104+
? allUsers.map((user) => ({
105+
label: user.username,
106+
value: user.username,
107+
}))
108+
: !inputValue &&
109+
totalUserCount > 0 &&
110+
selectedUsers.length >= totalUserCount
111+
? selectedUsers
112+
: (data?.pages.flatMap((page) => {
113+
return page.data.map((user) => ({
114+
label: user.username,
115+
value: user.username,
116+
}));
117+
}) ?? []);
118+
119+
const isSearching =
120+
inputValue.length > 0 && debouncedInputValue !== inputValue;
121+
122+
const isLoadingOptions = isFetching || isFetchingAllUsers;
123+
124+
const showNoOptionsText = !isLoadingOptions && !isSearching;
125+
108126
const onSubmit = async (values: UpdateDelegationsFormValues) => {
109127
const usersList = values.users.map((user) => user.value);
110128

@@ -171,8 +189,11 @@ export const UpdateDelegationForm = ({
171189
name="users"
172190
render={({ field, fieldState }) => (
173191
<Autocomplete
192+
autoHighlight
193+
clearOnBlur
174194
data-testid="delegates-autocomplete"
175-
disabled={isFetchingAllUsers}
195+
disableClearable={true}
196+
disabled={isFetchingAllUsers || isSubmitting}
176197
errorText={fieldState.error?.message ?? error?.[0].reason}
177198
isOptionEqualToValue={(option, value) =>
178199
option.value === value.value
@@ -188,17 +209,19 @@ export const UpdateDelegationForm = ({
188209
onInputChange={(_, value) => {
189210
setInputValue(value);
190211
}}
191-
onSelectAllClick={(isSelectAllActive) => {
192-
if (isSelectAllActive && !allUserSelected) {
212+
onSelectAllClick={(_event) => {
213+
const allCurrentOptionsSelected =
214+
totalUserCount > 0 &&
215+
selectedUsers.length >= totalUserCount;
216+
if (allCurrentOptionsSelected) {
217+
setValue('users', []);
218+
setAllUserSelected(false);
219+
} else {
193220
onSelectAllClick();
194221
}
195222
}}
196223
options={users}
197-
placeholder={getPlaceholder(
198-
'delegates',
199-
field.value.length,
200-
users?.length ?? 0
201-
)}
224+
renderTags={() => null}
202225
slotProps={{
203226
listbox: {
204227
onScroll: (event: React.SyntheticEvent) => {
@@ -221,11 +244,53 @@ export const UpdateDelegationForm = ({
221244
InputProps: isSelectAllFetching
222245
? { startAdornment: null }
223246
: undefined,
247+
placeholder: getPlaceholder(
248+
'delegates',
249+
selectedUsers.length,
250+
totalUserCount
251+
),
224252
}}
225253
value={field.value}
226254
/>
227255
)}
228256
/>
257+
<Typography sx={{ mb: 1, mt: 2 }}>
258+
Users in the account delegation
259+
{isFetchingAllUsers ? '' : ` (${selectedUsers.length})`}:
260+
</Typography>
261+
<Paper
262+
sx={(theme) => ({
263+
backgroundColor: isFetchingAllUsers
264+
? theme.tokens.alias.Interaction.Background.Disabled
265+
: theme.palette.background.paper,
266+
maxHeight: 370,
267+
overflowY: 'auto',
268+
p: 2,
269+
py: 1,
270+
})}
271+
variant="outlined"
272+
>
273+
<Stack spacing={1}>
274+
{selectedUsers.length === 0 && (
275+
<Typography py={1} textAlign="center">
276+
No users selected
277+
</Typography>
278+
)}
279+
{selectedUsers.map((user) => (
280+
<DelegationUserRow
281+
isSubmitting={isSubmitting}
282+
key={user.value}
283+
onRemove={() =>
284+
setValue(
285+
'users',
286+
selectedUsers.filter((u) => u.value !== user.value)
287+
)
288+
}
289+
username={user.label}
290+
/>
291+
))}
292+
</Stack>
293+
</Paper>
229294

230295
<ActionsPanel
231296
primaryButtonProps={{
@@ -249,3 +314,29 @@ export const UpdateDelegationForm = ({
249314
</>
250315
);
251316
};
317+
318+
interface DelegationUserRowProps {
319+
isSubmitting: boolean;
320+
onRemove: () => void;
321+
username: string;
322+
}
323+
324+
const DelegationUserRow = ({
325+
onRemove,
326+
username,
327+
isSubmitting,
328+
}: DelegationUserRowProps) => {
329+
return (
330+
<Stack alignItems="center" direction="row" justifyContent="space-between">
331+
<Typography>{username}</Typography>
332+
<IconButton
333+
aria-label={`Remove ${username}`}
334+
disabled={isSubmitting}
335+
onClick={onRemove}
336+
sx={{ p: 0.75 }}
337+
>
338+
<CloseIcon />
339+
</IconButton>
340+
</Stack>
341+
);
342+
};

packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export const AssignSelectedRolesDrawer = ({
167167
<Drawer
168168
onClose={handleClose}
169169
open={open}
170-
title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to Users`}
170+
title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to a User`}
171171
>
172172
<FormProvider {...form}>
173173
<form onSubmit={handleSubmit(onSubmit)}>

packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ describe('UpdateEntitiesDrawer', () => {
105105
});
106106

107107
it('should prefill the form with assigned entities', async () => {
108+
queryMocks.useAllAccountEntities.mockReturnValue({
109+
data: mockEntities,
110+
isLoading: false,
111+
});
108112
renderWithTheme(<UpdateEntitiesDrawer {...props} />);
109113

110114
// Verify the prefilled entities

packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx

Lines changed: 79 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Autocomplete, Notice, TextField, Typography } from '@linode/ui';
1+
import {
2+
Autocomplete,
3+
CloseIcon,
4+
IconButton,
5+
Notice,
6+
Paper,
7+
Stack,
8+
Typography,
9+
} from '@linode/ui';
210
import { useTheme } from '@mui/material';
311
import React from 'react';
412

@@ -101,7 +109,9 @@ export const EntitiesSelect = ({
101109
return (
102110
<>
103111
<Autocomplete
112+
disableClearable={true}
104113
disabled={!memoizedEntities.length}
114+
errorText={errorText}
105115
getOptionLabel={(option) => option.label}
106116
isOptionEqualToValue={(option, value) => option.value === value.value}
107117
label="Entities"
@@ -123,26 +133,8 @@ export const EntitiesSelect = ({
123133
setInputValue(value);
124134
}}
125135
options={visibleOptions}
126-
placeholder={getPlaceholder(
127-
type,
128-
value.length,
129-
filteredEntities.length
130-
)}
131136
readOnly={mode === 'change-role'}
132-
renderInput={(params) => (
133-
<TextField
134-
{...params}
135-
error={!!errorText}
136-
errorText={errorText}
137-
label="Entities"
138-
noMarginTop
139-
placeholder={getPlaceholder(
140-
type,
141-
value.length,
142-
filteredEntities.length
143-
)}
144-
/>
145-
)}
137+
renderTags={() => null}
146138
slotProps={{
147139
listbox: {
148140
onScroll: (e) => {
@@ -155,26 +147,53 @@ export const EntitiesSelect = ({
155147
},
156148
},
157149
}}
158-
sx={{
159-
marginTop: 0,
160-
'& .MuiChip-root': {
161-
padding: theme.tokens.spacing.S4,
162-
height: 'auto',
163-
},
164-
'& .MuiInputLabel-root': {
165-
color: theme.tokens.alias.Content.Text.Primary.Default,
166-
},
167-
'& .MuiChip-labelMedium': {
168-
textWrap: 'auto',
169-
height: 'auto',
170-
},
171-
'& .MuiAutocomplete-tag': {
172-
wordBreak: 'break-all',
173-
},
150+
textFieldProps={{
151+
placeholder: getPlaceholder(
152+
type,
153+
value.length,
154+
filteredEntities.length
155+
),
174156
}}
175157
value={value || []}
176158
/>
177-
{!memoizedEntities.length && (
159+
{memoizedEntities.length > 0 && !isLoading && (
160+
<>
161+
<Typography sx={{ mb: 1, mt: 2 }}>
162+
Selected entities ({value.length}):
163+
</Typography>
164+
<Paper
165+
sx={(theme) => ({
166+
backgroundColor: isLoading
167+
? theme.tokens.alias.Interaction.Background.Disabled
168+
: theme.palette.background.paper,
169+
maxHeight: 370,
170+
overflowY: 'auto',
171+
p: 2,
172+
py: 1,
173+
})}
174+
variant="outlined"
175+
>
176+
<Stack spacing={1}>
177+
{value.length === 0 && (
178+
<Typography py={1} textAlign="center">
179+
No entities selected
180+
</Typography>
181+
)}
182+
{value.map((entity) => (
183+
<EntityRow
184+
disabled={mode === 'change-role'}
185+
key={entity.value}
186+
label={entity.label}
187+
onRemove={() =>
188+
onChange(value.filter((v) => v.value !== entity.value))
189+
}
190+
/>
191+
))}
192+
</Stack>
193+
</Paper>
194+
</>
195+
)}
196+
{!memoizedEntities.length && !isLoading && (
178197
<Notice spacingBottom={0} spacingTop={8} variant="warning">
179198
<Typography fontSize="inherit">
180199
<Link to={getCreateLinkForEntityType(type)}>
@@ -188,3 +207,26 @@ export const EntitiesSelect = ({
188207
</>
189208
);
190209
};
210+
211+
interface EntityRowProps {
212+
disabled?: boolean;
213+
label: string;
214+
onRemove: () => void;
215+
}
216+
217+
const EntityRow = ({ disabled, label, onRemove }: EntityRowProps) => {
218+
return (
219+
<Stack alignItems="center" direction="row" justifyContent="space-between">
220+
<Typography>{label}</Typography>
221+
{!disabled && (
222+
<IconButton
223+
aria-label={`Remove ${label}`}
224+
onClick={onRemove}
225+
sx={{ p: 0.75 }}
226+
>
227+
<CloseIcon />
228+
</IconButton>
229+
)}
230+
</Stack>
231+
);
232+
};

0 commit comments

Comments
 (0)