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-13143-fixed-1764672257327.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

IAM Permissions performance improvements: Create from Backup & Clone ([#13143](https://github.com/linode/manager/pull/13143))
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableRowError } from 'src/components/TableRowError/TableRowError';
import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
import { TableSortCell } from 'src/components/TableSortCell';
import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import {
linodesCreateTypesMap,
useGetLinodeCreateType,
Expand Down Expand Up @@ -56,6 +58,26 @@ export const LinodeSelectTable = (props: Props) => {
theme.breakpoints.up('md')
);

const { data: accountPermissions, isLoading: isLoadingAccountPermissions } =
usePermissions('account', ['create_linode']);

const {
data: shutdownableLinodes = [],
isLoading: isLoadingShutdownableLinodes,
error: shutdownableLinodesError,
} = useGetAllUserEntitiesByPermission({
entityType: 'linode',
permission: 'shutdown_linode',
});
const {
data: cloneableLinodes = [],
isLoading: isLoadingCloneableLinodes,
error: cloneableLinodesError,
} = useGetAllUserEntitiesByPermission({
entityType: 'linode',
permission: 'clone_linode',
});

const {
control,
formState: {
Expand Down Expand Up @@ -102,7 +124,12 @@ export const LinodeSelectTable = (props: Props) => {

const { filter, filterError } = getLinodeXFilter(query, order, orderBy);

const { data, error, isFetching, isLoading } = useLinodesQuery(
const {
data,
error: linodesError,
isFetching,
isLoading: isLoadingLinodes,
} = useLinodesQuery(
{
page: pagination.page,
page_size: pagination.pageSize,
Expand Down Expand Up @@ -144,6 +171,14 @@ export const LinodeSelectTable = (props: Props) => {

const columns = enablePowerOff ? 6 : 5;

const isLoading =
isLoadingAccountPermissions ||
isLoadingShutdownableLinodes ||
isLoadingCloneableLinodes ||
isLoadingLinodes;
const error =
shutdownableLinodesError || cloneableLinodesError || linodesError;

return (
<Stack pt={1} spacing={2}>
{fieldState.error?.message && (
Expand Down Expand Up @@ -195,27 +230,38 @@ export const LinodeSelectTable = (props: Props) => {
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableRowLoading columns={columns} rows={10} />}
{isLoading && (
<TableRowLoading columns={columns} rows={pagination.pageSize} />
)}
{error && (
<TableRowError colSpan={columns} message={error[0].reason} />
)}
{data?.results === 0 && <TableRowEmpty colSpan={columns} />}
{data?.data.map((linode) => (
<LinodeSelectTableRow
key={linode.id}
linode={linode}
onPowerOff={
enablePowerOff
? () => {
setLinodeToPowerOff(linode);
sendLinodePowerOffEvent('Clone Linode');
}
: undefined
}
onSelect={() => handleSelect(linode)}
selected={linode.id === field.value?.id}
/>
))}
{!isLoading &&
!error &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to check if it's not an error or loading state here if we did it above?

Copy link
Contributor Author

@abailly-akamai abailly-akamai Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - we're not blocking the rendering of the table with those checks as we do in other components. Basically we could have loading state + an empty table here without proper checks

data?.data.map((linode) => (
<LinodeSelectTableRow
disabled={!accountPermissions?.create_linode}
isCloneable={cloneableLinodes?.some(
(l) => l.id === linode.id
)}
isShutdownable={shutdownableLinodes?.some(
(l) => l.id === linode.id
)}
key={linode.id}
linode={linode}
onPowerOff={
enablePowerOff
? () => {
setLinodeToPowerOff(linode);
sendLinodePowerOffEvent('Clone Linode');
}
: undefined
}
onSelect={() => handleSelect(linode)}
selected={linode.id === field.value?.id}
/>
))}
</TableBody>
</Table>
) : (
Expand All @@ -224,6 +270,10 @@ export const LinodeSelectTable = (props: Props) => {
<SelectLinodeCard
handlePowerOff={() => handlePowerOff(linode)}
handleSelection={() => handleSelect(linode)}
isCloneable={cloneableLinodes?.some((l) => l.id === linode.id)}
isShutdownable={shutdownableLinodes?.some(
(l) => l.id === linode.id
)}
key={linode.id}
linode={linode}
selected={linode.id === field.value?.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';

import { imageFactory, typeFactory } from 'src/factories';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { http, HttpResponse, server } from 'src/mocks/testServer';
import {
renderWithThemeAndHookFormContext,
wrapWithTableBody,
Expand All @@ -13,26 +11,35 @@ import {
import { LinodeSelectTableRow } from './LinodeSelectTableRow';

const queryMocks = vi.hoisted(() => ({
userPermissions: vi.fn(() => ({
data: {
shutdown_linode: false,
clone_linode: false,
create_linode: false,
},
})),
useImageQuery: vi.fn().mockReturnValue({}),
useRegionsQuery: vi.fn().mockReturnValue({}),
useTypeQuery: vi.fn().mockReturnValue({}),
}));

vi.mock('src/features/IAM/hooks/usePermissions', () => ({
usePermissions: queryMocks.userPermissions,
}));
vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useImageQuery: queryMocks.useImageQuery,
useRegionsQuery: queryMocks.useRegionsQuery,
useTypeQuery: queryMocks.useTypeQuery,
};
});

describe('LinodeSelectTableRow', () => {
it('should render a Radio that is labeled by the Linode label', () => {
const linode = linodeFactory.build();

const { getByLabelText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow linode={linode} onSelect={vi.fn()} selected />
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onSelect={vi.fn()}
selected
/>
),
});

Expand All @@ -44,7 +51,14 @@ describe('LinodeSelectTableRow', () => {

const { getByLabelText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow linode={linode} onSelect={vi.fn()} selected />
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onSelect={vi.fn()}
selected
/>
),
});

Expand All @@ -57,6 +71,9 @@ describe('LinodeSelectTableRow', () => {
const { getByLabelText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onSelect={vi.fn()}
selected={false}
Expand All @@ -68,20 +85,16 @@ describe('LinodeSelectTableRow', () => {
});

it('should should call onSelect when a radio is selected', async () => {
queryMocks.userPermissions.mockReturnValue({
data: {
shutdown_linode: false,
clone_linode: true,
create_linode: true,
},
});
const linode = linodeFactory.build();

const onSelect = vi.fn();

const { getByLabelText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow
disabled={false}
isCloneable={true}
isShutdownable={false}
linode={linode}
onSelect={onSelect}
selected={false}
Expand All @@ -102,15 +115,20 @@ describe('LinodeSelectTableRow', () => {
label: 'My Image Nice Label',
});

server.use(
http.get('*/v4/images/my-image', () => {
return HttpResponse.json(image);
})
);
queryMocks.useImageQuery.mockReturnValue({
data: image,
});

const { findByText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow linode={linode} onSelect={vi.fn()} selected />
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onSelect={vi.fn()}
selected
/>
),
});

Expand All @@ -124,19 +142,24 @@ describe('LinodeSelectTableRow', () => {
label: 'US Test',
});

server.use(
http.get('*/v4*/regions', () => {
return HttpResponse.json(makeResourcePage([region]));
})
);
queryMocks.useRegionsQuery.mockReturnValue({
data: [region],
});

const { findByText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow linode={linode} onSelect={vi.fn()} selected />
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onSelect={vi.fn()}
selected
/>
),
});

await findByText(`US, ${region.label}`);
await findByText(region.label);
});

it('should render a Linode plan label', async () => {
Expand All @@ -146,15 +169,20 @@ describe('LinodeSelectTableRow', () => {
label: 'Linode Type 1',
});

server.use(
http.get('*/v4/linode/types/linode-type-1', () => {
return HttpResponse.json(type);
})
);
queryMocks.useTypeQuery.mockReturnValue({
data: type,
});

const { findByText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow linode={linode} onSelect={vi.fn()} selected />
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onSelect={vi.fn()}
selected
/>
),
});

Expand All @@ -167,6 +195,9 @@ describe('LinodeSelectTableRow', () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow
disabled={false}
isCloneable={false}
isShutdownable={false}
linode={linode}
onPowerOff={vi.fn()}
onSelect={vi.fn()}
Expand All @@ -180,17 +211,13 @@ describe('LinodeSelectTableRow', () => {

it('should render an enabled power off button if the Linode is powered on, a onPowerOff function is passed, and the row is selected, if user has shutdown_linode permission', async () => {
const linode = linodeFactory.build({ status: 'running' });
queryMocks.userPermissions.mockReturnValue({
data: {
shutdown_linode: true,
clone_linode: true,
create_linode: true,
},
});

const { getByText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow
disabled={false}
isCloneable={true}
isShutdownable={true}
linode={linode}
onPowerOff={vi.fn()}
onSelect={vi.fn()}
Expand All @@ -209,6 +236,9 @@ describe('LinodeSelectTableRow', () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: wrapWithTableBody(
<LinodeSelectTableRow
disabled={false}
isCloneable={true}
isShutdownable={true}
linode={linode}
onPowerOff={onPowerOff}
onSelect={vi.fn()}
Expand Down
Loading