Skip to content

Commit ad7742a

Browse files
feat: [UIE-9282] - IAM - User Delegations Tab (linode#12920)
* Routing and skeleton * build UI * search behavior * search behavior improve * cleanup and tests * improve test * Added changeset: IAM - User Delegations Tab * replace flags checks with useIsIAMDelegationEnabled * feedback @bnussman-akamai
1 parent 2527d97 commit ad7742a

File tree

14 files changed

+409
-97
lines changed

14 files changed

+409
-97
lines changed

packages/api-v4/src/iam/delegation.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface GetMyDelegatedChildAccountsParams {
1919
}
2020

2121
export interface GetDelegatedChildAccountsForUserParams {
22+
enabled?: boolean;
2223
params?: Params;
2324
username: string;
2425
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
IAM - User Delegations Tab ([#12920](https://github.com/linode/manager/pull/12920))

packages/manager/src/components/ActionMenu/ActionMenu.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import KebabIcon from 'src/assets/icons/kebab.svg';
88

99
export interface Action {
1010
disabled?: boolean;
11+
hidden?: boolean;
1112
id?: string;
1213
onClick: () => void;
1314
title: string;
@@ -47,6 +48,8 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => {
4748
const { actionsList, ariaLabel, loading, onOpen, stopClickPropagation } =
4849
props;
4950

51+
const filteredActionsList = actionsList.filter((action) => !action.hidden);
52+
5053
const menuId = convertToKebabCase(ariaLabel);
5154
const buttonId = `${convertToKebabCase(ariaLabel)}-button`;
5255

@@ -82,7 +85,7 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => {
8285
const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) =>
8386
e.currentTarget.focus();
8487

85-
if (!actionsList || actionsList.length === 0) {
88+
if (!filteredActionsList || filteredActionsList.length === 0) {
8689
return null;
8790
}
8891

@@ -154,7 +157,7 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => {
154157
}}
155158
transitionDuration={225}
156159
>
157-
{actionsList.map((a, idx) => (
160+
{filteredActionsList.map((a, idx) => (
158161
<MenuItem
159162
data-qa-action-menu-item={a.title}
160163
data-testid={a.title}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { childAccountFactory } from '@linode/utilities';
2+
import { screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import React from 'react';
5+
6+
import { renderWithTheme } from 'src/utilities/testHelpers';
7+
8+
import { UserDelegations } from './UserDelegations';
9+
10+
const mockChildAccounts = [
11+
{
12+
company: 'Test Account 1',
13+
euuid: '123',
14+
},
15+
{
16+
company: 'Test Account 2',
17+
euuid: '456',
18+
},
19+
];
20+
21+
const queryMocks = vi.hoisted(() => ({
22+
useAllGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}),
23+
useParams: vi.fn().mockReturnValue({}),
24+
}));
25+
26+
vi.mock('@linode/queries', async () => {
27+
const actual = await vi.importActual('@linode/queries');
28+
return {
29+
...actual,
30+
useAllGetDelegatedChildAccountsForUserQuery:
31+
queryMocks.useAllGetDelegatedChildAccountsForUserQuery,
32+
};
33+
});
34+
35+
vi.mock('@tanstack/react-router', async () => {
36+
const actual = await vi.importActual('@tanstack/react-router');
37+
return {
38+
...actual,
39+
useParams: queryMocks.useParams,
40+
};
41+
});
42+
43+
describe('UserDelegations', () => {
44+
beforeEach(() => {
45+
queryMocks.useParams.mockReturnValue({
46+
username: 'test-user',
47+
});
48+
queryMocks.useAllGetDelegatedChildAccountsForUserQuery.mockReturnValue({
49+
data: mockChildAccounts,
50+
isLoading: false,
51+
});
52+
});
53+
54+
it('renders the correct number of child accounts', () => {
55+
renderWithTheme(<UserDelegations />, {
56+
flags: {
57+
iamDelegation: {
58+
enabled: true,
59+
},
60+
},
61+
});
62+
63+
screen.getByText('Test Account 1');
64+
screen.getByText('Test Account 2');
65+
});
66+
67+
it('shows pagination when there are more than 25 child accounts', () => {
68+
queryMocks.useAllGetDelegatedChildAccountsForUserQuery.mockReturnValue({
69+
data: childAccountFactory.buildList(30),
70+
isLoading: false,
71+
});
72+
73+
renderWithTheme(<UserDelegations />, {
74+
flags: {
75+
iamDelegation: {
76+
enabled: true,
77+
},
78+
},
79+
});
80+
81+
const tabelRows = screen.getAllByRole('row');
82+
const paginationRow = screen.getByRole('navigation', {
83+
name: 'pagination navigation',
84+
});
85+
expect(tabelRows).toHaveLength(27); // 25 rows + header row + pagination row
86+
expect(paginationRow).toBeInTheDocument();
87+
});
88+
89+
it('filters child accounts by search', async () => {
90+
queryMocks.useAllGetDelegatedChildAccountsForUserQuery.mockReturnValue({
91+
data: childAccountFactory.buildList(30),
92+
isLoading: false,
93+
});
94+
95+
renderWithTheme(<UserDelegations />, {
96+
flags: {
97+
iamDelegation: {
98+
enabled: true,
99+
},
100+
},
101+
});
102+
103+
const paginationRow = screen.getByRole('navigation', {
104+
name: 'pagination navigation',
105+
});
106+
107+
screen.getByText('child-account-31');
108+
screen.getByText('child-account-32');
109+
110+
expect(paginationRow).toBeInTheDocument();
111+
112+
const searchInput = screen.getByPlaceholderText('Search');
113+
await userEvent.type(searchInput, 'child-account-31');
114+
115+
screen.getByText('child-account-31');
116+
117+
await waitFor(() => {
118+
expect(screen.queryByText('Child Account 32')).not.toBeInTheDocument();
119+
});
120+
await waitFor(() => {
121+
expect(paginationRow).not.toBeInTheDocument();
122+
});
123+
});
124+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useAllGetDelegatedChildAccountsForUserQuery } from '@linode/queries';
2+
import {
3+
CircleProgress,
4+
ErrorState,
5+
Paper,
6+
Stack,
7+
Typography,
8+
} from '@linode/ui';
9+
import { useParams } from '@tanstack/react-router';
10+
import * as React from 'react';
11+
12+
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
13+
import Paginate from 'src/components/Paginate';
14+
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
15+
import { Table } from 'src/components/Table';
16+
import { TableBody } from 'src/components/TableBody';
17+
import { TableCell } from 'src/components/TableCell';
18+
import { TableHead } from 'src/components/TableHead';
19+
import { TableRow } from 'src/components/TableRow';
20+
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
21+
import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
22+
23+
import type { Theme } from '@mui/material';
24+
25+
export const UserDelegations = () => {
26+
const { username } = useParams({ from: '/iam/users/$username' });
27+
28+
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
29+
const [search, setSearch] = React.useState('');
30+
31+
// TODO: UIE-9298 - Replace with API filtering
32+
const {
33+
data: allDelegatedChildAccounts,
34+
isLoading: allDelegatedChildAccountsLoading,
35+
error: allDelegatedChildAccountsError,
36+
} = useAllGetDelegatedChildAccountsForUserQuery({
37+
username,
38+
});
39+
40+
const handleSearch = (value: string) => {
41+
setSearch(value);
42+
};
43+
44+
const childAccounts = React.useMemo(() => {
45+
if (!allDelegatedChildAccounts) {
46+
return [];
47+
}
48+
49+
if (search.length === 0) {
50+
return allDelegatedChildAccounts;
51+
}
52+
53+
return allDelegatedChildAccounts.filter((childAccount) =>
54+
childAccount.company.toLowerCase().includes(search.toLowerCase())
55+
);
56+
}, [allDelegatedChildAccounts, search]);
57+
58+
if (!isIAMDelegationEnabled) {
59+
return null;
60+
}
61+
62+
if (allDelegatedChildAccountsLoading) {
63+
return <CircleProgress />;
64+
}
65+
66+
if (allDelegatedChildAccountsError) {
67+
return <ErrorState errorText={allDelegatedChildAccountsError[0].reason} />;
68+
}
69+
70+
return (
71+
<Paper>
72+
<Stack>
73+
<Typography variant="h2">Account Delegations</Typography>
74+
<DebouncedSearchTextField
75+
debounceTime={250}
76+
hideLabel
77+
isSearching={allDelegatedChildAccountsLoading}
78+
label="Search"
79+
onSearch={handleSearch}
80+
placeholder="Search"
81+
sx={{ mt: 3 }}
82+
value={search}
83+
/>
84+
<Table sx={{ mt: 2 }}>
85+
<TableHead>
86+
<TableRow>
87+
<TableCell>Account</TableCell>
88+
</TableRow>
89+
</TableHead>
90+
<TableBody>
91+
{childAccounts?.length === 0 && (
92+
<TableRowEmpty colSpan={1} message="No accounts found" />
93+
)}
94+
<Paginate data={childAccounts} pageSize={25}>
95+
{({
96+
count,
97+
data: paginatedData,
98+
handlePageChange,
99+
handlePageSizeChange,
100+
page,
101+
pageSize,
102+
}) => (
103+
<>
104+
{paginatedData?.map((childAccount) => (
105+
<TableRow key={childAccount.euuid}>
106+
<TableCell>{childAccount.company}</TableCell>
107+
</TableRow>
108+
))}
109+
{count > 25 && (
110+
<TableRow>
111+
<TableCell
112+
colSpan={1}
113+
sx={(theme: Theme) => ({
114+
padding: 0,
115+
'& > div': {
116+
border: 'none',
117+
borderTop: `1px solid ${theme.borderColors.divider}`,
118+
},
119+
})}
120+
>
121+
<PaginationFooter
122+
count={count}
123+
eventCategory="Delegated Child Accounts"
124+
handlePageChange={handlePageChange}
125+
handleSizeChange={handlePageSizeChange}
126+
page={page}
127+
pageSize={pageSize}
128+
/>
129+
</TableCell>
130+
</TableRow>
131+
)}
132+
</>
133+
)}
134+
</Paginate>
135+
</TableBody>
136+
</Table>
137+
</Stack>
138+
</Paper>
139+
);
140+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createLazyRoute } from '@tanstack/react-router';
2+
3+
import { UserDelegations } from './UserDelegations';
4+
5+
export const userDelegationsLazyRoute = createLazyRoute(
6+
'/iam/users/$username/delegations'
7+
)({
8+
component: UserDelegations,
9+
});

packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { LandingHeader } from 'src/components/LandingHeader';
55
import { TabPanels } from 'src/components/Tabs/TabPanels';
66
import { Tabs } from 'src/components/Tabs/Tabs';
77
import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList';
8+
import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
89
import { useTabs } from 'src/hooks/useTabs';
910

1011
import {
@@ -16,6 +17,8 @@ import {
1617

1718
export const UserDetailsLanding = () => {
1819
const { username } = useParams({ from: '/iam/users/$username' });
20+
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
21+
1922
const { tabs, tabIndex, handleTabChange } = useTabs([
2023
{
2124
to: `/iam/users/$username/details`,
@@ -29,6 +32,11 @@ export const UserDetailsLanding = () => {
2932
to: `/iam/users/$username/entities`,
3033
title: 'Entity Access',
3134
},
35+
{
36+
to: `/iam/users/$username/delegations`,
37+
title: 'Account Delegations',
38+
hide: !isIAMDelegationEnabled,
39+
},
3240
]);
3341

3442
const docsLinks = [USER_DETAILS_LINK, USER_ROLES_LINK, USER_ENTITIES_LINK];

packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ export const UserRow = ({ onDelete, user }: Props) => {
7777
)}
7878
<TableCell actionCell>
7979
<UsersActionMenu
80-
isProxyUser={isProxyUser}
8180
onDelete={onDelete}
8281
permissions={permissions}
8382
username={user.username}

0 commit comments

Comments
 (0)