diff --git a/packages/manager/.changeset/pr-11474-tech-stories-1736547433123.md b/packages/manager/.changeset/pr-11474-tech-stories-1736547433123.md new file mode 100644 index 00000000000..713ea203d09 --- /dev/null +++ b/packages/manager/.changeset/pr-11474-tech-stories-1736547433123.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Refactor routing for Placement Groups to use Tanstack Router ([#11474](https://github.com/linode/manager/pull/11474)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index cc85bbb89d0..73ce5518bd7 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -91,6 +91,7 @@ module.exports = { // for each new features added to the migration router, add its directory here 'src/features/Betas/**/*', 'src/features/Domains/**/*', + 'src/features/PlacementGroups/**/*', 'src/features/Volumes/**/*', ], rules: { @@ -122,12 +123,6 @@ module.exports = { 'Please use routing utilities from @tanstack/react-router.', name: 'react-router-dom', }, - { - importNames: ['renderWithTheme'], - message: - 'Please use the wrapWithThemeAndRouter helper function for testing components being migrated to TanStack Router.', - name: 'src/utilities/testHelpers', - }, ], }, ], diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 4242ba01f9d..77d99e42a30 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -155,7 +155,7 @@ describe('resize linode', () => { }); }); - it.only('resizes a linode by decreasing size', () => { + it('resizes a linode by decreasing size', () => { // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index d065bf1140f..314d24969e0 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -5,6 +5,7 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockDeletePlacementGroup, + mockGetPlacementGroup, mockGetPlacementGroups, mockUnassignPlacementGroupLinodes, mockDeletePlacementGroupError, @@ -62,6 +63,7 @@ describe('Placement Group deletion', () => { }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); @@ -172,6 +174,7 @@ describe('Placement Group deletion', () => { mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( 'getPlacementGroups' ); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); cy.wait(['@getPlacementGroups', '@getLinodes']); @@ -296,6 +299,9 @@ describe('Placement Group deletion', () => { placementGroupAfterUnassignment, secondMockPlacementGroup, ]).as('getPlacementGroups'); + mockGetPlacementGroup(placementGroupAfterUnassignment).as( + 'getPlacementGroups' + ); cy.findByText(mockLinode.label) .should('be.visible') @@ -363,6 +369,7 @@ describe('Placement Group deletion', () => { }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); @@ -488,6 +495,7 @@ describe('Placement Group deletion', () => { mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( 'getPlacementGroups' ); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); cy.wait(['@getPlacementGroups', '@getLinodes']); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts index 0c52a148848..3f0bd28aa7d 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts @@ -378,6 +378,7 @@ describe('Placement Groups Linode assignment', () => { mockGetRegions(mockRegions); mockGetLinodes(mockLinodes); + mockGetLinodeDetails(mockLinodeUnassigned.id, mockLinodeUnassigned); mockGetPlacementGroups([mockPlacementGroup]); mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); diff --git a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts index b857c69e1ca..de6c903c887 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts @@ -4,6 +4,7 @@ import { randomLabel, randomNumber } from 'support/util/random'; import { + mockGetPlacementGroup, mockGetPlacementGroups, mockUpdatePlacementGroup, mockUpdatePlacementGroupError, @@ -48,6 +49,7 @@ describe('Placement Group update label flow', () => { }; mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); mockUpdatePlacementGroup( mockPlacementGroup.id, @@ -114,6 +116,7 @@ describe('Placement Group update label flow', () => { }; mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); mockUpdatePlacementGroupError( mockPlacementGroup.id, diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index dcdae2ec607..d576bc0b103 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -32,7 +32,6 @@ import { switchAccountSessionContext } from './context/switchAccountSessionConte import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsIAMEnabled } from './features/IAM/Shared/utilities'; -import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; import { useProfile } from './queries/profile/profile'; @@ -180,11 +179,6 @@ const AccountActivationLanding = React.lazy( const Firewalls = React.lazy(() => import('src/features/Firewalls')); const Databases = React.lazy(() => import('src/features/Databases')); const VPC = React.lazy(() => import('src/features/VPCs')); -const PlacementGroups = React.lazy(() => - import('src/features/PlacementGroups').then((module) => ({ - default: module.PlacementGroups, - })) -); const CloudPulse = React.lazy(() => import('src/features/CloudPulse/CloudPulseLanding').then((module) => ({ @@ -230,7 +224,6 @@ export const MainContent = () => { const username = profile?.username || ''; const { isDatabasesEnabled } = useIsDatabasesEnabled(); - const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { data: accountSettings } = useAccountSettings(); const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; @@ -328,12 +321,6 @@ export const MainContent = () => { }> - {isPlacementGroupsEnabled && ( - - )} { const { getByPlaceholderText, getByRole, getByText } = renderWithTheme( { expect(radioInputs[0]).toBeChecked(); }); - it('Placement Group Type select should have the correct options', async () => { + it('Placement Group Type select should have the correct options', () => { const { getByPlaceholderText, getByText } = renderWithTheme( ); @@ -107,9 +107,9 @@ describe('PlacementGroupsCreateDrawer', () => { expect( queryMocks.useCreatePlacementGroup().mutateAsync ).toHaveBeenCalledWith({ - placement_group_type: 'anti_affinity:local', - placement_group_policy: 'strict', label: 'my-label', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', region: 'us-east', }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index ccd9a0058ca..5d52c888a7d 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -11,6 +11,7 @@ import { createPlacementGroupSchema } from '@linode/validation'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +// eslint-disable-next-line no-restricted-imports import { useLocation } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx index e95dd1e6c83..73531f747c8 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx @@ -45,7 +45,7 @@ const props = { }; describe('PlacementGroupsDeleteModal', () => { - it('should render the right form elements', async () => { + it('should render the right form elements', () => { queryMocks.usePreferences.mockReturnValue({ data: preference, }); @@ -73,6 +73,7 @@ describe('PlacementGroupsDeleteModal', () => { region: 'us-east', })} disableUnassignButton={false} + isFetching={false} /> ); @@ -115,6 +116,7 @@ describe('PlacementGroupsDeleteModal', () => { placement_group_type: 'anti_affinity:local', })} disableUnassignButton={false} + isFetching={false} /> ); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index 31a2111eb91..3f5892ad665 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -1,15 +1,7 @@ -import { - Button, - CircleProgress, - List, - ListItem, - Notice, - Typography, -} from '@linode/ui'; +import { Button, List, ListItem, Notice, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { @@ -28,6 +20,7 @@ import type { ButtonProps } from '@linode/ui'; interface Props { disableUnassignButton: boolean; + isFetching: boolean; linodes: Linode[] | undefined; onClose: () => void; open: boolean; @@ -37,6 +30,7 @@ interface Props { export const PlacementGroupsDeleteModal = (props: Props) => { const { disableUnassignButton, + isFetching, linodes, onClose, open, @@ -105,28 +99,6 @@ export const PlacementGroupsDeleteModal = (props: Props) => { return null; } - if (!assignedLinodes) { - return ( - .MuiDialogContent-root > div': { - maxHeight: 300, - padding: 4, - }, - maxHeight: 500, - width: 500, - }, - }} - onClose={handleClose} - open={open} - title="Delete Placement Group" - > - - - ); - } - return ( { disableTypeToConfirmInput={isDisabled} disableTypeToConfirmSubmit={isDisabled} expand + isFetching={isFetching} label="Placement Group" loading={deletePlacementLoading} onClick={onDelete} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx index 30d11496904..450f318a971 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx @@ -5,15 +5,16 @@ import { placementGroupFactory, regionFactory, } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { PlacementGroupsDetail } from './PlacementGroupsDetail'; const queryMocks = vi.hoisted(() => ({ useAllLinodesQuery: vi.fn().mockReturnValue({}), - useParams: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({ id: 1 }), usePlacementGroupQuery: vi.fn().mockReturnValue({}), useRegionsQuery: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({ query: undefined }), })); vi.mock('src/queries/placementGroups', async () => { @@ -32,11 +33,12 @@ vi.mock('src/queries/linodes/linodes', async () => { }; }); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, }; }); @@ -48,19 +50,20 @@ vi.mock('src/queries/regions/regions', async () => { }; }); -describe('PlacementGroupsLanding', () => { - it('renders a error page', () => { - const { getByText } = renderWithTheme(); +describe('PlacementGroupsDetail', () => { + it('renders a error page', async () => { + const { getByText } = await renderWithThemeAndRouter( + + ); expect(getByText('Not Found')).toBeInTheDocument(); }); - it('renders a loading state', () => { + it('renders a loading state', async () => { queryMocks.usePlacementGroupQuery.mockReturnValue({ data: placementGroupFactory.build({ id: 1, }), - isLoading: true, }); queryMocks.useAllLinodesQuery.mockReturnValue({ @@ -80,31 +83,34 @@ describe('PlacementGroupsLanding', () => { ], }); - const { getByRole } = renderWithTheme(, { - MemoryRouter: { - initialEntries: [{ pathname: '/placement-groups/1' }], - }, - }); + const { getByRole } = await renderWithThemeAndRouter( + + ); expect(getByRole('progressbar')).toBeInTheDocument(); }); - it('renders breadcrumbs, docs link and tabs', () => { + it('renders breadcrumbs, docs link and tabs', async () => { queryMocks.usePlacementGroupQuery.mockReturnValue({ data: placementGroupFactory.build({ - placement_group_type: 'anti_affinity:local', id: 1, is_compliant: true, label: 'My first PG', + placement_group_type: 'anti_affinity:local', }), }); - - const { getByText } = renderWithTheme(, { - MemoryRouter: { - initialEntries: [{ pathname: '/placement-groups/1' }], - }, + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [], + isLoading: false, + page: 1, + pages: 1, + results: 0, }); + const { getByText } = await renderWithThemeAndRouter( + + ); + expect(getByText(/my first pg/i)).toBeInTheDocument(); expect(getByText(/docs/i)).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx index 67d1cb570d1..8c222d02f25 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx @@ -1,8 +1,7 @@ import { PLACEMENT_GROUP_TYPES } from '@linode/api-v4'; import { CircleProgress, Notice } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -10,7 +9,6 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useMutatePlacementGroup, usePlacementGroupQuery, @@ -23,22 +21,13 @@ import { PlacementGroupsLinodes } from './PlacementGroupsLinodes/PlacementGroups import { PlacementGroupsSummary } from './PlacementGroupsSummary/PlacementGroupsSummary'; export const PlacementGroupsDetail = () => { - const { id } = useParams<{ id: string }>(); - const placementGroupId = +id; + const { id: placementGroupId } = useParams({ from: '/placement-groups/$id' }); const { data: placementGroup, error: placementGroupError, isLoading, } = usePlacementGroupQuery(placementGroupId); - const { data: linodes, isFetching: isFetchingLinodes } = useAllLinodesQuery( - {}, - { - '+or': placementGroup?.members.map((member) => ({ - id: member.linode_id, - })), - } - ); const { data: regions } = useRegionsQuery(); const region = regions?.find( @@ -71,10 +60,6 @@ export const PlacementGroupsDetail = () => { ); } - const assignedLinodes = linodes?.filter((linode) => - placementGroup?.members.some((pgLinode) => pgLinode.linode_id === linode.id) - ); - const { label, placement_group_type } = placementGroup; const resetEditableLabel = () => { @@ -125,8 +110,6 @@ export const PlacementGroupsDetail = () => { )} { ); }; - -export const placementGroupsDetailLazyRoute = createLazyRoute( - '/placement-groups/$id' -)({ - component: PlacementGroupsDetail, -}); - -export const placementGroupsUnassignLazyRoute = createLazyRoute( - '/placement-groups/$id/linodes/unassign/$linodeId' -)({ - component: PlacementGroupsDetail, -}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx index 16e06911d3d..70f7d217cf6 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.test.tsx @@ -1,17 +1,27 @@ import * as React from 'react'; import { placementGroupFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { PLACEMENT_GROUP_LINODES_ERROR_MESSAGE } from '../../constants'; import { PlacementGroupsLinodes } from './PlacementGroupsLinodes'; +const queryMocks = vi.hoisted(() => ({ + useSearch: vi.fn().mockReturnValue({ query: undefined }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useSearch: queryMocks.useSearch, + }; +}); + describe('PlacementGroupsLinodes', () => { - it('renders an error state if placement groups are undefined', () => { - const { getByText } = renderWithTheme( + it('renders an error state if placement groups are undefined', async () => { + const { getByText } = await renderWithThemeAndRouter( { ).toBeInTheDocument(); }); - it('features the linodes table, a filter field, a create button and a docs link', () => { + it('features the linodes table, a filter field, a create button and a docs link', async () => { const placementGroup = placementGroupFactory.build({ members: [ { @@ -33,10 +43,8 @@ describe('PlacementGroupsLinodes', () => { ], }); - const { getByPlaceholderText, getByRole } = renderWithTheme( + const { getByPlaceholderText, getByRole } = await renderWithThemeAndRouter( { - const { - assignedLinodes, - isFetchingLinodes, - isLinodeReadOnly, - placementGroup, - region, - } = props; - const history = useHistory(); - const [searchText, setSearchText] = React.useState(''); - const [selectedLinode, setSelectedLinode] = React.useState< - Linode | undefined - >(); + const { isLinodeReadOnly, placementGroup, region } = props; + const navigate = useNavigate(); + const params = useParams({ strict: false }); + + const search: PlacementGroupLinodesSearchParams = useSearch({ + from: PLACEMENT_GROUPS_DETAILS_ROUTE, + }); + const { query } = search; + + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: PG_LANDING_TABLE_DEFAULT_ORDER, + orderBy: PG_LANDING_TABLE_DEFAULT_ORDER_BY, + }, + from: PLACEMENT_GROUPS_DETAILS_ROUTE, + }, + preferenceKey: `${PG_LINODES_TABLE_PREFERENCE_KEY}-order`, + }); + + const pagination = usePaginationV2({ + currentRoute: PLACEMENT_GROUPS_DETAILS_ROUTE, + preferenceKey: PG_LINODES_TABLE_PREFERENCE_KEY, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); + + const filter: Filter = { + ['+or']: placementGroup?.members.map((member) => ({ + id: member.linode_id, + })), + ['+order']: order, + ['+order_by']: orderBy, + ...(query && { label: { '+contains': query } }), + }; + + const { data: linodes, isFetching: isFetchingLinodes } = useAllLinodesQuery( + { + page: pagination.page, + page_size: pagination.pageSize, + }, + filter + ); + + const assignedLinodes = linodes?.filter((linode) => + placementGroup?.members.some((pgLinode) => pgLinode.linode_id === linode.id) + ); + + const { data: selectedLinode, isFetching: isFetchingLinode } = useDialogData({ + enabled: !!params.linodeId, + paramKey: 'linodeId', + queryHook: useLinodeQuery, + redirectToOnNotFound: '/placement-groups/$id', + }); if (!placementGroup) { return ; } - const getLinodesList = () => { - if (!assignedLinodes) { - return []; - } - - if (searchText) { - return assignedLinodes.filter((linode: Linode) => { - return linode.label.toLowerCase().includes(searchText.toLowerCase()); - }); - } - - return assignedLinodes; + const onSearch = (searchString: string) => { + navigate({ + params: { id: placementGroup.id }, + search: (prev) => ({ + ...prev, + page: undefined, + query: searchString || undefined, + }), + to: PLACEMENT_GROUPS_DETAILS_ROUTE, + }); }; const hasReachedCapacity = hasPlacementGroupReachedCapacity({ @@ -63,24 +118,32 @@ export const PlacementGroupsLinodes = (props: Props) => { }); const handleAssignLinodesDrawer = () => { - history.replace(`/placement-groups/${placementGroup.id}/linodes/assign`); + navigate({ + params: { action: 'assign', id: placementGroup.id }, + search: (prev) => prev, + to: '/placement-groups/$id/linodes/$action', + }); }; + const handleUnassignLinodeModal = (linode: Linode) => { - setSelectedLinode(linode); - history.replace( - `/placement-groups/${placementGroup.id}/linodes/unassign/${linode.id}` - ); + navigate({ + params: { + action: 'unassign', + id: placementGroup.id, + linodeId: linode.id, + }, + search: (prev) => prev, + to: '/placement-groups/$id/linodes/$action/$linodeId', + }); }; + const handleCloseDrawer = () => { - setSelectedLinode(undefined); - history.replace(`/placement-groups/${placementGroup.id}/linodes`); + navigate({ + params: { id: placementGroup.id }, + search: (prev) => prev, + to: PLACEMENT_GROUPS_DETAILS_ROUTE, + }); }; - const isAssignLinodesDrawerOpen = history.location.pathname.includes( - '/assign' - ); - const isUnassignLinodesDrawerOpen = history.location.pathname.includes( - '/unassign' - ); return ( @@ -91,9 +154,9 @@ export const PlacementGroupsLinodes = (props: Props) => { debounceTime={250} hideLabel label="Search Linodes" - onSearch={setSearchText} + onSearch={onSearch} placeholder="Search Linodes" - value={searchText} + value={query ?? ''} /> @@ -115,17 +178,27 @@ export const PlacementGroupsLinodes = (props: Props) => { + diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx index 952e47f2674..56a41adce9e 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx @@ -5,11 +5,30 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsLinodesTable } from './PlacementGroupsLinodesTable'; +import type { Order } from 'src/hooks/useOrderV2'; + +const queryMocks = vi.hoisted(() => ({ + useParams: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + const defaultProps = { error: [], handleUnassignLinodeModal: vi.fn(), isFetchingLinodes: false, linodes: linodeFactory.buildList(5), + orderByProps: { + handleOrderChange: vi.fn(), + order: 'asc' as Order, + orderBy: 'label', + }, }; describe('PlacementGroupsLinodesTable', () => { diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx index ca59ee861c0..c690ee23c0a 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.tsx @@ -1,8 +1,5 @@ import * as React from 'react'; -import OrderBy from 'src/components/OrderBy'; -import Paginate from 'src/components/Paginate'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -16,12 +13,18 @@ import { PLACEMENT_GROUP_LINODES_ERROR_MESSAGE } from '../../constants'; import { PlacementGroupsLinodesTableRow } from './PlacementGroupsLinodesTableRow'; import type { APIError, Linode } from '@linode/api-v4'; +import type { Order } from 'src/hooks/useOrderV2'; export interface Props { error?: APIError[]; handleUnassignLinodeModal: (linode: Linode) => void; isFetchingLinodes: boolean; linodes: Linode[]; + orderByProps: { + handleOrderChange: (newOrderBy: string, newOrder: Order) => void; + order: Order; + orderBy: string; + }; } export const PlacementGroupsLinodesTable = React.memo((props: Props) => { @@ -30,84 +33,55 @@ export const PlacementGroupsLinodesTable = React.memo((props: Props) => { handleUnassignLinodeModal, isFetchingLinodes, linodes, + orderByProps, } = props; + const { handleOrderChange, order, orderBy } = orderByProps; + const orderLinodeKey = 'label'; - const orderStatusKey = 'status'; const _error = error ? getAPIErrorOrDefault(error, PLACEMENT_GROUP_LINODES_ERROR_MESSAGE) : undefined; return ( - - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - - {({ - count, - data: paginatedAndOrderedLinodes, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - Linode - - - Status - - - - - - - {paginatedAndOrderedLinodes.map((linode) => ( - - ))} - - -
- - - )} -
- )} -
+ + + + + Linode + + + Status + + + + + + + {linodes.map((linode) => ( + + ))} + + +
); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx index c4d32c1caa9..befbd550601 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx @@ -6,6 +6,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsLinodesTableRow } from './PlacementGroupsLinodesTableRow'; +const queryMocks = vi.hoisted(() => ({ + useParams: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + const defaultProps = { handleUnassignLinodeModal: vi.fn(), linode: linodeFactory.build({ diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx index 5b53be099bc..6aafac59542 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx @@ -1,5 +1,5 @@ +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Link } from 'src/components/Link'; @@ -36,7 +36,9 @@ type MigrationType = 'inbound' | 'outbound' | null; export const PlacementGroupsLinodesTableRow = React.memo((props: Props) => { const { handleUnassignLinodeModal, linode } = props; const { label, status } = linode; - const { id: placementGroupId } = useParams<{ id: string }>(); + const { id: placementGroupId } = useParams({ + from: '/placement-groups/$id', // todo connie - check about $id/linode + }); const notificationContext = React.useContext(notificationCenterContext); const isLinodeMigrating = Boolean(linode.placement_group?.migrating_to); const { data: placementGroup } = usePlacementGroupQuery( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx index 73b8a19223e..27922913f5c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx @@ -10,7 +10,6 @@ describe('PlacementGroups Summary', () => { const { getByTestId, getByText } = renderWithTheme( { linode_id: 10, }, ], + placement_group_type: 'affinity:local', region: 'us-east', })} region={regionFactory.build({ diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx index b788bcd91d8..29eea22abf1 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx @@ -61,10 +61,8 @@ describe('PlacementGroupsDetailPanel', () => { queryMocks.useAllPlacementGroupsQuery.mockReturnValue({ data: [ placementGroupFactory.build({ - placement_group_type: 'affinity:local', id: 1, is_compliant: true, - placement_group_policy: 'strict', label: 'my-placement-group', members: [ { @@ -72,6 +70,8 @@ describe('PlacementGroupsDetailPanel', () => { linode_id: 1, }, ], + placement_group_policy: 'strict', + placement_group_type: 'affinity:local', region: 'us-west', }), ], diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx index 9c039a125e8..47b4ce0a0dc 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { placementGroupFactory, regionFactory } from 'src/factories'; @@ -11,17 +11,8 @@ const queryMocks = vi.hoisted(() => ({ mutateAsync: vi.fn().mockResolvedValue({}), reset: vi.fn(), }), - useParams: vi.fn().mockReturnValue({}), })); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useParams: queryMocks.useParams, - }; -}); - vi.mock('src/queries/placementGroups', async () => { const actual = await vi.importActual('src/queries/placementGroups'); return { @@ -30,19 +21,18 @@ vi.mock('src/queries/placementGroups', async () => { }; }); -describe('PlacementGroupsCreateDrawer', () => { +describe('PlacementGroupsEditDrawer', () => { it('should render, have the proper fields populated with PG values, and have uneditable fields disabled', async () => { - queryMocks.useParams.mockReturnValue({ id: '1' }); - const { getByLabelText, getByRole, getByText } = renderWithTheme( { const editButton = getByRole('button', { name: 'Edit' }); expect(editButton).toBeEnabled(); - fireEvent.click(editButton); + await userEvent.click(editButton); expect(queryMocks.useMutatePlacementGroup).toHaveBeenCalled(); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx index 728ae6759e9..91f8940ce3c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx @@ -2,22 +2,18 @@ import { PLACEMENT_GROUP_POLICIES, PLACEMENT_GROUP_TYPES, } from '@linode/api-v4'; -import { CircleProgress, Divider, Notice, Stack, TextField } from '@linode/ui'; +import { Divider, Notice, Stack, TextField } from '@linode/ui'; import { updatePlacementGroupSchema } from '@linode/validation'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; import { Drawer } from 'src/components/Drawer'; import { NotFound } from 'src/components/NotFound'; import { useFormValidateOnChange } from 'src/hooks/useFormValidateOnChange'; -import { - useMutatePlacementGroup, - usePlacementGroupQuery, -} from 'src/queries/placementGroups'; +import { useMutatePlacementGroup } from 'src/queries/placementGroups'; import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -30,27 +26,13 @@ export const PlacementGroupsEditDrawer = ( ) => { const { disableEditButton, + isFetching, onClose, onPlacementGroupEdit, open, region, - selectedPlacementGroup: placementGroupFromProps, + selectedPlacementGroup: placementGroup, } = props; - const { id } = useParams<{ id: string }>(); - const { - data: placementGroupFromParam, - isFetching, - status, - } = usePlacementGroupQuery( - Number(id), - open && placementGroupFromProps === undefined - ); - - const placementGroup = React.useMemo( - () => - open ? placementGroupFromProps ?? placementGroupFromParam : undefined, - [open, placementGroupFromProps, placementGroupFromParam] - ); const { error, mutateAsync } = useMutatePlacementGroup( placementGroup?.id ?? -1 @@ -124,6 +106,7 @@ export const PlacementGroupsEditDrawer = ( ? `Edit Placement Group ${placementGroup.label}` : 'Edit Placement Group' } + isFetching={isFetching} onClose={handleClose} open={open} > @@ -189,8 +172,6 @@ export const PlacementGroupsEditDrawer = ( - ) : isFetching ? ( - ) : status === 'error' ? ( ) : null} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx index 82b9ccc4316..61911455d85 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx @@ -1,15 +1,24 @@ import * as React from 'react'; import { placementGroupFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { PlacementGroupsLanding } from './PlacementGroupsLanding'; import { headers } from './PlacementGroupsLandingEmptyStateData'; const queryMocks = vi.hoisted(() => ({ + useParams: vi.fn().mockReturnValue({}), usePlacementGroupsQuery: vi.fn().mockReturnValue({}), })); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + vi.mock('src/queries/placementGroups', async () => { const actual = await vi.importActual('src/queries/placementGroups'); return { @@ -19,27 +28,37 @@ vi.mock('src/queries/placementGroups', async () => { }); describe('PlacementGroupsLanding', () => { - it('renders loading state', () => { + it('renders loading state', async () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ isLoading: true, }); - const { getByRole } = renderWithTheme(); + const { getByRole } = await renderWithThemeAndRouter( + , + { + initialRoute: '/placement-groups', + } + ); expect(getByRole('progressbar')).toBeInTheDocument(); }); - it('renders error state', () => { + it('renders error state', async () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ error: [{ reason: 'Not found' }], }); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + , + { + initialRoute: '/placement-groups', + } + ); expect(getByText(/not found/i)).toBeInTheDocument(); }); - it('renders docs link and create button', () => { + it('renders docs link and create button', async () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ data: { data: [ @@ -51,13 +70,18 @@ describe('PlacementGroupsLanding', () => { }, }); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + , + { + initialRoute: '/placement-groups', + } + ); expect(getByText(/create placement group/i)).toBeInTheDocument(); expect(getByText(/docs/i)).toBeInTheDocument(); }); - it('renders placement groups', () => { + it('renders placement groups', async () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ data: { data: [ @@ -72,13 +96,18 @@ describe('PlacementGroupsLanding', () => { }, }); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + , + { + initialRoute: '/placement-groups', + } + ); expect(getByText(/group 1/i)).toBeInTheDocument(); expect(getByText(/group 2/i)).toBeInTheDocument(); }); - it('should render placement group landing with empty state', () => { + it('should render placement group landing with empty state', async () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ data: { data: [], @@ -86,12 +115,17 @@ describe('PlacementGroupsLanding', () => { }, }); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + , + { + initialRoute: '/placement-groups', + } + ); expect(getByText(headers.description)).toBeInTheDocument(); }); - it('should render placement group Getting Started Guides on landing page with empty state', () => { + it('should render placement group Getting Started Guides on landing page with empty state', async () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ data: { data: [], @@ -99,7 +133,12 @@ describe('PlacementGroupsLanding', () => { }, }); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + , + { + initialRoute: '/placement-groups', + } + ); expect(getByText('Getting Started Guides')).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index f6c3a75f986..6246a6fdd9c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -1,9 +1,12 @@ import { CircleProgress } from '@linode/ui'; import { useMediaQuery, useTheme } from '@mui/material'; -import { createLazyRoute } from '@tanstack/react-router'; +import { + useLocation, + useNavigate, + useParams, + useSearch, +} from '@tanstack/react-router'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; -import { useHistory } from 'react-router-dom'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -18,15 +21,25 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useOrder } from 'src/hooks/useOrder'; -import { usePagination } from 'src/hooks/usePagination'; +import { useDialogData } from 'src/hooks/useDialogData'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { usePlacementGroupsQuery } from 'src/queries/placementGroups'; +import { + usePlacementGroupQuery, + usePlacementGroupsQuery, +} from 'src/queries/placementGroups'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { PLACEMENT_GROUPS_DOCS_LINK } from '../constants'; +import { + PG_LANDING_TABLE_DEFAULT_ORDER, + PG_LANDING_TABLE_DEFAULT_ORDER_BY, + PG_LANDING_TABLE_PREFERENCE_KEY, + PLACEMENT_GROUPS_DOCS_LINK, + PLACEMENT_GROUPS_LANDING_ROUTE, +} from '../constants'; import { PlacementGroupsCreateDrawer } from '../PlacementGroupsCreateDrawer'; import { PlacementGroupsDeleteModal } from '../PlacementGroupsDeleteModal'; import { PlacementGroupsEditDrawer } from '../PlacementGroupsEditDrawer'; @@ -35,23 +48,36 @@ import { PlacementGroupsLandingEmptyState } from './PlacementGroupsLandingEmptyS import { PlacementGroupsRow } from './PlacementGroupsRow'; import type { Filter, PlacementGroup } from '@linode/api-v4'; - -const preferenceKey = 'placement-groups'; +import type { PlacementGroupsSearchParams } from 'src/routes/placementGroups'; export const PlacementGroupsLanding = React.memo(() => { - const history = useHistory(); - const pagination = usePagination(1, preferenceKey); - const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const pagination = usePaginationV2({ + currentRoute: PLACEMENT_GROUPS_LANDING_ROUTE, + preferenceKey: PG_LANDING_TABLE_PREFERENCE_KEY, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); + const params = useParams({ strict: false }); + const search: PlacementGroupsSearchParams = useSearch({ + from: PLACEMENT_GROUPS_LANDING_ROUTE, + }); + const { query } = search; const theme = useTheme(); - const [query, setQuery] = React.useState(''); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); - const { handleOrderChange, order, orderBy } = useOrder( - { - order: 'asc', - orderBy: 'label', + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: PG_LANDING_TABLE_DEFAULT_ORDER, + orderBy: PG_LANDING_TABLE_DEFAULT_ORDER_BY, + }, + from: PLACEMENT_GROUPS_LANDING_ROUTE, }, - `${preferenceKey}-order` - ); + preferenceKey: `${PG_LANDING_TABLE_PREFERENCE_KEY}-order`, + }); const filter: Filter = { ['+order']: order, @@ -72,10 +98,6 @@ export const PlacementGroupsLanding = React.memo(() => { filter ); - const selectedPlacementGroup = placementGroups?.data.find( - (pg) => pg.id === Number(id) - ); - const allLinodeIDsAssigned = placementGroups?.data.reduce( (acc, placementGroup) => { return acc.concat( @@ -85,13 +107,23 @@ export const PlacementGroupsLanding = React.memo(() => { [] as number[] ); - const { data: linodes } = useAllLinodesQuery( + const { data: linodes, isFetching: isFetchingLinodes } = useAllLinodesQuery( {}, { '+or': allLinodeIDsAssigned?.map((linodeId) => ({ id: linodeId })), } ); + const { + data: selectedPlacementGroup, + isFetching: isFetchingPlacementGroup, + } = useDialogData({ + enabled: !!params.id, + paramKey: 'id', + queryHook: usePlacementGroupQuery, + redirectToOnNotFound: '/placement-groups', + }); + const { data: regions } = useRegionsQuery(); const getPlacementGroupRegion = ( placementGroup: PlacementGroup | undefined @@ -104,30 +136,47 @@ export const PlacementGroupsLanding = React.memo(() => { }); const handleCreatePlacementGroup = () => { - history.push('/placement-groups/create'); + navigate({ search: (prev) => prev, to: '/placement-groups/create' }); }; const handleEditPlacementGroup = (placementGroup: PlacementGroup) => { - history.push(`/placement-groups/edit/${placementGroup.id}`); + navigate({ + params: { action: 'edit', id: placementGroup.id }, + search: (prev) => prev, + to: '/placement-groups/$action/$id', + }); }; const handleDeletePlacementGroup = (placementGroup: PlacementGroup) => { - history.push(`/placement-groups/delete/${placementGroup.id}`); + navigate({ + params: { action: 'delete', id: placementGroup.id }, + search: (prev) => prev, + to: '/placement-groups/$action/$id', + }); }; const onClosePlacementGroupDrawer = () => { - history.push('/placement-groups'); + navigate({ search: (prev) => prev, to: PLACEMENT_GROUPS_LANDING_ROUTE }); }; const isPlacementGroupCreateDrawerOpen = location.pathname.endsWith('create'); - const isPlacementGroupDeleteModalOpen = location.pathname.includes('delete'); - const isPlacementGroupEditDrawerOpen = location.pathname.includes('edit'); + + const onSearch = (searchString: string) => { + navigate({ + search: (prev) => ({ + ...prev, + page: undefined, + query: searchString || undefined, + }), + to: PLACEMENT_GROUPS_LANDING_ROUTE, + }); + }; if (placementGroupsLoading) { return ; } - if (placementGroups?.results === 0 && query === '') { + if (placementGroups?.results === 0 && !query) { return ( <> { resourceType: 'Placement Groups', }), }} - breadcrumbProps={{ pathname: '/placement-groups' }} + breadcrumbProps={{ pathname: PLACEMENT_GROUPS_LANDING_ROUTE }} disabledCreateButton={isLinodeReadOnly} docsLink={PLACEMENT_GROUPS_DOCS_LINK} entity="Placement Group" @@ -177,10 +226,10 @@ export const PlacementGroupsLanding = React.memo(() => { hideLabel isSearching={isFetching} label="Search" - onSearch={setQuery} + onSearch={onSearch} placeholder="Search Placement Groups" sx={{ mb: 4 }} - value={query} + value={query ?? ''} /> @@ -266,24 +315,20 @@ export const PlacementGroupsLanding = React.memo(() => { /> ); }); - -export const placementGroupsLandingLazyRoute = createLazyRoute( - '/placement-groups' -)({ - component: PlacementGroupsLanding, -}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx index 54ba5906a06..7b5fc4f1a8b 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx @@ -22,7 +22,6 @@ const linode = linodeFactory.build({ }); const placementGroup = placementGroupFactory.build({ - placement_group_type: 'anti_affinity:local', id: 1, is_compliant: false, label: 'group 1', @@ -32,6 +31,7 @@ const placementGroup = placementGroupFactory.build({ linode_id: 1, }, ], + placement_group_type: 'anti_affinity:local', region: 'us-east', }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx index 1b165a911a7..14c5ab66a7c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx @@ -6,33 +6,22 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsUnassignModal } from './PlacementGroupsUnassignModal'; const queryMocks = vi.hoisted(() => ({ - useLinodeQuery: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), })); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, useParams: queryMocks.useParams, }; }); -vi.mock('src/queries/linodes/linodes', async () => { - const actual = await vi.importActual('src/queries/linodes/linodes'); - return { - ...actual, - useLinodeQuery: queryMocks.useLinodeQuery, - }; -}); - describe('PlacementGroupsUnassignModal', () => { it('should render and have the proper content and CTAs', () => { - queryMocks.useLinodeQuery.mockReturnValue({ - data: linodeFactory.build({ - id: 1, - label: 'test-linode', - }), + const linode = linodeFactory.build({ + id: 1, + label: 'test-linode', }); queryMocks.useParams.mockReturnValue({ id: '1', @@ -41,9 +30,10 @@ describe('PlacementGroupsUnassignModal', () => { const { getByLabelText, getByRole } = renderWithTheme( null} open - selectedLinode={undefined} + selectedLinode={linode} /> ); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx index e7c03e7bcd4..8cd16484355 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx @@ -1,13 +1,11 @@ -import { CircleProgress, Notice, Typography } from '@linode/ui'; +import { Notice, Typography } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { NotFound } from 'src/components/NotFound'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; -import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useUnassignLinodesFromPlacementGroup } from 'src/queries/placementGroups'; import type { @@ -16,41 +14,28 @@ import type { } from '@linode/api-v4'; interface Props { + isFetching: boolean; onClose: () => void; open: boolean; selectedLinode: Linode | undefined; } export const PlacementGroupsUnassignModal = (props: Props) => { - const { onClose, open, selectedLinode } = props; + const { isFetching, onClose, open, selectedLinode: linode } = props; const { enqueueSnackbar } = useSnackbar(); - const { id: placementGroupId, linodeId } = useParams<{ - id: string; - linodeId: string; - }>(); - - const [linode, setLinode] = React.useState( - selectedLinode - ); + const { id: placementGroupId, linodeId } = useParams({ + strict: false, + }); const { error, isPending, mutateAsync: unassignLinodes, - } = useUnassignLinodesFromPlacementGroup(+placementGroupId); - - const { data: linodeFromQuery, isFetching } = useLinodeQuery( - +linodeId, - open && selectedLinode === undefined + } = useUnassignLinodesFromPlacementGroup( + placementGroupId ? +placementGroupId : -1 ); - React.useEffect(() => { - if (open) { - setLinode(selectedLinode ?? linodeFromQuery); - } - }, [selectedLinode, linodeFromQuery, open]); - const payload: UnassignLinodesFromPlacementGroupPayload = { linodes: [linode?.id ?? -1], }; @@ -69,7 +54,7 @@ export const PlacementGroupsUnassignModal = (props: Props) => { const isLinodeReadOnly = useIsResourceRestricted({ grantLevel: 'read_write', grantType: 'linode', - id: +linodeId, + id: linodeId ? linodeId : -1, }); const actions = ( @@ -88,32 +73,11 @@ export const PlacementGroupsUnassignModal = (props: Props) => { /> ); - if (!linode) { - return ( - .MuiDialogContent-root > div': { - maxHeight: 300, - padding: 4, - }, - maxHeight: 500, - width: 500, - }, - }} - onClose={onClose} - open={open} - title="Delete Placement Group" - > - {isFetching ? : } - - ); - } - return ( - import('./PlacementGroupsLanding/PlacementGroupsLanding').then((module) => ({ - default: module.PlacementGroupsLanding, - })) -); - -const PlacementGroupsDetail = React.lazy(() => - import('./PlacementGroupsDetail/PlacementGroupsDetail').then((module) => ({ - default: module.PlacementGroupsDetail, - })) -); - -export const PlacementGroups = () => { - const { path } = useRouteMatch(); - - return ( - }> - - - - - - - - - - - - - - ); -}; diff --git a/packages/manager/src/features/PlacementGroups/types.ts b/packages/manager/src/features/PlacementGroups/types.ts index 15e78d12e3b..dc264c25074 100644 --- a/packages/manager/src/features/PlacementGroups/types.ts +++ b/packages/manager/src/features/PlacementGroups/types.ts @@ -1,4 +1,4 @@ -import { PlacementGroup, Region } from '@linode/api-v4'; +import type { PlacementGroup, Region } from '@linode/api-v4'; export interface PlacementGroupsDrawerPropsBase { onClose: () => void; @@ -15,6 +15,7 @@ export interface PlacementGroupsCreateDrawerProps { export interface PlacementGroupsEditDrawerProps { disableEditButton: boolean; + isFetching: boolean; onClose: PlacementGroupsDrawerPropsBase['onClose']; onPlacementGroupEdit?: (placementGroup: PlacementGroup) => void; open: PlacementGroupsDrawerPropsBase['open']; diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 9aa1261c7a6..10f2cecc5cf 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -87,6 +87,7 @@ declare module '@tanstack/react-router' { export const migrationRouteTree = migrationRootRoute.addChildren([ betaRouteTree, domainsRouteTree, + placementGroupsRouteTree, volumesRouteTree, ]); export type MigrationRouteTree = typeof migrationRouteTree; diff --git a/packages/manager/src/routes/placementGroups/PlacementGroupsRoute.tsx b/packages/manager/src/routes/placementGroups/PlacementGroupsRoute.tsx index cd6ab27ee14..588ded3f83a 100644 --- a/packages/manager/src/routes/placementGroups/PlacementGroupsRoute.tsx +++ b/packages/manager/src/routes/placementGroups/PlacementGroupsRoute.tsx @@ -2,15 +2,19 @@ import { Outlet } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { NotFound } from 'src/components/NotFound'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; export const PlacementGroupsRoute = () => { + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); + return ( }> - + {isPlacementGroupsEnabled ? : } ); }; diff --git a/packages/manager/src/routes/placementGroups/index.ts b/packages/manager/src/routes/placementGroups/index.ts index 9088af62b15..d15820870c7 100644 --- a/packages/manager/src/routes/placementGroups/index.ts +++ b/packages/manager/src/routes/placementGroups/index.ts @@ -1,8 +1,27 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { PlacementGroupsRoute } from './PlacementGroupsRoute'; +import type { TableSearchParams } from '../types'; + +export interface PlacementGroupsSearchParams extends TableSearchParams { + 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 const placementGroupsRoute = createRoute({ component: PlacementGroupsRoute, getParentRoute: () => rootRoute, @@ -12,43 +31,53 @@ export const placementGroupsRoute = createRoute({ const placementGroupsIndexRoute = createRoute({ getParentRoute: () => placementGroupsRoute, path: '/', + validateSearch: (search: PlacementGroupsSearchParams) => search, }).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding' - ).then((m) => m.placementGroupsLandingLazyRoute) + import('./placementGroupsLazyRoutes').then( + (m) => m.placementGroupsLandingLazyRoute + ) ); const placementGroupsCreateRoute = createRoute({ getParentRoute: () => placementGroupsRoute, path: 'create', }).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding' - ).then((m) => m.placementGroupsLandingLazyRoute) + import('./placementGroupsLazyRoutes').then( + (m) => m.placementGroupsLandingLazyRoute + ) ); -const placementGroupsEditRoute = createRoute({ - getParentRoute: () => placementGroupsRoute, - parseParams: (params) => ({ - id: Number(params.id), - }), - path: 'edit/$id', -}).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding' - ).then((m) => m.placementGroupsLandingLazyRoute) -); +type PlacementGroupActionRouteParams

= { + action: PlacementGroupAction; + id: P; +}; -const placementGroupsDeleteRoute = createRoute({ +const placementGroupActionRoute = createRoute({ + beforeLoad: async ({ params }) => { + if (!(params.action in placementGroupAction)) { + throw redirect({ + search: () => ({}), + to: '/placement-groups', + }); + } + }, getParentRoute: () => placementGroupsRoute, - parseParams: (params) => ({ - id: Number(params.id), - }), - path: 'delete/$id', + params: { + parse: ({ action, id }: PlacementGroupActionRouteParams) => ({ + action, + id: Number(id), + }), + stringify: ({ action, id }: PlacementGroupActionRouteParams) => ({ + action, + id: String(id), + }), + }, + path: '$action/$id', + validateSearch: (search: PlacementGroupsSearchParams) => search, }).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding' - ).then((m) => m.placementGroupsLandingLazyRoute) + import('./placementGroupsLazyRoutes').then( + (m) => m.placementGroupsLandingLazyRoute + ) ); const placementGroupsDetailRoute = createRoute({ @@ -58,49 +87,61 @@ const placementGroupsDetailRoute = createRoute({ }), path: '$id', }).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail' - ).then((m) => m.placementGroupsDetailLazyRoute) + import('./placementGroupsLazyRoutes').then( + (m) => m.placementGroupsDetailLazyRoute + ) ); -const placementGroupsDetailLinodesRoute = createRoute({ - getParentRoute: () => placementGroupsDetailRoute, - path: 'linodes', -}).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail' - ).then((m) => m.placementGroupsDetailLazyRoute) -); +type PlacementGroupLinodesActionRouteParams

= { + action: PlacementGroupLinodesAction; +}; -const placementGroupsAssignRoute = createRoute({ - getParentRoute: () => placementGroupsDetailLinodesRoute, - path: 'assign', +const placementGroupLinodesActionBaseRoute = createRoute({ + beforeLoad: async ({ params }) => { + if (!(params.action in placementGroupLinodeAction)) { + throw redirect({ + search: () => ({}), + to: `/placement-groups/${params.id}`, + }); + } + }, + getParentRoute: () => placementGroupsDetailRoute, + params: { + parse: ({ action }: PlacementGroupLinodesActionRouteParams) => ({ + action, + }), + stringify: ({ + action, + }: PlacementGroupLinodesActionRouteParams) => ({ + action, + }), + }, + path: 'linodes/$action', + validateSearch: (search: PlacementGroupsSearchParams) => search, }).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail' - ).then((m) => m.placementGroupsUnassignLazyRoute) + import('./placementGroupsLazyRoutes').then( + (m) => m.placementGroupsDetailLazyRoute + ) ); const placementGroupsUnassignRoute = createRoute({ - getParentRoute: () => placementGroupsDetailLinodesRoute, + getParentRoute: () => placementGroupLinodesActionBaseRoute, parseParams: (params) => ({ linodeId: Number(params.linodeId), }), - path: 'unassign/$linodeId', + path: '$linodeId', }).lazy(() => - import( - 'src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail' - ).then((m) => m.placementGroupsUnassignLazyRoute) + import('./placementGroupsLazyRoutes').then( + (m) => m.placementGroupsDetailLazyRoute + ) ); export const placementGroupsRouteTree = placementGroupsRoute.addChildren([ - placementGroupsIndexRoute, + placementGroupsIndexRoute.addChildren([placementGroupActionRoute]), placementGroupsCreateRoute, - placementGroupsEditRoute, - placementGroupsDeleteRoute, placementGroupsDetailRoute.addChildren([ - placementGroupsDetailLinodesRoute, - placementGroupsAssignRoute, - placementGroupsUnassignRoute, + placementGroupLinodesActionBaseRoute.addChildren([ + placementGroupsUnassignRoute, + ]), ]), ]); diff --git a/packages/manager/src/routes/placementGroups/placementGroupsLazyRoutes.ts b/packages/manager/src/routes/placementGroups/placementGroupsLazyRoutes.ts new file mode 100644 index 00000000000..a49a13a3d87 --- /dev/null +++ b/packages/manager/src/routes/placementGroups/placementGroupsLazyRoutes.ts @@ -0,0 +1,16 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { PlacementGroupsDetail } from 'src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail'; +import { PlacementGroupsLanding } from 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding'; + +export const placementGroupsDetailLazyRoute = createLazyRoute( + '/placement-groups/$id' +)({ + component: PlacementGroupsDetail, +}); + +export const placementGroupsLandingLazyRoute = createLazyRoute( + '/placement-groups' +)({ + component: PlacementGroupsLanding, +}); diff --git a/packages/manager/src/routes/routes.test.tsx b/packages/manager/src/routes/routes.test.tsx deleted file mode 100644 index b760499ec50..00000000000 --- a/packages/manager/src/routes/routes.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { RouterProvider } from '@tanstack/react-router'; -import { screen, waitFor } from '@testing-library/react'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { migrationRouter } from './index'; -import { getAllRoutePaths } from './utils/allPaths'; - -import type { useQuery } from '@tanstack/react-query'; -// TODO: Tanstack Router - replace AnyRouter once migration is complete. -import type { AnyRouter } from '@tanstack/react-router'; - -vi.mock('@tanstack/react-query', async () => { - const actual = await vi.importActual('@tanstack/react-query'); - return { - ...actual, - useQuery: vi - .fn() - .mockImplementation((...args: Parameters) => { - const actualResult = (actual.useQuery as typeof useQuery)(...args); - return { - ...actualResult, - isLoading: false, - }; - }), - }; -}); - -const allMigrationPaths = getAllRoutePaths(migrationRouter); - -describe('Migration Router', () => { - const renderWithRouter = (initialEntry: string) => { - migrationRouter.invalidate(); - migrationRouter.navigate({ replace: true, to: initialEntry }); - - return renderWithTheme( - , - { - flags: { - selfServeBetas: true, - }, - } - ); - }; - - /** - * This test is meant to incrementally test all routes being added to the migration router. - * It will hopefully catch any issues with routes not being added or set up correctly: - * - Route is not found in the router - * - Route is found in the router but the component is not rendered - * - Route is found in the router and the component is rendered but missing a heading (which should be a requirement for all routes) - */ - test.each(allMigrationPaths)('route: %s', async (path) => { - renderWithRouter(path); - - await waitFor( - async () => { - const migrationRouter = screen.getByTestId('migration-router'); - const h1 = screen.getByRole('heading', { level: 1 }); - expect(migrationRouter).toBeInTheDocument(); - expect(h1).toBeInTheDocument(); - expect(h1).not.toHaveTextContent('Not Found'); - }, - { - timeout: 5000, - } - ); - }); - - it('should render the NotFound component for broken routes', async () => { - renderWithRouter('/broken-route'); - - await waitFor( - async () => { - const migrationRouter = screen.getByTestId('migration-router'); - const h1 = screen.getByRole('heading', { level: 1 }); - expect(migrationRouter).toBeInTheDocument(); - expect(h1).toBeInTheDocument(); - expect(h1).toHaveTextContent('Not Found'); - }, - { - timeout: 5000, - } - ); - }); -}); diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 0ae3424c2bf..43918a2acbc 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -18,6 +18,7 @@ import { Provider } from 'react-redux'; import { MemoryRouter, Route } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; +import { BrowserRouter } from 'react-router-dom'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { queryClientFactory } from 'src/queries/base'; @@ -206,7 +207,9 @@ export const wrapWithThemeAndRouter = ( options={{ bootstrap: options.flags }} > - + + +