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
12 changes: 8 additions & 4 deletions packages/api-v4/src/iam/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,22 @@ import type { IamUserRoles } from './types';
export const getChildAccountsIam = ({
params,
users,
}: GetChildAccountsIamParams) =>
users
filter,
}: GetChildAccountsIamParams) => {
return users
? Request<Page<ChildAccountWithDelegates>>(
setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts?users=true`),
setMethod('GET'),
setParams({ ...params }),
setParams(params),
setXFilter(filter),
)
: Request<Page<ChildAccount>>(
setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts`),
setMethod('GET'),
setParams({ ...params }),
setParams(params),
setXFilter(filter),
);
};

export const getDelegatedChildAccountsForUser = ({
username,
Expand Down
2 changes: 2 additions & 0 deletions packages/api-v4/src/iam/delegation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface ChildAccount {
}

export interface GetChildAccountsIamParams {
enabled?: boolean;
filter?: Filter;
params?: Params;
users?: boolean;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

IAM Parent/Child - Enable server side filters, pagination and search on Child Delegations ([#13342](https://github.com/linode/manager/pull/13342))
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,26 @@ beforeAll(() => mockMatchMedia());

const mocks = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockUseGetAllChildAccountsQuery: vi.fn(),
mockUseGetChildAccountsQuery: vi.fn(),
useParams: vi.fn().mockReturnValue({}),
useSearch: vi.fn().mockReturnValue({}),
}));

vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual('@tanstack/react-router');
return {
...actual,
useNavigate: () => mocks.mockNavigate,
useParams: mocks.useParams,
useSearch: mocks.useSearch,
};
});

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useGetAllChildAccountsQuery: mocks.mockUseGetAllChildAccountsQuery,
useGetChildAccountsQuery: mocks.mockUseGetChildAccountsQuery,
};
});

