From 6ea6c7c3dbe165a296dad7288dc432d962820519 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 5 Mar 2026 12:35:15 +0100 Subject: [PATCH 1/7] Update Delegation UI --- .../IAM/Delegations/UpdateDelegationForm.tsx | 137 ++++++++++++++---- 1 file changed, 108 insertions(+), 29 deletions(-) diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx index a5d6b9c347b..687497ff237 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx @@ -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'; @@ -12,7 +21,6 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import { usePermissions } from '../hooks/usePermissions'; import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../Shared/constants'; -import { getPlaceholder } from '../Shared/Entities/utils'; import type { ChildAccount, @@ -57,6 +65,8 @@ export const UpdateDelegationForm = ({ const { data, error, fetchNextPage, hasNextPage, isFetching } = useAccountUsersInfiniteQuery(apiFilter); + const totalUserCount = data?.pages[0]?.results ?? 0; + const { data: allUsers, isFetching: isFetchingAllUsers, @@ -65,26 +75,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 } = @@ -103,8 +93,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); @@ -171,7 +188,10 @@ export const UpdateDelegationForm = ({ name="users" render={({ field, fieldState }) => ( @@ -188,17 +208,20 @@ 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 - )} + placeholder="Select users" + renderTags={() => null} slotProps={{ listbox: { onScroll: (event: React.SyntheticEvent) => { @@ -226,6 +249,42 @@ export const UpdateDelegationForm = ({ /> )} /> + + Users in the account delegation + {isFetchingAllUsers ? '' : ` (${selectedUsers.length})`}: + + ({ + backgroundColor: isFetchingAllUsers + ? theme.tokens.alias.Interaction.Background.Disabled + : theme.palette.background.paper, + maxHeight: 370, + overflowY: 'auto', + p: 2, + py: 1, + })} + variant="outlined" + > + + {selectedUsers.length === 0 && ( + + No users selected + + )} + {selectedUsers.map((user) => ( + + setValue( + 'users', + selectedUsers.filter((u) => u.value !== user.value) + ) + } + username={user.label} + /> + ))} + + ); }; + +interface DelegationUserRowProps { + onRemove: () => void; + username: string; +} + +const DelegationUserRow = ({ onRemove, username }: DelegationUserRowProps) => { + return ( + + {username} + + + + + ); +}; From b1456479a7f373f626b957e0997de1e0e6402981 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 5 Mar 2026 13:59:43 +0100 Subject: [PATCH 2/7] Entities --- .../IAM/Delegations/UpdateDelegationForm.tsx | 7 +- .../IAM/Shared/Entities/EntitiesSelect.tsx | 110 ++++++++++++------ .../IAM/Shared/Entities/utils.test.ts | 11 +- .../src/features/IAM/Shared/Entities/utils.ts | 18 +-- 4 files changed, 97 insertions(+), 49 deletions(-) diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx index 687497ff237..6eddf6ec96f 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx @@ -21,6 +21,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import { usePermissions } from '../hooks/usePermissions'; import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../Shared/constants'; +import { getPlaceholder } from '../Shared/Entities/utils'; import type { ChildAccount, @@ -220,7 +221,6 @@ export const UpdateDelegationForm = ({ } }} options={users} - placeholder="Select users" renderTags={() => null} slotProps={{ listbox: { @@ -244,6 +244,11 @@ export const UpdateDelegationForm = ({ InputProps: isSelectAllFetching ? { startAdornment: null } : undefined, + placeholder: getPlaceholder( + 'delegates', + selectedUsers.length, + totalUserCount + ), }} value={field.value} /> diff --git a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx index e1e477892ed..988ba67094e 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx @@ -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'; @@ -101,7 +109,9 @@ export const EntitiesSelect = ({ return ( <> option.label} isOptionEqualToValue={(option, value) => option.value === value.value} label="Entities" @@ -123,26 +133,8 @@ export const EntitiesSelect = ({ setInputValue(value); }} options={visibleOptions} - placeholder={getPlaceholder( - type, - value.length, - filteredEntities.length - )} readOnly={mode === 'change-role'} - renderInput={(params) => ( - - )} + renderTags={() => null} slotProps={{ listbox: { onScroll: (e) => { @@ -155,25 +147,48 @@ 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 || []} /> + + Selected entities ({value.length}): + + ({ + backgroundColor: isLoading + ? theme.tokens.alias.Interaction.Background.Disabled + : theme.palette.background.paper, + maxHeight: 370, + overflowY: 'auto', + p: 2, + py: 1, + })} + variant="outlined" + > + + {value.length === 0 && ( + + No entities selected + + )} + {value.map((entity) => ( + + onChange(value.filter((v) => v.value !== entity.value)) + } + /> + ))} + + {!memoizedEntities.length && ( @@ -188,3 +203,26 @@ export const EntitiesSelect = ({ ); }; + +interface EntityRowProps { + disabled?: boolean; + label: string; + onRemove: () => void; +} + +const EntityRow = ({ disabled, label, onRemove }: EntityRowProps) => { + return ( + + {label} + {!disabled && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts index e620f21bf65..0b65f1156d1 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts @@ -20,17 +20,22 @@ describe('getCreateLinkForEntityType', () => { }); describe('getPlaceholder', () => { - it('should return a space if currentValueLength is greater than 0', () => { - expect(getPlaceholder('linode', 1, 10)).toBe(' '); + it('should return the placeholder from placeholderMap if some but not all items are selected', () => { + expect(getPlaceholder('linode', 1, 10)).toBe(placeholderMap['linode']); }); it('should return "None" if possibleEntitiesLength is 0', () => { expect(getPlaceholder('linode', 0, 0)).toBe('None'); }); - it('should return the placeholder from placeholderMap if type exists', () => { + it('should return the placeholder from placeholderMap if type exists and nothing is selected', () => { expect(getPlaceholder('linode', 0, 10)).toBe(placeholderMap['linode']); }); + + it('should return "All X selected" when all items are selected', () => { + expect(getPlaceholder('linode', 10, 10)).toBe('All Linodes selected'); + expect(getPlaceholder('delegates', 5, 5)).toBe('All Users selected'); + }); }); describe('getEntitiesByType', () => { diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.ts index aab965e3fd6..6d2ca9a2396 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.ts @@ -19,7 +19,7 @@ export const placeholderMap: Record = { stackscript: 'Select Stackscripts', volume: 'Select Volumes', vpc: 'Select VPCs', - delegates: 'Select Users', + delegates: 'Select delegates', }; export const getCreateLinkForEntityType = (entityType: AccessType): string => { @@ -41,17 +41,17 @@ export const getPlaceholder = ( currentValueLength: number, possibleEntitiesLength: number ): string => { - let placeholder: string; + if (possibleEntitiesLength === 0) { + return 'None'; + } - if (currentValueLength > 0) { - placeholder = ' '; - } else if (possibleEntitiesLength === 0) { - placeholder = 'None'; - } else { - placeholder = placeholderMap[type] || 'Select'; + if (currentValueLength > 0 && currentValueLength >= possibleEntitiesLength) { + const label = placeholderMap[type] || type; + const subject = label.startsWith('Select ') ? label.slice(7) : label; + return `All ${subject} selected`; } - return placeholder; + return placeholderMap[type] || 'Select'; }; export const mapEntitiesToOptions = ( From a94de1055b1e53731a77cd2413291b59008e89bc Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 5 Mar 2026 15:12:39 +0100 Subject: [PATCH 3/7] test --- packages/manager/src/features/IAM/Shared/Entities/utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts index 0b65f1156d1..cfab9428396 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts @@ -34,7 +34,7 @@ describe('getPlaceholder', () => { it('should return "All X selected" when all items are selected', () => { expect(getPlaceholder('linode', 10, 10)).toBe('All Linodes selected'); - expect(getPlaceholder('delegates', 5, 5)).toBe('All Users selected'); + expect(getPlaceholder('delegates', 5, 5)).toBe('All delegates selected'); }); }); From bf9b1de8f16b4c14fadeda8a0b337a5ae0c2dce6 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 5 Mar 2026 15:16:24 +0100 Subject: [PATCH 4/7] Added changeset: Improve UpdateDelegateDrawer & EntitiesSelect UI --- .../manager/.changeset/pr-13468-changed-1772720184107.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13468-changed-1772720184107.md diff --git a/packages/manager/.changeset/pr-13468-changed-1772720184107.md b/packages/manager/.changeset/pr-13468-changed-1772720184107.md new file mode 100644 index 00000000000..4f6cad9389f --- /dev/null +++ b/packages/manager/.changeset/pr-13468-changed-1772720184107.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve UpdateDelegateDrawer & EntitiesSelect UI ([#13468](https://github.com/linode/manager/pull/13468)) From 38545f16ae714814d486a560c896a29ba8e787c2 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 9 Mar 2026 14:12:05 +0100 Subject: [PATCH 5/7] Address feedback + copy changes --- .../IAM/Delegations/UpdateDelegationForm.tsx | 11 ++- .../RolesTable/AssignSelectedRolesDrawer.tsx | 2 +- .../IAM/Shared/Entities/EntitiesSelect.tsx | 70 ++++++++++--------- .../IAM/Shared/Entities/utils.test.ts | 2 +- .../src/features/IAM/Shared/Entities/utils.ts | 2 +- 5 files changed, 49 insertions(+), 38 deletions(-) diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx index 6eddf6ec96f..e5b0120c081 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx @@ -193,7 +193,7 @@ export const UpdateDelegationForm = ({ clearOnBlur data-testid="delegates-autocomplete" disableClearable={true} - disabled={isFetchingAllUsers} + disabled={isFetchingAllUsers || isSubmitting} errorText={fieldState.error?.message ?? error?.[0].reason} isOptionEqualToValue={(option, value) => option.value === value.value @@ -278,6 +278,7 @@ export const UpdateDelegationForm = ({ )} {selectedUsers.map((user) => ( setValue( @@ -315,16 +316,22 @@ export const UpdateDelegationForm = ({ }; interface DelegationUserRowProps { + isSubmitting: boolean; onRemove: () => void; username: string; } -const DelegationUserRow = ({ onRemove, username }: DelegationUserRowProps) => { +const DelegationUserRow = ({ + onRemove, + username, + isSubmitting, +}: DelegationUserRowProps) => { return ( {username} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 2e593f8a0fd..83b70dbbc67 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -167,7 +167,7 @@ export const AssignSelectedRolesDrawer = ({ 1 ? `s` : ``} to Users`} + title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to a User`} >
diff --git a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx index 988ba67094e..a5b526f2db4 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx @@ -156,39 +156,43 @@ export const EntitiesSelect = ({ }} value={value || []} /> - - Selected entities ({value.length}): - - ({ - backgroundColor: isLoading - ? theme.tokens.alias.Interaction.Background.Disabled - : theme.palette.background.paper, - maxHeight: 370, - overflowY: 'auto', - p: 2, - py: 1, - })} - variant="outlined" - > - - {value.length === 0 && ( - - No entities selected - - )} - {value.map((entity) => ( - - onChange(value.filter((v) => v.value !== entity.value)) - } - /> - ))} - - + {memoizedEntities.length > 0 && ( + <> + + Selected entities ({value.length}): + + ({ + backgroundColor: isLoading + ? theme.tokens.alias.Interaction.Background.Disabled + : theme.palette.background.paper, + maxHeight: 370, + overflowY: 'auto', + p: 2, + py: 1, + })} + variant="outlined" + > + + {value.length === 0 && ( + + No entities selected + + )} + {value.map((entity) => ( + + onChange(value.filter((v) => v.value !== entity.value)) + } + /> + ))} + + + + )} {!memoizedEntities.length && ( diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts index cfab9428396..261b655d19b 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.test.ts @@ -34,7 +34,7 @@ describe('getPlaceholder', () => { it('should return "All X selected" when all items are selected', () => { expect(getPlaceholder('linode', 10, 10)).toBe('All Linodes selected'); - expect(getPlaceholder('delegates', 5, 5)).toBe('All delegates selected'); + expect(getPlaceholder('delegates', 5, 5)).toBe('All users selected'); }); }); diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.ts index 6d2ca9a2396..c3d6ee86d4f 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.ts @@ -19,7 +19,7 @@ export const placeholderMap: Record = { stackscript: 'Select Stackscripts', volume: 'Select Volumes', vpc: 'Select VPCs', - delegates: 'Select delegates', + delegates: 'Select users', }; export const getCreateLinkForEntityType = (entityType: AccessType): string => { From f6283dc0a4d901d0807e858a4fb9e2334e9370e7 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 9 Mar 2026 14:53:37 +0100 Subject: [PATCH 6/7] test fix --- .../Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx | 4 ++++ .../src/features/IAM/Shared/Entities/EntitiesSelect.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx index ecbcf169880..f158474ee2f 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx @@ -105,6 +105,10 @@ describe('UpdateEntitiesDrawer', () => { }); it('should prefill the form with assigned entities', async () => { + queryMocks.useAllAccountEntities.mockReturnValue({ + data: mockEntities, + isLoading: false, + }); renderWithTheme(); // Verify the prefilled entities diff --git a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx index a5b526f2db4..f2bf5ba4f28 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx @@ -156,7 +156,7 @@ export const EntitiesSelect = ({ }} value={value || []} /> - {memoizedEntities.length > 0 && ( + {memoizedEntities.length > 0 && !isLoading && ( <> Selected entities ({value.length}): @@ -193,7 +193,7 @@ export const EntitiesSelect = ({ )} - {!memoizedEntities.length && ( + {!memoizedEntities.length && !isLoading && ( From e1b8e47a23f327f23ec4c18877f47a91721fdb64 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 9 Mar 2026 19:55:22 +0100 Subject: [PATCH 7/7] e2e small fix --- packages/manager/src/features/Account/SwitchAccountDrawer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index 0f914e25e6e..f8df9fa34fe 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -230,6 +230,7 @@ export const SwitchAccountDrawer = (props: Props) => { )} {childAccounts && childAccounts.length === 0 && + isIAMDelegationEnabled && !Object.prototype.hasOwnProperty.call(filter, 'company') ? (