Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13441-fixed-1772114115915.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

IAM Parent/Child: Use User infinite query in UpdateDelegationDrawer ([#13441](https://github.com/linode/manager/pull/13441))
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { vi } from 'vitest';

import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers';

import { UpdateDelegationForm } from './UpdateDelegationForm';

import type { ChildAccountWithDelegates, User } from '@linode/api-v4';

beforeAll(() => mockMatchMedia());

const mocks = vi.hoisted(() => ({
useAccountUsersInfiniteQuery: vi.fn(),
mockUseUpdateChildAccountDelegatesQuery: vi.fn(),
mockMutateAsync: vi.fn(),
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useAccountUsersInfiniteQuery: mocks.useAccountUsersInfiniteQuery,
useUpdateChildAccountDelegatesQuery:
mocks.mockUseUpdateChildAccountDelegatesQuery,
};
});

const mockUsers: User[] = [
{
email: 'user1@example.com',
last_login: null,
password_created: null,
restricted: false,
ssh_keys: [],
tfa_enabled: false,
user_type: 'default',
username: 'user1',
verified_phone_number: null,
},
{
email: 'user2@example.com',
last_login: null,
password_created: null,
restricted: false,
ssh_keys: [],
tfa_enabled: false,
user_type: 'default',
username: 'user2',
verified_phone_number: null,
},
];

const mockChildAccountWithDelegates: ChildAccountWithDelegates = {
company: 'Test Company',
euuid: 'E1234567-89AB-CDEF-0123-456789ABCDEF',
users: ['user1'],
};

const defaultProps = {
delegation: mockChildAccountWithDelegates,
formattedCurrentUsers: [
{ label: mockUsers[0].username, value: mockUsers[0].username },
],
onClose: vi.fn(),
};

describe('UpdateDelegationsDrawer', () => {
beforeEach(() => {
vi.clearAllMocks();

mocks.useAccountUsersInfiniteQuery.mockReturnValue({
data: { pages: [{ data: mockUsers }] },
isFetching: false,
});

mocks.mockUseUpdateChildAccountDelegatesQuery.mockReturnValue({
mutateAsync: mocks.mockMutateAsync,
});

mocks.mockMutateAsync.mockResolvedValue({});
});

it('renders the drawer with current delegates', () => {
renderWithTheme(<UpdateDelegationForm {...defaultProps} />);

const companyName = screen.getByText(/test company/i);
expect(companyName).toBeInTheDocument();
const userName = screen.getByText(/user1/i);
expect(userName).toBeInTheDocument();
});

it('allows adding a new delegate', async () => {
renderWithTheme(<UpdateDelegationForm {...defaultProps} />);

const user = userEvent.setup();

const autocompleteInput = screen.getByRole('combobox');
await user.click(autocompleteInput);

await waitFor(async () => {
screen.getByRole('option', { name: 'user2' });
});

const user2Option = screen.getByRole('option', { name: 'user2' });
await user.click(user2Option);

const submitButton = screen.getByRole('button', { name: /save changes/i });
await user.click(submitButton);

await waitFor(() => {
expect(mocks.mockMutateAsync).toHaveBeenCalledWith({
euuid: mockChildAccountWithDelegates.euuid,
users: ['user1', 'user2'],
});
});
});

it('allows sending an empty payload', async () => {
renderWithTheme(<UpdateDelegationForm {...defaultProps} />);

const user = userEvent.setup();

// Open the autocomplete and deselect the preselected user (user1)
const autocompleteInput = screen.getByRole('combobox');
await user.click(autocompleteInput);

await waitFor(() => {
// Ensure options are rendered
expect(screen.getByRole('option', { name: 'user1' })).toBeInTheDocument();

Check warning on line 131 in packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Don't wrap `getBy*` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `getBy*` queries fail implicitly when element is not found Raw Output: {"ruleId":"testing-library/prefer-implicit-assert","severity":1,"message":"Don't wrap `getBy*` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `getBy*` queries fail implicitly when element is not found","line":131,"column":14,"nodeType":"MemberExpression","messageId":"preferImplicitAssert","endLine":131,"endColumn":30}
});

const user1Option = screen.getByRole('option', { name: 'user1' });
await user.click(user1Option); // toggles off the selected user

// Submit with no users selected
const submitButton = screen.getByRole('button', { name: /save changes/i });
await user.click(submitButton);

await waitFor(() => {
expect(mocks.mockMutateAsync).toHaveBeenCalledWith({
euuid: mockChildAccountWithDelegates.euuid,
users: [],
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useUpdateChildAccountDelegatesQuery } from '@linode/queries';
import {
useAccountUsersInfiniteQuery,
useUpdateChildAccountDelegatesQuery,
} from '@linode/queries';
import { ActionsPanel, Autocomplete, Notice, Typography } from '@linode/ui';
import { useDebouncedValue } from '@linode/utilities';
import { useTheme } from '@mui/material';
import { enqueueSnackbar } from 'notistack';
import * as React from 'react';
Expand All @@ -9,7 +13,11 @@ import { usePermissions } from '../hooks/usePermissions';
import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../Shared/constants';
import { getPlaceholder } from '../Shared/Entities/utils';

import type { ChildAccount, ChildAccountWithDelegates } from '@linode/api-v4';
import type {
ChildAccount,
ChildAccountWithDelegates,
Filter,
} from '@linode/api-v4';

interface UpdateDelegationsFormValues {
users: UserOption[];
Expand All @@ -23,23 +31,38 @@ interface UserOption {
interface DelegationsFormProps {
delegation: ChildAccount | ChildAccountWithDelegates;
formattedCurrentUsers: UserOption[];
isLoading: boolean;
onClose: () => void;
userOptions: UserOption[];
}

export const UpdateDelegationForm = ({
delegation,
formattedCurrentUsers,
isLoading,
onClose,
userOptions,
}: DelegationsFormProps) => {
const theme = useTheme();
const [inputValue, setInputValue] = React.useState<string>('');
const debouncedInputValue = useDebouncedValue(inputValue);

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

const apiFilter: Filter = {
user_type: 'parent',
username: { '+contains': debouncedInputValue },
};

const { data, error, fetchNextPage, hasNextPage, isFetching } =
useAccountUsersInfiniteQuery(apiFilter);

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

const { mutateAsync: updateDelegates } =
useUpdateChildAccountDelegatesQuery();

Expand Down Expand Up @@ -112,23 +135,40 @@ export const UpdateDelegationForm = ({
render={({ field, fieldState }) => (
<Autocomplete
data-testid="delegates-autocomplete"
errorText={fieldState.error?.message}
errorText={fieldState.error?.message ?? error?.[0].reason}
isOptionEqualToValue={(option, value) =>
option.value === value.value
}
label={'Delegate Users'}
loading={isLoading}
loading={isFetching}
multiple
noMarginTop
onChange={(_, newValue) => {
field.onChange(newValue || []);
}}
options={userOptions}
onInputChange={(_, value) => {
setInputValue(value);
}}
options={users}
placeholder={getPlaceholder(
'delegates',
field.value.length,
userOptions.length
users?.length ?? 0
)}
slotProps={{
listbox: {
onScroll: (event: React.SyntheticEvent) => {
const listboxNode = event.currentTarget;
if (
listboxNode.scrollTop + listboxNode.clientHeight >=
listboxNode.scrollHeight &&
hasNextPage
) {
fetchNextPage();
}
},
},
}}
textFieldProps={{
hideLabel: true,
}}
Expand Down
Loading
Loading