diff --git a/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md b/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md new file mode 100644 index 00000000000..f1920ce7895 --- /dev/null +++ b/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Migrate `/volumes` to Tanstack router ([#11154](https://github.com/linode/manager/pull/11154)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 99870119ff7..3e3b3de5a66 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -89,10 +89,14 @@ module.exports = { { files: [ // for each new features added to the migration router, add its directory here - 'src/features/Betas/*', + 'src/features/Betas/**/*', + 'src/features/Volumes/**/*', ], rules: { 'no-restricted-imports': [ + // This needs to remain an error however trying to link to a feature that is not yet migrated will break the router + // For those cases react-router-dom history.push is still needed + // using `eslint-disable-next-line no-restricted-imports` can help bypass those imports 'error', { paths: [ diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 55197641f90..6de6f42f5ce 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -9,7 +9,10 @@ import { Controls, Stories, } from '@storybook/blocks'; -import { wrapWithTheme } from '../src/utilities/testHelpers'; +import { + wrapWithTheme, + wrapWithThemeAndRouter, +} from '../src/utilities/testHelpers'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; import { themes } from '@storybook/theming'; @@ -42,9 +45,13 @@ export const DocsContainer = ({ children, context }) => { const preview: Preview = { decorators: [ - (Story) => { + (Story, context) => { const isDark = useDarkMode(); - return wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); + return context.parameters.tanStackRouter + ? wrapWithThemeAndRouter(, { + theme: isDark ? 'dark' : 'light', + }) + : wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); }, ], loaders: [ diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index 3c645ace145..0ca5c8ce4e2 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -11,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { mockCreateVolume, + mockGetVolume, mockGetVolumes, mockDetachVolume, mockGetVolumeTypesError, @@ -85,6 +86,7 @@ describe('volumes', () => { mockGetVolumes([]).as('getVolumes'); mockCreateVolume(mockVolume).as('createVolume'); + mockGetVolume(mockVolume).as('getVolume'); mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes'); cy.visitWithLogin('/volumes', { @@ -114,7 +116,7 @@ describe('volumes', () => { mockGetVolumes([mockVolume]).as('getVolumes'); ui.button.findByTitle('Create Volume').should('be.visible').click(); - cy.wait(['@createVolume', '@getVolumes']); + cy.wait(['@createVolume', '@getVolume', '@getVolumes']); validateBasicVolume(mockVolume.label); ui.actionMenu @@ -193,6 +195,7 @@ describe('volumes', () => { mockDetachVolume(mockAttachedVolume.id).as('detachVolume'); mockGetVolumes([mockAttachedVolume]).as('getAttachedVolumes'); + mockGetVolume(mockAttachedVolume).as('getVolume'); cy.visitWithLogin('/volumes', { preferenceOverrides, localStorageOverrides, @@ -209,6 +212,8 @@ describe('volumes', () => { ui.actionMenuItem.findByTitle('Detach').click(); + cy.wait('@getVolume'); + ui.dialog .findByTitle(`Detach Volume ${mockAttachedVolume.label}?`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index f6d807e4c15..27f5e379b84 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -10,7 +10,11 @@ import { mockGetLinodeDisks, mockGetLinodeVolumes, } from 'support/intercepts/linodes'; -import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes'; +import { + mockMigrateVolumes, + mockGetVolumes, + mockGetVolume, +} from 'support/intercepts/volumes'; import { ui } from 'support/ui'; describe('volume upgrade/migration', () => { @@ -23,6 +27,7 @@ describe('volume upgrade/migration', () => { }); mockGetVolumes([volume]).as('getVolumes'); + mockGetVolume(volume).as('getVolume'); mockMigrateVolumes().as('migrateVolumes'); mockGetNotifications([migrationScheduledNotification]).as( 'getNotifications' @@ -53,7 +58,7 @@ describe('volume upgrade/migration', () => { .click(); }); - cy.wait(['@migrateVolumes', '@getNotifications']); + cy.wait(['@migrateVolumes', '@getVolume', '@getNotifications']); cy.findByText('UPGRADE PENDING').should('be.visible'); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index e0bfc2f2af3..82a2b3eb6d5 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,5 +1,6 @@ import { Box } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; @@ -130,7 +131,6 @@ const LinodesRoutes = React.lazy(() => default: module.LinodesRoutes, })) ); -const Volumes = React.lazy(() => import('src/features/Volumes')); const Domains = React.lazy(() => import('src/features/Domains').then((module) => ({ default: module.DomainsRoutes, @@ -207,6 +207,7 @@ export const MainContent = () => { const { classes, cx } = useStyles(); const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); + const queryClient = useQueryClient(); const globalErrors = useGlobalErrors(); @@ -336,8 +337,6 @@ export const MainContent = () => { path="/placement-groups" /> )} - - { */} diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 7d89f4fa297..a8e12fd54f3 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,3 +1,4 @@ +import { QueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -24,6 +25,7 @@ export const Router = () => { isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, + queryClient: new QueryClient(), }, }); diff --git a/packages/manager/src/components/TanstackLink.stories.tsx b/packages/manager/src/components/TanstackLink.stories.tsx new file mode 100644 index 00000000000..badc132c03f --- /dev/null +++ b/packages/manager/src/components/TanstackLink.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { TanstackLink } from './TanstackLinks'; + +import type { TanstackLinkComponentProps } from './TanstackLinks'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const AsButtonPrimary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsButtonSecondary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsLink: StoryObj = { + render: () => ( + + Home + + ), +}; + +const meta: Meta = { + parameters: { + tanStackRouter: true, + }, + title: 'Foundations/Link/Tanstack Link', +}; +export default meta; diff --git a/packages/manager/src/components/TanstackLinks.tsx b/packages/manager/src/components/TanstackLinks.tsx new file mode 100644 index 00000000000..bea4ddd332d --- /dev/null +++ b/packages/manager/src/components/TanstackLinks.tsx @@ -0,0 +1,83 @@ +import { Button } from '@linode/ui'; +import { omitProps } from '@linode/ui'; +import { LinkComponent } from '@tanstack/react-router'; +import { createLink } from '@tanstack/react-router'; +import * as React from 'react'; + +import { MenuItem } from 'src/components/MenuItem'; + +import type { ButtonProps, ButtonType } from '@linode/ui'; +import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; + +export interface TanstackLinkComponentProps + extends Omit { + linkType: 'link' | ButtonType; + tooltipAnalyticsEvent?: (() => void) | undefined; + tooltipText?: string; +} + +export interface TanStackLinkRoutingProps { + linkType: TanstackLinkComponentProps['linkType']; + params?: TanStackLinkProps['params']; + preload?: TanStackLinkProps['preload']; + search?: TanStackLinkProps['search']; + to: TanStackLinkProps['to']; +} + +const LinkComponent = React.forwardRef< + HTMLButtonElement, + TanstackLinkComponentProps +>((props, ref) => { + const _props = omitProps(props, ['linkType']); + + return