Skip to content
Merged
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13468-changed-1772720184107.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Improve UpdateDelegateDrawer & EntitiesSelect UI ([#13468](https://github.com/linode/manager/pull/13468))
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export const SwitchAccountDrawer = (props: Props) => {
)}
{childAccounts &&
childAccounts.length === 0 &&
isIAMDelegationEnabled &&
!Object.prototype.hasOwnProperty.call(filter, 'company') ? (
<Box alignItems="center" display="flex" flexDirection="column" mt={8}>
<NoResultsState />
Expand Down
149 changes: 120 additions & 29 deletions packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import {
useAllAccountUsersQuery,
useUpdateChildAccountDelegatesQuery,
} from '@linode/queries';
import { ActionsPanel, Autocomplete, Notice, Typography } from '@linode/ui';
import {
ActionsPanel,
Autocomplete,
CloseIcon,
IconButton,
Notice,
Paper,
Stack,
Typography,
} from '@linode/ui';
import { useDebouncedValue } from '@linode/utilities';
import { useTheme } from '@mui/material';
import { enqueueSnackbar } from 'notistack';
Expand Down Expand Up @@ -57,6 +66,8 @@ export const UpdateDelegationForm = ({
const { data, error, fetchNextPage, hasNextPage, isFetching } =
useAccountUsersInfiniteQuery(apiFilter);

const totalUserCount = data?.pages[0]?.results ?? 0;

const {
data: allUsers,
isFetching: isFetchingAllUsers,
Expand All @@ -65,26 +76,6 @@ export const UpdateDelegationForm = ({
user_type: 'parent',
});

const users =
allUserSelected && allUsers
? allUsers.map((user) => ({
label: user.username,
value: user.username,
}))
: (data?.pages.flatMap((page) => {
return page.data.map((user) => ({
label: user.username,
value: user.username,
}));
}) ?? []);

const isSearching =
inputValue.length > 0 && debouncedInputValue !== inputValue;

const isLoadingOptions = isFetching || isFetchingAllUsers;

const showNoOptionsText = !isLoadingOptions && !isSearching;

const isSelectAllFetching = allUserSelected && isFetchingAllUsers;

const { mutateAsync: updateDelegates } =
Expand All @@ -103,8 +94,35 @@ export const UpdateDelegationForm = ({
reset,
setError,
setValue,
watch,
} = form;

const selectedUsers = watch('users');

const users =
allUserSelected && allUsers
? allUsers.map((user) => ({
label: user.username,
value: user.username,
}))
: !inputValue &&
totalUserCount > 0 &&
selectedUsers.length >= totalUserCount
? selectedUsers
: (data?.pages.flatMap((page) => {
return page.data.map((user) => ({
label: user.username,
value: user.username,
}));
}) ?? []);

const isSearching =
inputValue.length > 0 && debouncedInputValue !== inputValue;

const isLoadingOptions = isFetching || isFetchingAllUsers;

const showNoOptionsText = !isLoadingOptions && !isSearching;

const onSubmit = async (values: UpdateDelegationsFormValues) => {
const usersList = values.users.map((user) => user.value);

Expand Down Expand Up @@ -171,8 +189,11 @@ export const UpdateDelegationForm = ({
name="users"
render={({ field, fieldState }) => (
<Autocomplete
autoHighlight
clearOnBlur
data-testid="delegates-autocomplete"
disabled={isFetchingAllUsers}
disableClearable={true}
disabled={isFetchingAllUsers || isSubmitting}
errorText={fieldState.error?.message ?? error?.[0].reason}
isOptionEqualToValue={(option, value) =>
option.value === value.value
Expand All @@ -188,17 +209,19 @@ export const UpdateDelegationForm = ({
onInputChange={(_, value) => {
setInputValue(value);
}}
onSelectAllClick={(isSelectAllActive) => {
if (isSelectAllActive && !allUserSelected) {
onSelectAllClick={(_event) => {
const allCurrentOptionsSelected =
totalUserCount > 0 &&
selectedUsers.length >= totalUserCount;
if (allCurrentOptionsSelected) {
setValue('users', []);
setAllUserSelected(false);
} else {
onSelectAllClick();
}
}}
options={users}
placeholder={getPlaceholder(
'delegates',
field.value.length,
users?.length ?? 0
)}
renderTags={() => null}
slotProps={{
listbox: {
onScroll: (event: React.SyntheticEvent) => {
Expand All @@ -221,11 +244,53 @@ export const UpdateDelegationForm = ({
InputProps: isSelectAllFetching
? { startAdornment: null }
: undefined,
placeholder: getPlaceholder(
'delegates',
selectedUsers.length,
totalUserCount
),
}}
value={field.value}
/>
)}
/>
<Typography sx={{ mb: 1, mt: 2 }}>
Users in the account delegation
{isFetchingAllUsers ? '' : ` (${selectedUsers.length})`}:
</Typography>
<Paper
sx={(theme) => ({
backgroundColor: isFetchingAllUsers
? theme.tokens.alias.Interaction.Background.Disabled
: theme.palette.background.paper,
maxHeight: 370,
overflowY: 'auto',
p: 2,
py: 1,
})}
variant="outlined"
>
<Stack spacing={1}>
{selectedUsers.length === 0 && (
<Typography py={1} textAlign="center">
No users selected
</Typography>
)}
{selectedUsers.map((user) => (
<DelegationUserRow
isSubmitting={isSubmitting}
key={user.value}
onRemove={() =>
setValue(
'users',
selectedUsers.filter((u) => u.value !== user.value)
)
}
username={user.label}
/>
))}
</Stack>
</Paper>

<ActionsPanel
primaryButtonProps={{
Expand All @@ -249,3 +314,29 @@ export const UpdateDelegationForm = ({
</>
);
};

interface DelegationUserRowProps {
isSubmitting: boolean;
onRemove: () => void;
username: string;
}

const DelegationUserRow = ({
onRemove,
username,
isSubmitting,
}: DelegationUserRowProps) => {
return (
<Stack alignItems="center" direction="row" justifyContent="space-between">
<Typography>{username}</Typography>
<IconButton
aria-label={`Remove ${username}`}
disabled={isSubmitting}
onClick={onRemove}
sx={{ p: 0.75 }}
>
<CloseIcon />
</IconButton>
</Stack>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export const AssignSelectedRolesDrawer = ({
<Drawer
onClose={handleClose}
open={open}
title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to Users`}
title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to a User`}
>
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ describe('UpdateEntitiesDrawer', () => {
});

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

// Verify the prefilled entities
Expand Down
116 changes: 79 additions & 37 deletions packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { Autocomplete, Notice, TextField, Typography } from '@linode/ui';
import {
Autocomplete,
CloseIcon,
IconButton,
Notice,
Paper,
Stack,
Typography,
} from '@linode/ui';
import { useTheme } from '@mui/material';
import React from 'react';

Expand Down Expand Up @@ -101,7 +109,9 @@ export const EntitiesSelect = ({
return (
<>
<Autocomplete
disableClearable={true}
disabled={!memoizedEntities.length}
errorText={errorText}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.value === value.value}
label="Entities"
Expand All @@ -123,26 +133,8 @@ export const EntitiesSelect = ({
setInputValue(value);
}}
options={visibleOptions}
placeholder={getPlaceholder(
type,
value.length,
filteredEntities.length
)}
readOnly={mode === 'change-role'}
renderInput={(params) => (
<TextField
{...params}
error={!!errorText}
errorText={errorText}
label="Entities"
noMarginTop
placeholder={getPlaceholder(
type,
value.length,
filteredEntities.length
)}
/>
)}
renderTags={() => null}
slotProps={{
listbox: {
onScroll: (e) => {
Expand All @@ -155,26 +147,53 @@ export const EntitiesSelect = ({
},
},
}}
sx={{
marginTop: 0,
'& .MuiChip-root': {
padding: theme.tokens.spacing.S4,
height: 'auto',
},
'& .MuiInputLabel-root': {
color: theme.tokens.alias.Content.Text.Primary.Default,
},
'& .MuiChip-labelMedium': {
textWrap: 'auto',
height: 'auto',
},
'& .MuiAutocomplete-tag': {
wordBreak: 'break-all',
},
textFieldProps={{
placeholder: getPlaceholder(
type,
value.length,
filteredEntities.length
),
}}
value={value || []}
/>
{!memoizedEntities.length && (
{memoizedEntities.length > 0 && !isLoading && (
<>
<Typography sx={{ mb: 1, mt: 2 }}>
Selected entities ({value.length}):
</Typography>
<Paper
sx={(theme) => ({
backgroundColor: isLoading
? theme.tokens.alias.Interaction.Background.Disabled
: theme.palette.background.paper,
maxHeight: 370,
overflowY: 'auto',
p: 2,
py: 1,
})}
variant="outlined"
>
<Stack spacing={1}>
{value.length === 0 && (
<Typography py={1} textAlign="center">
No entities selected
</Typography>
)}
{value.map((entity) => (
<EntityRow
disabled={mode === 'change-role'}
key={entity.value}
label={entity.label}
onRemove={() =>
onChange(value.filter((v) => v.value !== entity.value))
}
/>
))}
</Stack>
</Paper>
</>
)}
{!memoizedEntities.length && !isLoading && (
<Notice spacingBottom={0} spacingTop={8} variant="warning">
<Typography fontSize="inherit">
<Link to={getCreateLinkForEntityType(type)}>
Expand All @@ -188,3 +207,26 @@ export const EntitiesSelect = ({
</>
);
};

interface EntityRowProps {
disabled?: boolean;
label: string;
onRemove: () => void;
}

const EntityRow = ({ disabled, label, onRemove }: EntityRowProps) => {
return (
<Stack alignItems="center" direction="row" justifyContent="space-between">
<Typography>{label}</Typography>
{!disabled && (
<IconButton
aria-label={`Remove ${label}`}
onClick={onRemove}
sx={{ p: 0.75 }}
>
<CloseIcon />
</IconButton>
)}
</Stack>
);
};
Loading