Skip to content

Commit 4e922c0

Browse files
fix: [M3-10329] - IAM Parent/Child: Use User infinite query in UpdateDelegationDrawer (#13441)
* Use user inifinte query in UpdateDelegationDrawer * tests * Added changeset: IAM Parent/Child: Use User infinite query in UpdateDelegationDrawer
1 parent d7a6ceb commit 4e922c0

File tree

5 files changed

+205
-140
lines changed

5 files changed

+205
-140
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Fixed
3+
---
4+
5+
IAM Parent/Child: Use User infinite query in UpdateDelegationDrawer ([#13441](https://github.com/linode/manager/pull/13441))
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import React from 'react';
4+
import { vi } from 'vitest';
5+
6+
import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers';
7+
8+
import { UpdateDelegationForm } from './UpdateDelegationForm';
9+
10+
import type { ChildAccountWithDelegates, User } from '@linode/api-v4';
11+
12+
beforeAll(() => mockMatchMedia());
13+
14+
const mocks = vi.hoisted(() => ({
15+
useAccountUsersInfiniteQuery: vi.fn(),
16+
mockUseUpdateChildAccountDelegatesQuery: vi.fn(),
17+
mockMutateAsync: vi.fn(),
18+
}));
19+
20+
vi.mock('@linode/queries', async () => {
21+
const actual = await vi.importActual('@linode/queries');
22+
return {
23+
...actual,
24+
useAccountUsersInfiniteQuery: mocks.useAccountUsersInfiniteQuery,
25+
useUpdateChildAccountDelegatesQuery:
26+
mocks.mockUseUpdateChildAccountDelegatesQuery,
27+
};
28+
});
29+
30+
const mockUsers: User[] = [
31+
{
32+
email: 'user1@example.com',
33+
last_login: null,
34+
password_created: null,
35+
restricted: false,
36+
ssh_keys: [],
37+
tfa_enabled: false,
38+
user_type: 'default',
39+
username: 'user1',
40+
verified_phone_number: null,
41+
},
42+
{
43+
email: 'user2@example.com',
44+
last_login: null,
45+
password_created: null,
46+
restricted: false,
47+
ssh_keys: [],
48+
tfa_enabled: false,
49+
user_type: 'default',
50+
username: 'user2',
51+
verified_phone_number: null,
52+
},
53+
];
54+
55+
const mockChildAccountWithDelegates: ChildAccountWithDelegates = {
56+
company: 'Test Company',
57+
euuid: 'E1234567-89AB-CDEF-0123-456789ABCDEF',
58+
users: ['user1'],
59+
};
60+
61+
const defaultProps = {
62+
delegation: mockChildAccountWithDelegates,
63+
formattedCurrentUsers: [
64+
{ label: mockUsers[0].username, value: mockUsers[0].username },
65+
],
66+
onClose: vi.fn(),
67+
};
68+
69+
describe('UpdateDelegationsDrawer', () => {
70+
beforeEach(() => {
71+
vi.clearAllMocks();
72+
73+
mocks.useAccountUsersInfiniteQuery.mockReturnValue({
74+
data: { pages: [{ data: mockUsers }] },
75+
isFetching: false,
76+
});
77+
78+
mocks.mockUseUpdateChildAccountDelegatesQuery.mockReturnValue({
79+
mutateAsync: mocks.mockMutateAsync,
80+
});
81+
82+
mocks.mockMutateAsync.mockResolvedValue({});
83+
});
84+
85+
it('renders the drawer with current delegates', () => {
86+
renderWithTheme(<UpdateDelegationForm {...defaultProps} />);
87+
88+
const companyName = screen.getByText(/test company/i);
89+
expect(companyName).toBeInTheDocument();
90+
const userName = screen.getByText(/user1/i);
91+
expect(userName).toBeInTheDocument();
92+
});
93+
94+
it('allows adding a new delegate', async () => {
95+
renderWithTheme(<UpdateDelegationForm {...defaultProps} />);
96+
97+
const user = userEvent.setup();
98+
99+
const autocompleteInput = screen.getByRole('combobox');
100+
await user.click(autocompleteInput);
101+
102+
await waitFor(async () => {
103+
screen.getByRole('option', { name: 'user2' });
104+
});
105+
106+
const user2Option = screen.getByRole('option', { name: 'user2' });
107+
await user.click(user2Option);
108+
109+
const submitButton = screen.getByRole('button', { name: /save changes/i });
110+
await user.click(submitButton);
111+
112+
await waitFor(() => {
113+
expect(mocks.mockMutateAsync).toHaveBeenCalledWith({
114+
euuid: mockChildAccountWithDelegates.euuid,
115+
users: ['user1', 'user2'],
116+
});
117+
});
118+
});
119+
120+
it('allows sending an empty payload', async () => {
121+
renderWithTheme(<UpdateDelegationForm {...defaultProps} />);
122+
123+
const user = userEvent.setup();
124+
125+
// Open the autocomplete and deselect the preselected user (user1)
126+
const autocompleteInput = screen.getByRole('combobox');
127+
await user.click(autocompleteInput);
128+
129+
await waitFor(() => {
130+
// Ensure options are rendered
131+
expect(screen.getByRole('option', { name: 'user1' })).toBeInTheDocument();
132+
});
133+
134+
const user1Option = screen.getByRole('option', { name: 'user1' });
135+
await user.click(user1Option); // toggles off the selected user
136+
137+
// Submit with no users selected
138+
const submitButton = screen.getByRole('button', { name: /save changes/i });
139+
await user.click(submitButton);
140+
141+
await waitFor(() => {
142+
expect(mocks.mockMutateAsync).toHaveBeenCalledWith({
143+
euuid: mockChildAccountWithDelegates.euuid,
144+
users: [],
145+
});
146+
});
147+
});
148+
});

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

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { useUpdateChildAccountDelegatesQuery } from '@linode/queries';
1+
import {
2+
useAccountUsersInfiniteQuery,
3+
useUpdateChildAccountDelegatesQuery,
4+
} from '@linode/queries';
25
import { ActionsPanel, Autocomplete, Notice, Typography } from '@linode/ui';
6+
import { useDebouncedValue } from '@linode/utilities';
37
import { useTheme } from '@mui/material';
48
import { enqueueSnackbar } from 'notistack';
59
import * as React from 'react';
@@ -9,7 +13,11 @@ import { usePermissions } from '../hooks/usePermissions';
913
import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../Shared/constants';
1014
import { getPlaceholder } from '../Shared/Entities/utils';
1115

12-
import type { ChildAccount, ChildAccountWithDelegates } from '@linode/api-v4';
16+
import type {
17+
ChildAccount,
18+
ChildAccountWithDelegates,
19+
Filter,
20+
} from '@linode/api-v4';
1321

1422
interface UpdateDelegationsFormValues {
1523
users: UserOption[];
@@ -23,23 +31,38 @@ interface UserOption {
2331
interface DelegationsFormProps {
2432
delegation: ChildAccount | ChildAccountWithDelegates;
2533
formattedCurrentUsers: UserOption[];
26-
isLoading: boolean;
2734
onClose: () => void;
28-
userOptions: UserOption[];
2935
}
36+
3037
export const UpdateDelegationForm = ({
3138
delegation,
3239
formattedCurrentUsers,
33-
isLoading,
3440
onClose,
35-
userOptions,
3641
}: DelegationsFormProps) => {
3742
const theme = useTheme();
43+
const [inputValue, setInputValue] = React.useState<string>('');
44+
const debouncedInputValue = useDebouncedValue(inputValue);
3845

3946
const { data: permissions } = usePermissions('account', [
4047
'update_delegate_users',
4148
]);
4249

50+
const apiFilter: Filter = {
51+
user_type: 'parent',
52+
username: { '+contains': debouncedInputValue },
53+
};
54+
55+
const { data, error, fetchNextPage, hasNextPage, isFetching } =
56+
useAccountUsersInfiniteQuery(apiFilter);
57+
58+
const users =
59+
data?.pages.flatMap((page) => {
60+
return page.data.map((user) => ({
61+
label: user.username,
62+
value: user.username,
63+
}));
64+
}) ?? [];
65+
4366
const { mutateAsync: updateDelegates } =
4467
useUpdateChildAccountDelegatesQuery();
4568

@@ -112,23 +135,40 @@ export const UpdateDelegationForm = ({
112135
render={({ field, fieldState }) => (
113136
<Autocomplete
114137
data-testid="delegates-autocomplete"
115-
errorText={fieldState.error?.message}
138+
errorText={fieldState.error?.message ?? error?.[0].reason}
116139
isOptionEqualToValue={(option, value) =>
117140
option.value === value.value
118141
}
119142
label={'Delegate Users'}
120-
loading={isLoading}
143+
loading={isFetching}
121144
multiple
122145
noMarginTop
123146
onChange={(_, newValue) => {
124147
field.onChange(newValue || []);
125148
}}
126-
options={userOptions}
149+
onInputChange={(_, value) => {
150+
setInputValue(value);
151+
}}
152+
options={users}
127153
placeholder={getPlaceholder(
128154
'delegates',
129155
field.value.length,
130-
userOptions.length
156+
users?.length ?? 0
131157
)}
158+
slotProps={{
159+
listbox: {
160+
onScroll: (event: React.SyntheticEvent) => {
161+
const listboxNode = event.currentTarget;
162+
if (
163+
listboxNode.scrollTop + listboxNode.clientHeight >=
164+
listboxNode.scrollHeight &&
165+
hasNextPage
166+
) {
167+
fetchNextPage();
168+
}
169+
},
170+
},
171+
}}
132172
textFieldProps={{
133173
hideLabel: true,
134174
}}

0 commit comments

Comments
 (0)