{
isFetching={isLoadingPlacementGroup}
linodes={linodes}
onClose={onClosePlacementGroupDrawer}
- open={params.action === 'delete'}
+ open={search.action === 'delete'}
selectedPlacementGroup={selectedPlacementGroup}
selectedPlacementGroupError={selectedPlacementGroupError}
/>
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx
index 13b7171e9e9..cdef345f76b 100644
--- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx
@@ -7,6 +7,7 @@ import { PlacementGroupsUnassignModal } from './PlacementGroupsUnassignModal';
const queryMocks = vi.hoisted(() => ({
useParams: vi.fn().mockReturnValue({}),
+ useSearch: vi.fn().mockReturnValue({}),
}));
vi.mock('@tanstack/react-router', async () => {
@@ -14,6 +15,7 @@ vi.mock('@tanstack/react-router', async () => {
return {
...actual,
useParams: queryMocks.useParams,
+ useSearch: queryMocks.useSearch,
};
});
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx
index 9a067b9c1f8..da6491ea67c 100644
--- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx
@@ -1,12 +1,14 @@
import { useUnassignLinodesFromPlacementGroup } from '@linode/queries';
import { ActionsPanel, Notice, Typography } from '@linode/ui';
-import { useParams } from '@tanstack/react-router';
+import { useParams, useSearch } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import * as React from 'react';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted';
+import { PLACEMENT_GROUPS_DETAILS_ROUTE } from './constants';
+
import type {
APIError,
Linode,
@@ -31,8 +33,12 @@ export const PlacementGroupsUnassignModal = (props: Props) => {
} = props;
const { enqueueSnackbar } = useSnackbar();
- const { id: placementGroupId, linodeId } = useParams({
- strict: false,
+ const { id: placementGroupId } = useParams({
+ from: PLACEMENT_GROUPS_DETAILS_ROUTE,
+ });
+
+ const { linodeId } = useSearch({
+ from: PLACEMENT_GROUPS_DETAILS_ROUTE,
});
const {
diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx
index 59676b2d43e..c20cd8d58c4 100644
--- a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx
+++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx
@@ -82,7 +82,7 @@ export const CreateMenu = () => {
description: "Control your Linodes' physical placement",
display: 'Placement Group',
hide: !isPlacementGroupsEnabled,
- href: '/placement-groups/create',
+ href: '/placement-groups?action=create',
},
{
attr: { 'data-qa-one-click-add-new': true },
diff --git a/packages/manager/src/routes/placementGroups/index.ts b/packages/manager/src/routes/placementGroups/index.ts
index f9995ae7203..a989861129b 100644
--- a/packages/manager/src/routes/placementGroups/index.ts
+++ b/packages/manager/src/routes/placementGroups/index.ts
@@ -1,81 +1,24 @@
-import { createRoute, redirect } from '@tanstack/react-router';
+import { createRoute } from '@tanstack/react-router';
import { rootRoute } from '../root';
-import { PlacementGroupsRoute } from './PlacementGroupsRoute';
import type { TableSearchParams } from '../types';
export interface PlacementGroupsSearchParams extends TableSearchParams {
+ action?: 'create' | 'delete' | 'edit';
+ id?: number;
query?: string;
}
-const placementGroupAction = {
- delete: 'delete',
- edit: 'edit',
-} as const;
-
-const placementGroupLinodeAction = {
- assign: 'assign',
- unassign: 'unassign',
-} as const;
-
-export type PlacementGroupAction =
- (typeof placementGroupAction)[keyof typeof placementGroupAction];
-export type PlacementGroupLinodesAction =
- (typeof placementGroupLinodeAction)[keyof typeof placementGroupLinodeAction];
+export interface PlacementGroupLinodesSearchParams extends TableSearchParams {
+ action?: 'assign' | 'unassign';
+ linodeId?: number;
+ query?: string;
+}
-export const placementGroupsRoute = createRoute({
- component: PlacementGroupsRoute,
+const placementGroupsLandingRoute = createRoute({
getParentRoute: () => rootRoute,
- path: 'placement-groups',
- validateSearch: (search: PlacementGroupsSearchParams) => search,
-});
-
-const placementGroupsIndexRoute = createRoute({
- getParentRoute: () => placementGroupsRoute,
- path: '/',
- validateSearch: (search: PlacementGroupsSearchParams) => search,
-}).lazy(() =>
- import('./placementGroupsLazyRoutes').then(
- (m) => m.placementGroupsLandingLazyRoute
- )
-);
-
-const placementGroupsCreateRoute = createRoute({
- getParentRoute: () => placementGroupsRoute,
- path: 'create',
-}).lazy(() =>
- import('./placementGroupsLazyRoutes').then(
- (m) => m.placementGroupsLandingLazyRoute
- )
-);
-
-type PlacementGroupActionRouteParams = {
- action: PlacementGroupAction;
- id: P;
-};
-
-const placementGroupActionRoute = createRoute({
- beforeLoad: async ({ params }) => {
- if (!(params.action in placementGroupAction)) {
- throw redirect({
- search: () => ({}),
- to: '/placement-groups',
- });
- }
- },
- getParentRoute: () => placementGroupsRoute,
- params: {
- parse: ({ action, id }: PlacementGroupActionRouteParams) => ({
- action,
- id: Number(id),
- }),
- stringify: ({ action, id }: PlacementGroupActionRouteParams) => ({
- action,
- id: String(id),
- }),
- },
- path: '$action/$id',
+ path: '/placement-groups',
validateSearch: (search: PlacementGroupsSearchParams) => search,
}).lazy(() =>
import('./placementGroupsLazyRoutes').then(
@@ -84,69 +27,18 @@ const placementGroupActionRoute = createRoute({
);
const placementGroupsDetailRoute = createRoute({
- getParentRoute: () => placementGroupsRoute,
+ getParentRoute: () => rootRoute,
parseParams: (params) => ({
id: Number(params.id),
}),
- path: '$id',
- validateSearch: (search: PlacementGroupsSearchParams) => search,
-}).lazy(() =>
- import('./placementGroupsLazyRoutes').then(
- (m) => m.placementGroupsDetailLazyRoute
- )
-);
-
-type PlacementGroupLinodesActionRouteParams = {
- action: PlacementGroupLinodesAction;
-};
-
-const placementGroupLinodesActionBaseRoute = createRoute({
- beforeLoad: async ({ params }) => {
- if (!(params.action in placementGroupLinodeAction)) {
- throw redirect({
- params: {
- id: params.id,
- },
- search: () => ({}),
- to: `/placement-groups/$id`,
- });
- }
- },
- getParentRoute: () => placementGroupsDetailRoute,
- params: {
- parse: ({ action }: PlacementGroupLinodesActionRouteParams) => ({
- action,
- }),
- stringify: ({ action }: PlacementGroupLinodesActionRouteParams) => ({
- action,
- }),
- },
- path: 'linodes/$action',
- validateSearch: (search: PlacementGroupsSearchParams) => search,
+ path: 'placement-groups/$id',
+ validateSearch: (search: PlacementGroupLinodesSearchParams) => search,
}).lazy(() =>
import('./placementGroupsLazyRoutes').then(
(m) => m.placementGroupsDetailLazyRoute
)
);
-const placementGroupsUnassignRoute = createRoute({
- getParentRoute: () => placementGroupLinodesActionBaseRoute,
- parseParams: (params) => ({
- linodeId: Number(params.linodeId),
- }),
- path: '$linodeId',
-}).lazy(() =>
- import('./placementGroupsLazyRoutes').then(
- (m) => m.placementGroupsDetailLazyRoute
- )
+export const placementGroupsRouteTree = placementGroupsLandingRoute.addChildren(
+ [placementGroupsDetailRoute]
);
-
-export const placementGroupsRouteTree = placementGroupsRoute.addChildren([
- placementGroupsIndexRoute.addChildren([placementGroupActionRoute]),
- placementGroupsCreateRoute,
- placementGroupsDetailRoute.addChildren([
- placementGroupLinodesActionBaseRoute.addChildren([
- placementGroupsUnassignRoute,
- ]),
- ]),
-]);
From 7b03972ec8a45ccca1fe569c7a5cf4d0f7dc6635 Mon Sep 17 00:00:00 2001
From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com>
Date: Thu, 12 Jun 2025 13:10:57 -0400
Subject: [PATCH 03/52] change: [M3-9816] - Streamline Linode Interface logic
for Firewall Landing and Device (#12283)
* update types
* update naming, update logic for device row
* firewall landing row
* cleanup and tests
* Added changeset: Streamline Linode Interface logic for Firewall Landing and Device tables to use new API fields
* Added changeset: Add `parent_entity` field to `FirewallDeviceEntity`
* fix e2e tests
* fix type error
* fix order by label
* cleanup more references to interface in new tests
---
...r-12283-upcoming-features-1748378763674.md | 5 ++
packages/api-v4/src/firewalls/types.ts | 1 +
...r-12283-upcoming-features-1748378495996.md | 5 ++
.../firewalls/add-device-to-firewall.spec.ts | 21 +++++
.../manager/src/__data__/firewallDevices.ts | 2 +
packages/manager/src/__data__/firewalls.ts | 2 +
packages/manager/src/factories/firewalls.ts | 3 +
.../Devices/FirewallDeviceRow.test.tsx | 27 +++++++
.../Devices/FirewallDeviceRow.tsx | 23 ++----
.../Devices/FirewallDeviceTable.tsx | 41 +++-------
.../Devices/RemoveDeviceDialog.tsx | 9 +++
.../Firewalls/FirewallDetail/index.tsx | 10 +--
.../FirewallLanding/FirewallRow.test.tsx | 32 ++++++--
.../Firewalls/FirewallLanding/FirewallRow.tsx | 78 +++++--------------
.../src/features/Firewalls/shared.test.ts | 25 +-----
.../manager/src/features/Firewalls/shared.ts | 16 +---
.../mocks/presets/crud/handlers/firewalls.ts | 2 +
.../mocks/presets/crud/handlers/linodes.ts | 1 +
.../presets/crud/handlers/nodebalancers.ts | 1 +
19 files changed, 145 insertions(+), 159 deletions(-)
create mode 100644 packages/api-v4/.changeset/pr-12283-upcoming-features-1748378763674.md
create mode 100644 packages/manager/.changeset/pr-12283-upcoming-features-1748378495996.md
diff --git a/packages/api-v4/.changeset/pr-12283-upcoming-features-1748378763674.md b/packages/api-v4/.changeset/pr-12283-upcoming-features-1748378763674.md
new file mode 100644
index 00000000000..1a012042623
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12283-upcoming-features-1748378763674.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Upcoming Features
+---
+
+Add `parent_entity` field to `FirewallDeviceEntity` ([#12283](https://github.com/linode/manager/pull/12283))
diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts
index b949ed34444..411c6cf501c 100644
--- a/packages/api-v4/src/firewalls/types.ts
+++ b/packages/api-v4/src/firewalls/types.ts
@@ -51,6 +51,7 @@ export interface FirewallRuleType {
export interface FirewallDeviceEntity {
id: number;
label: null | string;
+ parent_entity: FirewallDeviceEntity | null;
type: FirewallDeviceEntityType;
url: string;
}
diff --git a/packages/manager/.changeset/pr-12283-upcoming-features-1748378495996.md b/packages/manager/.changeset/pr-12283-upcoming-features-1748378495996.md
new file mode 100644
index 00000000000..d4ecc2e4b0e
--- /dev/null
+++ b/packages/manager/.changeset/pr-12283-upcoming-features-1748378495996.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Streamline Linode Interface logic for Firewall Landing and Device tables to use new API fields ([#12283](https://github.com/linode/manager/pull/12283))
diff --git a/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts
index 27c1e3a9680..73624d9b9ee 100644
--- a/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts
+++ b/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts
@@ -80,6 +80,13 @@ describe('Can add Linode and Linode Interface devices to firewalls', () => {
type: 'linode_interface',
id: mockLinodeInterface.id,
url: `/v4/linode/instances/${mockNewInterfaceLinode.id}/interfaces/${mockLinodeInterface.id}`,
+ label: null,
+ parent_entity: {
+ label: mockNewInterfaceLinode.label,
+ id: mockNewInterfaceLinode.id,
+ type: 'linode',
+ url: `/v4/linode/instances/${mockNewInterfaceLinode.id}`,
+ },
},
});
@@ -226,6 +233,13 @@ describe('Can add Linode and Linode Interface devices to firewalls', () => {
type: 'linode_interface',
id: mockVPCInterface.id,
url: `/v4/linode/instances/${mockMultipleEligibleInterfacesLinode.id}/interfaces/${mockVPCInterface.id}`,
+ label: null,
+ parent_entity: {
+ label: mockMultipleEligibleInterfacesLinode.label,
+ id: mockMultipleEligibleInterfacesLinode.id,
+ type: 'linode',
+ url: `/v4/linode/instances/${mockMultipleEligibleInterfacesLinode.id}`,
+ },
},
});
@@ -356,6 +370,13 @@ describe('Can add Linode and Linode Interface devices to firewalls', () => {
type: 'linode_interface',
id: mockPublicInterface.id,
url: `/v4/linode/instances/${mockAlreadyAssignedLILinode.id}/interfaces/${mockPublicInterface.id}`,
+ label: null,
+ parent_entity: {
+ label: mockAlreadyAssignedLILinode.label,
+ id: mockAlreadyAssignedLILinode.id,
+ type: 'linode',
+ url: `/v4/linode/instances/${mockAlreadyAssignedLILinode.id}`,
+ },
},
});
diff --git a/packages/manager/src/__data__/firewallDevices.ts b/packages/manager/src/__data__/firewallDevices.ts
index ff11aecc672..1fa2e81c0d9 100644
--- a/packages/manager/src/__data__/firewallDevices.ts
+++ b/packages/manager/src/__data__/firewallDevices.ts
@@ -7,6 +7,7 @@ export const device: FirewallDevice = {
label: 'Some Linode',
type: 'linode' as any,
url: 'v4/linode/instances/16621754',
+ parent_entity: null,
},
id: 1,
updated: '2020-01-01',
@@ -19,6 +20,7 @@ export const device2: FirewallDevice = {
label: 'Other Linode',
type: 'linode' as any,
url: 'v4/linode/instances/15922741',
+ parent_entity: null,
},
id: 2,
updated: '2020-01-01',
diff --git a/packages/manager/src/__data__/firewalls.ts b/packages/manager/src/__data__/firewalls.ts
index 24de901f7d5..29eb3dcdd0c 100644
--- a/packages/manager/src/__data__/firewalls.ts
+++ b/packages/manager/src/__data__/firewalls.ts
@@ -9,6 +9,7 @@ export const firewall: Firewall = {
label: 'my-linode',
type: 'linode' as FirewallDeviceEntityType,
url: '/test',
+ parent_entity: null,
},
],
id: 1,
@@ -50,6 +51,7 @@ export const firewall2: Firewall = {
label: 'my-linode',
type: 'linode' as FirewallDeviceEntityType,
url: '/test',
+ parent_entity: null,
},
],
id: 2,
diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts
index 5c2804b1a63..419ecc21c6f 100644
--- a/packages/manager/src/factories/firewalls.ts
+++ b/packages/manager/src/factories/firewalls.ts
@@ -49,6 +49,7 @@ export const firewallFactory = Factory.Sync.makeFactory({
label: 'my-linode',
type: 'linode' as FirewallDeviceEntityType,
url: '/test',
+ parent_entity: null,
},
],
id: Factory.each((id) => id),
@@ -65,6 +66,7 @@ export const firewallEntityfactory =
label: 'my-linode',
type: 'linode' as FirewallDeviceEntityType,
url: '/test',
+ parent_entity: null,
});
export const firewallDeviceFactory = Factory.Sync.makeFactory({
@@ -74,6 +76,7 @@ export const firewallDeviceFactory = Factory.Sync.makeFactory({
label: 'entity',
type: 'linode' as FirewallDeviceEntityType,
url: '/linodes/1',
+ parent_entity: null,
},
id: Factory.each((i) => i),
updated: '2020-01-01',
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx
index c1977439cbb..6e7039fcb4d 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx
@@ -58,6 +58,33 @@ describe('FirewallDeviceRow', () => {
expect(getByText('Remove')).toBeVisible();
});
+ it('shows the Linode label for an interface device', () => {
+ const device = firewallDeviceFactory.build({
+ entity: {
+ id: 10,
+ label: null,
+ type: 'linode_interface' as FirewallDeviceEntityType,
+ url: '/linodes/11/interfaces/10',
+ parent_entity: {
+ id: 11,
+ label: 'test-linode-label',
+ type: 'linode' as FirewallDeviceEntityType,
+ url: '/linodes/11',
+ parent_entity: null,
+ },
+ },
+ });
+
+ const { getByText } = renderWithTheme(
+ ,
+ {
+ flags: { linodeInterfaces: { enabled: false } },
+ }
+ );
+
+ expect(getByText('test-linode-label')).toBeVisible();
+ });
+
it('does not show the network interface type for nodebalancer devices', () => {
const nodeBalancerEntity = firewallDeviceFactory.build({
entity: {
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx
index 2c64747771d..cfee62c9155 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx
@@ -1,11 +1,11 @@
import * as React from 'react';
import { Link } from 'src/components/Link';
-import { Skeleton } from 'src/components/Skeleton';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';
+import { getDeviceLinkAndLabel } from '../../FirewallLanding/FirewallRow';
import { FirewallDeviceActionMenu } from './FirewallDeviceActionMenu';
import type { FirewallDeviceActionMenuProps } from './FirewallDeviceActionMenu';
@@ -16,31 +16,20 @@ interface FirewallDeviceRowProps extends FirewallDeviceActionMenuProps {
export const FirewallDeviceRow = React.memo((props: FirewallDeviceRowProps) => {
const { device, isLinodeRelatedDevice } = props;
- const { id, label, type, url } = device.entity;
+ const { id, type } = device.entity;
const isInterfaceDevice = type === 'linode_interface';
- // for Linode Interfaces, the url comes in as '/v4/linode/instances/:linodeId/interfaces/:interfaceId
- // we need the Linode ID to create a link
- const entityId = isInterfaceDevice ? Number(url.split('/')[4]) : id;
const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();
- const link = isInterfaceDevice
- ? `/linodes/${entityId}/networking/interfaces/${id}`
- : `/${type}s/${id}/${type === 'linode' ? 'networking' : 'summary'}`;
+ const { entityLabel, entityLink } = getDeviceLinkAndLabel(device.entity);
return (
- {/* The only time a firewall device's label comes in as null is for Linode Interface devices. This label won't stay null - we do some
- processing to give the interface device its associated Linode's label. However, processing may take time, so we show a loading indicator first */}
- {isInterfaceDevice && !label ? (
-
- ) : (
-
- {label}
-
- )}
+
+ {entityLabel}
+
{isLinodeInterfacesEnabled && isLinodeRelatedDevice && (
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx
index 505ce89a5e3..d50da4f0edc 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx
@@ -1,7 +1,4 @@
-import {
- useAllFirewallDevicesQuery,
- useAllLinodesQuery,
-} from '@linode/queries';
+import { useAllFirewallDevicesQuery } from '@linode/queries';
import * as React from 'react';
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
@@ -17,7 +14,6 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';
-import { getLinodeIdFromInterfaceDevice } from '../../shared';
import { formattedTypes } from './constants';
import { FirewallDeviceRow } from './FirewallDeviceRow';
@@ -46,38 +42,21 @@ export const FirewallDeviceTable = React.memo(
const devices =
allDevices?.filter((device) =>
type === 'linode' && isLinodeInterfacesEnabled
- ? device.entity.type !== 'nodebalancer' // include entities with type 'interface' in Linode table
+ ? device.entity.type !== 'nodebalancer' // include entities with type 'linode_interface' in Linode table
: device.entity.type === type
) || [];
- const linodeInterfaceDevices =
- type === 'linode'
- ? allDevices?.filter(
- (device) => device.entity.type === 'linode_interface'
- )
- : [];
-
- // only fire this query if we have linode interface devices. We fetch the Linodes those devices are attached to
- // so that we can add a label to the devices for sorting and display purposes
- const { data: linodesWithInterfaces } = useAllLinodesQuery(
- {},
- {},
- isLinodeInterfacesEnabled &&
- linodeInterfaceDevices &&
- linodeInterfaceDevices.length > 0
- );
-
- const updatedDevices = devices.map((device) => {
- if (device.entity.type === 'linode_interface') {
- const linodeId = getLinodeIdFromInterfaceDevice(device.entity);
- const associatedLinode = linodesWithInterfaces?.find(
- (linode) => linode.id === linodeId
- );
+ const devicesWithEntityLabels = devices.map((device) => {
+ // Linode Interface devices don't have a label, so we need to use their parent entity's label for sorting purposes
+ if (
+ device.entity.type === 'linode_interface' &&
+ device.entity.parent_entity
+ ) {
return {
...device,
entity: {
...device.entity,
- label: associatedLinode?.label ?? null,
+ label: device.entity.parent_entity.label,
},
};
} else {
@@ -102,7 +81,7 @@ export const FirewallDeviceTable = React.memo(
orderBy,
sortedData: sortedDevices,
} = useOrderV2({
- data: updatedDevices,
+ data: devicesWithEntityLabels,
initialRoute: {
defaultOrder: {
order: 'asc',
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx
index 2006daad769..d80f0e9ddf7 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx
@@ -85,6 +85,15 @@ export const RemoveDeviceDialog = React.memo((props: Props) => {
});
}
+ if (deviceType === 'linode_interface' && device.entity.parent_entity) {
+ queryClient.invalidateQueries({
+ queryKey: linodeQueries
+ .linode(device.entity.parent_entity.id)
+ ._ctx.interfaces._ctx.interface(device.entity.id)._ctx.firewalls
+ .queryKey,
+ });
+ }
+
onClose();
};
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx
index 6104edd220f..5e8bd07315b 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx
@@ -37,10 +37,7 @@ import {
FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME,
getFirewallDefaultEntities,
} from '../components/FirewallSelectOption.utils';
-import {
- checkIfUserCanModifyFirewall,
- getLinodeIdFromInterfaceDevice,
-} from '../shared';
+import { checkIfUserCanModifyFirewall } from '../shared';
const FirewallRulesLanding = React.lazy(() =>
import('./Rules/FirewallRulesLanding').then((module) => ({
@@ -95,9 +92,10 @@ export const FirewallDetail = () => {
acc.nodebalancerCount += 1;
} else if (
isLinodeInterfacesEnabled &&
- device.entity.type === 'linode_interface'
+ device.entity.type === 'linode_interface' &&
+ device.entity.parent_entity
) {
- const linodeId = getLinodeIdFromInterfaceDevice(device.entity);
+ const linodeId = device.entity.parent_entity.id;
if (!acc.seenLinodeIdsForInterfaces.has(linodeId)) {
acc.linodeCount += 1;
}
diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx
index 017ab8edd95..f41265b44ef 100644
--- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx
@@ -21,6 +21,8 @@ import {
getRuleString,
} from './FirewallRow';
+import type { FirewallDeviceEntityType } from '@linode/api-v4';
+
const queryMocks = vi.hoisted(() => ({
useAccount: vi.fn().mockReturnValue({}),
useFirewallSettingsQuery: vi.fn().mockReturnValue({}),
@@ -110,19 +112,39 @@ describe('FirewallRow', () => {
const device = firewallDeviceFactory.build();
const links = getDeviceLinks({
entities: [device.entity],
- isLoading: false,
- linodesWithInterfaceDevices: undefined,
});
const { getByText } = renderWithTheme(links);
expect(getByText(device.entity.label ?? ''));
});
+ it('should show the Linode label for a link for an interface device', () => {
+ const device = firewallDeviceFactory.build({
+ entity: {
+ id: 10,
+ label: null,
+ type: 'linode_interface' as FirewallDeviceEntityType,
+ url: '/linodes/11/interfaces/10',
+ parent_entity: {
+ id: 11,
+ label: 'test-linode-label',
+ type: 'linode' as FirewallDeviceEntityType,
+ url: '/linodes/11',
+ parent_entity: null,
+ },
+ },
+ });
+
+ const links = getDeviceLinks({
+ entities: [device.entity],
+ });
+ const { getByText } = renderWithTheme(links);
+ expect(getByText('test-linode-label')).toBeVisible();
+ });
+
it('should render up to three comma-separated links', () => {
const devices = firewallDeviceFactory.buildList(3);
const links = getDeviceLinks({
entities: devices.map((device) => device.entity),
- isLoading: false,
- linodesWithInterfaceDevices: undefined,
});
const { queryAllByTestId } = renderWithTheme(links);
expect(queryAllByTestId('firewall-row-link')).toHaveLength(3);
@@ -132,8 +154,6 @@ describe('FirewallRow', () => {
const devices = firewallDeviceFactory.buildList(13);
const links = getDeviceLinks({
entities: devices.map((device) => device.entity),
- isLoading: false,
- linodesWithInterfaceDevices: undefined,
});
const { getByText, queryAllByTestId } = renderWithTheme(links);
expect(queryAllByTestId('firewall-row-link')).toHaveLength(3);
diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx
index 4684a15913c..efdd36419c0 100644
--- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx
@@ -1,11 +1,9 @@
-import { useAllLinodesQuery } from '@linode/queries';
import { Box } from '@linode/ui';
import { Hidden } from '@linode/ui';
import { capitalize } from '@linode/utilities';
import React from 'react';
import { Link } from 'src/components/Link';
-import { Skeleton } from 'src/components/Skeleton';
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
@@ -13,16 +11,10 @@ import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallC
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';
import { DefaultFirewallChip } from '../components/DefaultFirewallChip';
-import { getLinodeIdFromInterfaceDevice } from '../shared';
import { FirewallActionMenu } from './FirewallActionMenu';
import type { ActionHandlers } from './FirewallActionMenu';
-import type {
- Filter,
- Firewall,
- FirewallDeviceEntity,
- Linode,
-} from '@linode/api-v4';
+import type { Firewall, FirewallDeviceEntity } from '@linode/api-v4';
export interface FirewallRowProps extends Firewall, ActionHandlers {}
@@ -34,25 +26,6 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => {
const { defaultNumEntities, isDefault, tooltipText } =
useDefaultFirewallChipInformation(id);
- const neededLinodeIdsForInterfaceDevices = entities
- .slice(0, 3) // only take the first three entities since we only show those entity links
- .filter((entity) => entity.type === 'linode_interface')
- .map((entity) => {
- return { id: getLinodeIdFromInterfaceDevice(entity) };
- });
-
- const filterForInterfaceDeviceLinodes: Filter = {
- ['+or']: neededLinodeIdsForInterfaceDevices,
- };
-
- // only fire this query if we have linode interface devices. We fetch the Linodes those devices are attached to
- // so that we can add a label to the devices for sorting and display purposes
- const { data: linodesWithInterfaceDevices, isLoading } = useAllLinodesQuery(
- {},
- filterForInterfaceDeviceLinodes,
- isLinodeInterfacesEnabled && neededLinodeIdsForInterfaceDevices.length > 0
- );
-
const count = getCountOfRules(rules);
return (
@@ -87,8 +60,6 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => {
{getDevicesCellString({
entities,
isLinodeInterfacesEnabled,
- isLoading,
- linodesWithInterfaceDevices,
})}
@@ -139,16 +110,9 @@ export const getCountOfRules = (rules: Firewall['rules']): [number, number] => {
interface DeviceLinkInputs {
entities: FirewallDeviceEntity[];
isLinodeInterfacesEnabled: boolean;
- isLoading: boolean;
- linodesWithInterfaceDevices: Linode[] | undefined;
}
const getDevicesCellString = (inputs: DeviceLinkInputs) => {
- const {
- entities,
- isLinodeInterfacesEnabled,
- isLoading,
- linodesWithInterfaceDevices,
- } = inputs;
+ const { entities, isLinodeInterfacesEnabled } = inputs;
const filteredEntities = isLinodeInterfacesEnabled
? entities
: entities.filter((entity) => entity.type !== 'linode_interface');
@@ -159,39 +123,19 @@ const getDevicesCellString = (inputs: DeviceLinkInputs) => {
return getDeviceLinks({
entities: filteredEntities,
- isLoading,
- linodesWithInterfaceDevices,
});
};
export const getDeviceLinks = (
inputs: Omit
) => {
- const { entities, isLoading, linodesWithInterfaceDevices } = inputs;
+ const { entities } = inputs;
const firstThree = entities.slice(0, 3);
- if (isLoading) {
- return ;
- }
-
return (
<>
{firstThree.map((entity, idx) => {
- // TODO @Linode Interfaces - switch to parent entity when endpoints are updated
- const isInterfaceDevice = entity.type === 'linode_interface';
- let entityLabel = entity.label;
- let entityLink = `/${entity.type}s/${entity.id}/${
- entity.type === 'linode' ? 'networking' : 'summary'
- }`;
-
- if (isInterfaceDevice) {
- const parentEntityId = getLinodeIdFromInterfaceDevice(entity);
- entityLabel =
- linodesWithInterfaceDevices?.find(
- (linode) => linode.id === parentEntityId
- )?.label ?? entity.label;
- entityLink = `/linodes/${parentEntityId}/networking/interfaces/${entity.id}`;
- }
+ const { entityLabel, entityLink } = getDeviceLinkAndLabel(entity);
return (
@@ -210,3 +154,17 @@ export const getDeviceLinks = (
>
);
};
+
+export const getDeviceLinkAndLabel = (entity: FirewallDeviceEntity) => {
+ const { id, label, parent_entity, type } = entity;
+ const isInterfaceDevice = type === 'linode_interface';
+
+ const entityLabel =
+ isInterfaceDevice && parent_entity ? parent_entity.label : label;
+ const entityLink =
+ isInterfaceDevice && parent_entity
+ ? `/linodes/${parent_entity.id}/networking/interfaces/${id}`
+ : `/${type}s/${id}/${type === 'linode' ? 'networking' : 'summary'}`;
+
+ return { entityLabel, entityLink };
+};
diff --git a/packages/manager/src/features/Firewalls/shared.test.ts b/packages/manager/src/features/Firewalls/shared.test.ts
index 092edb562c5..463cbdfaa62 100644
--- a/packages/manager/src/features/Firewalls/shared.test.ts
+++ b/packages/manager/src/features/Firewalls/shared.test.ts
@@ -2,14 +2,10 @@ import {
allIPv4,
allIPv6,
generateAddressesLabel,
- getLinodeIdFromInterfaceDevice,
predefinedFirewallFromRule,
} from './shared';
-import type {
- FirewallDeviceEntityType,
- FirewallRuleType,
-} from '@linode/api-v4/lib/firewalls/types';
+import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types';
const addresses = {
ipv4: [allIPv4],
@@ -148,22 +144,3 @@ describe('generateAddressLabel', () => {
);
});
});
-
-describe('getLinodeIdFromInterfaceDevice', () => {
- it('returns the ID', () => {
- const entity = {
- id: 123,
- label: null,
- type: 'interface' as FirewallDeviceEntityType,
- url: '/v4/linode/instances/123/interfaces/123',
- };
-
- expect(getLinodeIdFromInterfaceDevice(entity)).toEqual(123);
- expect(
- getLinodeIdFromInterfaceDevice({
- ...entity,
- url: '/v4/linode/instances/456/interfaces/123',
- })
- ).toEqual(456);
- });
-});
diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts
index a9d94a947a5..beeaa834c50 100644
--- a/packages/manager/src/features/Firewalls/shared.ts
+++ b/packages/manager/src/features/Firewalls/shared.ts
@@ -2,7 +2,7 @@ import { truncateAndJoinList } from '@linode/utilities';
import { capitalize } from '@linode/utilities';
import type { PORT_PRESETS } from './FirewallDetail/Rules/shared';
-import type { FirewallDeviceEntity, Grants, Profile } from '@linode/api-v4';
+import type { Grants, Profile } from '@linode/api-v4';
import type {
Firewall,
FirewallRuleProtocol,
@@ -268,17 +268,3 @@ export const getFirewallDescription = (firewall: Firewall) => {
];
return description.join(', ');
};
-
-// TODO @Linode Interfaces - probably get rid of this once the API changes to FirewallDevice come in
-/**
- * Utility function to extract the Linode ID from firewall interface device entities. For Interface devices,
- * the URL is "/v4/linode/instances/123/interfaces/123"
- *
- * Assumptions: the entity device being passed into this function always has type "interface". The URL is
- * always in the above format.
- */
-export const getLinodeIdFromInterfaceDevice = (
- entity: FirewallDeviceEntity
-): number => {
- return Number(entity.url.split('/')[4]);
-};
diff --git a/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts b/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts
index 74b33648c48..645141b6974 100644
--- a/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts
+++ b/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts
@@ -132,6 +132,7 @@ export const createFirewall = (mockState: MockState) => [
label: `linode-${linodeId}`,
type: 'linode' as FirewallDeviceEntityType,
url: `/linodes/${linodeId}`,
+ parent_entity: null,
};
firewallEntities.push(entity);
@@ -157,6 +158,7 @@ export const createFirewall = (mockState: MockState) => [
label: `nodebalancer-${nbId}`,
type: 'nodebalancer' as FirewallDeviceEntityType,
url: `/nodebalancers/${nbId}`,
+ parent_entity: null,
};
firewallEntities.push(entity);
diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts
index aaec70f6436..0d6b89d3507 100644
--- a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts
+++ b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts
@@ -191,6 +191,7 @@ const addFirewallDevice = async (inputs: {
label: entityLabel,
type: interfaceType,
url: `/linodes/${entityId}`,
+ parent_entity: null,
};
const updatedFirewall = {
diff --git a/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts b/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts
index 6ed50810923..f9ecf041997 100644
--- a/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts
+++ b/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts
@@ -252,6 +252,7 @@ export const createNodeBalancer = (mockState: MockState) => [
label: nodeBalancer.label,
type: 'nodebalancer' as FirewallDeviceEntityType,
url: `/nodebalancer/${nodeBalancer.id}`,
+ parent_entity: null,
};
const updatedFirewall = {
...firewall,
From fdbaeb90d0a506a8284d8cdce9f9bffc76a5263a Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Thu, 12 Jun 2025 16:31:35 -0400
Subject: [PATCH 04/52] Tech Stories - [M3-10021]: Reroute IAM (#12312)
* intitial commit - save progress
* replace utils
* fix units
* missing usePagination instances
* missing unit
* another missing unit
* fix user roles filtering
* Added changeset: Reroute IAM
* some redirect feedback
* feedback @kwojtowiakamai
* feedback @kwojtowiakamai
* feedback @kwojtowiakamai
---
.../pr-12312-tech-stories-1748963557069.md | 5 +
packages/manager/eslint.config.js | 1 +
packages/manager/src/MainContent.tsx | 12 --
.../src/dev-tools/ServiceWorkerTool.tsx | 2 +-
.../manager/src/features/IAM/IAMLanding.tsx | 34 +---
.../src/features/IAM/Roles/Roles.test.tsx | 10 +-
.../IAM/Roles/RolesTable/RolesTable.test.tsx | 17 +-
.../IAM/Roles/RolesTable/RolesTable.tsx | 8 +-
.../AssignedRolesTable.test.tsx | 27 ++-
.../AssignedRolesTable/AssignedRolesTable.tsx | 21 ++-
.../ChangeRoleDrawer.test.tsx | 39 +++-
.../AssignedRolesTable/ChangeRoleDrawer.tsx | 4 +-
.../UnassignRoleConfirmationDialog.test.tsx | 38 ++--
.../UnassignRoleConfirmationDialog.tsx | 4 +-
.../UpdateEntitiesDrawer.test.tsx | 31 +++-
.../UpdateEntitiesDrawer.tsx | 4 +-
.../NoAssignedRoles/NoAssignedRoles.test.tsx | 26 ++-
...emoveAssignmentConfirmationDialog.test.tsx | 42 +++--
.../RemoveAssignmentConfirmationDialog.tsx | 4 +-
.../IAM/Users/UserDetails/DeleteUserPanel.tsx | 1 +
.../IAM/Users/UserDetails/UserProfile.tsx | 4 +-
.../Users/UserDetails/UsernamePanel.test.tsx | 8 +-
.../IAM/Users/UserDetails/UsernamePanel.tsx | 9 +-
.../features/IAM/Users/UserDetailsLanding.tsx | 49 ++---
.../AssignedEntitiesTable.test.tsx | 36 +++-
.../UserEntities/AssignedEntitiesTable.tsx | 26 +--
.../ChangeRoleForEntityDrawer.test.tsx | 21 ++-
.../ChangeRoleForEntityDrawer.tsx | 6 +-
.../Users/UserEntities/UserEntities.test.tsx | 30 ++-
.../IAM/Users/UserEntities/UserEntities.tsx | 4 +-
.../Users/UserRoles/AssignNewRoleDrawer.tsx | 6 +-
.../IAM/Users/UserRoles/UserRoles.test.tsx | 34 +++-
.../IAM/Users/UserRoles/UserRoles.tsx | 4 +-
.../IAM/Users/UsersTable/UserRow.test.tsx | 16 +-
.../features/IAM/Users/UsersTable/Users.tsx | 39 ++--
.../Users/UsersTable/UsersActionMenu.test.tsx | 45 +++--
.../IAM/Users/UsersTable/UsersActionMenu.tsx | 24 ++-
.../UsersTable/UsersLandingTableBody.test.tsx | 18 +-
packages/manager/src/features/IAM/index.tsx | 61 -------
.../manager/src/routes/IAM/IAMLazyRoutes.ts | 14 ++
packages/manager/src/routes/IAM/IAMRoute.tsx | 24 +++
packages/manager/src/routes/IAM/index.ts | 172 ++++++++++++++++++
packages/manager/src/routes/index.tsx | 3 +
43 files changed, 650 insertions(+), 333 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12312-tech-stories-1748963557069.md
delete mode 100644 packages/manager/src/features/IAM/index.tsx
create mode 100644 packages/manager/src/routes/IAM/IAMLazyRoutes.ts
create mode 100644 packages/manager/src/routes/IAM/IAMRoute.tsx
create mode 100644 packages/manager/src/routes/IAM/index.ts
diff --git a/packages/manager/.changeset/pr-12312-tech-stories-1748963557069.md b/packages/manager/.changeset/pr-12312-tech-stories-1748963557069.md
new file mode 100644
index 00000000000..045add02599
--- /dev/null
+++ b/packages/manager/.changeset/pr-12312-tech-stories-1748963557069.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+Reroute IAM ([#12312](https://github.com/linode/manager/pull/12312))
diff --git a/packages/manager/eslint.config.js b/packages/manager/eslint.config.js
index 13cdfed5f1f..235809b7e85 100644
--- a/packages/manager/eslint.config.js
+++ b/packages/manager/eslint.config.js
@@ -413,6 +413,7 @@ export const baseConfig = [
'src/features/Events/**/*',
'src/features/Firewalls/**/*',
'src/features/Help/**/*',
+ 'src/features/IAM/**/*',
'src/features/Images/**/*',
'src/features/Kubernetes/**/*',
'src/features/Longview/**/*',
diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx
index 6c1aa081ff9..54c01403d56 100644
--- a/packages/manager/src/MainContent.tsx
+++ b/packages/manager/src/MainContent.tsx
@@ -36,7 +36,6 @@ import { ENABLE_MAINTENANCE_MODE } from './constants';
import { complianceUpdateContext } from './context/complianceUpdateContext';
import { sessionExpirationContext } from './context/sessionExpirationContext';
import { switchAccountSessionContext } from './context/switchAccountSessionContext';
-import { useIsIAMEnabled } from './features/IAM/hooks/useIsIAMEnabled';
import { TOPMENU_HEIGHT } from './features/TopMenu/constants';
import { useGlobalErrors } from './hooks/useGlobalErrors';
import { migrationRouter } from './routes';
@@ -112,12 +111,6 @@ const LinodesRoutes = React.lazy(() =>
}))
);
-const IAM = React.lazy(() =>
- import('src/features/IAM').then((module) => ({
- default: module.IdentityAccessManagement,
- }))
-);
-
export const MainContent = () => {
const contentRef = React.useRef(null);
const { classes, cx } = useStyles();
@@ -153,8 +146,6 @@ export const MainContent = () => {
const { data: accountSettings } = useAccountSettings();
const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes';
- const { isIAMEnabled } = useIsIAMEnabled();
-
const isNarrowViewport = useMediaQuery((theme: Theme) =>
theme.breakpoints.down(960)
);
@@ -286,9 +277,6 @@ export const MainContent = () => {
component={LinodesRoutes}
path="/linodes"
/>
- {isIAMEnabled && (
-
- )}
{/** We don't want to break any bookmarks. This can probably be removed eventually. */}
diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx
index 9d9e21bade9..d617bf4f69d 100644
--- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx
+++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx
@@ -139,7 +139,7 @@ export const ServiceWorkerTool = () => {
JSON.stringify(currentNotificationsData) !==
JSON.stringify(customNotificationsData);
- const hasCustomUserAccountPermissionsChanges =
+ const hasCustomUserAccountPermissionsChanges =
JSON.stringify(currentUserAccountPermissionsData) !==
JSON.stringify(customUserAccountPermissionsData);
const hasCustomUserEntityPermissionsChanges =
diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx
index 607c8a96f79..12d34b69cf5 100644
--- a/packages/manager/src/features/IAM/IAMLanding.tsx
+++ b/packages/manager/src/features/IAM/IAMLanding.tsx
@@ -1,13 +1,12 @@
import * as React from 'react';
-import { matchPath, useHistory, useLocation } from 'react-router-dom';
-import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { LandingHeader } from 'src/components/LandingHeader';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
-import { TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
+import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList';
+import { useTabs } from 'src/hooks/useTabs';
import { IAM_DOCS_LINK } from './Shared/constants';
@@ -24,31 +23,16 @@ const Roles = React.lazy(() =>
);
export const IdentityAccessLanding = React.memo(() => {
- const history = useHistory();
- const location = useLocation();
-
- const tabs = [
+ const { tabs, tabIndex, handleTabChange } = useTabs([
{
- routeName: `/iam/users`,
+ to: `/iam/users`,
title: 'Users',
},
{
- routeName: `/iam/roles`,
+ to: `/iam/roles`,
title: 'Roles',
},
- ];
-
- const navToURL = (index: number) => {
- history.push(tabs[index].routeName);
- };
-
- const getDefaultTabIndex = () => {
- return (
- tabs.findIndex((tab) =>
- Boolean(matchPath(tab.routeName, { path: location.pathname }))
- ) || 0
- );
- };
+ ]);
const landingHeaderProps = {
breadcrumbProps: {
@@ -63,11 +47,9 @@ export const IdentityAccessLanding = React.memo(() => {
return (
<>
-
-
-
-
+
+
}>
diff --git a/packages/manager/src/features/IAM/Roles/Roles.test.tsx b/packages/manager/src/features/IAM/Roles/Roles.test.tsx
index ea07b9b3198..2e01cf1a5cb 100644
--- a/packages/manager/src/features/IAM/Roles/Roles.test.tsx
+++ b/packages/manager/src/features/IAM/Roles/Roles.test.tsx
@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
import React from 'react';
import { accountRolesFactory } from 'src/factories/accountRoles';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { RolesLanding } from './Roles';
@@ -33,25 +33,25 @@ beforeEach(() => {
});
describe('RolesLanding', () => {
- it('renders loading state when permissions are loading', () => {
+ it('renders loading state when permissions are loading', async () => {
queryMocks.useAccountRoles.mockReturnValue({
data: null,
isLoading: true,
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
- it('renders roles table when permissions are loaded', () => {
+ it('renders roles table when permissions are loaded', async () => {
const mockPermissions = accountRolesFactory.build();
queryMocks.useAccountRoles.mockReturnValue({
data: mockPermissions,
isLoading: false,
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
// RolesTable has a textbox at the top
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx
index 92bfdc7fa5c..c9401d8cdc3 100644
--- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx
+++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx
@@ -1,7 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { RolesTable } from './RolesTable';
@@ -43,8 +43,8 @@ beforeEach(() => {
});
describe('RolesTable', () => {
- it('renders no roles when roles array is empty', () => {
- const { getByText, getByTestId } = renderWithTheme(
+ it('renders no roles when roles array is empty', async () => {
+ const { getByText, getByTestId } = await renderWithThemeAndRouter(
);
@@ -52,10 +52,9 @@ describe('RolesTable', () => {
expect(getByText('No items to display.')).toBeInTheDocument();
});
- it('renders roles correctly when roles array is provided', () => {
- const { getByText, getByTestId, getAllByRole } = renderWithTheme(
-
- );
+ it('renders roles correctly when roles array is provided', async () => {
+ const { getByText, getByTestId, getAllByRole } =
+ await renderWithThemeAndRouter();
expect(getByTestId('roles-table')).toBeInTheDocument();
expect(getAllByRole('combobox').length).toEqual(1);
@@ -63,7 +62,7 @@ describe('RolesTable', () => {
});
it('filters roles to warranted results based on search input', async () => {
- renderWithTheme();
+ await renderWithThemeAndRouter();
const searchInput: HTMLInputElement = screen.getByPlaceholderText('Search');
fireEvent.change(searchInput, { target: { value: 'Account' } });
@@ -78,7 +77,7 @@ describe('RolesTable', () => {
});
it('filters roles to no results based on search input if warranted', async () => {
- renderWithTheme();
+ await renderWithThemeAndRouter();
const searchInput: HTMLInputElement = screen.getByPlaceholderText('Search');
fireEvent.change(searchInput, {
diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx
index 4006e77c1a2..e7cad068649 100644
--- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx
+++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx
@@ -24,7 +24,7 @@ import {
getFacadeRoleDescription,
mapEntityTypesForSelect,
} from 'src/features/IAM/Shared/utilities';
-import { usePagination } from 'src/hooks/usePagination';
+import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { ROLES_TABLE_PREFERENCE_KEY } from '../../Shared/constants';
@@ -52,7 +52,11 @@ export const RolesTable = ({ roles = [] }: Props) => {
const [selectedRows, setSelectedRows] = useState([]);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const pagination = usePagination(1, ROLES_TABLE_PREFERENCE_KEY);
+ const pagination = usePaginationV2({
+ currentRoute: '/iam/roles',
+ initialPage: 1,
+ preferenceKey: ROLES_TABLE_PREFERENCE_KEY,
+ });
// Filtering
const getFilteredRows = (
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
index 0fe10a40a6b..4ff8445b1dd 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
@@ -6,12 +6,13 @@ import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
import { makeResourcePage } from 'src/mocks/serverHandlers';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { AssignedRolesTable } from './AssignedRolesTable';
const queryMocks = vi.hoisted(() => ({
useAccountEntities: vi.fn().mockReturnValue({}),
+ useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -33,6 +34,14 @@ vi.mock('src/queries/entities/entities', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
const mockEntities = [
accountEntityFactory.build({
id: 7,
@@ -46,12 +55,18 @@ const mockEntities = [
];
describe('AssignedRolesTable', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ });
+
it('should display no roles text if there are no roles assigned to user', async () => {
queryMocks.useUserRoles.mockReturnValue({
data: {},
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByText('No items to display.')).toBeVisible();
});
@@ -69,7 +84,7 @@ describe('AssignedRolesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByText('account_linode_admin')).toBeVisible();
expect(screen.getAllByText('All Linodes')[0]).toBeVisible();
@@ -97,7 +112,7 @@ describe('AssignedRolesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const searchInput = screen.getByPlaceholderText('Search');
await userEvent.type(searchInput, 'NonExistentRole');
@@ -120,7 +135,7 @@ describe('AssignedRolesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const searchInput = screen.getByPlaceholderText('Search');
await userEvent.type(searchInput, 'account_linode_admin');
@@ -143,7 +158,7 @@ describe('AssignedRolesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const autocomplete = screen.getByPlaceholderText('All Assigned Roles');
await userEvent.type(autocomplete, 'Firewall Roles');
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
index a3417cc9ab7..d69cbcb4cc1 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
@@ -1,8 +1,8 @@
import { Button, CircleProgress, Select, Typography } from '@linode/ui';
import { useTheme } from '@mui/material';
import Grid from '@mui/material/Grid';
+import { useNavigate, useParams } from '@tanstack/react-router';
import React from 'react';
-import { useHistory, useParams } from 'react-router-dom';
import { CollapsibleTable } from 'src/components/CollapsibleTable/CollapsibleTable';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
@@ -13,7 +13,7 @@ import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableSortCell } from 'src/components/TableSortCell/TableSortCell';
-import { usePagination } from 'src/hooks/usePagination';
+import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { useAccountEntities } from 'src/queries/entities/entities';
import { useAccountRoles, useUserRoles } from 'src/queries/iam/iam';
@@ -63,15 +63,19 @@ const ALL_ROLES_OPTION: SelectOption = {
};
export const AssignedRolesTable = () => {
- const { username } = useParams<{ username: string }>();
- const history = useHistory();
+ const { username } = useParams({ from: '/iam/users/$username' });
+ const navigate = useNavigate();
const theme = useTheme();
const [order, setOrder] = React.useState<'asc' | 'desc'>('asc');
const [orderBy, setOrderBy] = React.useState('name');
const [isInitialLoad, setIsInitialLoad] = React.useState(true);
- const pagination = usePagination(1, ASSIGNED_ROLES_TABLE_PREFERENCE_KEY);
+ const pagination = usePaginationV2({
+ currentRoute: '/iam/users/$username/roles',
+ initialPage: 1,
+ preferenceKey: ASSIGNED_ROLES_TABLE_PREFERENCE_KEY,
+ });
const handleOrderChange = (newOrderBy: OrderByKeys) => {
if (orderBy === newOrderBy) {
@@ -163,9 +167,10 @@ export const AssignedRolesTable = () => {
roleName: AccountAccessRole | EntityAccessRole
) => {
const selectedRole = roleName;
- history.push({
- pathname: `/iam/users/${username}/entities`,
- state: { selectedRole },
+ navigate({
+ to: '/iam/users/$username/entities',
+ params: { username },
+ search: { selectedRole },
});
};
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx
index 163242ebbf8..4f4547397ea 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx
@@ -4,7 +4,7 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { ChangeRoleDrawer } from './ChangeRoleDrawer';
@@ -12,12 +12,13 @@ import type { ExtendedRoleView } from '../types';
const queryMocks = vi.hoisted(() => ({
useAccountEntities: vi.fn().mockReturnValue({}),
+ useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
vi.mock('src/queries/iam/iam', async () => {
- const actual = await vi.importActual('src/queries/iam/iam');
+ const actual = await vi.importActual('src/queries/iam/iam');
return {
...actual,
useAccountRoles: queryMocks.useAccountRoles,
@@ -26,13 +27,21 @@ vi.mock('src/queries/iam/iam', async () => {
});
vi.mock('src/queries/entities/entities', async () => {
- const actual = await vi.importActual('src/queries/entities/entities');
+ const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
useAccountEntities: queryMocks.useAccountEntities,
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
const mockAccountAccessRole: ExtendedRoleView = {
access: 'account_access',
description:
@@ -73,22 +82,32 @@ vi.mock('@linode/api-v4', async () => {
});
describe('ChangeRoleDrawer', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ });
+
it('should render', async () => {
- renderWithTheme();
+ await renderWithThemeAndRouter(
+
+ );
// Verify title renders
expect(screen.getByText('Change Role')).toBeVisible();
});
- it('renders the correct text for account_access roles', () => {
- renderWithTheme();
+ it('renders the correct text for account_access roles', async () => {
+ await renderWithThemeAndRouter(
+
+ );
// Check that the correct text is displayed for account_access
expect(screen.getByText('Select a role you want to assign.')).toBeVisible();
});
- it('renders the correct text for entity_access roles', () => {
- renderWithTheme(
+ it('renders the correct text for entity_access roles', async () => {
+ await renderWithThemeAndRouter(
{
data: accountEntityFactory.build(),
});
- renderWithTheme();
+ await renderWithThemeAndRouter(
+
+ );
const autocomplete = screen.getByRole('combobox');
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx
index 571cfc7aac6..a1324792204 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx
@@ -6,9 +6,9 @@ import {
Typography,
} from '@linode/ui';
import { useTheme } from '@mui/material/styles';
+import { useParams } from '@tanstack/react-router';
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
-import { useParams } from 'react-router-dom';
import { Link } from 'src/components/Link';
import {
@@ -32,7 +32,7 @@ interface Props {
export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => {
const theme = useTheme();
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const { data: accountRoles, isLoading: accountPermissionsLoading } =
useAccountRoles();
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx
index 6edbf1065d8..b842fffe47d 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx
@@ -1,10 +1,9 @@
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
-import { MemoryRouter } from 'react-router-dom';
import { accountRolesFactory } from 'src/factories/accountRoles';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { UnassignRoleConfirmationDialog } from './UnassignRoleConfirmationDialog';
@@ -28,15 +27,8 @@ const props = {
role: mockRole,
};
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom');
- return {
- ...actual,
- useParams: () => ({ username: 'test_user' }),
- };
-});
-
const queryMocks = vi.hoisted(() => ({
+ useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -50,6 +42,14 @@ vi.mock('src/queries/iam/iam', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
const mockDeleteUserRole = vi.fn();
vi.mock('@linode/api-v4', async () => {
return {
@@ -62,11 +62,15 @@ vi.mock('@linode/api-v4', async () => {
});
describe('UnassignRoleConfirmationDialog', () => {
- it('should render', () => {
- const { getAllByRole, getByText } = renderWithTheme(
-
- {' '}
-
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ });
+
+ it('should render', async () => {
+ const { getAllByRole, getByText } = await renderWithThemeAndRouter(
+
);
const headerText = getByText('Unassign the account_admin role?');
@@ -86,7 +90,7 @@ describe('UnassignRoleConfirmationDialog', () => {
});
it('calls the corresponding functions when buttons are clicked', async () => {
- const { getByText } = renderWithTheme(
+ const { getByText } = await renderWithThemeAndRouter(
);
@@ -117,7 +121,7 @@ describe('UnassignRoleConfirmationDialog', () => {
data: accountRolesFactory.build(),
});
- const { getByText } = renderWithTheme(
+ const { getByText } = await renderWithThemeAndRouter(
);
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx
index 70507555159..bb814ccb07b 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx
@@ -1,7 +1,7 @@
import { ActionsPanel, Notice, Typography } from '@linode/ui';
+import { useParams } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import React from 'react';
-import { useParams } from 'react-router-dom';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { useUserRoles, useUserRolesMutation } from 'src/queries/iam/iam';
@@ -19,7 +19,7 @@ interface Props {
export const UnassignRoleConfirmationDialog = (props: Props) => {
const { onClose: _onClose, onSuccess, open, role } = props;
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const { enqueueSnackbar } = useSnackbar();
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
index 285b4cd097c..1703103ecb0 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
@@ -4,7 +4,7 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { makeResourcePage } from 'src/mocks/serverHandlers';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { UpdateEntitiesDrawer } from './UpdateEntitiesDrawer';
@@ -12,6 +12,7 @@ import type { ExtendedRoleView } from '../types';
const queryMocks = vi.hoisted(() => ({
useAccountEntities: vi.fn().mockReturnValue({}),
+ useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -25,6 +26,14 @@ vi.mock('src/queries/iam/iam', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
const mockEntities = [
accountEntityFactory.build({
id: 1,
@@ -39,7 +48,7 @@ const mockEntities = [
];
vi.mock('src/queries/entities/entities', async () => {
- const actual = await vi.importActual('src/queries/entities/entities');
+ const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
useAccountEntities: queryMocks.useAccountEntities,
@@ -75,8 +84,14 @@ vi.mock('@linode/api-v4', async () => {
});
describe('UpdateEntitiesDrawer', () => {
- it('should render correctly', () => {
- renderWithTheme();
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ });
+
+ it('should render correctly', async () => {
+ await renderWithThemeAndRouter();
// Verify the title renders
expect(screen.getByText('Update List of Entities')).toBeVisible();
@@ -90,8 +105,8 @@ describe('UpdateEntitiesDrawer', () => {
expect(screen.getByText(mockRole.name)).toBeVisible();
});
- it('should prefill the form with assigned entities', () => {
- renderWithTheme();
+ it('should prefill the form with assigned entities', async () => {
+ await renderWithThemeAndRouter();
// Verify the prefilled entities
expect(screen.getByText('Linode 1')).toBeVisible();
@@ -114,7 +129,7 @@ describe('UpdateEntitiesDrawer', () => {
},
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const autocomplete = screen.getByRole('combobox');
@@ -160,7 +175,7 @@ describe('UpdateEntitiesDrawer', () => {
});
it('should close the drawer when cancel is clicked', async () => {
- renderWithTheme();
+ await renderWithThemeAndRouter();
// Click the cancel button
const cancelButton = screen.getByTestId('cancel');
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx
index 86afb2dca6d..af06047ce92 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx
@@ -1,8 +1,8 @@
import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui';
import { useTheme } from '@mui/material';
+import { useParams } from '@tanstack/react-router';
import React from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
-import { useParams } from 'react-router-dom';
import { useUserRoles, useUserRolesMutation } from 'src/queries/iam/iam';
@@ -22,7 +22,7 @@ interface Props {
export const UpdateEntitiesDrawer = ({ onClose, open, role }: Props) => {
const theme = useTheme();
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const { data: assignedRoles } = useUserRoles(username ?? '');
diff --git a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.test.tsx b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.test.tsx
index 8e51c7f4ad7..efecb9fa1b1 100644
--- a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.test.tsx
@@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import React from 'react';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import {
NO_ASSIGNED_ENTITIES_TEXT,
@@ -9,9 +9,25 @@ import {
} from '../constants';
import { NoAssignedRoles } from './NoAssignedRoles';
+const queryProps = vi.hoisted(() => ({
+ useParams: vi.fn(),
+}));
+
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryProps.useParams,
+ };
+});
+
describe('NoAssignedRoles', () => {
- it('renders with correct text for the Assigned Roles tab', () => {
- renderWithTheme(
+ beforeEach(() => {
+ queryProps.useParams.mockReturnValue({ username: 'testuser' });
+ });
+
+ it('renders with correct text for the Assigned Roles tab', async () => {
+ await renderWithThemeAndRouter(
{
).toBeVisible();
});
- it('renders with correct text for the Assigned Entities tab', () => {
- renderWithTheme(
+ it('renders with correct text for the Assigned Entities tab', async () => {
+ await renderWithThemeAndRouter(
{
- const actual = await vi.importActual('react-router-dom');
- return {
- ...actual,
- useParams: () => ({ username: 'test_user' }),
- };
-});
-
const queryMocks = vi.hoisted(() => ({
+ useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -48,6 +40,14 @@ vi.mock('src/queries/iam/iam', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
const mockDeleteUserRole = vi.fn();
vi.mock('@linode/api-v4', async () => {
return {
@@ -60,11 +60,15 @@ vi.mock('@linode/api-v4', async () => {
});
describe('RemoveAssignmentConfirmationDialog', () => {
- it('should render', () => {
- renderWithTheme(
-
- {' '}
-
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ });
+
+ it('should render', async () => {
+ await renderWithThemeAndRouter(
+
);
const headerText = screen.getByText(
@@ -88,7 +92,9 @@ describe('RemoveAssignmentConfirmationDialog', () => {
});
it('calls onClose when the cancel button is clicked', async () => {
- renderWithTheme();
+ await renderWithThemeAndRouter(
+
+ );
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeVisible();
@@ -115,7 +121,9 @@ describe('RemoveAssignmentConfirmationDialog', () => {
data: accountRolesFactory.build(),
});
- renderWithTheme();
+ await renderWithThemeAndRouter(
+
+ );
const removeButton = screen.getByText('Remove');
expect(removeButton).toBeVisible();
diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx
index a7f19726d92..35d4e5ff483 100644
--- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx
+++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx
@@ -1,7 +1,7 @@
import { ActionsPanel, Notice, Typography } from '@linode/ui';
+import { useParams } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import React from 'react';
-import { useParams } from 'react-router-dom';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { useUserRoles, useUserRolesMutation } from 'src/queries/iam/iam';
@@ -19,7 +19,7 @@ interface Props {
export const RemoveAssignmentConfirmationDialog = (props: Props) => {
const { onClose: _onClose, onSuccess, open, role } = props;
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const { enqueueSnackbar } = useSnackbar();
diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx
index 132b0ade077..90b8558908d 100644
--- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx
@@ -1,6 +1,7 @@
import { useProfile } from '@linode/queries';
import { Box, Button, Paper, Stack, Typography } from '@linode/ui';
import React, { useState } from 'react';
+// eslint-disable-next-line no-restricted-imports
import { useHistory } from 'react-router-dom';
import { PARENT_USER } from 'src/features/Account/constants';
diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx
index 321076653aa..44509a2403e 100644
--- a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx
@@ -1,7 +1,7 @@
import { useAccountUser } from '@linode/queries';
import { CircleProgress, ErrorState, NotFound, Stack } from '@linode/ui';
+import { useParams } from '@tanstack/react-router';
import React from 'react';
-import { useParams } from 'react-router-dom';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { useUserRoles } from 'src/queries/iam/iam';
@@ -12,7 +12,7 @@ import { UserEmailPanel } from './UserEmailPanel';
import { UsernamePanel } from './UsernamePanel';
export const UserProfile = () => {
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const { data: user, error, isLoading } = useAccountUser(username ?? '');
const { data: assignedRoles } = useUserRoles(username ?? '');
diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx
index db683423a19..d80e7fdec05 100644
--- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { accountUserFactory } from 'src/factories';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { UsernamePanel } from './UsernamePanel';
@@ -9,7 +9,9 @@ describe('UsernamePanel', () => {
it("initializes the form with the user's username", async () => {
const user = accountUserFactory.build();
- const { getByLabelText } = renderWithTheme();
+ const { getByLabelText } = await renderWithThemeAndRouter(
+
+ );
const usernameTextField = getByLabelText('Username');
@@ -22,7 +24,7 @@ describe('UsernamePanel', () => {
username: 'proxy-user-1',
});
- const { getByLabelText, getByText } = renderWithTheme(
+ const { getByLabelText, getByText } = await renderWithThemeAndRouter(
);
diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx
index 0eb6b544def..a1538b8aa09 100644
--- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx
@@ -1,9 +1,9 @@
import { useUpdateUserMutation } from '@linode/queries';
import { Button, Paper, TextField } from '@linode/ui';
+import { useNavigate } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
-import { useHistory } from 'react-router-dom';
import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants';
@@ -14,7 +14,7 @@ interface Props {
}
export const UsernamePanel = ({ user }: Props) => {
- const history = useHistory();
+ const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const isProxyUserProfile = user?.user_type === 'proxy';
@@ -36,7 +36,10 @@ export const UsernamePanel = ({ user }: Props) => {
const user = await mutateAsync(values);
// Because the username changed, we need to update the username in the URL
- history.replace(`/iam/users/${user.username}/details`);
+ navigate({
+ to: '/iam/users/$username/details',
+ params: { username: user.username },
+ });
enqueueSnackbar('Username updated successfully', { variant: 'success' });
} catch (error) {
diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx
index e0056c3797a..e81a8faad98 100644
--- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx
@@ -1,16 +1,12 @@
+import { useParams } from '@tanstack/react-router';
import React from 'react';
-import {
- matchPath,
- useHistory,
- useLocation,
- useParams,
-} from 'react-router-dom';
import { LandingHeader } from 'src/components/LandingHeader';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
-import { TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
+import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList';
+import { useTabs } from 'src/hooks/useTabs';
import { IAM_DOCS_LINK, IAM_LABEL } from '../Shared/constants';
@@ -33,38 +29,21 @@ const UserEntities = React.lazy(() =>
);
export const UserDetailsLanding = () => {
- const { username } = useParams<{ username: string }>();
- const location = useLocation();
- const history = useHistory();
-
- const tabs = [
+ const { username } = useParams({ from: '/iam/users/$username' });
+ const { tabs, tabIndex, handleTabChange } = useTabs([
{
- routeName: `/iam/users/${username}/details`,
+ to: `/iam/users/$username/details`,
title: 'User Details',
},
{
- routeName: `/iam/users/${username}/roles`,
+ to: `/iam/users/$username/roles`,
title: 'Assigned Roles',
},
{
- routeName: `/iam/users/${username}/entities`,
+ to: `/iam/users/$username/entities`,
title: 'Entity Access',
},
- ];
-
- const navToURL = (index: number) => {
- history.push(tabs[index].routeName);
- };
-
- const getDefaultTabIndex = () => {
- return (
- tabs.findIndex((tab) =>
- Boolean(matchPath(tab.routeName, { path: location.pathname }))
- ) || 0
- );
- };
-
- let idx = 0;
+ ]);
return (
<>
@@ -86,16 +65,16 @@ export const UserDetailsLanding = () => {
spacingBottom={4}
title={username}
/>
-
-
+
+
-
+
-
+
-
+
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
index 34faae1f10f..f56340b3b9f 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
@@ -5,17 +5,19 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { userRolesFactory } from 'src/factories/userRoles';
import { makeResourcePage } from 'src/mocks/serverHandlers';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable';
const queryMocks = vi.hoisted(() => ({
useAccountEntities: vi.fn().mockReturnValue({}),
+ useParams: vi.fn().mockReturnValue({}),
+ useSearch: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
vi.mock('src/queries/iam/iam', async () => {
- const actual = await vi.importActual('src/queries/iam/iam');
+ const actual = await vi.importActual('src/queries/iam/iam');
return {
...actual,
useUserRoles: queryMocks.useUserRoles,
@@ -23,13 +25,22 @@ vi.mock('src/queries/iam/iam', async () => {
});
vi.mock('src/queries/entities/entities', async () => {
- const actual = await vi.importActual('src/queries/entities/entities');
+ const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
useAccountEntities: queryMocks.useAccountEntities,
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ useSearch: queryMocks.useSearch,
+ };
+});
+
const mockEntities = [
accountEntityFactory.build({
id: 1,
@@ -39,12 +50,21 @@ const mockEntities = [
];
describe('AssignedEntitiesTable', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ queryMocks.useSearch.mockReturnValue({
+ query: '',
+ });
+ });
+
it('should display no roles text if there are no roles assigned to user', async () => {
queryMocks.useUserRoles.mockReturnValue({
data: {},
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByText('No items to display.')).toBeVisible();
});
@@ -58,7 +78,7 @@ describe('AssignedEntitiesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByText('no_devices')).toBeVisible();
expect(screen.getByText('Firewall')).toBeVisible();
@@ -83,7 +103,7 @@ describe('AssignedEntitiesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const searchInput = screen.getByPlaceholderText('Search');
await userEvent.type(searchInput, 'NonExistentRole');
@@ -102,7 +122,7 @@ describe('AssignedEntitiesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const searchInput = screen.getByPlaceholderText('Search');
await userEvent.type(searchInput, 'no_devices');
@@ -121,7 +141,7 @@ describe('AssignedEntitiesTable', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const autocomplete = screen.getByPlaceholderText('All Entities');
await userEvent.type(autocomplete, 'Firewalls');
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
index 94183b2dd14..b34cd1e610b 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
@@ -1,7 +1,7 @@
import { Select, Typography, useTheme } from '@linode/ui';
import Grid from '@mui/material/Grid';
+import { useParams, useSearch } from '@tanstack/react-router';
import React from 'react';
-import { useLocation, useParams } from 'react-router-dom';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
@@ -16,7 +16,7 @@ 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 { usePagination } from 'src/hooks/usePagination';
+import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { useAccountEntities } from 'src/queries/entities/entities';
import { useUserRoles } from 'src/queries/iam/iam';
@@ -36,10 +36,6 @@ import type { EntityType } from '@linode/api-v4';
import type { SelectOption } from '@linode/ui';
import type { Action } from 'src/components/ActionMenu/ActionMenu';
-interface LocationState {
- selectedRole?: string;
-}
-
const ALL_ENTITIES_OPTION: SelectOption = {
label: 'All Entities',
value: 'all',
@@ -48,17 +44,23 @@ const ALL_ENTITIES_OPTION: SelectOption = {
type OrderByKeys = 'entity_name' | 'entity_type' | 'role_name';
export const AssignedEntitiesTable = () => {
- const { username } = useParams<{ username: string }>();
- const location = useLocation();
-
+ const { username } = useParams({
+ from: '/iam/users/$username',
+ });
const theme = useTheme();
- const locationState = location.state as LocationState;
+ const { selectedRole: selectedRoleSearchParam } = useSearch({
+ strict: false,
+ });
const [order, setOrder] = React.useState<'asc' | 'desc'>('asc');
const [orderBy, setOrderBy] = React.useState('entity_name');
- const pagination = usePagination(1, ENTITIES_TABLE_PREFERENCE_KEY);
+ const pagination = usePaginationV2({
+ currentRoute: '/iam/users/$username/entities',
+ initialPage: 1,
+ preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY,
+ });
const handleOrderChange = (newOrderBy: OrderByKeys) => {
if (orderBy === newOrderBy) {
@@ -69,7 +71,7 @@ export const AssignedEntitiesTable = () => {
}
};
- const [query, setQuery] = React.useState(locationState?.selectedRole ?? '');
+ const [query, setQuery] = React.useState(selectedRoleSearchParam ?? '');
const [entityType, setEntityType] = React.useState(
ALL_ENTITIES_OPTION
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.test.tsx
index 2255769678b..70b09d80bd5 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.test.tsx
@@ -4,13 +4,14 @@ import React from 'react';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { ChangeRoleForEntityDrawer } from './ChangeRoleForEntityDrawer';
import type { EntitiesRole } from '../../Shared/types';
const queryMocks = vi.hoisted(() => ({
+ useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -24,6 +25,14 @@ vi.mock('src/queries/iam/iam', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
const mockRole: EntitiesRole = {
access: 'entity_access',
entity_type: 'linode',
@@ -52,8 +61,14 @@ vi.mock('@linode/api-v4', async () => {
});
describe('ChangeRoleForEntityDrawer', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test_user',
+ });
+ });
+
it('should render', async () => {
- renderWithTheme();
+ await renderWithThemeAndRouter();
// Verify title renders
expect(screen.getByText('Change Role')).toBeVisible();
@@ -80,7 +95,7 @@ describe('ChangeRoleForEntityDrawer', () => {
data: accountRolesFactory.build(),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
const autocomplete = screen.getByRole('combobox');
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx b/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx
index ca648ccaaf8..870c51c57bc 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx
@@ -6,9 +6,9 @@ import {
Typography,
} from '@linode/ui';
import { useTheme } from '@mui/material/styles';
+import { useParams } from '@tanstack/react-router';
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
-import { useParams } from 'react-router-dom';
import { Link } from 'src/components/Link';
import {
@@ -41,7 +41,9 @@ export const ChangeRoleForEntityDrawer = ({
role,
}: Props) => {
const theme = useTheme();
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({
+ from: '/iam/users/$username',
+ });
const { data: accountRoles, isLoading: accountPermissionsLoading } =
useAccountRoles();
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
index eb7f32dc6c0..3b2d257ac86 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
@@ -6,7 +6,7 @@ import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
import { makeResourcePage } from 'src/mocks/serverHandlers';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import {
ERROR_STATE_TEXT,
@@ -24,6 +24,8 @@ const mockEntities = [
const queryMocks = vi.hoisted(() => ({
useAccountEntities: vi.fn().mockReturnValue({}),
+ useParams: vi.fn().mockReturnValue({}),
+ useSearch: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -45,7 +47,25 @@ vi.mock('src/queries/entities/entities', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ useSearch: queryMocks.useSearch,
+ };
+});
+
describe('UserEntities', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test-user',
+ });
+ queryMocks.useSearch.mockReturnValue({
+ selectedRole: '',
+ });
+ });
+
it('should display no entities text if no entity roles are assigned to user', async () => {
queryMocks.useUserRoles.mockReturnValue({
data: userRolesFactory.build({
@@ -54,7 +74,7 @@ describe('UserEntities', () => {
}),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByText('This list is empty')).toBeVisible();
expect(screen.queryByText('Assign New Roles')).toBeNull();
@@ -69,7 +89,7 @@ describe('UserEntities', () => {
}),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.getByText('This list is empty')).toBeVisible();
@@ -91,7 +111,7 @@ describe('UserEntities', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ await renderWithThemeAndRouter();
expect(screen.queryByText('Assign New Roles')).toBeNull();
@@ -116,7 +136,7 @@ describe('UserEntities', () => {
status: 'error',
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible();
});
});
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx
index 929b7f99cd5..be524591194 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx
@@ -6,8 +6,8 @@ import {
Typography,
useTheme,
} from '@linode/ui';
+import { useParams } from '@tanstack/react-router';
import React from 'react';
-import { useParams } from 'react-router-dom';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { useUserRoles } from 'src/queries/iam/iam';
@@ -22,7 +22,7 @@ import { AssignedEntitiesTable } from './AssignedEntitiesTable';
export const UserEntities = () => {
const theme = useTheme();
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const {
data: assignedRoles,
isLoading,
diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx
index 981a31bb4fa..a89cae0212e 100644
--- a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx
+++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx
@@ -2,10 +2,10 @@ import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui';
import { useTheme } from '@mui/material';
import Grid from '@mui/material/Grid';
import { useQueryClient } from '@tanstack/react-query';
+import { useParams } from '@tanstack/react-router';
import { enqueueSnackbar } from 'notistack';
import React, { useEffect, useState } from 'react';
import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
-import { useParams } from 'react-router-dom';
import { Link } from 'src/components/Link';
import { LinkButton } from 'src/components/LinkButton';
@@ -33,7 +33,9 @@ interface Props {
export const AssignNewRoleDrawer = ({ onClose, open }: Props) => {
const theme = useTheme();
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({
+ from: '/iam/users/$username',
+ });
const queryClient = useQueryClient();
const { data: accountRoles } = useAccountRoles();
diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
index 2d9f868d288..0751e330372 100644
--- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
@@ -6,7 +6,7 @@ import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
import { makeResourcePage } from 'src/mocks/serverHandlers';
-import { renderWithTheme } from 'src/utilities/testHelpers';
+import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import {
ERROR_STATE_TEXT,
@@ -23,6 +23,8 @@ const mockEntities = [
const queryMocks = vi.hoisted(() => ({
useAccountEntities: vi.fn().mockReturnValue({}),
+ useParams: vi.fn().mockReturnValue({}),
+ useSearch: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
}));
@@ -44,7 +46,25 @@ vi.mock('src/queries/entities/entities', async () => {
};
});
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ useSearch: queryMocks.useSearch,
+ };
+});
+
describe('UserRoles', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({
+ username: 'test-user',
+ });
+ queryMocks.useSearch.mockReturnValue({
+ selectedRole: '',
+ });
+ });
+
it('should display no roles text if no roles are assigned to user', async () => {
queryMocks.useUserRoles.mockReturnValue({
data: userRolesFactory.build({
@@ -53,7 +73,7 @@ describe('UserRoles', () => {
}),
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(screen.getByText('This list is empty')).toBeVisible();
expect(screen.getByText(NO_ASSIGNED_ROLES_TEXT)).toBeVisible();
@@ -78,7 +98,7 @@ describe('UserRoles', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(
screen.getByText('View and manage roles assigned to the user.')
@@ -110,7 +130,7 @@ describe('UserRoles', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(screen.getByText('firewall_admin')).toBeVisible();
});
@@ -137,7 +157,7 @@ describe('UserRoles', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(screen.getByText('account_admin')).toBeVisible();
expect(screen.queryByText('firewall_admin')).not.toBeInTheDocument();
@@ -155,7 +175,7 @@ describe('UserRoles', () => {
data: makeResourcePage(mockEntities),
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(screen.getByText('account_linode_admin')).toBeVisible();
expect(screen.getAllByText('All Linodes')[0]).toBeVisible();
@@ -178,7 +198,7 @@ describe('UserRoles', () => {
status: 'error',
});
- renderWithTheme();
+ renderWithThemeAndRouter();
expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible();
});
});
diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx
index d4e25802def..145d7641ce0 100644
--- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx
+++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx
@@ -6,8 +6,8 @@ import {
Typography,
useTheme,
} from '@linode/ui';
+import { useParams } from '@tanstack/react-router';
import React from 'react';
-import { useParams } from 'react-router-dom';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { useUserRoles } from 'src/queries/iam/iam';
@@ -20,7 +20,7 @@ import {
import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles';
export const UserRoles = () => {
- const { username } = useParams<{ username: string }>();
+ const { username } = useParams({ from: '/iam/users/$username' });
const theme = useTheme();
const {
diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx
index 948da975db4..b2b8936620a 100644
--- a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx
@@ -5,7 +5,7 @@ import { accountUserFactory } from 'src/factories/accountUsers';
import { http, HttpResponse, server } from 'src/mocks/testServer';
import {
mockMatchMedia,
- renderWithTheme,
+ renderWithThemeAndRouter,
wrapWithTableBody,
} from 'src/utilities/testHelpers';
@@ -16,10 +16,10 @@ import { UserRow } from './UserRow';
beforeAll(() => mockMatchMedia());
describe('UserRow', () => {
- it('renders a username and email', () => {
+ it('renders a username and email', async () => {
const user = accountUserFactory.build();
- const { getByText } = renderWithTheme(
+ const { getByText } = await renderWithThemeAndRouter(
wrapWithTableBody()
);
@@ -45,7 +45,7 @@ describe('UserRow', () => {
})
);
- const { findByText, queryByText } = renderWithTheme(
+ const { findByText, queryByText } = await renderWithThemeAndRouter(
wrapWithTableBody()
);
@@ -57,10 +57,10 @@ describe('UserRow', () => {
expect(queryByText('2022-02-09T16:19:26')).not.toBeInTheDocument();
});
- it('renders "Never" if last_login is null', () => {
+ it('renders "Never" if last_login is null', async () => {
const user = accountUserFactory.build({ last_login: null });
- const { getByText } = renderWithTheme(
+ const { getByText } = await renderWithThemeAndRouter(
wrapWithTableBody()
);
@@ -81,7 +81,7 @@ describe('UserRow', () => {
},
});
- const { findByText } = renderWithTheme(
+ const { findByText } = await renderWithThemeAndRouter(
wrapWithTableBody()
);
@@ -104,7 +104,7 @@ describe('UserRow', () => {
},
});
- const { findByText, getByText } = renderWithTheme(
+ const { findByText, getByText } = await renderWithThemeAndRouter(
wrapWithTableBody()
);
diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
index c609872f099..0b89300b7dd 100644
--- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
+++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
@@ -3,15 +3,15 @@ import { getAPIFilterFromQuery } from '@linode/search';
import { Button, Paper, Stack, Typography } from '@linode/ui';
import { useMediaQuery } from '@mui/material';
import { useTheme } from '@mui/material/styles';
+import { useNavigate, useSearch } from '@tanstack/react-router';
import React from 'react';
-import { useHistory, useLocation } from 'react-router-dom';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
import { Table } from 'src/components/Table';
import { TableBody } from 'src/components/TableBody';
-import { useOrder } from 'src/hooks/useOrder';
-import { usePagination } from 'src/hooks/usePagination';
+import { useOrderV2 } from 'src/hooks/useOrderV2';
+import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation';
import { CreateUserDrawer } from './CreateUserDrawer';
@@ -22,24 +22,36 @@ import { UsersLandingTableHead } from './UsersLandingTableHead';
import type { Filter } from '@linode/api-v4';
export const UsersLanding = () => {
+ const navigate = useNavigate();
+ const { query } = useSearch({
+ from: '/iam',
+ });
const [isCreateDrawerOpen, setIsCreateDrawerOpen] =
React.useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [selectedUsername, setSelectedUsername] = React.useState('');
-
const { data: profile } = useProfile();
const theme = useTheme();
- const pagination = usePagination(1, 'account-users');
- const order = useOrder();
-
- const location = useLocation();
- const history = useHistory();
+ const pagination = usePaginationV2({
+ currentRoute: '/iam/users',
+ initialPage: 1,
+ preferenceKey: 'iam-account-users-pagination',
+ });
+ const order = useOrderV2({
+ initialRoute: {
+ defaultOrder: {
+ order: 'desc',
+ orderBy: 'username',
+ },
+ from: '/iam/users',
+ },
+ preferenceKey: 'iam-account-users-order',
+ });
const isProxyUser =
profile?.user_type === 'child' || profile?.user_type === 'proxy';
const queryParams = new URLSearchParams(location.search);
- const query = queryParams.get('query') ?? '';
const { error: searchError, filter } = getAPIFilterFromQuery(query, {
searchableFieldsWithoutOperator: ['username', 'email'],
@@ -81,7 +93,10 @@ export const UsersLanding = () => {
} else {
queryParams.delete('query');
}
- history.push({ search: queryParams.toString() });
+ navigate({
+ to: '/iam/users',
+ search: { query: value },
+ });
};
const handleDelete = (username: string) => {
@@ -132,7 +147,7 @@ export const UsersLanding = () => {
label="Filter"
onSearch={handleSearch}
placeholder="Filter"
- value={query}
+ value={query ?? ''}
/>
)}