Expand All @@ -45,15 +49,17 @@ const mockDelegations = [
describe('AccountDelegations', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.mockUseGetAllChildAccountsQuery.mockReturnValue({
data: mockDelegations,
mocks.mockUseGetChildAccountsQuery.mockReturnValue({
data: { data: mockDelegations, results: mockDelegations.length },
isLoading: false,
});
});

it('should render the delegations table with data', async () => {
renderWithTheme(<AccountDelegations />, {
flags: {
iamDelegation: { enabled: true },
iam: { enabled: true },
},
initialRoute: '/iam',
});
Expand All @@ -72,12 +78,13 @@ describe('AccountDelegations', () => {
});

it('should render empty state when no delegations', async () => {
mocks.mockUseGetAllChildAccountsQuery.mockReturnValue({
data: [],
mocks.mockUseGetChildAccountsQuery.mockReturnValue({
data: { data: [], results: 0 },
isLoading: false,
});

renderWithTheme(<AccountDelegations />, {
flags: { iamDelegation: { enabled: true } },
flags: { iamDelegation: { enabled: true }, iam: { enabled: true } },
initialRoute: '/iam',
});

Expand Down
135 changes: 52 additions & 83 deletions packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { useGetAllChildAccountsQuery } from '@linode/queries';
import { useGetChildAccountsQuery } from '@linode/queries';
import { CircleProgress, Paper, Stack } from '@linode/ui';
import { useMediaQuery, useTheme } from '@mui/material';
import { useNavigate, useSearch } from '@tanstack/react-router';
import React from 'react';

import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
import Paginate from 'src/components/Paginate';
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
import { useFlags } from 'src/hooks/useFlags';
import { useOrderV2 } from 'src/hooks/useOrderV2';
import { usePaginationV2 } from 'src/hooks/usePaginationV2';

import { useIsIAMDelegationEnabled } from '../hooks/useIsIAMEnabled';
import { AccountDelegationsTable } from './AccountDelegationsTable';

const DELEGATIONS_ROUTE = '/iam/delegations';

export const AccountDelegations = () => {
const navigate = useNavigate();
const flags = useFlags();
const { query } = useSearch({
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();

const { company } = useSearch({
from: '/iam',
});
const theme = useTheme();
Expand All @@ -29,73 +29,59 @@ export const AccountDelegations = () => {
const numColsLg = isLgDown ? 3 : 2;
const numCols = isSmDown ? 2 : numColsLg;

// TODO: UIE-9292 - replace this with API filtering
const {
data: childAccountsWithDelegates,
error,
isLoading,
} = useGetAllChildAccountsQuery({
params: {},
users: true,
});

const { handleOrderChange, order, orderBy } = useOrderV2({
initialRoute: {
defaultOrder: {
order: 'asc',
orderBy: 'company',
},
from: '/iam/delegations',
from: DELEGATIONS_ROUTE,
},
preferenceKey: 'iam-delegations-order',
preferenceKey: 'iam-delegations-pagination',
});

// Apply search filter
const filteredDelegations = React.useMemo(() => {
if (!childAccountsWithDelegates) return [];
if (!query?.trim()) return childAccountsWithDelegates;

const searchTerm = query.toLowerCase().trim();
return childAccountsWithDelegates.filter((delegation) =>
delegation.company?.toLowerCase().includes(searchTerm)
);
}, [childAccountsWithDelegates, query]);

// Sort filtered data globally
const sortedDelegations = React.useMemo(() => {
if (!filteredDelegations.length) return [];

return [...filteredDelegations].sort((a, b) => {
const aValue = a.company || '';
const bValue = b.company || '';

const comparison = aValue.localeCompare(bValue, undefined, {
numeric: true,
sensitivity: 'base',
});

return order === 'asc' ? comparison : -comparison;
});
}, [filteredDelegations, order]);

const pagination = usePaginationV2({
currentRoute: '/iam/delegations',
initialPage: 1,
currentRoute: DELEGATIONS_ROUTE,
preferenceKey: 'iam-delegations-pagination',
clientSidePaginationData: sortedDelegations,
initialPage: 1,
searchParams: (prev) => ({
...prev,
company: company || undefined,
}),
});

const filter = {
['+order']: order,
['+order_by']: orderBy,
...(company && { company: { '+contains': company } }),
};

const {
data: childAccountsWithDelegates,
isFetching,
isLoading,
error,
} = useGetChildAccountsQuery({
params: {
page: pagination.page,
page_size: pagination.pageSize,
},
users: true,
filter,
});

const handleSearch = (value: string) => {
pagination.handlePageChange(1);
navigate({
to: DELEGATIONS_ROUTE,
search: { query: value || undefined },
search: { company: value || undefined },
});
};

if (isLoading) {
return <CircleProgress />;
}
if (!flags.iamDelegation?.enabled) {
if (!isIAMDelegationEnabled) {
return null;
}
return (
Expand All @@ -115,46 +101,29 @@ export const AccountDelegations = () => {
}}
debounceTime={250}
hideLabel
isSearching={isFetching}
label="Search"
onSearch={handleSearch}
placeholder="Search"
value={query ?? ''}
value={company ?? ''}
/>
</Stack>

<Paginate
data={pagination.paginatedData}
<AccountDelegationsTable
delegations={childAccountsWithDelegates?.data ?? []}
error={error}
handleOrderChange={handleOrderChange}
isLoading={isLoading}
numCols={numCols}
order={order}
orderBy={orderBy}
/>
<PaginationFooter
count={childAccountsWithDelegates?.results ?? 0}
handlePageChange={pagination.handlePageChange}
handleSizeChange={pagination.handlePageSizeChange}
page={pagination.page}
pageSize={pagination.pageSize}
pageSizeSetter={pagination.handlePageSizeChange}
updatePageUrl={pagination.handlePageChange}
>
{({
count,
data: paginatedData,
handlePageChange,
handlePageSizeChange,
}) => (
<>
<AccountDelegationsTable
delegations={paginatedData}
error={error}
handleOrderChange={handleOrderChange}
isLoading={isLoading}
numCols={numCols}
order={order}
orderBy={orderBy}
/>
<PaginationFooter
count={count}
handlePageChange={handlePageChange}
handleSizeChange={handlePageSizeChange}
page={pagination.page}
pageSize={pagination.pageSize}
/>
</>
)}
</Paginate>
/>
</Paper>
);
};
60 changes: 14 additions & 46 deletions packages/queries/src/iam/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,15 @@ import type {
} from '@linode/api-v4';
import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query';

const getAllDelegationsRequest = (
_params: Params = {},
_users: boolean = true,
) => {
return getAll<ChildAccount | ChildAccountWithDelegates>((params) => {
return getChildAccountsIam({
params: { ...params, ..._params },
users: _users,
});
})().then((data) => {
return data.data;
});
};

export const delegationQueries = createQueryKeys('delegation', {
childAccounts: ({ params, users }: GetChildAccountsIamParams) => ({
queryFn: () => getChildAccountsIam({ params, users }),
queryKey: [params, users],
}),
allChildAccounts: (params: Params = {}, users: boolean = true) => ({
queryFn: () => getAllDelegationsRequest(params, users),
queryKey: ['all', params, users],
childAccounts: ({
params,
users,
enabled = true,
filter = {},
}: GetChildAccountsIamParams) => ({
queryFn: () => getChildAccountsIam({ params, users, enabled, filter }),
queryKey: [params, users, enabled, filter],
}),
delegatedChildAccountsForUser: ({
username,
Expand Down Expand Up @@ -107,31 +94,16 @@ export const delegationQueries = createQueryKeys('delegation', {
export const useGetChildAccountsQuery = ({
params,
users,
}: GetChildAccountsIamParams): UseQueryResult<
filter,
enabled = true,
}: GetChildAccountsIamParams & { enabled?: boolean }): UseQueryResult<
ResourcePage<ChildAccount | ChildAccountWithDelegates>,
APIError[]
> => {
return useQuery({
...delegationQueries.childAccounts({ params, users }),
});
};

/**
* List ALL child accounts (fetches all data) - gets all child accounts without pagination
* - Purpose: Get ALL child accounts under a parent account for client-side operations
* - Scope: All child accounts for the parent (for sorting, filtering, etc.)
* - Audience: Parent account administrators needing full dataset.
* - CRUD: GET /iam/delegation/child-accounts?users=true (uses getAll utility)
*/
export const useGetAllChildAccountsQuery = ({
params = {},
users = true,
}: Partial<GetChildAccountsIamParams> = {}): UseQueryResult<
(ChildAccount | ChildAccountWithDelegates)[],
APIError[]
> => {
return useQuery({
...delegationQueries.allChildAccounts(params, users),
...delegationQueries.childAccounts({ params, users, filter }),
placeholderData: keepPreviousData,
enabled,
});
};

Expand Down Expand Up @@ -208,10 +180,6 @@ export const useUpdateChildAccountDelegatesQuery = (): UseMutationResult<
queryKey: delegationQueries.childAccounts({ params: {}, users: true })
.queryKey,
});
// Invalidate all child accounts
queryClient.invalidateQueries({
queryKey: delegationQueries.allChildAccounts._def,
});
// Invalidate all child account delegates
queryClient.invalidateQueries({
queryKey: delegationQueries.childAccountDelegates({ euuid }).queryKey,
Expand Down