From cd2342aed1e157790268f39a441cbb1cbe47f099 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 7 Aug 2025 23:45:46 +0200 Subject: [PATCH 01/88] [M3-10372] - Routing: Full switch + cleanup + remove `react-router-dom` (#12602) * initial pass * initial pass * auth * auth progress * auth progress * typing progress * type fixes * tackling all the strays * tackling all the strays * remove react-router-dom entirely * unit tests * cleanup 1 * post rebase fix * moar cleanup * fix marketplace tab * cleanup comments * Added changeset: Routing: remove `react-router-dom` and fully switch to tanstack router * some e2e fixes * snackbar provider can contain links! * feedback @bnussman-akamai * feedback @coliu-akamai part 1 --------- Co-authored-by: Alban Bailly Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- .../pr-12602-tech-stories-1753879656857.md | 5 + .../e2e/core/linodes/resize-linode.spec.ts | 6 +- .../cypress/support/component/setup.tsx | 5 +- packages/manager/eslint.config.js | 92 +----- packages/manager/package.json | 4 - packages/manager/src/App.tsx | 41 +-- packages/manager/src/GoTo.tsx | 6 +- packages/manager/src/MainContent.tsx | 301 ------------------ .../src/OAuth/LoginAsCustomerCallback.tsx | 8 +- packages/manager/src/OAuth/OAuthCallback.tsx | 7 +- packages/manager/src/Root.tsx | 287 +++++++++++++---- packages/manager/src/Router.tsx | 10 +- .../manager/src/__data__/reactRouterProps.ts | 39 --- .../AbuseTicketBanner/AbuseTicketBanner.tsx | 2 +- .../AccessPanel/UserSSHKeyPanel.tsx | 11 +- .../AccountActivationLanding.tsx | 14 +- .../src/components/Breadcrumb/Crumbs.tsx | 6 +- .../LandingHeader/LandingHeader.stories.tsx | 2 +- .../LandingHeader/LandingHeader.test.tsx | 2 +- packages/manager/src/components/Link.tsx | 57 +++- packages/manager/src/components/OrderBy.tsx | 23 +- .../LinodePlatformMaintenanceBanner.test.tsx | 21 +- .../LinodePlatformMaintenanceBanner.tsx | 2 +- .../src/components/PrimaryNav/PrimaryLink.tsx | 12 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 78 ++--- .../src/components/PrimaryNav/utils.ts | 26 +- .../manager/src/components/Prompt/Prompt.tsx | 119 ------- .../components/StackScript/StackScript.tsx | 9 +- .../components/SupportLink/SupportLink.tsx | 19 +- .../src/components/Tabs/TabLinkList.test.tsx | 12 +- .../src/components/Tabs/TabList.test.tsx | 12 +- .../components/Tabs/TanStackTabLinkList.tsx | 2 +- packages/manager/src/components/Tag/Tag.tsx | 10 +- .../features/Account/CloseAccountDialog.tsx | 10 +- .../Maintenance/MaintenanceTableRow.tsx | 1 - .../features/Account/Quotas/QuotasTable.tsx | 8 +- .../SessionExpirationDialog.test.tsx | 30 +- .../SessionExpirationDialog.tsx | 7 +- .../SwitchAccountSessionDialog.test.tsx | 34 +- .../SwitchAccountSessionDialog.tsx | 7 +- .../CancelLanding/CancelLanding.test.tsx | 46 ++- .../features/CancelLanding/CancelLanding.tsx | 8 +- .../Alerts/AlertsDetail/AlertDetail.tsx | 9 +- .../AlertsListing/AlertTableRow.test.tsx | 23 +- .../AlertInformationActionTable.tsx | 1 - .../AlertReusableComponent.test.tsx | 22 +- .../ContextualView/AlertReusableComponent.tsx | 7 +- .../CreateAlert/CreateAlertDefinition.tsx | 8 +- .../Alerts/EditAlert/EditAlertDefinition.tsx | 8 +- .../Alerts/EditAlert/EditAlertLanding.tsx | 7 +- .../EditAlert/EditAlertResources.test.tsx | 15 +- .../Alerts/EditAlert/EditAlertResources.tsx | 8 +- .../DestinationCreate/DestinationCreate.tsx | 3 +- .../Streams/StreamCreate/StreamCreate.tsx | 3 +- .../legacy/DatabaseBackupsLegacy.tsx | 14 +- .../DatabaseResize/DatabaseResize.test.tsx | 12 +- .../DatabaseLanding/DatabaseLandingTable.tsx | 10 +- .../DomainRecords/DomainRecords.tsx | 1 - .../src/features/Domains/DomainTableRow.tsx | 4 +- .../features/Domains/DomainsLanding.test.tsx | 4 +- .../EntityTransfersCreate.tsx | 16 +- .../LinodeTransferTable.tsx | 8 +- .../entityTransfersCreateLazyRoute.ts | 9 + .../EntityTransfersLanding.tsx | 17 +- .../TransferControls.tsx | 8 +- .../ErrorBoundary/ErrorBoundaryFallback.tsx | 13 +- .../Devices/FirewallDeviceRow.tsx | 4 +- .../Rules/FirewallRulesLanding.tsx | 66 ++-- .../FirewallLanding/FirewallLanding.tsx | 24 +- .../Firewalls/FirewallLanding/FirewallRow.tsx | 4 +- .../CreditCardExpiredBanner.tsx | 6 +- .../GlobalNotifications/EmailBounce.tsx | 19 +- .../TaxCollectionBanner.tsx | 9 +- .../VerificationDetailsBanner.test.tsx | 34 +- .../VerificationDetailsBanner.tsx | 14 +- .../IAM/Users/UserDetails/DeleteUserPanel.tsx | 7 +- .../Images/ImagesCreate/ImageUpload.tsx | 85 ++--- .../ImagesLanding/RebuildImageDrawer.test.tsx | 23 +- .../ImagesLanding/RebuildImageDrawer.tsx | 15 +- .../ClusterList/ClusterActionMenu.test.tsx | 2 - .../ClusterList/KubernetesClusterRow.tsx | 2 +- .../LinodeCreate/Details/Details.test.tsx | 1 + .../StackScriptSelectionList.test.tsx | 3 + .../Linodes/LinodeCreate/index.test.tsx | 9 +- .../Linodes/LinodeEntityDetailBody.tsx | 9 +- .../LinodeAlerts/AlertsPanel.tsx | 62 ++-- .../LinodeFirewalls/LinodeFirewallsRow.tsx | 4 +- .../LinodeNetworking/LinodeIPAddresses.tsx | 15 +- .../LinodesLanding/LinodeRow/LinodeRow.tsx | 4 +- packages/manager/src/features/Lish/Lish.tsx | 14 +- packages/manager/src/features/Lish/index.tsx | 3 - .../src/features/Lish/lishLazyRoute.ts | 7 + .../LongviewLanding/LongviewLanding.test.tsx | 2 - .../src/features/Managed/SupportWidget.tsx | 15 +- .../NodeBalancerFirewallsRow.tsx | 4 +- .../NodeBalancerTableRow.tsx | 6 +- .../NodeBalancersLanding.tsx | 25 +- .../AccessKeyLanding/AccessKeyLanding.tsx | 8 +- .../BucketDetail/FolderTableRow.test.tsx | 32 +- .../BucketDetail/FolderTableRow.tsx | 7 +- .../PlacementGroupsCreateDrawer.test.tsx | 4 +- .../src/features/Profile/SSHKeys/SSHKeys.tsx | 8 +- .../StackScripts/StackScriptsDetail.tsx | 10 +- .../SupportTicketDetail.tsx | 4 +- .../SupportTickets/SupportTicketDialog.tsx | 43 +-- .../SupportTickets/ticketUtils.test.ts | 6 + .../TopMenu/CreateMenu/CreateMenu.test.tsx | 14 +- .../TopMenu/CreateMenu/CreateMenu.tsx | 27 +- .../TopMenu/CreateMenu/ProductFamilyGroup.tsx | 2 +- .../NotificationMenu/NotificationMenu.tsx | 6 +- .../features/TopMenu/SearchBar/SearchBar.tsx | 47 +-- .../TopMenu/SearchBar/SearchSuggestion.tsx | 10 +- .../Users/UserPermissionsEntitySection.tsx | 8 +- .../VPCs/VPCDetail/SubnetNodebalancerRow.tsx | 3 +- .../src/features/Volumes/VolumeTableRow.tsx | 13 +- .../components/PlansPanel/PlanContainer.tsx | 5 +- .../manager/src/hooks/useAdobeAnalytics.ts | 23 +- packages/manager/src/hooks/useCreateVPC.ts | 3 +- packages/manager/src/hooks/useOrder.test.tsx | 171 ---------- packages/manager/src/hooks/useOrder.ts | 94 ------ packages/manager/src/hooks/usePagination.ts | 79 ----- packages/manager/src/index.tsx | 65 +--- packages/manager/src/request.tsx | 16 +- packages/manager/src/routes/account/index.ts | 2 +- .../manager/src/routes/auth/AuthRoute.tsx | 12 + packages/manager/src/routes/auth/index.ts | 44 +++ packages/manager/src/routes/index.tsx | 60 +--- packages/manager/src/routes/linodes/index.ts | 8 + packages/manager/src/routes/root.ts | 6 - .../src/utilities/routing/isPathOneOf.test.ts | 23 -- .../src/utilities/routing/isPathOneOf.ts | 18 -- .../src/utilities/routing/matchPath.ts | 0 packages/manager/src/utilities/storage.ts | 6 + .../manager/src/utilities/testHelpers.tsx | 15 +- .../Accordion/Accordion.stories.tsx | 2 +- .../EditableText/EditableText.stories.tsx | 2 +- pnpm-lock.yaml | 97 +----- 137 files changed, 1106 insertions(+), 2025 deletions(-) create mode 100644 packages/manager/.changeset/pr-12602-tech-stories-1753879656857.md delete mode 100644 packages/manager/src/MainContent.tsx delete mode 100644 packages/manager/src/__data__/reactRouterProps.ts delete mode 100644 packages/manager/src/components/Prompt/Prompt.tsx create mode 100644 packages/manager/src/features/EntityTransfers/EntityTransfersCreate/entityTransfersCreateLazyRoute.ts delete mode 100644 packages/manager/src/features/Lish/index.tsx create mode 100644 packages/manager/src/features/Lish/lishLazyRoute.ts delete mode 100644 packages/manager/src/hooks/useOrder.test.tsx delete mode 100644 packages/manager/src/hooks/useOrder.ts delete mode 100644 packages/manager/src/hooks/usePagination.ts create mode 100644 packages/manager/src/routes/auth/AuthRoute.tsx create mode 100644 packages/manager/src/routes/auth/index.ts delete mode 100644 packages/manager/src/utilities/routing/isPathOneOf.test.ts delete mode 100644 packages/manager/src/utilities/routing/isPathOneOf.ts create mode 100644 packages/manager/src/utilities/routing/matchPath.ts diff --git a/packages/manager/.changeset/pr-12602-tech-stories-1753879656857.md b/packages/manager/.changeset/pr-12602-tech-stories-1753879656857.md new file mode 100644 index 00000000000..35a2354c55b --- /dev/null +++ b/packages/manager/.changeset/pr-12602-tech-stories-1753879656857.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Routing: remove `react-router-dom` and fully switch to tanstack router ([#12602](https://github.com/linode/manager/pull/12602)) 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 cf426d672d3..1f6c3bdfa67 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -21,7 +21,7 @@ describe('resize linode', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics?resize=true`); ui.dialog .findByTitle(`Resize Linode ${linode.label}`) @@ -63,7 +63,7 @@ describe('resize linode', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics?resize=true`); ui.dialog .findByTitle(`Resize Linode ${linode.label}`) @@ -168,7 +168,7 @@ describe('resize linode', () => { // Error flow when attempting to resize a linode to a smaller size without // resizing the disk to the requested size first. interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics?resize=true`); ui.dialog .findByTitle(`Resize Linode ${linode.label}`) diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx index 5b3fafa9d84..54a8c743334 100644 --- a/packages/manager/cypress/support/component/setup.tsx +++ b/packages/manager/cypress/support/component/setup.tsx @@ -29,7 +29,6 @@ import { LDProvider } from 'launchdarkly-react-client-sdk'; import { SnackbarProvider } from 'notistack'; import * as React from 'react'; import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { storeFactory } from 'src/store'; @@ -79,9 +78,7 @@ export const mountWithTheme = ( options={{ bootstrap: flags }} > - - - + diff --git a/packages/manager/eslint.config.js b/packages/manager/eslint.config.js index 0de8306be5e..52e6fbdb0a5 100644 --- a/packages/manager/eslint.config.js +++ b/packages/manager/eslint.config.js @@ -29,10 +29,10 @@ const restrictedImportPaths = [ 'Please use Typography component from @linode/ui instead of @mui/material', }, { - name: 'react-router-dom', + name: '@tanstack/react-router', importNames: ['Link'], message: - 'Please use the Link component from src/components/Link instead of react-router-dom', + 'Please use the Link component from src/components/Link instead of direct imports from @tanstack/react-router', }, ]; @@ -401,93 +401,7 @@ export const baseConfig = [ }, }, - // 14. Tanstack Router (temporary) - { - files: [ - // for each new features added to the migration router, add its directory here - 'src/features/Account/**/*', - 'src/features/Billing/**/*', - 'src/features/Betas/**/*', - 'src/features/CloudPulse/**/*', - 'src/features/Databases/**/*', - 'src/features/Domains/**/*', - 'src/features/DataStream/**/*', - 'src/features/Events/**/*', - 'src/features/Firewalls/**/*', - 'src/features/Help/**/*', - 'src/features/IAM/**/*', - 'src/features/Images/**/*', - 'src/features/Kubernetes/**/*', - 'src/features/Linodes/**/*', - 'src/features/Longview/**/*', - 'src/features/Managed/**/*', - 'src/features/NodeBalancers/**/*', - 'src/features/ObjectStorage/**/*', - 'src/features/PlacementGroups/**/*', - 'src/features/Profile/**/*', - 'src/features/Search/**/*', - 'src/features/TopMenu/SearchBar/**/*', - 'src/components/Tag/**/*', - 'src/features/StackScripts/**/*', - 'src/features/Support/**/*', - 'src/features/Users/**/*', - 'src/features/Volumes/**/*', - 'src/features/VPCs/**/*', - ], - 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: [ - { - importNames: [ - // intentionally not including in this list as this will be updated last globally - 'useNavigate', - 'useParams', - 'useLocation', - 'useHistory', - 'useRouteMatch', - 'matchPath', - 'MemoryRouter', - 'Route', - 'RouteProps', - 'Switch', - 'Redirect', - 'RouteComponentProps', - 'withRouter', - ], - message: - 'Please use routing utilities intended for @tanstack/react-router.', - name: 'react-router-dom', - }, - { - importNames: ['TabLinkList'], - message: - 'Please use the TanStackTabLinkList component for components being migrated to TanStack Router.', - name: 'src/components/Tabs/TabLinkList', - }, - { - importNames: ['OrderBy', 'default'], - message: - 'Please use useOrderV2 hook for components being migrated to TanStack Router.', - name: 'src/components/OrderBy', - }, - { - importNames: ['Prompt'], - message: - 'Please use the TanStack useBlocker hook for components/features being migrated to TanStack Router.', - name: 'src/components/Prompt/Prompt', - }, - ], - }, - ], - }, - }, - - // 15. Prettier (coming last as recommended) + // 14. Prettier (coming last as recommended) { files: ['**/*.{js,ts,tsx}'], plugins: { diff --git a/packages/manager/package.json b/packages/manager/package.json index c95de968d37..7f24f3d91af 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -77,8 +77,6 @@ "react-hook-form": "^7.51.0", "react-number-format": "^3.5.0", "react-redux": "~7.1.3", - "react-router-dom": "~5.3.4", - "react-router-hash-link": "^2.3.1", "react-vnc": "^3.0.7", "react-waypoint": "^10.3.0", "recharts": "^2.14.1", @@ -153,8 +151,6 @@ "@types/react-csv": "^1.1.3", "@types/react-dom": "^19.1.6", "@types/react-redux": "~7.1.7", - "@types/react-router-dom": "~5.3.3", - "@types/react-router-hash-link": "^1.2.1", "@types/redux-mock-store": "^1.0.1", "@types/throttle-debounce": "^1.0.0", "@types/zxcvbn": "^4.4.0", diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 4b50f49f28f..ace9e20e6e7 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -9,21 +9,27 @@ import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.cont import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; import { SplashScreen } from './components/SplashScreen'; -import { GoTo } from './GoTo'; -import { useAdobeAnalytics } from './hooks/useAdobeAnalytics'; import { useInitialRequests } from './hooks/useInitialRequests'; -import { useNewRelic } from './hooks/useNewRelic'; -import { usePendo } from './hooks/usePendo'; -import { useSessionExpiryToast } from './hooks/useSessionExpiryToast'; -import { MainContent } from './MainContent'; -import { useEventsPoller } from './queries/events/events'; -// import { Router } from './Router'; +import { Router } from './Router'; import { useSetupFeatureFlags } from './useSetupFeatureFlags'; export const App = withDocumentTitleProvider( withFeatureFlagProvider(() => { - const { isLoading } = useInitialRequests(); + // Skip all initialization if we're on any authentication callback - just let the router handle it + const isAuthCallback = + window.location.pathname === '/oauth/callback' || + window.location.pathname === '/admin/callback'; + + if (isAuthCallback) { + return ( + + + + + ); + } + const { isLoading } = useInitialRequests(); const { areFeatureFlagsLoading } = useSetupFeatureFlags(); if (isLoading || areFeatureFlagsLoading) { @@ -43,24 +49,9 @@ export const App = withDocumentTitleProvider( Opens an external site in a new window - - {/** - * Eventually we will have the here in place of - * - */} - - + ); }) ); - -const GlobalListeners = () => { - useEventsPoller(); - useAdobeAnalytics(); - usePendo(); - useNewRelic(); - useSessionExpiryToast(); - return null; -}; diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index 8314f43cd16..e2ae326c780 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,7 +1,7 @@ import { useAccountSettings, useGrants, useProfile } from '@linode/queries'; import { Dialog, Select } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; @@ -10,7 +10,7 @@ import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; import type { SelectOption } from '@linode/ui'; export const GoTo = React.memo(() => { - const routerHistory = useHistory(); + const navigate = useNavigate(); const { data: accountSettings } = useAccountSettings(); const { data: grants } = useGrants(); @@ -30,7 +30,7 @@ export const GoTo = React.memo(() => { }; const onSelect = (item: SelectOption) => { - routerHistory.push(item.value); + navigate({ to: item.value }); onClose(); }; diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx deleted file mode 100644 index 635f8815806..00000000000 --- a/packages/manager/src/MainContent.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { - useAccountSettings, - useMutatePreferences, - usePreferences, - useProfile, -} from '@linode/queries'; -import { Box } from '@linode/ui'; -import { useMediaQuery } from '@mui/material'; -import Grid from '@mui/material/Grid'; -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'; -import { makeStyles } from 'tss-react/mui'; - -import { MainContentBanner } from 'src/components/MainContentBanner'; -import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; -import { - SIDEBAR_COLLAPSED_WIDTH, - SIDEBAR_WIDTH, -} from 'src/components/PrimaryNav/constants'; -import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; -import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { useDialogContext } from 'src/context/useDialogContext'; -import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; -import { Footer } from 'src/features/Footer'; -import { GlobalNotifications } from 'src/features/GlobalNotifications/GlobalNotifications'; -import { - notificationCenterContext, - useNotificationContext, -} from 'src/features/NotificationCenter/NotificationCenterContext'; -import { TopMenu } from 'src/features/TopMenu/TopMenu'; - -import { useIsPageScrollable } from './components/PrimaryNav/utils'; -import { ENABLE_MAINTENANCE_MODE } from './constants'; -import { complianceUpdateContext } from './context/complianceUpdateContext'; -import { sessionExpirationContext } from './context/sessionExpirationContext'; -import { switchAccountSessionContext } from './context/switchAccountSessionContext'; -import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; -import { useGlobalErrors } from './hooks/useGlobalErrors'; -import { migrationRouter } from './routes'; - -import type { Theme } from '@mui/material/styles'; -import type { AnyRouter } from '@tanstack/react-router'; - -const useStyles = makeStyles()((theme: Theme) => ({ - activationWrapper: { - padding: theme.spacing(4), - [theme.breakpoints.up('xl')]: { - margin: '0 auto', - width: '50%', - }, - }, - appFrame: { - backgroundColor: theme.bg.app, - display: 'flex', - flexDirection: 'column', - minHeight: '100vh', - position: 'relative', - zIndex: 1, - }, - bgStyling: { - backgroundColor: theme.bg.main, - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - minHeight: '100vh', - }, - content: { - display: 'flex', - flex: 1, - flexDirection: 'column', - transition: 'margin-left .1s linear', - width: '100%', - }, - fullWidthContent: { - marginLeft: 0, - }, - grid: { - marginLeft: 0, - marginRight: 0, - [theme.breakpoints.up('lg')]: { - height: '100%', - }, - width: '100%', - }, - logo: { - '& > g': { - fill: theme.color.black, - }, - }, - switchWrapper: { - '& > .MuiGrid-container': { - maxWidth: theme.breakpoints.values.lg, - width: '100%', - }, - '&.mlMain': { - [theme.breakpoints.up('lg')]: { - maxWidth: '78.8%', - }, - }, - flex: 1, - maxWidth: '100%', - position: 'relative', - }, -})); - -export const MainContent = () => { - const contentRef = React.useRef(null); - const { classes, cx } = useStyles(); - const { data: isDesktopSidebarOpenPreference } = usePreferences( - (preferences) => preferences?.desktop_sidebar_open - ); - const { mutateAsync: updatePreferences } = useMutatePreferences(); - const queryClient = useQueryClient(); - - const globalErrors = useGlobalErrors(); - - const NotificationProvider = notificationCenterContext.Provider; - const contextValue = useNotificationContext(); - - const ComplianceUpdateProvider = complianceUpdateContext.Provider; - const complianceUpdateContextValue = useDialogContext(); - - const SwitchAccountSessionProvider = switchAccountSessionContext.Provider; - const switchAccountSessionContextValue = useDialogContext({ - isOpen: false, - }); - - const SessionExpirationProvider = sessionExpirationContext.Provider; - const sessionExpirationContextValue = useDialogContext({ - isOpen: false, - }); - - const [menuIsOpen, toggleMenu] = React.useState(false); - - const { data: profile } = useProfile(); - const username = profile?.username || ''; - - const { data: accountSettings } = useAccountSettings(); - const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; - - const isNarrowViewport = useMediaQuery((theme: Theme) => - theme.breakpoints.down(960) - ); - - const { isPageScrollable } = useIsPageScrollable(contentRef); - - migrationRouter.update({ - context: { - globalErrors, - queryClient, - }, - }); - - /** - * this is the case where the user has successfully completed signup - * but needs a manual review from Customer Support. In this case, - * the user is going to get 403 errors from almost every single endpoint. - * - * So in this case, we'll show something more user-friendly - */ - if (globalErrors.account_unactivated) { - return ( - <> - - - - ); - } - - // If the API is in maintenance mode, return a Maintenance screen - if (globalErrors.api_maintenance_mode || ENABLE_MAINTENANCE_MODE) { - return ; - } - - const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; - - const desktopMenuToggle = () => { - updatePreferences({ - desktop_sidebar_open: !isDesktopSidebarOpenPreference, - }); - }; - - return ( -
- - - - - - toggleMenu(true)} - username={username} - /> - - - toggleMenu(false)} - collapse={desktopMenuIsOpen || false} - desktopMenuToggle={desktopMenuToggle} - open={menuIsOpen} - /> - -
- - ({ - flex: 1, - margin: '0 auto', - maxWidth: `${theme.breakpoints.values.lg}px !important`, - pb: theme.spacingFunction(32), - pt: theme.spacingFunction(24), - px: { - md: theme.spacingFunction(16), - xs: 0, - }, - transition: theme.transitions.create('opacity'), - width: isNarrowViewport - ? '100%' - : `calc(100vw - ${ - desktopMenuIsOpen - ? SIDEBAR_COLLAPSED_WIDTH - : SIDEBAR_WIDTH - }px)`, - })} - > - - -
- - }> - - - - {/** We don't want to break any bookmarks. This can probably be removed eventually. */} - - {/** - * This is the catch all routes that allows TanStack Router to take over. - * When a route is not found here, it will be handled by the migration router, which in turns handles the NotFound component. - * It is currently set to the migration router in order to incrementally migrate the app to the new routing. - * This is a temporary solution until we are ready to fully migrate to TanStack Router. - */} - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
- ); -}; diff --git a/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx b/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx index 4b67b40ddc3..2ac2d33948e 100644 --- a/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx +++ b/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/react'; +import { useNavigate } from '@tanstack/react-router'; import React, { useEffect } from 'react'; -import type { RouteComponentProps } from 'react-router-dom'; import { SplashScreen } from 'src/components/SplashScreen'; import { @@ -18,14 +18,16 @@ import { * Admin will redirect to Cloud Manager with a URL like: * https://cloud.linode.com/admin/callback#access_token=fjhwehkfg&destination=dashboard&expires_in=900&token_type=Admin */ -export const LoginAsCustomerCallback = (props: RouteComponentProps) => { +export const LoginAsCustomerCallback = () => { + const navigate = useNavigate(); + const authenticate = async () => { try { const { returnTo } = await handleLoginAsCustomerCallback({ params: location.hash.substring(1), // substring is called to remove the leading "#" from the hash params }); - props.history.push(returnTo); + navigate({ to: returnTo }); } catch (error) { // eslint-disable-next-line no-console console.error(error); diff --git a/packages/manager/src/OAuth/OAuthCallback.tsx b/packages/manager/src/OAuth/OAuthCallback.tsx index 3775f135f12..454bea3f446 100644 --- a/packages/manager/src/OAuth/OAuthCallback.tsx +++ b/packages/manager/src/OAuth/OAuthCallback.tsx @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/react'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -import type { RouteComponentProps } from 'react-router-dom'; import { SplashScreen } from 'src/components/SplashScreen'; @@ -12,14 +12,15 @@ import { clearStorageAndRedirectToLogout, handleOAuthCallback } from './oauth'; * * We will handle taking the code, turning it into an access token, and start a Cloud Manager session. */ -export const OAuthCallback = (props: RouteComponentProps) => { +export const OAuthCallback = () => { + const navigate = useNavigate(); const authenticate = async () => { try { const { returnTo } = await handleOAuthCallback({ params: location.search, }); - props.history.push(returnTo); + navigate({ to: returnTo }); } catch (error) { // eslint-disable-next-line no-console console.error(error); diff --git a/packages/manager/src/Root.tsx b/packages/manager/src/Root.tsx index 054c4c55c3e..526f7536180 100644 --- a/packages/manager/src/Root.tsx +++ b/packages/manager/src/Root.tsx @@ -11,16 +11,23 @@ import { useProfile, } from '@linode/queries'; import { Box } from '@linode/ui'; +import { useMediaQuery } from '@mui/material'; import Grid from '@mui/material/Grid'; -import { Outlet } from '@tanstack/react-router'; -import React from 'react'; +import { Outlet, useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; -import Logo from 'src/assets/logo/akamai-logo.svg'; import { MainContentBanner } from 'src/components/MainContentBanner'; import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; +import { + SIDEBAR_COLLAPSED_WIDTH, + SIDEBAR_WIDTH, +} from 'src/components/PrimaryNav/constants'; import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; +import { Snackbar } from 'src/components/Snackbar/Snackbar'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useDialogContext } from 'src/context/useDialogContext'; +import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; import { Footer } from 'src/features/Footer'; import { GlobalNotifications } from 'src/features/GlobalNotifications/GlobalNotifications'; import { @@ -29,15 +36,89 @@ import { } from 'src/features/NotificationCenter/NotificationCenterContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; +import { useIsPageScrollable } from './components/PrimaryNav/utils'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; +import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; +import { GoTo } from './GoTo'; +import { useAdobeAnalytics } from './hooks/useAdobeAnalytics'; import { useGlobalErrors } from './hooks/useGlobalErrors'; -import { useStyles } from './Root.styles'; +import { useNewRelic } from './hooks/useNewRelic'; +import { usePendo } from './hooks/usePendo'; +import { useSessionExpiryToast } from './hooks/useSessionExpiryToast'; +import { useEventsPoller } from './queries/events/events'; + +import type { Theme } from '@mui/material/styles'; + +const useStyles = makeStyles()((theme: Theme) => ({ + activationWrapper: { + padding: theme.spacing(4), + [theme.breakpoints.up('xl')]: { + margin: '0 auto', + width: '50%', + }, + }, + appFrame: { + backgroundColor: theme.bg.app, + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + position: 'relative', + zIndex: 1, + }, + bgStyling: { + backgroundColor: theme.bg.main, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + minHeight: '100vh', + }, + content: { + display: 'flex', + flex: 1, + flexDirection: 'column', + transition: 'margin-left .1s linear', + width: '100%', + }, + fullWidthContent: { + marginLeft: 0, + }, + grid: { + marginLeft: 0, + marginRight: 0, + [theme.breakpoints.up('lg')]: { + height: '100%', + }, + width: '100%', + }, + logo: { + '& > g': { + fill: theme.color.black, + }, + }, + switchWrapper: { + '& > .MuiGrid-container': { + maxWidth: theme.breakpoints.values.lg, + width: '100%', + }, + '&.mlMain': { + [theme.breakpoints.up('lg')]: { + maxWidth: '78.8%', + }, + }, + flex: 1, + maxWidth: '100%', + position: 'relative', + }, +})); export const Root = () => { + const navigate = useNavigate(); + const contentRef = React.useRef(null); const { classes, cx } = useStyles(); + const { data: isDesktopSidebarOpenPreference } = usePreferences( (preferences) => preferences?.desktop_sidebar_open ); @@ -66,85 +147,151 @@ export const Root = () => { const { data: profile } = useProfile(); const username = profile?.username || ''; - const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; + const isNarrowViewport = useMediaQuery((theme: Theme) => + theme.breakpoints.down(960) + ); - const desktopMenuToggle = () => { - updatePreferences({ - desktop_sidebar_open: !isDesktopSidebarOpenPreference, - }); - }; + const { isPageScrollable } = useIsPageScrollable(contentRef); + /** + * this is the case where the user has successfully completed signup + * but needs a manual review from Customer Support. In this case, + * the user is going to get 403 errors from almost every single endpoint. + * + * So in this case, we'll show something more user-friendly + */ if (globalErrors.account_unactivated) { - return ( -
-
- - - - -
-
- ); + navigate({ to: '/account-activation' }); } + // If the API is in maintenance mode, return a Maintenance screen if (globalErrors.api_maintenance_mode || ENABLE_MAINTENANCE_MODE) { return ; } + const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; + + const desktopMenuToggle = () => { + updatePreferences({ + desktop_sidebar_open: !isDesktopSidebarOpenPreference, + }); + }; + return ( -
- - - - - - toggleMenu(true)} - username={username} - /> -
- toggleMenu(false)} - collapse={desktopMenuIsOpen || false} +
+ + + + + + + + toggleMenu(true)} + username={username} /> - ({ - maxWidth: `${theme.breakpoints.values.lg}px !important`, - padding: `${theme.spacing(3)} 0`, - paddingTop: 12, - [theme.breakpoints.between('md', 'xl')]: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - }, - [theme.breakpoints.down('md')]: { - paddingLeft: 0, - paddingRight: 0, - paddingTop: theme.spacing(2), - }, - transition: theme.transitions.create('opacity'), - })} - > - - - - }> - - - - + + + toggleMenu(false)} + collapse={desktopMenuIsOpen || false} + desktopMenuToggle={desktopMenuToggle} + open={menuIsOpen} + /> + +
+ + ({ + flex: 1, + margin: '0 auto', + maxWidth: `${theme.breakpoints.values.lg}px !important`, + pb: theme.spacingFunction(32), + pt: theme.spacingFunction(24), + px: { + md: theme.spacingFunction(16), + xs: 0, + }, + transition: theme.transitions.create('opacity'), + width: isNarrowViewport + ? '100%' + : `calc(100vw - ${ + desktopMenuIsOpen + ? SIDEBAR_COLLAPSED_WIDTH + : SIDEBAR_WIDTH + }px)`, + })} + > + + +
+ + }> + + + + +
+
+
+
+
+
-
- -
- - - + + + + + +
); }; + +const GlobalListeners = () => { + useEventsPoller(); + useAdobeAnalytics(); + usePendo(); + useNewRelic(); + useSessionExpiryToast(); + return null; +}; diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 68a764302a0..9add88e45a6 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,5 +1,5 @@ import { useAccountSettings } from '@linode/queries'; -import { QueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -12,11 +12,13 @@ import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { router } from './routes'; export const Router = () => { + const queryClient = useQueryClient(); + const globalErrors = useGlobalErrors(); + const { data: accountSettings } = useAccountSettings(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isACLPEnabled } = useIsACLPEnabled(); - const globalErrors = useGlobalErrors(); // Update the router's context router.update({ @@ -26,12 +28,12 @@ export const Router = () => { isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, - queryClient: new QueryClient(), + queryClient, }, }); return ( - + ); diff --git a/packages/manager/src/__data__/reactRouterProps.ts b/packages/manager/src/__data__/reactRouterProps.ts deleted file mode 100644 index d618a8e67e7..00000000000 --- a/packages/manager/src/__data__/reactRouterProps.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { RouteComponentProps } from 'react-router-dom'; - -type History = RouteComponentProps<{}>['history']; -type Location = History['location']; - -export const mockLocation: Location = { - hash: '', - pathname: '/', - search: '?query=search', - state: {}, -}; - -export const match: RouteComponentProps<{}>['match'] = { - isExact: false, - params: 'test', - path: 'localhost', - url: 'localhost', -}; - -export const history: History = { - action: 'POP', - block: vi.fn(), - createHref: vi.fn(), - go: vi.fn(), - goBack: vi.fn(), - goForward: vi.fn(), - length: 1, - listen: vi.fn(), - location: mockLocation, - push: vi.fn(), - replace: vi.fn(), -}; - -export const reactRouterProps: RouteComponentProps = { - history, - location: mockLocation, - match, - staticContext: undefined, -}; diff --git a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx index 98a8488c7c9..ce8985467d8 100644 --- a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx +++ b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx @@ -1,9 +1,9 @@ import { useNotificationsQuery } from '@linode/queries'; import { Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { useLocation } from '@tanstack/react-router'; import { DateTime } from 'luxon'; import * as React from 'react'; -import { useLocation } from 'react-router-dom'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index 679d7bb530f..b961a9511a6 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -2,6 +2,7 @@ import { useAccountUsers, useProfile, useSSHKeysQuery } from '@linode/queries'; import { Box, Button, Checkbox, Typography } from '@linode/ui'; import { truncateAndJoinList } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; +import { useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -13,7 +14,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { CreateSSHKeyDrawer } from 'src/features/Profile/SSHKeys/CreateSSHKeyDrawer'; -import { usePagination } from 'src/hooks/usePagination'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { Avatar } from '../Avatar/Avatar'; import { PaginationFooter } from '../PaginationFooter/PaginationFooter'; @@ -21,6 +22,7 @@ import { TableRowLoading } from '../TableRowLoading/TableRowLoading'; import type { TypographyProps } from '@linode/ui'; import type { Theme } from '@mui/material/styles'; +import type { LinkProps } from '@tanstack/react-router'; const MAX_SSH_KEYS_DISPLAY = 25; @@ -55,6 +57,7 @@ interface Props { export const UserSSHKeyPanel = (props: Props) => { const { classes } = useStyles(); + const location = useLocation(); const theme = useTheme(); const { authorizedUsers, disabled, headingVariant, setAuthorizedUsers } = props; @@ -62,7 +65,11 @@ export const UserSSHKeyPanel = (props: Props) => { const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false); - const pagination = usePagination(1); + const pagination = usePaginationV2({ + currentRoute: location.pathname as LinkProps['to'], + initialPage: 1, + preferenceKey: 'ssh-keys-users-table', + }); const { data: profile } = useProfile(); diff --git a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx index 2e40219d44f..1f8f477c99b 100644 --- a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx +++ b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx @@ -1,7 +1,7 @@ import { Box, ErrorState, StyledLinkButton, Typography } from '@linode/ui'; import Warning from '@mui/icons-material/CheckCircle'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import Logo from 'src/assets/logo/akamai-logo.svg'; import { SupportTicketDialog } from 'src/features/Support/SupportTickets/SupportTicketDialog'; @@ -9,7 +9,7 @@ import { SupportTicketDialog } from 'src/features/Support/SupportTickets/Support import type { AttachmentError } from 'src/features/Support/SupportTicketDetail/SupportTicketDetail'; export const AccountActivationLanding = () => { - const history = useHistory(); + const navigate = useNavigate(); const [supportDrawerIsOpen, toggleSupportDrawer] = React.useState(false); @@ -18,9 +18,13 @@ export const AccountActivationLanding = () => { ticketID: number, attachmentErrors?: AttachmentError[] ) => { - history.push({ - pathname: `/support/tickets/${ticketID}`, - state: { attachmentErrors }, + navigate({ + to: '/support/tickets/$ticketId', + params: { ticketId: ticketID }, + state: (prev) => ({ + ...prev, + attachmentErrors, + }), }); toggleSupportDrawer(false); diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.tsx index a1c444b45ed..1b7261b7055 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { Link } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; + +import { Link } from 'src/components/Link'; import { StyledDiv, @@ -12,6 +11,7 @@ import { FinalCrumb } from './FinalCrumb'; import { FinalCrumbPrefix } from './FinalCrumbPrefix'; import type { EditableProps, LabelProps } from './types'; +import type { LinkProps } from '@tanstack/react-router'; export interface CrumbOverridesProps { label?: React.ReactNode | string; diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx index 2656371b9cb..133c431127e 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx @@ -69,7 +69,7 @@ export const withBreadcrumbOverrides: Story = { crumbOverrides: [ { label: 'My First Crumb', - linkTo: '/someRoute', + linkTo: '/linodes', noCap: true, position: 1, }, diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx index be4582f43eb..b557f996837 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx @@ -113,7 +113,7 @@ describe('LandingHeader', () => { crumbOverrides: [ { label: 'My First Crumb', - linkTo: '/someRoute', + linkTo: '/linodes', noCap: true, position: 1, }, diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index d1017373757..80468671141 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -5,15 +5,16 @@ import { flattenChildrenIntoAriaLabel, opensInNewTab, } from '@linode/utilities'; // `link.ts` utils from @linode/utilities -import * as React from 'react'; // eslint-disable-next-line no-restricted-imports -import { Link as RouterLink } from 'react-router-dom'; -import type { LinkProps as _LinkProps } from 'react-router-dom'; +import { Link as RouterLink } from '@tanstack/react-router'; +import * as React from 'react'; import ExternalLinkIcon from 'src/assets/icons/external-link.svg'; import { useStyles } from 'src/components/Link.styles'; -import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; +import type { LinkProps as _LinkProps } from '@tanstack/react-router'; + +type To = _LinkProps['to'] | (string & {}); export interface LinkProps extends Omit<_LinkProps, 'to'> { /** @@ -26,6 +27,10 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> { * @default false */ bypassSanitization?: boolean; + /** + * Optional prop to pass a className to the link. + */ + className?: string; /** * Optional prop to render the link as an external link, which features an external link icon, opens in a new tab
* and provides by default "noopener noreferrer" attributes to prevent security vulnerabilities. @@ -45,14 +50,21 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> { */ hideIcon?: boolean; /** - * The Link's destination. - * We are overwriting react-router-dom's `to` type because they allow objects, functions, and strings. - * We want to keep our `to` prop simple so that we can easily read and sanitize it. - * - * @example "/profile/display" - * @example "https://linode.com" + * Optional prop to pass a onClick handler to the link. + */ + onClick?: (e: React.MouseEvent) => void; + /** + * Optional prop to pass a sx style to the link. + */ + style?: React.CSSProperties; + /** + * Optional prop to pass a title to the link. + */ + title?: string; + /** + * The destination URL. Can be a relative path for internal navigation or an absolute URL for external links. */ - to: Exclude<(string & {}) | TanStackLinkProps['to'], null | undefined>; + to?: To; } /** @@ -87,18 +99,27 @@ export const Link = React.forwardRef( forceCopyColor, hideIcon, onClick, + style, + title, to, } = props; const { classes, cx } = useStyles(); - const processedUrl = () => (bypassSanitization ? to : sanitizeUrl(to)); + const processedUrl = () => { + if (!to) return ''; + return bypassSanitization ? to : sanitizeUrl(to); + }; const shouldOpenInNewTab = opensInNewTab(processedUrl()); - const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(children); + const resolvedChildren = + typeof children === 'function' + ? children({ isActive: false, isTransitioning: false }) + : children; + const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(resolvedChildren); const externalNotice = '- link opens in a new tab'; const ariaLabel = accessibleAriaLabel ? `${accessibleAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}` : `${childrenAsAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}`; - if (childrenContainsNoText(children) && !accessibleAriaLabel) { + if (childrenContainsNoText(resolvedChildren) && !accessibleAriaLabel) { // eslint-disable-next-line no-console console.error( 'Link component must have text content to be accessible to screen readers. Please provide an accessibleAriaLabel prop or text content.' @@ -127,9 +148,11 @@ export const Link = React.forwardRef( onClick={onClick} ref={ref} rel="noopener noreferrer" + style={style} target="_blank" + title={title} > - {children} + {resolvedChildren} {external && !hideIcon && ( ( className )} ref={ref} - to={to as string} + style={style} + title={title} + {...(to && !shouldOpenInNewTab ? { to: to as _LinkProps['to'] } : {})} /> ); } diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index f0b786cedcd..1af3c5cd8a0 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -1,6 +1,5 @@ import { useMutatePreferences, usePreferences } from '@linode/queries'; import { - getQueryParamsFromQueryString, pathOr, sortByArrayLength, sortByNumber, @@ -8,10 +7,10 @@ import { splitAt, usePrevious, } from '@linode/utilities'; +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; import { DateTime } from 'luxon'; import { equals, sort } from 'ramda'; import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { sortByUTFDate } from 'src/utilities/sortByUTFDate'; @@ -159,13 +158,18 @@ export const OrderBy = (props: CombinedProps) => { ); const { mutateAsync: updatePreferences } = useMutatePreferences(); const location = useLocation(); - const history = useHistory(); - const params = getQueryParamsFromQueryString(location.search); + const navigate = useNavigate(); + const search = useSearch({ + strict: false, + }); + const onlySearch = Object.fromEntries( + Object.entries(search).filter(([key]) => key.startsWith('order')) + ) as Record; const initialValues = getInitialValuesFromUserPreferences( props.preferenceKey ?? '', sortPreferences ?? {}, - params, + onlySearch, props.orderBy, props.order ); @@ -223,7 +227,14 @@ export const OrderBy = (props: CombinedProps) => { setOrder(newOrder); // Update the URL query params so that the current sort is bookmark-able - history.replace({ search: `?order=${newOrder}&orderBy=${newOrderBy}` }); + navigate({ + to: location.pathname, + search: (prev: Record) => ({ + ...prev, + order: newOrder, + orderBy: newOrderBy, + }), + }); debouncedUpdateUserPreferences(newOrderBy, newOrder); }; diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx index cd0be38fa20..5e2c5c58f8c 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx @@ -10,7 +10,6 @@ const queryMocks = vi.hoisted(() => ({ useNotificationsQuery: vi.fn().mockReturnValue({}), useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}), useLinodeQuery: vi.fn().mockReturnValue({}), - useLocation: vi.fn(), })); vi.mock('@linode/queries', async () => { @@ -21,17 +20,8 @@ vi.mock('@linode/queries', async () => { }; }); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useLocation: queryMocks.useLocation, - }; -}); - beforeEach(() => { vi.stubEnv('TZ', 'UTC'); - queryMocks.useLocation.mockReturnValue({ pathname: '/linodes' }); }); describe('LinodePlatformMaintenanceBanner', () => { @@ -161,9 +151,6 @@ describe('LinodePlatformMaintenanceBanner', () => { }), }); - // Mock location to be on a different page - queryMocks.useLocation.mockReturnValue({ pathname: '/linodes' }); - const { getByRole } = renderWithTheme( ); @@ -200,11 +187,11 @@ describe('LinodePlatformMaintenanceBanner', () => { }), }); - // Mock location to be on the linode detail page - queryMocks.useLocation.mockReturnValue({ pathname: '/linodes/123' }); - const { container, queryByRole } = renderWithTheme( - + , + { + initialRoute: '/linodes/123', + } ); // Should show the label as plain text within the Typography component diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx index 0d317de4d97..9963f22a970 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx @@ -1,8 +1,8 @@ import { useLinodeQuery } from '@linode/queries'; import { Notice } from '@linode/ui'; import { Box, Button, Stack, Typography } from '@linode/ui'; +import { useLocation } from '@tanstack/react-router'; import React from 'react'; -import { useLocation } from 'react-router-dom'; import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index 2a85aec40ac..3d7afbb9783 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -4,20 +4,20 @@ import * as React from 'react'; import { StyledActiveLink, StyledPrimaryLinkBox } from './PrimaryNav.styles'; import type { NavEntity } from './PrimaryNav'; +import type { LinkProps } from '@tanstack/react-router'; import type { CreateEntity } from 'src/features/TopMenu/CreateMenu/CreateMenu'; export interface BaseNavLink { attr?: { [key: string]: unknown }; display: CreateEntity | NavEntity; hide?: boolean; - href: string; + to: LinkProps['to']; } export interface PrimaryLink extends BaseNavLink { - activeLinks?: Array; betaChipClassName?: string; isBeta?: boolean; - onClick?: (e: React.ChangeEvent) => void; + onClick?: (e: React.MouseEvent) => void; } interface PrimaryLinkProps extends PrimaryLink { @@ -32,7 +32,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { betaChipClassName, closeMenu, display, - href, + to, isActiveLink, isBeta, isCollapsed, @@ -41,13 +41,13 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { return ( ) => { + onClick={(e: React.MouseEvent) => { closeMenu(); if (onClick) { onClick(e); } }} - to={href} + to={to} {...attr} aria-current={isActiveLink} data-testid={`menu-item-${display}`} diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 1ad111ebac0..de1a49035d1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -4,8 +4,8 @@ import { usePreferences, } from '@linode/queries'; import { Box } from '@linode/ui'; +import { useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { useLocation } from 'react-router-dom'; import Compute from 'src/assets/icons/entityIcons/compute.svg'; import Database from 'src/assets/icons/entityIcons/database.svg'; @@ -121,50 +121,36 @@ export const PrimaryNav = (props: PrimaryNavProps) => { icon: , links: [ { - activeLinks: [ - '/managed', - '/managed/summary', - '/managed/monitors', - '/managed/ssh-access', - '/managed/credentials', - '/managed/contacts', - ], display: 'Managed', hide: !isManaged, - href: '/managed', + to: '/managed', }, { - activeLinks: ['/linodes', '/linodes/create'], display: 'Linodes', - href: '/linodes', + to: '/linodes', }, { - activeLinks: [ - '/images/create/create-image', - '/images/create/upload-image', - ], display: 'Images', - href: '/images', + to: '/images', }, { - activeLinks: ['/kubernetes/create'], display: 'Kubernetes', - href: '/kubernetes/clusters', + to: '/kubernetes/clusters', }, { display: 'StackScripts', - href: '/stackscripts', + to: '/stackscripts', }, { betaChipClassName: 'beta-chip-placement-groups', display: 'Placement Groups', hide: !isPlacementGroupsEnabled, - href: '/placement-groups', + to: '/placement-groups', }, { attr: { 'data-qa-one-click-nav-btn': true }, display: 'Marketplace', - href: '/linodes/create/marketplace', + to: '/linodes/create/marketplace', }, ], name: 'Compute', @@ -173,16 +159,12 @@ export const PrimaryNav = (props: PrimaryNavProps) => { icon: , links: [ { - activeLinks: [ - '/object-storage/buckets', - '/object-storage/access-keys', - ], display: 'Object Storage', - href: '/object-storage/buckets', + to: '/object-storage/buckets', }, { display: 'Volumes', - href: '/volumes', + to: '/volumes', }, ], name: 'Storage', @@ -192,19 +174,19 @@ export const PrimaryNav = (props: PrimaryNavProps) => { links: [ { display: 'VPC', - href: '/vpcs', + to: '/vpcs', }, { display: 'Firewalls', - href: '/firewalls', + to: '/firewalls', }, { display: 'NodeBalancers', - href: '/nodebalancers', + to: '/nodebalancers', }, { display: 'Domains', - href: '/domains', + to: '/domains', }, ], name: 'Networking', @@ -215,7 +197,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { { display: 'Databases', hide: !isDatabasesEnabled, - href: '/databases', + to: '/databases', isBeta: isDatabasesV2Beta, }, ], @@ -227,23 +209,23 @@ export const PrimaryNav = (props: PrimaryNavProps) => { { display: 'Metrics', hide: !isACLPEnabled, - href: '/metrics', + to: '/metrics', isBeta: flags.aclp?.beta, }, { display: 'Alerts', hide: !isAlertsEnabled, - href: '/alerts', + to: '/alerts', isBeta: flags.aclp?.beta, }, { display: 'Longview', - href: '/longview', + to: '/longview', }, { display: 'DataStream', hide: !flags.aclpLogs?.enabled, - href: '/datastream', + to: '/datastream', isBeta: flags.aclpLogs?.beta, }, ], @@ -255,22 +237,22 @@ export const PrimaryNav = (props: PrimaryNavProps) => { { display: 'Betas', hide: !flags.selfServeBetas, - href: '/betas', + to: '/betas', }, { display: 'Identity & Access', hide: !isIAMEnabled, - href: '/iam', + to: '/iam', icon: , isBeta: isIAMBeta, }, { display: 'Account', - href: '/account', + to: '/account', }, { display: 'Help & Support', - href: '/support', + to: '/support', }, ], name: 'More', @@ -364,12 +346,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const filteredLinks = group.links.filter((link) => !link.hide); return filteredLinks.some((link) => - linkIsActive( - link.href, - location.search, - location.pathname, - link.activeLinks - ) + linkIsActive(location.pathname, link.to) ); }); @@ -433,12 +410,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const PrimaryLinks = filteredLinks.map((link) => { const isActiveLink = Boolean( - linkIsActive( - link.href, - location.search, - location.pathname, - link.activeLinks - ) + linkIsActive(location.pathname, link.to) ); if (isActiveLink) { diff --git a/packages/manager/src/components/PrimaryNav/utils.ts b/packages/manager/src/components/PrimaryNav/utils.ts index 1c5a6b58d6f..ee961632887 100644 --- a/packages/manager/src/components/PrimaryNav/utils.ts +++ b/packages/manager/src/components/PrimaryNav/utils.ts @@ -1,26 +1,20 @@ import React from 'react'; import { TOPMENU_HEIGHT } from 'src/features/TopMenu/constants'; -import { isPathOneOf } from 'src/utilities/routing/isPathOneOf'; -export const linkIsActive = ( - href: string, - locationSearch: string, - locationPathname: string, - activeLinks: Array = [] -) => { - const currentlyOnOneClickTab = locationSearch.match(/one-click/gi); - const isOneClickTab = href.match(/one-click/gi); +import type { LinkProps } from '@tanstack/react-router'; - /** - * mark as active if the tab is "one click" - * Other create tabs default back to Linodes active tabs - */ - if (currentlyOnOneClickTab) { - return isOneClickTab; +export const linkIsActive = (locationPathname: string, to: LinkProps['to']) => { + const marketPlacePath = '/linodes/create/marketplace'; + const currentlyOnOneClickTab = locationPathname === marketPlacePath; + const isOneClickTab = to?.match(marketPlacePath); + + // Special handling for marketplace tab + if (currentlyOnOneClickTab || isOneClickTab) { + return currentlyOnOneClickTab && isOneClickTab; } - return isPathOneOf([href, ...activeLinks], locationPathname); + return to?.split('/')[1] === locationPathname.split('/')[1]; }; /** diff --git a/packages/manager/src/components/Prompt/Prompt.tsx b/packages/manager/src/components/Prompt/Prompt.tsx deleted file mode 100644 index 7f35c1813cc..00000000000 --- a/packages/manager/src/components/Prompt/Prompt.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * The component is used to prevent transitions when a user has unsaved changes. - * Internal routes can be prevented using custom components (as render props). Prevention of - * external route changes (and closing of tabs/windows) is achieved by using an event listener on - * "beforeunload". The browser controls this prompt, and it is not possible to customize it. Pass - * in the `confirmWhenLeaving` prop if this behavior is desired. - * - * Example usage: - * - * ```typescript - * return ( - * - * (({ isModalOpen, handleCancel, handleConfirm }) => { - * - * }) - * - * ); - * ``` - */ - -import * as React from 'react'; -import { Prompt as ReactRouterPrompt, useHistory } from 'react-router-dom'; -import type { - PromptProps as ReactRouterPromptProps, - useLocation, -} from 'react-router-dom'; - -interface ChildrenProps { - handleCancel: () => void; - handleConfirm: () => void; - isModalOpen: boolean; -} - -interface PromptProps { - children: (props: ChildrenProps) => React.ReactNode; - confirmWhenLeaving?: boolean; - onConfirm?: (path: string) => void; - when: boolean; -} - -// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload -const handleBeforeUnload = (e: BeforeUnloadEvent) => { - // Prevent the unload event. - e.preventDefault(); - // Chrome requires returnValue to be set to a string. - e.returnValue = ''; -}; - -export const Prompt = React.memo((props: PromptProps) => { - const history = useHistory(); - - React.useEffect(() => { - if (!props.when || !props.confirmWhenLeaving) { - return; - } - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [props.when, props.confirmWhenLeaving]); - - // Whether or not the user has confirmed navigation. - const confirmedNav = React.useRef(false); - - // The location the user is navigating to. - const [nextLocation, setNextLocation] = React.useState>(null); - - const [isModalOpen, setIsModalOpen] = React.useState(false); - - const handleCancel = () => setIsModalOpen(false); - - const handleConfirm = () => { - setIsModalOpen(false); - - if (!nextLocation) { - return; - } - - // Set confirmedNav to `true`, which will allow navigation in `handleNavigation()`. - confirmedNav.current = true; - - window.removeEventListener('beforeunload', handleBeforeUnload); - - if (props.onConfirm) { - return props.onConfirm(nextLocation.pathname); - } - - history.push(nextLocation.pathname); - }; - - const handleNavigation: ReactRouterPromptProps['message'] = (location) => { - if (location.pathname === history.location.pathname) { - // Sorting order changes affect the search portion of the URL. - // The path is the same though, so the user isn't actually navigating away. - return true; - } - // If this user hasn't yet confirmed navigation, present a confirmation modal. - if (!confirmedNav.current) { - setIsModalOpen(true); - // We need to set the requested location as well. - setNextLocation(location); - return false; - } - // The user has confirmed navigation, so we allow it. - return true; - }; - - return ( - <> - - {props.children({ handleCancel, handleConfirm, isModalOpen })} - - ); -}); diff --git a/packages/manager/src/components/StackScript/StackScript.tsx b/packages/manager/src/components/StackScript/StackScript.tsx index 28b4dad8960..a17a8e5592b 100644 --- a/packages/manager/src/components/StackScript/StackScript.tsx +++ b/packages/manager/src/components/StackScript/StackScript.tsx @@ -13,9 +13,9 @@ import { Typography, } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import type { JSX } from 'react'; -import { useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; @@ -109,7 +109,7 @@ export const StackScript = React.memo((props: StackScriptProps) => { const { data: profile } = useProfile(); const theme = useTheme(); - const history = useHistory(); + const navigate = useNavigate(); const { data: imagesData } = useAllImagesQuery( {}, @@ -177,7 +177,10 @@ export const StackScript = React.memo((props: StackScriptProps) => { buttonType="secondary" className={classes.editBtn} onClick={() => { - history.push(`/stackscripts/${stackscriptId}/edit`); + navigate({ + to: '/stackscripts/$id/edit', + params: { id: stackscriptId }, + }); }} > Edit diff --git a/packages/manager/src/components/SupportLink/SupportLink.tsx b/packages/manager/src/components/SupportLink/SupportLink.tsx index 0bc52b417f2..fcf8174463c 100644 --- a/packages/manager/src/components/SupportLink/SupportLink.tsx +++ b/packages/manager/src/components/SupportLink/SupportLink.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { Link } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; + +import { Link } from 'src/components/Link'; import type { EntityType, @@ -13,7 +12,7 @@ export interface SupportLinkProps { description?: string; entity?: EntityForTicketDetails; formPayloadValues?: FormPayloadValues; - onClick?: LinkProps['onClick']; + onClick?: () => void; text: string; ticketType?: TicketType; title?: string; @@ -38,16 +37,20 @@ const SupportLink = (props: SupportLinkProps) => { return ( { + return { + ...prev, description, entity, formPayloadValues, ticketType, title, - }, + }; }} + to="/support/tickets/open" > {text} diff --git a/packages/manager/src/components/Tabs/TabLinkList.test.tsx b/packages/manager/src/components/Tabs/TabLinkList.test.tsx index a162772cc8d..a9d133f878d 100644 --- a/packages/manager/src/components/Tabs/TabLinkList.test.tsx +++ b/packages/manager/src/components/Tabs/TabLinkList.test.tsx @@ -23,9 +23,7 @@ describe('TabLinkList', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -42,9 +40,7 @@ describe('TabLinkList', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -63,9 +59,7 @@ describe('TabLinkList', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); diff --git a/packages/manager/src/components/Tabs/TabList.test.tsx b/packages/manager/src/components/Tabs/TabList.test.tsx index 78589f65116..7d14b3bb834 100644 --- a/packages/manager/src/components/Tabs/TabList.test.tsx +++ b/packages/manager/src/components/Tabs/TabList.test.tsx @@ -16,9 +16,7 @@ describe('TabList component', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -34,9 +32,7 @@ describe('TabList component', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -56,9 +52,7 @@ describe('TabList component', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); diff --git a/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx b/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx index f337bd17c12..5b5f8d582c4 100644 --- a/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx +++ b/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx @@ -1,6 +1,6 @@ -import { Link as TanstackLink } from '@tanstack/react-router'; import * as React from 'react'; +import { Link as TanstackLink } from 'src/components/Link'; import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; diff --git a/packages/manager/src/components/Tag/Tag.tsx b/packages/manager/src/components/Tag/Tag.tsx index 39458d4cc17..0009b194386 100644 --- a/packages/manager/src/components/Tag/Tag.tsx +++ b/packages/manager/src/components/Tag/Tag.tsx @@ -1,8 +1,7 @@ import { CloseIcon } from '@linode/ui'; import { truncateEnd } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { StyledChip, StyledDeleteButton } from './Tag.styles'; @@ -51,7 +50,7 @@ export const Tag = (props: TagProps) => { ...chipProps } = props; - const history = useHistory(); + const navigate = useNavigate(); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -59,7 +58,10 @@ export const Tag = (props: TagProps) => { if (closeMenu) { closeMenu(); } - history.push(`/search?query=tag:${label}`); + navigate({ + to: '/search', + search: { query: `tag:${label}` }, + }); }; // If maxLength is set, truncate display to that length. diff --git a/packages/manager/src/features/Account/CloseAccountDialog.tsx b/packages/manager/src/features/Account/CloseAccountDialog.tsx index eb83b0350e8..9352616df41 100644 --- a/packages/manager/src/features/Account/CloseAccountDialog.tsx +++ b/packages/manager/src/features/Account/CloseAccountDialog.tsx @@ -2,9 +2,8 @@ import { cancelAccount } from '@linode/api-v4/lib/account'; import { useProfile } from '@linode/queries'; import { Notice, TextField, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { @@ -24,7 +23,7 @@ const CloseAccountDialog = ({ closeDialog, open }: Props) => { React.useState(false); const [errors, setErrors] = React.useState(undefined); const [comments, setComments] = React.useState(''); - const history = useHistory(); + const navigate = useNavigate(); const { data: profile } = useProfile(); React.useEffect(() => { @@ -61,7 +60,10 @@ const CloseAccountDialog = ({ closeDialog, open }: Props) => { .then((response) => { setIsClosingAccount(false); /** shoot the user off to survey monkey to answer some questions */ - history.push('/cancel', { survey_link: response.survey_link }); + navigate({ + to: '/cancel', + search: { survey_link: response.survey_link }, + }); }) .catch((e: APIError[]) => { setIsClosingAccount(false); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index fa041639532..5c251bf3340 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -98,7 +98,6 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { ) : ( { const { selectedLocation, selectedService } = props; const navigate = useNavigate(); - const pagination = usePagination(1, 'quotas-table'); + const pagination = usePaginationV2({ + currentRoute: '/account/quotas', + initialPage: 1, + preferenceKey: 'quotas-table', + }); const hasSelectedLocation = Boolean(selectedLocation); const [supportModalOpen, setSupportModalOpen] = React.useState(false); const [selectedQuota, setSelectedQuota] = React.useState(); diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx index 3bdbf7ebcea..a648ea359ac 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx @@ -19,26 +19,26 @@ vi.mock( }) ); -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; +const mockNavigate = vi.fn(); -const realLocation = window.location; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => mockNavigate), +})); -afterAll(() => { - window.location = realLocation; -}); - -// Mock useHistory -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, - useHistory: vi.fn(() => mockHistory), + useNavigate: queryMocks.useNavigate, }; }); +const realLocation = window.location; + +afterAll(() => { + window.location = realLocation; +}); + describe('SessionExpirationDialog', () => { it('renders correctly when isOpen is true', async () => { const onCloseMock = vi.fn(); @@ -79,7 +79,9 @@ describe('SessionExpirationDialog', () => { await Promise.resolve(); }); - expect(mockHistory.push).toHaveBeenCalledWith('/logout'); + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/logout', + }); expect(mockReload).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx index fc4b19d1468..f25942cf0e2 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx @@ -1,9 +1,8 @@ import { useAccount } from '@linode/queries'; import { ActionsPanel, Typography } from '@linode/ui'; import { pluralize, useInterval } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import React, { useEffect } from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { sessionExpirationContext as _sessionExpirationContext } from 'src/context/sessionExpirationContext'; @@ -31,7 +30,7 @@ export const SessionExpirationDialog = React.memo( seconds: 0, }); const [logoutLoading, setLogoutLoading] = React.useState(false); - const history = useHistory(); + const navigate = useNavigate(); const { data: account } = useAccount(); const euuid = account?.euuid ?? ''; @@ -82,7 +81,7 @@ export const SessionExpirationDialog = React.memo( setLogoutLoading(true); if (!validateParentToken()) { - history.push('/logout'); + navigate({ to: '/logout' }); } await revokeToken().catch(() => { diff --git a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx index 3f95d950b73..cd9d13bf403 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx @@ -1,22 +1,20 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { MemoryRouter } from 'react-router-dom'; import { SwitchAccountSessionDialog } from 'src/features/Account/SwitchAccounts/SwitchAccountSessionDialog'; import { renderWithTheme } from 'src/utilities/testHelpers'; -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; +const mockNavigate = vi.fn(); -// Mock useHistory -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => mockNavigate), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, - useHistory: vi.fn(() => mockHistory), + useNavigate: queryMocks.useNavigate, }; }); @@ -24,9 +22,7 @@ describe('SwitchAccountSessionDialog', () => { it('renders correctly when isOpen is true', () => { const onCloseMock = vi.fn(); const { getByText } = renderWithTheme( - - - + ); expect(getByText('Session expired')).toBeInTheDocument(); @@ -42,9 +38,7 @@ describe('SwitchAccountSessionDialog', () => { it('calls onClose when close button is clicked', () => { const onCloseMock = vi.fn(); const { getByText } = renderWithTheme( - - - + ); fireEvent.click(getByText('Close')); @@ -53,12 +47,12 @@ describe('SwitchAccountSessionDialog', () => { it('calls history.push("/logout") when Log in button is clicked', () => { const { getByText } = renderWithTheme( - - - + ); fireEvent.click(getByText('Log in')); - expect(mockHistory.push).toHaveBeenCalledWith('/logout'); + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/logout', + }); }); }); diff --git a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx index cf2495b3fbd..3b878bd6bfb 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx @@ -1,14 +1,13 @@ import { ActionsPanel, Typography } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { sendSwitchAccountSessionExpiryEvent } from 'src/utilities/analytics/customEventAnalytics'; export const SwitchAccountSessionDialog = React.memo( ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { - const history = useHistory(); + const navigate = useNavigate(); const actions = ( { sendSwitchAccountSessionExpiryEvent('Log In'); - history.push('/logout'); + navigate({ to: '/logout' }); }, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/CancelLanding/CancelLanding.test.tsx b/packages/manager/src/features/CancelLanding/CancelLanding.test.tsx index 1501531c9ef..44fe6aac5a3 100644 --- a/packages/manager/src/features/CancelLanding/CancelLanding.test.tsx +++ b/packages/manager/src/features/CancelLanding/CancelLanding.test.tsx @@ -1,33 +1,34 @@ -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { CancelLanding } from './CancelLanding'; const realLocation = window.location; -afterAll(() => { +beforeAll(() => { + window.location = realLocation; +}); + +afterEach(() => { window.location = realLocation; }); describe('CancelLanding', () => { it('does not render the body when there is no survey_link in the state', () => { - const { queryByTestId } = render(wrapWithTheme()); + const { queryByTestId } = renderWithTheme(, { + initialEntries: ['/cancel'], + initialRoute: '/cancel', + }); expect(queryByTestId('body')).toBe(null); }); it('renders the body when there is a survey_link in the state', () => { - const { queryByTestId } = renderWithTheme( - - - - ); + const { queryByTestId } = renderWithTheme(, { + initialEntries: ['/cancel?survey_link=https://linode.com'], + initialRoute: '/cancel', + }); expect(queryByTestId('body')).toBeInTheDocument(); }); @@ -39,18 +40,13 @@ describe('CancelLanding', () => { window.location = { ...realLocation, assign: mockAssign }; - const survey_link = 'https://linode.com'; - const { getByTestId } = renderWithTheme( - // Use a custom MemoryRouter here because the renderWithTheme MemoryRouter option does not support state. - // This will likely need to be updated when CancelLanding uses TanStack router fully. - - - - ); + const surveyLink = 'https://linode.com'; + const { getByTestId } = renderWithTheme(, { + initialEntries: ['/cancel?survey_link=' + encodeURIComponent(surveyLink)], + initialRoute: '/cancel', + }); const button = getByTestId('survey-button'); fireEvent.click(button); - expect(mockAssign).toHaveBeenCalledWith(survey_link); + expect(mockAssign).toHaveBeenCalledWith(surveyLink); }); }); diff --git a/packages/manager/src/features/CancelLanding/CancelLanding.tsx b/packages/manager/src/features/CancelLanding/CancelLanding.tsx index 515627f1cd2..4d31cd45108 100644 --- a/packages/manager/src/features/CancelLanding/CancelLanding.tsx +++ b/packages/manager/src/features/CancelLanding/CancelLanding.tsx @@ -1,7 +1,7 @@ import { Button, H1Header, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; +import { redirect, useSearch } from '@tanstack/react-router'; import * as React from 'react'; -import { Redirect, useLocation } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import LightThemeAkamaiLogo from 'src/assets/logo/akamai-logo-color.svg'; @@ -37,13 +37,13 @@ const useStyles = makeStyles()((theme: Theme) => ({ export const CancelLanding = React.memo(() => { const { classes } = useStyles(); - const location = useLocation<{ survey_link?: string }>(); + const search = useSearch({ from: '/cancel' }); const theme = useTheme(); - const surveyLink = location.state?.survey_link; + const surveyLink = search.survey_link; if (!surveyLink) { - return ; + throw redirect({ to: '/' }); } const goToSurvey = () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index 6f342cd97f1..7d687a37726 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -24,6 +24,8 @@ import { AlertDetailCriteria } from './AlertDetailCriteria'; import { AlertDetailNotification } from './AlertDetailNotification'; import { AlertDetailOverview } from './AlertDetailOverview'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + export interface AlertRouteParams { /** * The id of the alert for which the data needs to be shown @@ -47,17 +49,12 @@ export const AlertDetail = () => { } = useAlertDefinitionQuery(alertId, serviceType); const { crumbOverrides, pathname } = React.useMemo(() => { - const overrides = [ + const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: '/alerts/definitions', position: 1, }, - { - label: 'Details', - linkTo: `/alerts/definitions/details/${serviceType}/${alertId}`, - position: 2, - }, ]; return { crumbOverrides: overrides, pathname: '/Definitions/Details' }; }, [alertId, serviceType]); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index 1796dd450b5..1b6e14de058 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -2,7 +2,6 @@ import { capitalize } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import * as React from 'react'; -import { Router } from 'react-router-dom'; import { alertFactory } from 'src/factories/cloudpulse/alerts'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; @@ -72,18 +71,16 @@ describe('Alert Row', () => { history.push('/alerts/definitions'); const link = `/alerts/definitions/detail/${alert.service_type}/${alert.id}`; const renderedAlert = ( - - - + ); const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index 8c596005ebc..90e01d25fc7 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -4,7 +4,6 @@ import { Grid, TableBody, TableHead } from '@mui/material'; import { useSnackbar } from 'notistack'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx index 2f8e251a68a..1cbd864b121 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx @@ -70,25 +70,12 @@ mockQuery.useServiceAlertsMutation.mockReturnValue({ mutateAsync: vi.fn(), }); -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useHistory: vi.fn(() => mockHistory), - }; -}); - describe('Alert Resuable Component for contextual view', () => { it('Should go to alerts definition page on clicking manage alerts button', async () => { - const { getByTestId } = renderWithTheme(component); + const { getByTestId, router } = renderWithTheme(component); await userEvent.click(getByTestId('manage-alerts')); - expect(mockHistory.push).toHaveBeenCalledWith('/alerts/definitions'); + expect(router.state.location.pathname).toBe('/alerts/definitions'); }); it('Should filter alerts based on search text', async () => { @@ -127,7 +114,10 @@ describe('Alert Resuable Component for contextual view', () => { }); it('Should show header for edit mode', async () => { - renderWithTheme(component); + renderWithTheme(component, { + initialEntries: ['/alerts/definitions'], + initialRoute: '/alerts/definitions', + }); await userEvent.click(screen.getByText('Manage Alerts')); expect(screen.getByText('Alerts')).toBeVisible(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx index 483628c260d..aa32099d89c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -8,9 +8,8 @@ import { Stack, Typography, } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useFlags } from 'src/hooks/useFlags'; @@ -86,7 +85,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { const { aclpBetaServices } = useFlags(); - const history = useHistory(); + const navigate = useNavigate(); // Filter unique alert types from alerts list const types = convertAlertsToTypeSet(alerts); @@ -108,7 +107,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { buttonType="outlined" data-qa-buttons="true" data-testid="manage-alerts" - onClick={() => history.push('/alerts/definitions')} + onClick={() => navigate({ to: '/alerts/definitions' })} > Manage Alerts diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 67f1836db31..cc6b538ce05 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -38,6 +38,7 @@ import type { TriggerConditionForm, } from './types'; import type { APIError } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; const triggerConditionInitialValues: TriggerConditionForm = { criteria_condition: 'ALL', @@ -66,17 +67,12 @@ const initialValues: CreateAlertDefinitionForm = { scope: 'entity', }; -const overrides = [ +const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: '/alerts/definitions', position: 1, }, - { - label: 'Details', - linkTo: `/alerts/definitions/create`, - position: 2, - }, ]; export const CreateAlertDefinition = () => { const navigate = useNavigate(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx index 43a1a037660..8140be55af3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx @@ -39,6 +39,7 @@ import type { APIError, EditAlertPayloadWithService, } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; export interface EditAlertProps { /** @@ -121,17 +122,12 @@ export const EditAlertDefinition = (props: EditAlertProps) => { }); const definitionLanding = '/alerts/definitions'; - const overrides = [ + const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: definitionLanding, position: 1, }, - { - label: 'Edit', - linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, - position: 2, - }, ]; const previousSubmitCount = React.useRef(0); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx index aa1dfb81a46..0a275b3e820 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx @@ -13,17 +13,12 @@ import { EditAlertResources } from './EditAlertResources'; import type { AlertServiceType } from '@linode/api-v4'; import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; -const overrides = [ +const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: '/alerts/definitions', position: 1, }, - { - label: 'Edit', - linkTo: `/alerts/definitions/edit`, - position: 2, - }, ]; export const EditAlertLanding = () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx index 1b7dabb62e6..038debeaa2d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx @@ -1,9 +1,7 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createMemoryHistory } from 'history'; import React from 'react'; -import { Router } from 'react-router-dom'; import { alertFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -160,15 +158,12 @@ describe('EditAlertResources component tests', () => { reset: vi.fn(), }); - const push = vi.fn(); - const history = createMemoryHistory(); // Create a memory history for testing - history.push = push; - history.push('/alerts/definitions/edit/linode/1'); - const { getByTestId, getByText } = renderWithTheme( - - - + , + { + initialEntries: ['/alerts/definitions/edit/linode/1'], + initialRoute: '/alerts/definitions/edit/linode/1', + } ); expect(getByTestId(saveResources)).toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx index 6ac187211d7..fb24c4b98c3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -13,6 +13,7 @@ import { getAlertBoxStyles } from '../Utils/utils'; import { EditAlertResourcesConfirmDialog } from './EditAlertResourcesConfirmationDialog'; import type { EditAlertProps } from './EditAlertDefinition'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; export const EditAlertResources = (props: EditAlertProps) => { const theme = useTheme(); @@ -36,17 +37,12 @@ export const EditAlertResources = (props: EditAlertProps) => { }, [alertDetails]); const { newPathname, overrides } = React.useMemo(() => { - const overrides = [ + const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: definitionLanding, position: 1, }, - { - label: 'Edit', - linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, - position: 2, - }, ]; return { newPathname: '/Definitions/Edit', overrides }; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx index 3d318f52d59..7de95df1e98 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx @@ -12,12 +12,13 @@ import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtil import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import type { LandingHeaderProps } from 'src/components/LandingHeader'; import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; export const DestinationCreate = () => { const theme = useTheme(); - const landingHeaderProps = { + const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/datastream/destinations/create', crumbOverrides: [ diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx index 65d5a4dd310..3e689d3025a 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx @@ -18,6 +18,7 @@ import { StreamCreateClusters } from './StreamCreateClusters'; import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; import type { CreateStreamPayload } from '@linode/api-v4'; +import type { LandingHeaderProps } from 'src/components/LandingHeader'; import type { CreateStreamAndDestinationForm, CreateStreamForm, @@ -50,7 +51,7 @@ export const StreamCreate = () => { name: 'stream.type', }); - const landingHeaderProps = { + const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/datastream/streams/create', crumbOverrides: [ diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/DatabaseBackupsLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/DatabaseBackupsLegacy.tsx index f2287320f2a..7a308a443d7 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/DatabaseBackupsLegacy.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/DatabaseBackupsLegacy.tsx @@ -9,7 +9,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import RestoreLegacyFromBackupDialog from 'src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/RestoreLegacyFromBackupDialog'; -import { useOrder } from 'src/hooks/useOrder'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; import DatabaseBackupTableBody from './DatabaseBackupTableBody'; @@ -41,9 +41,15 @@ export const DatabaseBackupsLegacy = (props: Props) => { isLoading: isBackupsLoading, } = useDatabaseBackupsQuery(engine, id, Boolean(database)); - const { handleOrderChange, order, orderBy } = useOrder({ - order: 'desc', - orderBy: 'created', + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'desc', + orderBy: 'created', + }, + from: '/databases/$engine/$databaseId/backups', + }, + preferenceKey: 'database-backups-legacy', }); if (!database) { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx index cdc3e0da3ed..1008611ad9b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx @@ -161,20 +161,16 @@ describe('database resize', () => { }); it('when a plan is selected, resize button should be enabled and on click of it, it should show a confirmation dialog', async () => { - // TODO: Tanstack Router: switch to mocking useLocation once fully migrated to Tanstack Router - const location = window.location; - window.location = { - ...location, - pathname: `/databases/${mockDatabase.engine}/${mockDatabase.id}/resize`, - } as any; - const { getByRole, getByTestId } = renderWithTheme( , - { flags } + { + flags, + initialRoute: `/databases/${mockDatabase.engine}/${mockDatabase.id}/resize`, + } ); await waitForElementToBeRemoved(getByTestId(loadingTestId)); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index b7507db1c4d..2b18c239c41 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -15,7 +15,7 @@ import DatabaseSettingsResetPasswordDialog from 'src/features/Databases/Database import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import DatabaseRow from 'src/features/Databases/DatabaseLanding/DatabaseRow'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import { usePagination } from 'src/hooks/usePagination'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useInProgressEvents } from 'src/queries/events/events'; import { DatabaseSettingsSuspendClusterDialog } from '../DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog'; @@ -41,13 +41,17 @@ const DatabaseLandingTable = ({ order, orderBy, results, - showSuspend, }: Props) => { const { data: events } = useInProgressEvents(); const { isDatabasesV2GA } = useIsDatabasesEnabled(); const dbPlatformType = isNewDatabase ? 'new' : 'legacy'; - const pagination = usePagination(1, preferenceKey, dbPlatformType); + const pagination = usePaginationV2({ + currentRoute: '/databases', + initialPage: 1, + preferenceKey, + queryParamsPrefix: dbPlatformType, + }); const [selectedDatabase, setSelectedDatabase] = React.useState({} as DatabaseInstance); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx index e7377bb17e1..258fda148df 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -// eslint-disable-next-line no-restricted-imports import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { diff --git a/packages/manager/src/features/Domains/DomainTableRow.tsx b/packages/manager/src/features/Domains/DomainTableRow.tsx index 8c39905c33e..2d44ce9827a 100644 --- a/packages/manager/src/features/Domains/DomainTableRow.tsx +++ b/packages/manager/src/features/Domains/DomainTableRow.tsx @@ -27,9 +27,7 @@ export const DomainTableRow = React.memo((props: DomainTableRowProps) => { {domain.type !== 'slave' ? ( - - {domain.domain} - + {domain.domain} ) : ( props.onEdit(domain)}> {domain.domain} diff --git a/packages/manager/src/features/Domains/DomainsLanding.test.tsx b/packages/manager/src/features/Domains/DomainsLanding.test.tsx index 7e35198e19f..09ffc8c6615 100644 --- a/packages/manager/src/features/Domains/DomainsLanding.test.tsx +++ b/packages/manager/src/features/Domains/DomainsLanding.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { migrationRouteTree } from 'src/routes'; +import { routeTree } from 'src/routes'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { DomainsLanding } from './DomainsLanding'; @@ -21,7 +21,7 @@ describe('Domains Landing', () => { it('should initially render a loading state', async () => { const { getByTestId } = renderWithTheme(, { initialRoute: '/domains', - routeTree: migrationRouteTree, + routeTree, }); expect(getByTestId('circle-progress')).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx index 84af7131a81..a2089731099 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx @@ -1,9 +1,8 @@ import { entityTransfersQueryKey, useCreateTransfer } from '@linode/queries'; import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -26,7 +25,7 @@ import type { CreateTransferPayload } from '@linode/api-v4'; import type { QueryClient } from '@tanstack/react-query'; export const EntityTransfersCreate = () => { - const { push } = useHistory(); + const navigate = useNavigate(); const { error, isPending, mutateAsync: createTransfer } = useCreateTransfer(); const queryClient = useQueryClient(); @@ -70,7 +69,10 @@ export const EntityTransfersCreate = () => { queryClient.invalidateQueries({ queryKey: [entityTransfersQueryKey], }); - push({ pathname: '/account/service-transfers', state: { transfer } }); + navigate({ + to: '/account/service-transfers', + state: (prev) => ({ ...prev, transfer }), + }); }, }).catch((_) => null); }; @@ -127,9 +129,3 @@ export const EntityTransfersCreate = () => { ); }; - -export const entityTransfersCreateLazyRoute = createLazyRoute( - '/account/service-transfers/create' -)({ - component: EntityTransfersCreate, -}); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index f49e6561500..1816f1f7c5e 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -11,7 +11,7 @@ import * as React from 'react'; import { SelectableTableRow } from 'src/components/SelectableTableRow/SelectableTableRow'; import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; -import { usePagination } from 'src/hooks/usePagination'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { extendType } from 'src/utilities/extendType'; import { TransferTable } from './TransferTable'; @@ -31,7 +31,11 @@ export const LinodeTransferTable = React.memo((props: Props) => { const { handleRemove, handleSelect, handleToggle, selectedLinodes } = props; const [searchText, setSearchText] = React.useState(''); - const pagination = usePagination(); + const pagination = usePaginationV2({ + currentRoute: '/account/service-transfers/create', + initialPage: 1, + preferenceKey: 'linode-transfer-table', + }); const { data, dataUpdatedAt, error, isError, isLoading } = useLinodesQuery( { diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/entityTransfersCreateLazyRoute.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/entityTransfersCreateLazyRoute.ts new file mode 100644 index 00000000000..9cc142ddf27 --- /dev/null +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/entityTransfersCreateLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { EntityTransfersCreate } from './EntityTransfersCreate'; + +export const entityTransfersCreateLazyRoute = createLazyRoute( + '/account/service-transfers/create' +)({ + component: EntityTransfersCreate, +}); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx index d1f55916563..c2ccafafdb9 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx @@ -1,7 +1,7 @@ import { TRANSFER_FILTERS, useEntityTransfersQuery } from '@linode/queries'; import { CircleProgress } from '@linode/ui'; +import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -18,19 +18,24 @@ export const EntityTransfersLanding = () => { undefined ); - const location = useLocation<{ transfer?: EntityTransfer }>(); - const history = useHistory(); + const location = useLocation(); + const navigate = useNavigate(); const handleCloseSuccessDialog = () => { setSuccessDialogOpen(false); setTransfer(undefined); - history.replace({ state: undefined }); + navigate({ + to: '/account/service-transfers', + state: (prev) => ({ ...prev, transfer: undefined }), + }); }; + const locationState = location.state as { transfer?: EntityTransfer }; + React.useEffect(() => { - if (location.state?.transfer) { + if (locationState?.transfer) { setSuccessDialogOpen(true); - setTransfer(location.state.transfer); + setTransfer(locationState.transfer); } }, [location]); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx index 73ab279f6eb..0b1efa53ce6 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx @@ -1,5 +1,5 @@ +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { ConfirmTransferDialog } from './ConfirmTransferDialog'; import { @@ -17,7 +17,7 @@ export const TransferControls = React.memo(() => { const [token, setToken] = React.useState(''); const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false); - const { push } = useHistory(); + const navigate = useNavigate(); const handleInputChange = (e: React.ChangeEvent) => { setToken(e.target.value); @@ -29,7 +29,9 @@ export const TransferControls = React.memo(() => { setTimeout(() => setToken(''), 150); }; - const handleCreateTransfer = () => push('/account/service-transfers/create'); + const handleCreateTransfer = () => + navigate({ to: '/account/service-transfers/create' }); + return ( <> ( - - {useTanStackRouterBoundary ? ( - 'error-boundary-fallback'}> - {children} - - ) : ( - children - )} - + {children} ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index cfee62c9155..f34496b4b23 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -27,9 +27,7 @@ export const FirewallDeviceRow = React.memo((props: FirewallDeviceRowProps) => { return ( - - {entityLabel} - + {entityLabel} {isLinodeInterfacesEnabled && isLinodeRelatedDevice && ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 1728fce9b96..913ea297f3d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -12,8 +12,6 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -// eslint-disable-next-line no-restricted-imports -import { Prompt } from 'src/components/Prompt/Prompt'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; @@ -336,48 +334,32 @@ export const FirewallRulesLanding = React.memo((props: Props) => { return ( <> - {/* - This Prompt eventually can be removed once react-router is fully deprecated - It is here only to preserve the behavior of non-Tanstack routes - */} - - {({ handleCancel, handleConfirm, isModalOpen }) => ( - ( - { - handleCancelNavigation(); - handleCancel(); - }, - }} - secondaryButtonProps={{ - buttonType: 'secondary', - color: 'error', - label: 'Leave and discard changes', - onClick: () => { - handleProceedNavigation(); - handleConfirm(); - }, - }} - /> - )} - onClose={() => { - handleCancelNavigation(); - handleCancel(); + ( + handleCancelNavigation(), + }} + secondaryButtonProps={{ + buttonType: 'secondary', + color: 'error', + label: 'Leave and discard changes', + onClick: () => handleProceedNavigation(), }} - open={status === 'blocked' || isModalOpen} - title="Discard Firewall changes?" - > - - The changes you made to this Firewall haven’t been applied. - If you navigate away from this page, your changes will be - discarded. - - + /> )} - + onClose={() => { + handleCancelNavigation(); + }} + open={status === 'blocked'} + title="Discard Firewall changes?" + > + + The changes you made to this Firewall haven’t been applied. If + you navigate away from this page, your changes will be discarded. + + {disabled ? ( { const navigate = useNavigate(); const location = useLocation(); - const pagination = usePagination(1, preferenceKey); - const { handleOrderChange, order, orderBy } = useOrder( - { - order: 'asc', - orderBy: 'label', + const pagination = usePaginationV2({ + currentRoute: '/firewalls', + preferenceKey, + }); + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'label', + }, + from: '/firewalls', }, - `${preferenceKey}-order` - ); + preferenceKey, + }); const filter = { ['+order']: order, diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx index f6503d2e619..655f4064043 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -38,9 +38,7 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { justifyContent: 'flex-start', }} > - - {label} - + {label} {isLinodeInterfacesEnabled && isDefault && ( { - const history = useHistory(); + const navigate = useNavigate(); const { data: account } = useAccount(); @@ -29,7 +29,7 @@ export const CreditCardExpiredBanner = () => { actionButton={ diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index 52d09bacfc2..f0a4015309c 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -9,9 +9,9 @@ import { Button, Notice, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { StyledGrid } from './EmailBounce.styles'; @@ -26,7 +26,8 @@ export const EmailBounceNotificationSection = React.memo(() => { const { data: profile } = useProfile(); const { mutateAsync: updateProfile } = useMutateProfile(); const { data: notifications } = useNotificationsQuery(); - const history = useHistory(); + + const navigate = useNavigate(); // Have to use refs here, because these values should be static. I.e. if we // used the raw Redux values, when the user updated their email, the text of @@ -52,9 +53,10 @@ export const EmailBounceNotificationSection = React.memo(() => { {billingEmailBounceNotification && accountEmailRef && ( - history.push( - '/account/billing?contactDrawerOpen=true&focusEmail=true' - ) + navigate({ + to: '/account/billing', + search: { contactDrawerOpen: true, focusEmail: true }, + }) } confirmEmail={confirmAccountEmail} text={ @@ -68,7 +70,12 @@ export const EmailBounceNotificationSection = React.memo(() => { )} {userEmailBounceNotification && profileEmailRef && ( history.push('/profile/display?focusEmail=true')} + changeEmail={() => + navigate({ + to: '/profile/display', + search: { focusEmail: true }, + }) + } confirmEmail={confirmProfileEmail} text={ diff --git a/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx b/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx index 95f314aed76..962a3394519 100644 --- a/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx @@ -1,19 +1,20 @@ import { useAccount } from '@linode/queries'; import { Button, Typography } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import { DateTime } from 'luxon'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; export const TaxCollectionBanner = () => { - const history = useHistory(); const flags = useFlags(); const { data: account } = useAccount(); + const navigate = useNavigate(); + const countryDateString = flags.taxCollectionBanner?.date ?? ''; const bannerHasAction = flags.taxCollectionBanner?.action ?? false; const bannerRegions = @@ -53,7 +54,9 @@ export const TaxCollectionBanner = () => { const actionButton = bannerHasAction ? ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.test.tsx index aea7301cb8c..7f907edc4d2 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.test.tsx @@ -14,7 +14,6 @@ const props: Props = { clusterRegionId: 'us-east', clusterTier: 'standard', isLkeClusterRestricted: false, - regionsData: [], }; describe('NodeTable', () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx index 45ceec0f02f..2dcee5f25a6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx @@ -30,7 +30,7 @@ import { NodePool } from './NodePool'; import { RecycleNodeDialog } from './RecycleNodeDialog'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; -import type { KubernetesTier, Region } from '@linode/api-v4'; +import type { KubernetesTier } from '@linode/api-v4'; export type StatusFilter = 'all' | 'offline' | 'provisioning' | 'running'; @@ -67,7 +67,6 @@ export interface Props { clusterRegionId: string; clusterTier: KubernetesTier; isLkeClusterRestricted: boolean; - regionsData: Region[]; } export const NodePoolsDisplay = (props: Props) => { @@ -78,7 +77,6 @@ export const NodePoolsDisplay = (props: Props) => { clusterRegionId, clusterTier, isLkeClusterRestricted, - regionsData, } = props; const { @@ -331,7 +329,6 @@ export const NodePoolsDisplay = (props: Props) => { clusterTier={clusterTier} onClose={() => setAddDrawerOpen(false)} open={addDrawerOpen} - regionsData={regionsData} /> void; value: string; } export const NodePoolUpdateStrategySelect = (props: Props) => { - const { onChange, value } = props; + const { onChange, value, noMarginTop, label } = props; return ( onChange(updateStrategy?.value)} options={UPDATE_STRATEGY_OPTIONS} placeholder="Select an Update Strategy" From 231458dfa92bb40cbcfc85a2245c2f776492f858 Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Tue, 12 Aug 2025 16:24:26 -0400 Subject: [PATCH 16/88] test: [M3-10325] - Show legacy 'Save Alerts' confirmation modal only if user has already opted into Beta Alerts mode (#12683) * prompt in beta region in legacy mode * Added changeset: Show legacy 'Save Alerts' confirmation modal only if user has already opted into Beta Alerts mode --- .../pr-12683-tests-1755010896822.md | 5 ++++ .../e2e/core/linodes/alerts-edit.spec.ts | 24 ++++++------------- .../manager/src/features/Linodes/constants.ts | 3 +++ 3 files changed, 15 insertions(+), 17 deletions(-) create mode 100644 packages/manager/.changeset/pr-12683-tests-1755010896822.md diff --git a/packages/manager/.changeset/pr-12683-tests-1755010896822.md b/packages/manager/.changeset/pr-12683-tests-1755010896822.md new file mode 100644 index 00000000000..9c95f1eb18f --- /dev/null +++ b/packages/manager/.changeset/pr-12683-tests-1755010896822.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Show legacy 'Save Alerts' confirmation modal only if user has already opted into Beta Alerts mode ([#12683](https://github.com/linode/manager/pull/12683)) diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index 29418530955..da0a70af60d 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -16,6 +16,7 @@ import { ALERTS_BETA_MODE_BUTTON_TEXT, ALERTS_LEGACY_MODE_BANNER_TEXT, ALERTS_LEGACY_MODE_BUTTON_TEXT, + ALERTS_LEGACY_PROMPT, } from 'src/features/Linodes/constants'; const MOCK_LINODE_ID = 2; @@ -387,23 +388,12 @@ describe('region enables alerts', function () { .should('be.enabled') .click(); }); - // TODO: this test passes but modal behavior may change when properly implemented in api (M3-10195) - // ui.dialog - // .findByTitle('Are you sure you want to save legacy Alerts?') - // .should('be.visible') - // .within(() => { - // ui.button.findByTitle('Confirm').should('be.visible') - // .click(); - // }); - // TODO: this test passes but modal behavior will change when properly implemented in api (M3-10195) - // TODO: this test passes but modal behavior may change when properly implemented in api (M3-10195) - // ui.dialog - // .findByTitle('Save Alerts?') - // .should('be.visible') - // .within(() => { - // ui.button.findByTitle('Save').should('be.visible') - // .click(); - // }); + ui.dialog + .findByTitle(ALERTS_LEGACY_PROMPT) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Confirm').should('be.visible').click(); + }); }); }); diff --git a/packages/manager/src/features/Linodes/constants.ts b/packages/manager/src/features/Linodes/constants.ts index d7ffb3e79a4..574beef12a8 100644 --- a/packages/manager/src/features/Linodes/constants.ts +++ b/packages/manager/src/features/Linodes/constants.ts @@ -26,3 +26,6 @@ export const ALERTS_BETA_MODE_BANNER_TEXT = export const ALERTS_LEGACY_MODE_BUTTON_TEXT = 'Try Alerts (Beta)'; export const ALERTS_BETA_MODE_BUTTON_TEXT = 'Switch to legacy Alerts'; + +export const ALERTS_LEGACY_PROMPT = + 'Are you sure you want to save legacy Alerts?'; From 9849406d899685cb31024529e50755479e386e96 Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Tue, 12 Aug 2025 16:12:28 -0500 Subject: [PATCH 17/88] refactor:[M3-7278] - SAST Scan Findings: Third party action not pinned to commit SHA (#12649) * Add SHAs * Added changeset: Pin third-party GitHub Actions to commit SHAs for security --- .github/workflows/ci.yml | 44 +++++++++---------- .github/workflows/coverage_badge.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/e2e_schedule_and_push.yml | 12 ++--- .github/workflows/eslint_review.yml | 6 +-- .github/workflows/security_scan.yml | 26 +++++------ .../pr-12649-tech-stories-1754511442121.md | 5 +++ 7 files changed, 51 insertions(+), 46 deletions(-) create mode 100644 packages/manager/.changeset/pr-12649-tech-stories-1754511442121.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60761224224..eab44266804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -63,7 +63,7 @@ jobs: needs: build-validation steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -83,7 +83,7 @@ jobs: needs: build-validation steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -107,7 +107,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -148,7 +148,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -171,7 +171,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -186,7 +186,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -202,7 +202,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -222,7 +222,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -242,7 +242,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -266,7 +266,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -282,7 +282,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -302,7 +302,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -316,13 +316,13 @@ jobs: path: packages/api-v4/lib - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/queries typecheck - + typecheck-shared: runs-on: ubuntu-latest needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -340,13 +340,13 @@ jobs: path: packages/validation/lib - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/shared typecheck - + typecheck-manager: runs-on: ubuntu-latest needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -375,7 +375,7 @@ jobs: - validate-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -397,7 +397,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} - run: pnpm publish -r --filter @linode/api-v4 --filter @linode/validation --no-git-checks --access public - name: slack-notify - uses: rtCamp/action-slack-notify@master + uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2.3.3 env: SLACK_CHANNEL: api-js-client SLACK_TITLE: "Packages published" @@ -413,7 +413,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -446,7 +446,7 @@ jobs: with: name: storybook-build path: storybook/build - - uses: jakejarvis/s3-sync-action@master + - uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1 with: args: --acl public-read --follow-symlinks --delete env: diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index 548ea89dfb1..639f21e3e0d 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4a81cf374f9..a0466e3b1b6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v5 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.0.21 diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 69c4e9f2a9c..ee26964a160 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -31,13 +31,13 @@ jobs: fail-fast: false matrix: user: - - { index: 1, name: 'USER_1' } - - { index: 2, name: 'USER_2' } - - { index: 3, name: 'USER_3' } - - { index: 4, name: 'USER_4' } + - { index: 1, name: "USER_1" } + - { index: 2, name: "USER_2" } + - { index: 3, name: "USER_3" } + - { index: 4, name: "USER_4" } steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -59,7 +59,7 @@ jobs: - run: pnpm run --filter @linode/validation build - run: pnpm run --filter @linode/api-v4 build - name: Run tests - uses: cypress-io/github-action@v6 + uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2 with: working-directory: packages/manager wait-on: "http://localhost:3000" diff --git a/.github/workflows/eslint_review.yml b/.github/workflows/eslint_review.yml index 5fc14caf77e..44430dfec58 100644 --- a/.github/workflows/eslint_review.yml +++ b/.github/workflows/eslint_review.yml @@ -12,7 +12,7 @@ jobs: package: [manager, api-v4, queries, shared, ui, utilities, validation] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 with: run_install: false version: 10 @@ -26,5 +26,5 @@ jobs: workdir: packages/${{ matrix.package }} github_token: ${{ secrets.GITHUB_TOKEN }} reporter: github-pr-check - level: warning # This will report both warnings and errors - filter_mode: added # Only comment on new/modified lines \ No newline at end of file + level: warning # This will report both warnings and errors + filter_mode: added # Only comment on new/modified lines diff --git a/.github/workflows/security_scan.yml b/.github/workflows/security_scan.yml index 05706bf466d..2b835cd894e 100644 --- a/.github/workflows/security_scan.yml +++ b/.github/workflows/security_scan.yml @@ -15,22 +15,22 @@ jobs: container: image: returntocorp/semgrep steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - # Perform scanning using Semgrep - # Pass even when it identifies issues or encounters errors. - - name: Run SAST scan - if: always() - run: semgrep ci || true - env: - SEMGREP_RULES: p/default + # Perform scanning using Semgrep + # Pass even when it identifies issues or encounters errors. + - name: Run SAST scan + if: always() + run: semgrep ci || true + env: + SEMGREP_RULES: p/default - # Post results to Slack notification channel. - - name: slack-notify - uses: rtCamp/action-slack-notify@master - env: + # Post results to Slack notification channel. + - name: slack-notify + uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2.3.3 + env: SLACK_WEBHOOK: ${{ secrets.SLACK_SAST_WEBHOOK }} SLACK_MESSAGE: "Message: ${{ github.event.head_commit.message }} \nRepository: ${{ github.event.repository.url }}" SLACK_COLOR: ${{ job.status }} - SLACK_FOOTER: '' + SLACK_FOOTER: "" MSG_MINIMAL: event,actions url,commit diff --git a/packages/manager/.changeset/pr-12649-tech-stories-1754511442121.md b/packages/manager/.changeset/pr-12649-tech-stories-1754511442121.md new file mode 100644 index 00000000000..cb4ad305582 --- /dev/null +++ b/packages/manager/.changeset/pr-12649-tech-stories-1754511442121.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Pin third-party GitHub Actions to commit SHAs for security ([#12649](https://github.com/linode/manager/pull/12649)) From 8c4211e22fe88da323b1adf71a6c497159bf64e6 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 13 Aug 2025 20:38:21 +0530 Subject: [PATCH 18/88] [DI-26394] - Add new flag to control services types in alerts and metrics (#12671) * [DI-26394] - Add new flag to control services types in alerts and metrics in ui * [DI-26394] - Update type * [DI-26394] - fix eslint issues * [DI-26394] - Cleanup * [DI-26394] - Remove aclpBetaServices flag * [DI-26394] - Update type * [DI-26394] - Remove fallbacks * [DI-26394] - Update reusable comp as per aclp-dev * [DI-26394] - Update comments * [DI-26394] - reuse type * upcoming: [DI-26394] - Update comment Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> * [DI-26394] - Make props optional and update test cases * [DI-26394] - Update component * [DI-26394] - Update component * test[DI-26398]: Add/Update spec to cover aclpServices LaunchDarkly flags for Alert and Metrics features * test[DI-26398]: Add/Update spec to cover aclpServices * test[DI-26398]: Fix typecheck issue * [DI-26394] - remove type assertion * [DI-26394] - Add changeset * upcoming:[DI-26394]: Use appropriate flag prop for linode create and details page * [DI-26394] - update flag check and e2e test desc * upcoming:[DI-26394]: Update flag correctly --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Co-authored-by: agorthi Co-authored-by: dmcintyr-akamai --- ...r-12671-upcoming-features-1754967230297.md | 5 + .../e2e/core/cloudpulse/aclp-support.spec.ts | 6 +- .../e2e/core/cloudpulse/alert-errors.spec.ts | 8 +- .../cloudpulse/alerts-listing-page.spec.ts | 17 +- .../alerts-service-ld-flags.spec.ts | 167 +++++++++ .../cloudpulse-dashboard-errors.spec.ts | 20 +- .../core/cloudpulse/create-user-alert.spec.ts | 8 +- .../dbaas-widgets-verification.spec.ts | 21 +- .../core/cloudpulse/edit-system-alert.spec.ts | 12 +- .../core/cloudpulse/edit-user-alert.spec.ts | 6 +- .../cloudpulse/feature-flag-disabled.spec.ts | 8 +- .../e2e/core/cloudpulse/groupby-tags.spec.ts | 7 +- .../linode-widget-verification.spec.ts | 19 +- .../metrics-service-ld-flags.spec.ts | 317 ++++++++++++++++++ .../nodebalancer-widget-verification.spec.ts | 26 +- .../cloudpulse/timerange-verification.spec.ts | 16 +- .../e2e/core/linodes/alerts-create.spec.ts | 26 +- .../e2e/core/linodes/alerts-edit.spec.ts | 24 +- .../manager/src/dev-tools/FeatureFlagTool.tsx | 2 +- .../manager/src/factories/featureFlags.ts | 46 ++- packages/manager/src/featureFlags.ts | 17 +- .../AlertsDetail/AlertDetailOverview.tsx | 4 +- .../AlertsListing/AlertListing.test.tsx | 181 ++++++++-- .../Alerts/AlertsListing/AlertListing.tsx | 27 +- .../Alerts/AlertsListing/AlertTableRow.tsx | 4 +- .../ContextualView/AlertReusableComponent.tsx | 4 +- .../CreateAlertDefinition.test.tsx | 73 +++- .../ServiceTypeSelect.test.tsx | 123 ++++++- .../GeneralInformation/ServiceTypeSelect.tsx | 24 +- .../CloudPulse/Alerts/Utils/utils.test.ts | 53 ++- .../features/CloudPulse/Alerts/Utils/utils.ts | 21 +- .../features/CloudPulse/Utils/utils.test.ts | 55 +++ .../src/features/CloudPulse/Utils/utils.ts | 17 +- .../shared/CloudPulseDashboardSelect.tsx | 35 +- .../features/Linodes/LinodeCreate/Actions.tsx | 4 +- .../AdditionalOptions/AdditionalOptions.tsx | 4 +- .../LinodeCreate/AdditionalOptions/Alerts.tsx | 8 +- .../Linodes/LinodeCreate/Summary/Summary.tsx | 4 +- .../features/Linodes/LinodeCreate/index.tsx | 4 +- .../LinodeAlerts/LinodeAlerts.tsx | 6 +- .../LinodeMetrics/LinodeMetrics.tsx | 6 +- .../LinodesDetail/LinodesDetailNavigation.tsx | 8 +- 42 files changed, 1197 insertions(+), 246 deletions(-) create mode 100644 packages/manager/.changeset/pr-12671-upcoming-features-1754967230297.md create mode 100644 packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts create mode 100644 packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts diff --git a/packages/manager/.changeset/pr-12671-upcoming-features-1754967230297.md b/packages/manager/.changeset/pr-12671-upcoming-features-1754967230297.md new file mode 100644 index 00000000000..9cee6408b07 --- /dev/null +++ b/packages/manager/.changeset/pr-12671-upcoming-features-1754967230297.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse: Add new flag - 'aclpServices', filter services at `CloudPulseDashboardSelect.tsx`, `AlertListing.tsx`, `ServiceTypeSelect.tsx` ([#12671](https://github.com/linode/manager/pull/12671)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts index d8f57e7ddaa..a1effa63ac8 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts @@ -32,10 +32,10 @@ import type { Stats } from '@linode/api-v4'; describe('ACLP Components UI varies according to ACLP support by region and user preference', function () { beforeEach(function () { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: false, - metrics: true, + alerts: { beta: false, enabled: false }, + metrics: { beta: true, enabled: true }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts index 328620c8cf5..6b986d80cb5 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts @@ -7,11 +7,7 @@ import { import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory } from 'src/factories'; - -import type { Flags } from 'src/featureFlags'; - -const flags: Partial = { aclp: { beta: true, enabled: true } }; +import { accountFactory, alertFactory, flagsFactory } from 'src/factories'; const mockAccount = accountFactory.build(); const mockAlerts = [ alertFactory.build({ @@ -39,7 +35,7 @@ describe('Alerts Listing Page - Error Handling', () => { * - Confirms that the UI does not reflect a successful state change if the request fails. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseServices(['linode', 'dbaas']); mockGetAllAlertDefinitions(mockAlerts).as('getAlertDefinitionsList'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index a31796a0fab..eebafe4b5ea 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -15,7 +15,12 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory, alertRulesFactory } from 'src/factories'; +import { + accountFactory, + alertFactory, + alertRulesFactory, + flagsFactory, +} from 'src/factories'; import { alertLimitMessage, alertToolTipText, @@ -33,19 +38,11 @@ import type { AlertStatusType, CloudPulseServiceType, } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; const alertDefinitionsUrl = '/alerts/definitions'; const mockProfile = profileFactory.build({ timezone: 'gmt', }); -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpBetaServices: { - dbaas: { metrics: true, alerts: true }, - linode: { metrics: true, alerts: true }, - }, -}; const mockAccount = accountFactory.build(); const now = new Date(); const mockAlerts = [ @@ -215,7 +212,7 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { * - Ensures API calls return correct responses and status codes. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetCloudPulseServices(['linode', 'dbaas']); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts new file mode 100644 index 00000000000..1c0a8b6cb9f --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts @@ -0,0 +1,167 @@ +/** + * @file Integration tests for feature flag behavior on the alert page. + */ +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + dashboardMetricFactory, + flagsFactory, +} from 'src/factories'; +/** + * This test ensures that widget titles are displayed correctly on the dashboard. + * This test suite is dedicated to verifying the functionality and display of widgets on the Cloudpulse dashboard. + * It includes: + * Validating that widgets are correctly loaded and displayed. + * Ensuring that widget titles and data match the expected values. + * Verifying that widget settings, such as granularity and aggregation, are applied correctly. + * Testing widget interactions, including zooming and filtering, to ensure proper behavior. + * Each test ensures that widgets on the dashboard operate correctly and display accurate information. + */ + +const { metrics } = widgetDetails.linode; +const serviceType = 'linode'; + +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); + +const mockAccount = accountFactory.build(); +const CREATE_ALERT_PAGE_URL = '/alerts/definitions/create'; +const NO_OPTIONS_TEXT = 'You have no options to choose from'; + +describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { + beforeEach(() => { + mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetCloudPulseServices([serviceType]).as('fetchServices'); + mockGetUserPreferences({}); + }); + + it('should show Linode with beta tag in Service dropdown on Alert page when alerts.beta is true', () => { + mockAppendFeatureFlags(flagsFactory.build()); + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + cy.get('[data-qa-id="linode"]') + .should('have.text', 'Linode') + .parent() + .as('linodeBetaServiceOption'); + + cy.get('@linodeBetaServiceOption') + .find('[data-testid="betaChip"]') + .should('be.visible') + .and('have.text', 'beta'); + + cy.get('@serviceInput').should('be.visible').type('Linode'); + ui.autocompletePopper.findByTitle('Linode').should('be.visible').click(); + }); + it('should exclude Linode beta in Service dropdown when alerts.beta is false', () => { + // Mock feature flags with alerts beta disabled + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: false, enabled: false }, + }, + }, + }); + + mockAppendFeatureFlags(mockflags); + + // Visit the alert creation page + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + + // Click the Service dropdown + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // Assert dropdown behavior + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('have.text', NO_OPTIONS_TEXT) + .and('not.contain.text', 'Linode beta'); + }); + + it('should show no available services in the Service dropdown when Linode alerts are disabled but beta is true', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: true, enabled: false }, + }, + }, + }); + + mockAppendFeatureFlags(mockflags); + // Visit the alert creation page + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + // Click the Service dropdown + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // ---------- Assert ---------- + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('have.text', NO_OPTIONS_TEXT) + .and('not.contain.text', 'Linode beta'); + }); + + it('should show no options and exclude Linode beta in Service dropdown when alerts are disabled but beta is true', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: true, enabled: false }, + }, + }, + }); + + mockAppendFeatureFlags(mockflags); + // Visit the alert creation page + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + // Click the Service dropdown + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // ---------- Assert ---------- + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .and('not.contain.text', 'Linode beta'); + }); + + it('should show Linode without beta tag in Service dropdown when alerts are enabled but not in beta', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: false, enabled: true }, + }, + }, + }); + mockAppendFeatureFlags(mockflags); + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // ---------- Assert ---------- + cy.get('[data-qa-id="linode"]') + .should('have.text', 'Linode') + .parent() + .as('linodeBetaServiceOption'); + + cy.get('@linodeBetaServiceOption') + .find('[data-testid="betaChip"]') + .should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index 639cb31deb3..1ced6eaa60d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -33,11 +33,11 @@ import { dashboardFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, widgetFactory, } from 'src/factories'; import type { Database } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; /** * Verifies the presence and values of specific properties within the aclpPreference object @@ -47,22 +47,6 @@ import type { Flags } from 'src/featureFlags'; * @param requestPayload - The payload received from the request, containing the aclpPreference object. * @param expectedValues - An object containing the expected values for properties to validate against the requestPayload. */ - -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; const { clusterName, dashboardName, engine, id, metrics, nodeType } = widgetDetails.dbaas; const serviceType = 'dbaas'; @@ -132,7 +116,7 @@ const mockAccount = accountFactory.build(); describe('Tests for API error handling', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 2da9c95684e..82b1f5fd4a4 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -26,15 +26,13 @@ import { cpuRulesFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, memoryRulesFactory, notificationChannelFactory, triggerConditionFactory, } from 'src/factories'; import { CREATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; - -import type { Flags } from 'src/featureFlags'; - export interface MetricDetails { aggregationType: string; dataField: string; @@ -43,8 +41,6 @@ export interface MetricDetails { threshold: string; } -const flags: Partial = { aclp: { beta: true, enabled: true } }; - // Create mock data const mockAccount = accountFactory.build(); const mockRegions = [ @@ -176,7 +172,7 @@ describe('Create Alert', () => { * - Confirms that the UI displays a success message after creating an alert. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetCloudPulseServices([serviceType]); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index ce1c4c76183..7302cfb398e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -27,6 +27,7 @@ import { dashboardMetricFactory, databaseFactory, dimensionFilterFactory, + flagsFactory, kubeLinodeFactory, widgetFactory, } from 'src/factories'; @@ -40,7 +41,6 @@ import type { DimensionFilter, Widgets, } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** @@ -55,23 +55,6 @@ import type { Interception } from 'support/cypress-exports'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; - -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; - const { clusterName, dashboardName, engine, id, metrics, nodeType } = widgetDetails.dbaas; const serviceType = 'dbaas'; @@ -199,7 +182,7 @@ const validateWidgetFilters = (widget: Widgets) => { describe('Integration Tests for DBaaS Dashboard ', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse mockGetLinodes([mockLinode]); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index f24a05fb99c..fc99e460e83 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -16,12 +16,14 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory, databaseFactory } from 'src/factories'; +import { + accountFactory, + alertFactory, + databaseFactory, + flagsFactory, +} from 'src/factories'; import type { Alert, Database } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; - -const flags: Partial = { aclp: { beta: true, enabled: true } }; const expectedResourceIds = Array.from({ length: 50 }, (_, i) => String(i + 1)); const mockAccount = accountFactory.build(); @@ -69,7 +71,7 @@ describe('Integration Tests for Edit Alert', () => { * - Confirms that after submitting, the data matches with the API response. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetRegions(regions); mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index 1b55b520162..3ab96ff6578 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -35,6 +35,7 @@ import { cpuRulesFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, memoryRulesFactory, notificationChannelFactory, triggerConditionFactory, @@ -43,10 +44,7 @@ import { UPDATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/con import { formatDate } from 'src/utilities/formatDate'; import type { Database } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; -// Feature flag setup -const flags: Partial = { aclp: { beta: true, enabled: true } }; const mockAccount = accountFactory.build(); // Mock alert details @@ -127,7 +125,7 @@ describe('Integration Tests for Edit Alert', () => { */ beforeEach(() => { // Mocking various API responses - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetRegions(regions); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts index b61ec199da9..d25dfba0a9a 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts @@ -8,13 +8,13 @@ import { randomLabel, randomNumber } from 'support/util/random'; import type { UserPreferences } from '@linode/api-v4'; -describe('User preferences for alerts and metrics have no effect when aclpBetaServices alerts/metrics feature flag is disabled', () => { +describe('User preferences for alerts and metrics have no effect when aclpServices alerts/metrics feature flag is disabled', () => { beforeEach(() => { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: false, - metrics: false, + alerts: { beta: false, enabled: false }, + metrics: { beta: false, enabled: false }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts index b6c012f4dc6..e6eb5094c01 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts @@ -13,16 +13,13 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory } from 'src/factories'; +import { accountFactory, alertFactory, flagsFactory } from 'src/factories'; import type { Alert, AlertStatusType, CloudPulseServiceType, } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; - -const flags: Partial = { aclp: { beta: true, enabled: true } }; const mockAccount = accountFactory.build(); const statusList: AlertStatusType[] = [ @@ -89,7 +86,7 @@ describe('Integration Tests for Grouping Alerts by Tags on the CloudPulse Alerts */ it('Displays alerts accurately grouped under their corresponding tags', () => { // Setup necessary mocks and feature flags - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseServices(serviceTypes); mockGetAllAlertDefinitions(mockAlerts).as('getAlertDefinitionsList'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 350261e517c..8e2b98211fa 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -24,6 +24,7 @@ import { cloudPulseMetricsResponseFactory, dashboardFactory, dashboardMetricFactory, + flagsFactory, kubeLinodeFactory, widgetFactory, } from 'src/factories'; @@ -31,7 +32,6 @@ import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidge import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; import type { CloudPulseMetricsResponse } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** @@ -46,21 +46,6 @@ import type { Interception } from 'support/cypress-exports'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; const { dashboardName, id, metrics, region, resource } = widgetDetails.linode; const serviceType = 'linode'; const dashboard = dashboardFactory.build({ @@ -159,7 +144,7 @@ const getWidgetLegendRowValuesFromResponse = ( describe('Integration Tests for Linode Dashboard ', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse mockGetLinodes([mockLinode]); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts new file mode 100644 index 00000000000..82a3548e5ed --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts @@ -0,0 +1,317 @@ +/** + * @file Integration tests for feature flag behavior on the Metrics page. + */ +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetCloudPulseDashboard, + mockGetCloudPulseDashboards, + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + dashboardFactory, + dashboardMetricFactory, + flagsFactory, + widgetFactory, +} from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; + +/** + * This test ensures that widget titles are displayed correctly on the dashboard. + * This test suite is dedicated to verifying the functionality and display of widgets on the Cloudpulse dashboard. + * It includes: + * Validating that widgets are correctly loaded and displayed. + * Ensuring that widget titles and data match the expected values. + * Verifying that widget settings, such as granularity and aggregation, are applied correctly. + * Testing widget interactions, including zooming and filtering, to ensure proper behavior. + * Each test ensures that widgets on the dashboard operate correctly and display accurate information. + */ + +const { dashboardName, id, metrics } = widgetDetails.linode; +const serviceType = 'linode'; +const dashboard = dashboardFactory.build({ + label: dashboardName, + service_type: serviceType, + widgets: metrics.map(({ name, title, unit, yLabel }) => { + return widgetFactory.build({ + label: title, + metric: name, + unit, + y_label: yLabel, + }); + }), +}); + +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); +const mockAccount = accountFactory.build(); + +describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { + /* + * - Mocks ACLP feature flags dynamically to simulate various flag combinations for Linode services. + * + * - Validates visibility of "Linode" dashboard option in Metrics dropdown based on: + * - Presence of `aclpServices.linode.metrics` flag. + * - Enabled and beta states under `metrics` and `alerts` keys. + * + * - Ensures correct rendering behavior: + * - "Linode" option should appear only when `metrics.enabled` is true. + * - Beta chip should appear only when `metrics.beta` is also true. + * - Linode should not appear if `metrics` flag is missing, disabled, or malformed. + * + * - Asserts "no options" message is shown when Linode dashboard is not available. + * + * - Uses Cypress commands to: + * - Visit Metrics page after login. + * - Interact with autocomplete dropdown and select dashboards. + * - Validate presence/absence of beta chip (`[data-testid="betaChip"]`). + * + * - Improves test coverage for conditional UI behavior tied to feature flag configurations. + * - Supports staged rollout testing and toggling of experimental dashboard features. + */ + + beforeEach(() => { + mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetCloudPulseServices([serviceType]).as('fetchServices'); + mockGetUserPreferences({}); + }); + it('should display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is enabled and the service is enabled', () => { + mockAppendFeatureFlags(flagsFactory.build()); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // navigate to the metrics page + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + + // Click using the alias + cy.get('@dashboardInput').click(); + + cy.get('[data-qa-id="linode"]') // Selects the Linode label + .should('have.text', 'Linode') + .parent() // Moves up to the
  • containing both label and chip + .as('linodeBetaServiceOption'); // Alias for reuse + + cy.get('@linodeBetaServiceOption') + .find('[data-testid="betaChip"]') + .should('be.visible') + .and('have.text', 'beta'); + + ui.autocomplete + .findByLabel('Dashboard') + .should('be.visible') + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('have.text', dashboardName) + .click(); + }); + + it('should display "Linode" without a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is enabled', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { beta: false, enabled: true }, + }, + }, + }); + + // Apply mock flags + mockAppendFeatureFlags(mockflags); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // Visit the Metrics page + cy.visitWithLogin('/metrics'); + + // Locate and open the Dashboard dropdown + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + cy.get('@dashboardInput').click(); + + // Verify "Linode" is present without a beta chip + ui.autocompletePopper + .findByTitle('Linode') + .should('be.visible') + .within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + + // Select the dashboard + cy.get('@dashboardInput').should('be.visible').type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('have.text', dashboardName) + .click(); + }); + + it('should display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is true and enabled is false', () => { + // Mock the feature flags to disable metrics for Linode + + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { beta: true, enabled: false }, + }, + }, + }); + // Apply the mock feature flags + mockAppendFeatureFlags(mockflags); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // Visit the Metrics page after login + + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').click(); + + // Verify the autocomplete dropdown is visible and contains the "no options" message + + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert that "Linode" does not appear in the dropdown + + cy.contains('Linode').should('not.exist'); + // Assert that the beta chip is not rendered + + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should not display "Linode" when its feature flag is missing', () => { + // Mock the feature flags without linode under aclpServices + const flags = { + aclp: { beta: true, enabled: true }, + aclpServices: { linode: {} }, + } as unknown as Partial; + mockAppendFeatureFlags(flags); + // Visit the Metrics page after login + cy.visitWithLogin('/metrics'); + // Open the dashboard autocomplete dropdown + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + cy.get('@dashboardInput').click(); + + // Verify the dropdown is visible and shows "no options" + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert "Linode" is not shown + cy.contains('Linode').should('not.exist'); + + // Assert no beta chip is visible + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should not display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is not enabled', () => { + // Mock the feature flags to disable metrics for Linode + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { beta: false, enabled: false }, + }, + }, + }); + // Apply the mock feature flags + mockAppendFeatureFlags(mockflags); + // Visit the Metrics page after login + + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').click(); + + // Verify the autocomplete dropdown is visible and contains the "no options" message + + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert that "Linode" does not appear in the dropdown + + cy.contains('Linode').should('not.exist'); + // Assert that the beta chip is not rendered + + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should show no service options when aclpServices flag is missing', () => { + // Mock the feature flags without linode under aclpServices + const flags = { + aclp: { beta: true, enabled: true }, + } as unknown as Partial; + mockAppendFeatureFlags(flags); + // Visit the Metrics page after login + cy.visitWithLogin('/metrics'); + // Open the dashboard autocomplete dropdown + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + cy.get('@dashboardInput').click(); + + // Verify the dropdown is visible and shows "no options" + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert "Linode" is not shown + cy.contains('Linode').should('not.exist'); + + // Assert no beta chip is visible + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should not show Linode in Dashboard dropdown when metrics flags are missing and service is not enabled', () => { + // Mock the feature flags to disable metrics for Linode + + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { enabled: false }, + }, + }, + }); + // Apply the mock feature flags + mockAppendFeatureFlags(mockflags); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // Visit the Metrics page after login + + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').click(); + + // Verify the autocomplete dropdown is visible and contains the "no options" message + + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert that "Linode" does not appear in the dropdown + + cy.contains('Linode').should('not.exist'); + // Assert that the beta chip is not rendered + + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts index e716a115ccd..3f253e78fce 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -30,6 +30,7 @@ import { cloudPulseMetricsResponseFactory, dashboardFactory, dashboardMetricFactory, + flagsFactory, kubeLinodeFactory, widgetFactory, } from 'src/factories'; @@ -37,7 +38,6 @@ import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidge import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; import type { CloudPulseMetricsResponse } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** * This test ensures that widget titles are displayed correctly on the dashboard. @@ -51,27 +51,6 @@ import type { Interception } from 'support/cypress-exports'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpBetaServices: { nodebalancer: { alerts: true, metrics: true } }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'nodebalancer', - }, - ], -}; const { dashboardName, id, metrics, region, resource } = widgetDetails.nodebalancer; const serviceType = 'nodebalancer'; @@ -167,9 +146,8 @@ const mockNodeBalancer = nodeBalancerFactory.build({ // Tests will be modified describe('Integration Tests for Nodebalancer Dashboard ', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(accountFactory.build({})); - mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); mockGetCloudPulseServices([serviceType]).as('fetchServices'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index 3b88670e2d5..0c5dc680376 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -30,12 +30,12 @@ import { dashboardFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, widgetFactory, } from 'src/factories'; import { formatDate } from 'src/utilities/formatDate'; import type { Database, DateTimeWithPreset } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; const formatter = "yyyy-MM-dd'T'HH:mm:ss'Z'"; @@ -59,17 +59,6 @@ const mockRegion = regionFactory.build({ }, }); -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; - const { dashboardName, engine, id, metrics } = widgetDetails.dbaas; const serviceType = 'dbaas'; const dashboard = dashboardFactory.build({ @@ -212,8 +201,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura */ beforeEach(() => { - cy.viewport(1280, 720); - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions.data); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts index 1c2761ba27f..87cbfc09cf0 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -36,10 +36,16 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.wrap(mockRegions).as('mockRegions'); mockGetRegions(mockRegions).as('getRegions'); mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: true, - metrics: false, + alerts: { + beta: true, + enabled: true, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); @@ -453,7 +459,7 @@ describe('Create flow when beta alerts enabled by region and feature flag', func }); }); -describe('aclpBetaServices feature flag disabled', function () { +describe('aclpServices feature flag disabled', function () { it('Alerts not present when feature flag disabled', function () { const mockEnabledRegion = regionFactory.build({ capabilities: ['Linodes'], @@ -465,10 +471,16 @@ describe('aclpBetaServices feature flag disabled', function () { cy.wrap(mockRegions).as('mockRegions'); mockGetRegions(mockRegions).as('getRegions'); mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: false, - metrics: false, + alerts: { + beta: false, + enabled: false, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index da0a70af60d..74c3ab9e3a5 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -58,10 +58,16 @@ const mockEnabledBetaAlerts = { describe('region enables alerts', function () { beforeEach(() => { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: true, - metrics: false, + alerts: { + beta: true, + enabled: true, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); @@ -400,10 +406,16 @@ describe('region enables alerts', function () { describe('region disables alerts. beta alerts not available regardless of linode settings', function () { beforeEach(() => { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: true, - metrics: false, + alerts: { + beta: true, + enabled: true, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 92bc087325a..f5a4055b900 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -21,7 +21,7 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclp', label: 'CloudPulse' }, { flag: 'aclpAlerting', label: 'CloudPulse Alerting' }, - { flag: 'aclpBetaServices', label: 'ACLP Beta Services' }, + { flag: 'aclpServices', label: 'ACLP Services' }, { flag: 'aclpLogs', label: 'ACLP Logs' }, { flag: 'apl', label: 'Akamai App Platform' }, { flag: 'aplGeneralAvailability', label: 'Akamai App Platform GA' }, diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index e3348c6c856..128355a483d 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -1,6 +1,6 @@ import { Factory } from '@linode/utilities'; -import type { ProductInformationBannerFlag } from 'src/featureFlags'; +import type { Flags, ProductInformationBannerFlag } from 'src/featureFlags'; export const productInformationBannerFactory = Factory.Sync.makeFactory({ @@ -15,3 +15,47 @@ export const productInformationBannerFactory = message: 'Store critical data and media files with S3-Compatible Object Storage. New Availability: Atlanta', }); + +export const flagsFactory = Factory.Sync.makeFactory>({ + aclp: { beta: true, enabled: true }, + aclpServices: { + linode: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + firewall: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + dbaas: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + nodebalancer: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'nodebalancer', + }, + { + dimensionKey: 'firewall', + maxResourceSelections: 10, + serviceType: 'firewall', + }, + ], +}); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index f32d66907a2..008bbddc867 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -68,8 +68,17 @@ interface GeckoFeatureFlag extends BaseFeatureFlag { } interface AclpFlag { + /** + * This property indicates whether the feature is in beta + */ beta: boolean; + /** + * This property indicates whether to bypass account capabilities check or not + */ bypassAccountCapabilities?: boolean; + /** + * This property indicates whether the feature is enabled + */ enabled: boolean; } @@ -126,10 +135,10 @@ export interface Flags { aclp: AclpFlag; aclpAlerting: AclpAlerting; aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[]; - aclpBetaServices: Partial; aclpLogs: BetaFeatureFlag; aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; + aclpServices: Partial; apicliButtonCopy: string; apiMaintenance: APIMaintenance; apl: boolean; @@ -318,9 +327,9 @@ export interface AclpAlertServiceTypeConfig { // This can be extended to have supportedRegions, supportedFilters and other tags } -export type AclpBetaServices = { +export type AclpServices = { [serviceType in CloudPulseServiceType]: { - alerts: boolean; - metrics: boolean; + alerts?: AclpFlag; + metrics?: AclpFlag; }; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx index 17e4eaa71a9..3609cd728d6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx @@ -37,7 +37,7 @@ export const AlertDetailOverview = React.memo((props: OverviewProps) => { } = alertDetails; const { data: serviceTypeList, isFetching } = useCloudPulseServiceTypes(true); - const { aclpBetaServices } = useFlags(); + const { aclpServices } = useFlags(); if (isFetching) { return ; @@ -65,7 +65,7 @@ export const AlertDetailOverview = React.memo((props: OverviewProps) => { ({ useAllAlertDefinitionsQuery: vi.fn().mockReturnValue({}), useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), + useFlags: vi.fn(), })); +const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, +}; + +const linodeLabel = 'Linode beta'; +const databasesLabel = 'Databases beta'; + vi.mock('src/queries/cloudpulse/alerts', async () => { const actual = await vi.importActual('src/queries/cloudpulse/alerts'); return { @@ -36,6 +53,14 @@ vi.mock('src/queries/cloudpulse/services', async () => { }; }); +vi.mock('src/hooks/useFlags', () => ({ + useFlags: queryMocks.useFlags, +})); + +queryMocks.useFlags.mockReturnValue({ + aclpServices: aclpServicesFlag, +}); + const mockResponse = alertFactory.buildList(3); const serviceTypes = [ { @@ -48,14 +73,23 @@ const serviceTypes = [ }, ]; -describe('Alert Listing', () => { - it('should render the alert landing table with items', async () => { +describe('Alert Listing - Core Functionality', () => { + beforeEach(() => { queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ data: mockResponse, isError: false, isLoading: false, status: 'success', }); + queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: { data: serviceTypes }, + isError: false, + isLoading: false, + status: 'success', + }); + }); + + it('should render the alert landing table with items', async () => { renderWithTheme(); expect(screen.getByText('Alert Name')).toBeVisible(); expect(screen.getByText('Service')).toBeVisible(); @@ -71,13 +105,6 @@ describe('Alert Listing', () => { }); it('should render the alert row', async () => { - queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ - data: mockResponse, - isError: false, - isLoading: false, - status: 'success', - }); - const { getByText } = renderWithTheme(); expect(getByText(mockResponse[0].label)).toBeVisible(); expect(getByText(mockResponse[1].label)).toBeVisible(); @@ -94,13 +121,6 @@ describe('Alert Listing', () => { status: 'success', }); - queryMocks.useCloudPulseServiceTypes.mockReturnValue({ - data: { data: serviceTypes }, - isError: false, - isLoading: false, - status: 'success', - }); - const { getByRole, getByTestId, getByText, queryByText } = renderWithTheme( ); @@ -112,11 +132,11 @@ describe('Alert Listing', () => { within(serviceFilter).getByRole('button', { name: 'Open' }) ); await waitFor(() => { - getByRole('option', { name: 'Databases' }); - getByRole('option', { name: 'Linode' }); + getByRole('option', { name: databasesLabel }); + getByRole('option', { name: linodeLabel }); }); await act(async () => { - await userEvent.click(getByRole('option', { name: 'Databases' })); + await userEvent.click(getByRole('option', { name: databasesLabel })); }); await waitFor(() => { @@ -250,6 +270,7 @@ describe('Alert Listing', () => { 'Creation of 3 alerts has failed as indicated in the status column. Please open a support ticket for assistance.' ); }); + it('should disable the create button when the alerts are loading', async () => { queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ data: null, @@ -263,3 +284,125 @@ describe('Alert Listing', () => { expect(createButton).toBeDisabled(); }); }); + +describe('Alert Listing - Feature Flag Management', () => { + beforeEach(() => { + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: mockResponse, + isError: false, + isLoading: false, + status: 'success', + }); + + queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: { data: serviceTypes }, + isError: false, + isLoading: false, + status: 'success', + }); + }); + + it('should render the alerts from the enabled services', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: aclpServicesFlag, + }); + + renderWithTheme(); + + expect(screen.getByText(mockResponse[0].label)).toBeVisible(); + expect(screen.getByText(mockResponse[1].label)).toBeVisible(); + expect(screen.getByText(mockResponse[2].label)).toBeVisible(); + }); + + it('should not render the alerts from the disabled services', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: false, beta: true }, + metrics: { enabled: false, beta: true }, + }, + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }, + }); + + renderWithTheme(); + expect(screen.queryByText(mockResponse[0].label)).not.toBeInTheDocument(); + expect(screen.queryByText(mockResponse[1].label)).not.toBeInTheDocument(); + expect(screen.queryByText(mockResponse[2].label)).not.toBeInTheDocument(); + }); + + it('should not render the alerts from the services which are missing in the flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }, + }); + + renderWithTheme(); + expect(screen.queryByText(mockResponse[0].label)).not.toBeInTheDocument(); + expect(screen.queryByText(mockResponse[1].label)).not.toBeInTheDocument(); + expect(screen.queryByText(mockResponse[2].label)).not.toBeInTheDocument(); + }); + + it('should render the service types based on the enabled services from the aclp services flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: false, beta: true }, + metrics: { enabled: false, beta: true }, + }, + }, + }); + + renderWithTheme(); + + const serviceFilterDropdown = screen.getByTestId('alert-service-filter'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + + expect(screen.getByRole('option', { name: linodeLabel })).toBeVisible(); + expect(screen.queryByRole('option', { name: databasesLabel })).toBeNull(); // Verify that Databases is NOT present (filtered out by the flag) + }); + + it('should not return service types that are missing in the flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }, + }); + renderWithTheme(); + const serviceFilterDropdown = screen.getByTestId('alert-service-filter'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + expect(screen.getByRole('option', { name: linodeLabel })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Databases' })).toBeNull(); + }); + + it('should not return service types that are missing the alerts property in the flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + metrics: { enabled: true, beta: true }, + }, + }, + }); + + renderWithTheme(); + expect(screen.queryByRole('option', { name: 'Linode' })).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx index 3d94db48c23..ab02de71775 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -24,6 +24,7 @@ import { usePreferencesToggle } from '../../Utils/UserPreference'; import { alertStatusOptions } from '../constants'; import { AlertListNoticeMessages } from '../Utils/AlertListNoticeMessages'; import { scrollToElement } from '../Utils/AlertResourceUtils'; +import { alertsFromEnabledServices } from '../Utils/utils'; import { AlertsListTable } from './AlertListTable'; import { alertLimitMessage, @@ -51,13 +52,18 @@ interface AlertsLimitErrorMessageProps { export const AlertListing = () => { const navigate = useNavigate(); - const { data: alerts, error, isLoading } = useAllAlertDefinitionsQuery(); + const { data: allAlerts, error, isLoading } = useAllAlertDefinitionsQuery(); const { data: serviceOptions, error: serviceTypesError, isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); - const { aclpBetaServices, aclpAlerting } = useFlags(); + + const { aclpServices, aclpAlerting } = useFlags(); + + // Filter alerts based on the enabled services from the LD flag + const alerts = alertsFromEnabledServices(allAlerts, aclpServices); + const userAlerts = alerts?.filter(({ type }) => type === 'user') ?? []; const isAlertLimitReached = userAlerts.length >= (aclpAlerting?.accountAlertLimit ?? 10); @@ -74,12 +80,17 @@ export const AlertListing = () => { CloudPulseServiceType >[] => { return serviceOptions && serviceOptions.data.length > 0 - ? serviceOptions.data.map((service) => ({ - label: service.label, - value: service.service_type, - })) + ? serviceOptions.data + .filter( + (service) => + aclpServices?.[service.service_type]?.alerts?.enabled ?? false + ) + .map((service) => ({ + label: service.label, + value: service.service_type, + })) : []; - }, [serviceOptions]); + }, [aclpServices, serviceOptions]); const [searchText, setSearchText] = React.useState(''); @@ -252,7 +263,7 @@ export const AlertListing = () => { return ( {option.label}{' '} - {aclpBetaServices?.[option.value]?.alerts && } + {aclpServices?.[option.value]?.alerts?.beta && } ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx index d4343734097..93707367ff9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -45,7 +45,7 @@ export const AlertTableRow = (props: Props) => { updated_by, } = alert; - const { aclpBetaServices } = useFlags(); + const { aclpServices } = useFlags(); return ( @@ -68,7 +68,7 @@ export const AlertTableRow = (props: Props) => { {services.find((service) => service.value === service_type)?.label}{' '} - {aclpBetaServices?.[service_type]?.alerts && } + {aclpServices?.[service_type]?.alerts?.beta && } {created_by} diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx index aa1459cf746..e49121f398b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -84,7 +84,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { [alerts, regionId, searchText, selectedType] ); - const { aclpBetaServices } = useFlags(); + const { aclpServices } = useFlags(); const navigate = useNavigate(); @@ -102,7 +102,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { Alerts - {aclpBetaServices?.[serviceType]?.alerts && } + {aclpServices?.[serviceType]?.alerts?.beta && } -
  • + ); }); diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx index 0a5614a4fc2..38c168c5591 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx @@ -3,9 +3,9 @@ import Grid from '@mui/material/Grid'; import * as React from 'react'; import EnabledIcon from 'src/assets/icons/checkmark-enabled.svg'; -import AkamaiWaveOnlyIcon from 'src/assets/icons/providers/akamai-logo-rgb-waveOnly.svg'; import GitHubIcon from 'src/assets/icons/providers/github-logo.svg'; import GoogleIcon from 'src/assets/icons/providers/google-logo.svg'; +import AkamaiWaveIcon from 'src/assets/logo/akamai-wave.svg'; import { Link } from 'src/components/Link'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { useFlags } from 'src/hooks/useFlags'; @@ -21,13 +21,13 @@ interface Props { const icons: Record = { github: GitHubIcon, google: GoogleIcon, - password: AkamaiWaveOnlyIcon, + password: AkamaiWaveIcon, }; const linode = { displayName: 'Cloud Manager', href: '', - icon: AkamaiWaveOnlyIcon, + icon: AkamaiWaveIcon, name: 'password' as TPAProvider, }; diff --git a/packages/manager/src/routes/auth/index.ts b/packages/manager/src/routes/auth/index.ts index 8b91f930a0d..fde7eff1a12 100644 --- a/packages/manager/src/routes/auth/index.ts +++ b/packages/manager/src/routes/auth/index.ts @@ -1,4 +1,4 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { CancelLanding } from 'src/features/CancelLanding/CancelLanding'; import { LoginAsCustomerCallback } from 'src/OAuth/LoginAsCustomerCallback'; @@ -7,15 +7,22 @@ import { OAuthCallback } from 'src/OAuth/OAuthCallback'; import { rootRoute } from '../root'; -interface CancelLandingSearch { - survey_link?: string; -} - const cancelLandingRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cancel', component: CancelLanding, - validateSearch: (search: CancelLandingSearch) => search, + onError() { + throw redirect({ to: '/' }); + }, + validateSearch(search) { + if (!search.survey_link) { + throw new Error('No survey in search params!'); + } + if (typeof search.survey_link !== 'string') { + throw new Error('Expected survey_link to be a string but it was not...'); + } + return { survey_link: search.survey_link }; + }, }); const logoutRoute = createRoute({ diff --git a/packages/search/README.md b/packages/search/README.md index bed7cb4d000..0666ef7c50f 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -2,7 +2,7 @@ Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). -The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. +The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Cloud Manager. ## Example diff --git a/packages/shared/README.md b/packages/shared/README.md index 33cd2c58897..16ebac5c8c2 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -1,6 +1,6 @@ # Shared Feature Component Library -`@linode/shared` contains definitions for React-based feature components and hooks that are used frequently across Akamai Connected Cloud Manager. +`@linode/shared` contains definitions for React-based feature components and hooks that are used frequently across Akamai Cloud Manager. In contrast to the other libraries, [`@linode/ui`](../ui/) and [`@linode/utilities`](../utilities/) in this repository, components and hooks in this package make use of [`@linode/api-v4`](../api-v4/), [`@linode/queries`](../queries/) and other dependencies to implement common, opinionated and complex components to enable a seamless experience for users as they navigate between features of the app. From 14c2f36271df3233f2c8f2729891f3e6abaaf7ed Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 15 Aug 2025 17:02:04 +0200 Subject: [PATCH 38/88] [UIE-9084] - Enable view payments based on new permissions (#12682) * Enable view payments based on new permissions * Added changeset: Enable view all payments query based on new permissions --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../manager/.changeset/pr-12682-changed-1755008751373.md | 5 +++++ packages/manager/src/features/Billing/BillingDetail.tsx | 6 +++++- .../BillingPanels/PaymentInfoPanel/PaymentMethods.tsx | 2 +- packages/queries/src/account/payment.ts | 7 ++----- 4 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-12682-changed-1755008751373.md diff --git a/packages/manager/.changeset/pr-12682-changed-1755008751373.md b/packages/manager/.changeset/pr-12682-changed-1755008751373.md new file mode 100644 index 00000000000..fec021f795b --- /dev/null +++ b/packages/manager/.changeset/pr-12682-changed-1755008751373.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Enable view all payments query based on new permissions ([#12682](https://github.com/linode/manager/pull/12682)) diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index 50b0ff6c636..db2bd9a2a32 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -12,6 +12,7 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PAYPAL_CLIENT_ID } from 'src/constants'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { BillingActivityPanel } from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; @@ -20,11 +21,14 @@ import { ContactInformation } from './BillingPanels/ContactInfoPanel/ContactInfo import PaymentInformation from './BillingPanels/PaymentInfoPanel'; export const BillingDetail = () => { + const { data: permissions } = usePermissions('account', [ + 'list_billing_payments', + ]); const { data: paymentMethods, error: paymentMethodsError, isLoading: paymentMethodsLoading, - } = useAllPaymentMethodsQuery(); + } = useAllPaymentMethodsQuery(permissions?.list_billing_payments ?? false); const { data: account, diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx index 4a11e83dc86..3a50edf5e17 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx @@ -49,7 +49,7 @@ const PaymentMethods = ({ ); } - if (!paymentMethods || paymentMethods?.length == 0) { + if (!paymentMethods || paymentMethods?.length === 0) { return ( No payment methods have been specified for this account. diff --git a/packages/queries/src/account/payment.ts b/packages/queries/src/account/payment.ts index 698f56442b7..cbfef4d3eeb 100644 --- a/packages/queries/src/account/payment.ts +++ b/packages/queries/src/account/payment.ts @@ -2,7 +2,6 @@ import { addPaymentMethod, makeDefaultPaymentMethod } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryPresets } from '../base'; -import { useGrants } from '../profile'; import { accountQueries } from './queries'; import type { @@ -12,13 +11,11 @@ import type { PaymentMethodPayload, } from '@linode/api-v4'; -export const useAllPaymentMethodsQuery = () => { - const { data: grants } = useGrants(); - +export const useAllPaymentMethodsQuery = (enabled: boolean) => { return useQuery({ ...accountQueries.paymentMethods, ...queryPresets.oneTimeFetch, - enabled: grants?.global?.account_access !== null, + enabled, }); }; From 37605ed3234e5901ac8178861ba838508e6a1188 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 15 Aug 2025 17:08:24 +0200 Subject: [PATCH 39/88] feat: [UIE-9078] - IAM RBAC: add the missing permission check for Linode CreateDiskDrawer (#12667) * feat: [UIE-9078] - IAM RBAC: add the missing permission checks * Added changeset: IAM RBAC: add the missing permission checks for creating a disk in the drawer --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../pr-12667-changed-1754909830208.md | 5 ++ .../LinodeStorage/CreateDiskDrawer.test.tsx | 54 +++++++++++++++++-- .../LinodeStorage/CreateDiskDrawer.tsx | 10 +--- .../LinodeStorage/LinodeDisks.tsx | 1 + 4 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-12667-changed-1754909830208.md diff --git a/packages/manager/.changeset/pr-12667-changed-1754909830208.md b/packages/manager/.changeset/pr-12667-changed-1754909830208.md new file mode 100644 index 00000000000..ff26f172649 --- /dev/null +++ b/packages/manager/.changeset/pr-12667-changed-1754909830208.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM RBAC: add the missing permission checks for creating a disk in the drawer ([#12667](https://github.com/linode/manager/pull/12667)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx index f441b080d9f..2f573b2cadc 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx @@ -8,6 +8,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateDiskDrawer } from './CreateDiskDrawer'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + create_linode_disk: true, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + +const props = { + onClose: vi.fn(), + disabled: !queryMocks.userPermissions().data.create_linode_disk, + linodeId: 1, + open: true, +}; + describe('CreateDiskDrawer', () => { it('should render', async () => { server.use( @@ -22,7 +41,7 @@ describe('CreateDiskDrawer', () => { ); const { findByText, getByLabelText, getByText } = renderWithTheme( - + ); // Title @@ -54,10 +73,37 @@ describe('CreateDiskDrawer', () => { }) ); - const { findByText } = renderWithTheme( - - ); + const { findByText } = renderWithTheme(); await findByText('Maximum size: 1000 MB'); }); + + it('should enable the "Create" button when user has permission', () => { + const { getByRole } = renderWithTheme(); + + const createBtn = getByRole('button', { + name: 'Create', + }); + expect(createBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('should disable the "Create" button when user does not have permission', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_linode_disk: false, + }, + }); + + const { getByRole } = renderWithTheme( + + ); + + const createBtn = getByRole('button', { + name: 'Create', + }); + expect(createBtn).toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx index ffd08da6703..1a7069b54cf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx @@ -21,7 +21,6 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ModeSelect } from 'src/components/ModeSelect/ModeSelect'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useEventsPollingActions } from 'src/queries/events/events'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; @@ -48,13 +47,14 @@ const modeList: Mode[] = [ ]; export interface Props { + disabled: boolean; linodeId: number; onClose: () => void; open: boolean; } export const CreateDiskDrawer = (props: Props) => { - const { linodeId, onClose, open } = props; + const { linodeId, onClose, open, disabled } = props; const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); @@ -65,12 +65,6 @@ export const CreateDiskDrawer = (props: Props) => { const { data: disks } = useAllLinodeDisksQuery(linodeId, open); - const disabled = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'linode', - id: linodeId, - }); - const { mutateAsync: createDisk, reset } = useLinodeDiskCreateMutation(linodeId); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx index 6b95b6ee4e7..9b962de61b9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx @@ -238,6 +238,7 @@ export const LinodeDisks = () => { open={isDeleteDialogOpen} /> setIsCreateDrawerOpen(false)} open={isCreateDrawerOpen} From 603ddd103007e1cb1fa1eb7ad472b87ffc39e059 Mon Sep 17 00:00:00 2001 From: rodonnel-akamai Date: Fri, 15 Aug 2025 11:13:05 -0400 Subject: [PATCH 40/88] feat: [UIE-9066] - IAM RBAC - Fix Assign Roles drawer users autocomplete (#12684) * feat: [UIE-9066] - IAM RBAC - Fix Assign Roles drawer users autocomplete * Added changeset: UIE-9066 - Assign Roles drawer fix for users autocomplete * Update to infinite scroll * Update packages/manager/.changeset/pr-12684-fixed-1755011334094.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * updated based on comments to add memoize, small cleanup --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-12684-fixed-1755011334094.md | 5 + .../RolesTable/AssignSelectedRolesDrawer.tsx | 101 +++++++++++++----- packages/queries/src/account/queries.ts | 5 + packages/queries/src/account/users.ts | 21 ++++ 4 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 packages/manager/.changeset/pr-12684-fixed-1755011334094.md diff --git a/packages/manager/.changeset/pr-12684-fixed-1755011334094.md b/packages/manager/.changeset/pr-12684-fixed-1755011334094.md new file mode 100644 index 00000000000..6e169abfdf8 --- /dev/null +++ b/packages/manager/.changeset/pr-12684-fixed-1755011334094.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Incomplete results shown when filtering by user in Assign Roles drawer ([#12684](https://github.com/linode/manager/pull/12684)) diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index f279d4a1b03..cab8084f9eb 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -1,6 +1,6 @@ import { useAccountRoles, - useAccountUsers, + useAccountUsersInfiniteQuery, useUserRoles, useUserRolesMutation, } from '@linode/queries'; @@ -11,10 +11,11 @@ import { Notice, Typography, } from '@linode/ui'; +import { useDebouncedValue } from '@linode/utilities'; import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; import { enqueueSnackbar } from 'notistack'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; @@ -44,15 +45,39 @@ export const AssignSelectedRolesDrawer = ({ }: Props) => { const theme = useTheme(); - const { data: allUsers } = useAccountUsers({}); + const [usernameInput, setUsernameInput] = useState(''); + const debouncedUsernameInput = useDebouncedValue(usernameInput); + const [username, setUsername] = useState(''); - const getUserOptions = () => { - return allUsers?.data.map((user: User) => ({ + const userSearchFilter = debouncedUsernameInput + ? { + ['+or']: [ + { username: { ['+contains']: debouncedUsernameInput } }, + { email: { ['+contains']: debouncedUsernameInput } }, + ], + } + : undefined; + + const { + data: accountUsers, + fetchNextPage, + hasNextPage, + isFetching: isFetchingAccountUsers, + isLoading: isLoadingAccountUsers, + } = useAccountUsersInfiniteQuery({ + ...userSearchFilter, + '+order': 'asc', + '+order_by': 'username', + }); + + const getUserOptions = useCallback(() => { + const users = accountUsers?.pages.flatMap((page) => page.data); + return users?.map((user: User) => ({ label: user.username, value: user.username, })); - }; + }, [accountUsers]); const { data: accountRoles } = useAccountRoles(); @@ -117,6 +142,17 @@ export const AssignSelectedRolesDrawer = ({ onClose(); }; + const handleScroll = (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + if ( + listboxNode.scrollTop + listboxNode.clientHeight >= + listboxNode.scrollHeight && + hasNextPage + ) { + fetchNextPage(); + } + }; + return ( Users - {allUsers && allUsers?.data?.length > 0 && ( - ( - option.label} - label="Select a User" - noMarginTop - onChange={(_, option) => { - const username = option?.label || null; - onChange(username); - setUsername(username); - }} - options={getUserOptions() || []} - placeholder="Select a User" - textFieldProps={{ hideLabel: true }} - /> - )} - rules={{ required: 'Select a user.' }} - /> - )} + + ( + option.label} + label="Select a User" + loading={isLoadingAccountUsers || isFetchingAccountUsers} + noMarginTop + onChange={(_, option) => { + setUsername(option?.label || null); + onChange(username); + }} + onInputChange={(_, value) => { + setUsernameInput(value); + }} + options={getUserOptions() || []} + placeholder="Select a User" + slotProps={{ + listbox: { + onScroll: handleScroll, + }, + }} + textFieldProps={{ hideLabel: true }} + /> + )} + rules={{ required: 'Select a user.' }} + /> getUsers(params, filter), queryKey: [params, filter], }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getUsers({ page: pageParam as number, page_size: 25 }, filter), + queryKey: [filter], + }), user: (username: string) => ({ contextQueries: { grants: { diff --git a/packages/queries/src/account/users.ts b/packages/queries/src/account/users.ts index 6696754c7cc..aae7a1150ed 100644 --- a/packages/queries/src/account/users.ts +++ b/packages/queries/src/account/users.ts @@ -1,6 +1,7 @@ import { createUser, deleteUser, updateUser } from '@linode/api-v4'; import { keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -37,6 +38,26 @@ export const useAccountUsers = ({ }); }; +export const useAccountUsersInfiniteQuery = ( + filter: Filter = {}, + enabled = true, +) => { + const { data: profile } = useProfile(); + + return useInfiniteQuery, APIError[]>({ + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + ...accountQueries.users._ctx.infinite(filter), + enabled: enabled && !profile?.restricted, + placeholderData: keepPreviousData, + }); +}; + export const useAccountUser = (username: string) => { return useQuery({ ...accountQueries.users._ctx.user(username), From 9e46eb41d33a3e15c7872c6a09141417994a9482 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 15 Aug 2025 18:37:10 +0200 Subject: [PATCH 41/88] feat: [UIE-9076, UIE-9076] - IAM RBAC: fix permission check for rebuilding and resizing linode (#12680) * feat: [UIE-9076] - IAM RBAC: fix permission check for rebuilding linode * feat: [UIE-9077] - IAM RBAC: fix permission check for resizing linode * Added changeset: IAM RBAC: fix permission check for rebuilding and resizing linode --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../pr-12680-changed-1754998781297.md | 5 ++ .../LinodeRebuild/LinodeRebuildForm.test.tsx | 56 +++++++++++++++++++ .../LinodeRebuild/LinodeRebuildForm.tsx | 30 +++++----- .../LinodeResize/LinodeResize.test.tsx | 39 +++++++++++++ .../LinodeResize/LinodeResize.tsx | 16 +++--- 5 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-12680-changed-1754998781297.md diff --git a/packages/manager/.changeset/pr-12680-changed-1754998781297.md b/packages/manager/.changeset/pr-12680-changed-1754998781297.md new file mode 100644 index 00000000000..5fce6b4391e --- /dev/null +++ b/packages/manager/.changeset/pr-12680-changed-1754998781297.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM RBAC: fix permission check for rebuilding and resizing linode ([#12680](https://github.com/linode/manager/pull/12680)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx index 99d7b75310e..1653da8be44 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx @@ -6,6 +6,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { LinodeRebuildForm } from './LinodeRebuildForm'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + rebuild_linode: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + describe('LinodeRebuildForm', () => { it('renders a notice reccomending users add user data when the Linode already uses user data', async () => { const linode = linodeFactory.build({ has_user_data: true }); @@ -48,4 +60,48 @@ describe('LinodeRebuildForm', () => { getByLabelText('This Linode does not have existing user data.') ).toBeVisible(); }); + + it('should disable all fields if user does not have permission', async () => { + const linode = linodeFactory.build(); + + const { getByRole, getByPlaceholderText, getAllByRole } = renderWithTheme( + + ); + + const passwordInput = getByPlaceholderText('Enter a password.'); + expect(passwordInput).toBeDisabled(); + + const rebuildBtn = getByRole('button', { + name: 'Rebuild Linode', + }); + expect(rebuildBtn).toHaveAttribute('aria-disabled', 'true'); + + const rebuildInput = getAllByRole('combobox')[0]; + expect(rebuildInput).toBeDisabled(); + }); + + it('should enable all fields if user has permission', async () => { + const linode = linodeFactory.build(); + + queryMocks.userPermissions.mockReturnValue({ + data: { + rebuild_linode: true, + }, + }); + + const { getByRole, getByPlaceholderText, getAllByRole } = renderWithTheme( + + ); + + const passwordInput = getByPlaceholderText('Enter a password.'); + expect(passwordInput).toBeEnabled(); + + const rebuildBtn = getByRole('button', { + name: 'Rebuild Linode', + }); + expect(rebuildBtn).not.toHaveAttribute('aria-disabled', 'true'); + + const rebuildInput = getAllByRole('combobox')[0]; + expect(rebuildInput).toBeEnabled(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx index c738eaae733..0679b2290f5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx @@ -8,7 +8,7 @@ import { useSnackbar } from 'notistack'; import React, { useEffect, useRef, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { StackScriptSelectionList } from '../../LinodeCreate/Tabs/StackScripts/StackScriptSelectionList'; @@ -43,11 +43,11 @@ export const LinodeRebuildForm = (props: Props) => { const [type, setType] = useState('Image'); - const isLinodeReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'linode', - id: linode.id, - }); + const { data: permissions } = usePermissions( + 'linode', + ['rebuild_linode'], + linode.id + ); const { data: isTypeToConfirmEnabled } = usePreferences( (preferences) => preferences?.type_to_confirm ?? true @@ -127,7 +127,7 @@ export const LinodeRebuildForm = (props: Props) => {
    - {isLinodeReadOnly && } + {!permissions.rebuild_linode && } {form.formState.errors.root && ( )} @@ -150,7 +150,7 @@ export const LinodeRebuildForm = (props: Props) => { }} > @@ -167,21 +167,21 @@ export const LinodeRebuildForm = (props: Props) => { )} {type.includes('StackScript') && } - - - + + + - + - +
    diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.test.tsx index 514af0a8a86..3f664adca67 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.test.tsx @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/react'; import * as React from 'react'; import { extDisk, swapDisk } from 'src/__data__/disks'; @@ -19,6 +20,18 @@ const props: Props = { open: true, }; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + resize_linode: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + beforeAll(() => { mockMatchMedia(); }); @@ -101,4 +114,30 @@ describe('LinodeResize', () => { }); }); }); + + it('should not allow resizing if user does not have permission', async () => { + const { findByText, getByRole } = renderWithTheme( + + ); + await findByText( + "You don't have permissions to edit this Linode. Please contact your account administrator to request the necessary permissions." + ); + + const resizeBtn = getByRole('button', { name: 'Resize Linode' }); + expect(resizeBtn).toBeDisabled(); + }); + + it('should not render LinodePermissionsError when user has resize_linode permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + resize_linode: true, + }, + }); + + const { queryByTestId } = renderWithTheme(); + + await waitFor(() => { + expect(queryByTestId('linode-permissions-error')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index e317f26eaae..025d672c1a2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -27,8 +27,8 @@ import { ErrorMessage } from 'src/components/ErrorMessage'; import { Link } from 'src/components/Link'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { linodeInTransition } from 'src/features/Linodes/transitions'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useEventsPollingActions } from 'src/queries/events/events'; import { extendType } from 'src/utilities/extendType'; @@ -96,11 +96,11 @@ export const LinodeResize = (props: Props) => { const hostMaintenance = linode?.status === 'stopped'; const isLinodeOffline = linode?.status === 'offline'; - const isLinodesGrantReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'linode', - id: linodeId, - }); + const { data: permissions } = usePermissions( + 'linode', + ['resize_linode'], + linodeId + ); const formik = useFormik({ initialValues: { @@ -164,7 +164,7 @@ export const LinodeResize = (props: Props) => { } }, [error]); - const tableDisabled = hostMaintenance || isLinodesGrantReadOnly; + const tableDisabled = hostMaintenance || !permissions.resize_linode; const submitButtonDisabled = Boolean(typeToConfirmPreference) && confirmationText !== linode?.label; @@ -195,7 +195,7 @@ export const LinodeResize = (props: Props) => { ) : (
    - {isLinodesGrantReadOnly && } + {!permissions.resize_linode && } {hostMaintenance && } {disksError && ( Date: Fri, 15 Aug 2025 14:42:34 -0400 Subject: [PATCH 42/88] fix: [M3-9034] - Use empty string instead of unknown for delete dialog titles (#12701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Use empty string instead of unknown for delete dialog titles ## How to test 🧪 ### Reproduction steps (How to reproduce the issue, if applicable) - [ ] On another branch, go to the Domains landing page - [ ] Open the network tab, disable cache, and throttle to the slowest speed you can - [ ] Open the delete dialog for a domain and while it is still fetching, hover over the close icon - [ ] The text tooltip displays unknown ### Verification steps (How to verify changes) - [ ] Checkout this PR and go to the Domains landing page - [ ] Open the network tab, disable cache, and throttle to the slowest speed you can - [ ] Open the delete dialog for a domain and while it is still fetching, hover over the close icon - [ ] The text tooltip should not display unknown --- .../pr-12701-fixed-1755186870164.md | 5 ++ .../delete-placement-groups.spec.ts | 28 +++---- .../features/Domains/DeleteDomain.test.tsx | 44 ----------- .../src/features/Domains/DeleteDomain.tsx | 76 ------------------- .../features/Domains/DeleteDomainDialog.tsx | 68 +++++++++++++++++ .../features/Domains/DisableDomainDialog.tsx | 2 +- .../Domains/DomainDetail/DomainDetail.tsx | 23 +++++- .../src/features/Domains/DomainsLanding.tsx | 52 +++---------- .../features/Managed/Contacts/Contacts.tsx | 2 +- .../Managed/Credentials/CredentialList.tsx | 2 +- .../Managed/Monitors/MonitorTable.tsx | 2 +- .../NodeBalancerDeleteDialog.tsx | 2 +- .../PlacementGroupsDeleteModal.test.tsx | 2 +- .../PlacementGroupsDeleteModal.tsx | 2 +- .../StackScriptDeleteDialog.tsx | 2 +- .../VPCs/VPCLanding/VPCDeleteDialog.tsx | 2 +- .../Volumes/Dialogs/DeleteVolumeDialog.tsx | 2 +- 17 files changed, 127 insertions(+), 189 deletions(-) create mode 100644 packages/manager/.changeset/pr-12701-fixed-1755186870164.md delete mode 100644 packages/manager/src/features/Domains/DeleteDomain.test.tsx delete mode 100644 packages/manager/src/features/Domains/DeleteDomain.tsx create mode 100644 packages/manager/src/features/Domains/DeleteDomainDialog.tsx diff --git a/packages/manager/.changeset/pr-12701-fixed-1755186870164.md b/packages/manager/.changeset/pr-12701-fixed-1755186870164.md new file mode 100644 index 00000000000..058c72424eb --- /dev/null +++ b/packages/manager/.changeset/pr-12701-fixed-1755186870164.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Use empty string instead of unknown for delete dialog titles ([#12701](https://github.com/linode/manager/pull/12701)) 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 1d7911d1280..ad69ded675f 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 @@ -84,7 +84,7 @@ describe('Placement Group deletion', () => { ).as('deletePlacementGroupError'); ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); @@ -106,7 +106,7 @@ describe('Placement Group deletion', () => { // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByText(deletionWarning).should('be.visible'); @@ -197,7 +197,7 @@ describe('Placement Group deletion', () => { ).as('UnassignPlacementGroupError'); ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.get('[data-qa-selection-list]').within(() => { @@ -223,7 +223,7 @@ describe('Placement Group deletion', () => { // Confirm deletion warning appears and that form cannot be submitted // while Linodes are assigned. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByText(deletionWarning).should('be.visible'); @@ -346,7 +346,7 @@ describe('Placement Group deletion', () => { // The dialog can be closed after an unexpect error show up ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); @@ -366,9 +366,9 @@ describe('Placement Group deletion', () => { .should('be.enabled') .click(); }); - cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( - 'not.exist' - ); + cy.findByTitle( + `Delete Placement Group ${mockPlacementGroup.label}?` + ).should('not.exist'); // Click "Delete" button next to the mock Placement Group, // mock a successful response and confirm that Cloud @@ -389,7 +389,7 @@ describe('Placement Group deletion', () => { // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { // ensure error message not exist when reopening the dialog @@ -472,7 +472,7 @@ describe('Placement Group deletion', () => { ).as('UnassignPlacementGroupError'); ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.get('[data-qa-selection-list]').within(() => { @@ -501,9 +501,9 @@ describe('Placement Group deletion', () => { .click(); }); - cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( - 'not.exist' - ); + cy.findByTitle( + `Delete Placement Group ${mockPlacementGroup.label}?` + ).should('not.exist'); // Click "Delete" button next to the mock Placement Group to reopen the dialog. cy.findByText(mockPlacementGroup.label) @@ -519,7 +519,7 @@ describe('Placement Group deletion', () => { // Confirm that the error message from the previous attempt is no longer present. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByText(PlacementGroupErrorMessage).should('not.exist'); diff --git a/packages/manager/src/features/Domains/DeleteDomain.test.tsx b/packages/manager/src/features/Domains/DeleteDomain.test.tsx deleted file mode 100644 index 673c00feda5..00000000000 --- a/packages/manager/src/features/Domains/DeleteDomain.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { fireEvent, render, waitFor } from '@testing-library/react'; -import * as React from 'react'; - -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -import { DeleteDomain } from './DeleteDomain'; - -import type { DeleteDomainProps } from './DeleteDomain'; - -const domainId = 1; -const domainLabel = 'example.com'; - -const props: DeleteDomainProps = { - domainError: null, - domainId, - domainLabel, -}; - -describe('DeleteDomain', () => { - it('includes a button to delete the domain', () => { - const { getByText } = render(wrapWithTheme()); - getByText('Delete Domain'); - }); - - it('displays the modal when the button is clicked', async () => { - const { findByText, getByText } = render( - wrapWithTheme() - ); - fireEvent.click(getByText('Delete Domain')); - await findByText('Delete Domain example.com?'); - expect(getByText('Delete Domain example.com?')).toBeInTheDocument(); - }); - - it('closes the modal when the "Cancel" button is clicked', async () => { - const { getByText, queryByText } = render( - wrapWithTheme() - ); - fireEvent.click(getByText('Delete Domain')); - fireEvent.click(getByText('Cancel')); - await waitFor(() => - expect(queryByText(/Are you sure you want to delete/)).toBeNull() - ); - }); -}); diff --git a/packages/manager/src/features/Domains/DeleteDomain.tsx b/packages/manager/src/features/Domains/DeleteDomain.tsx deleted file mode 100644 index 2493a975d34..00000000000 --- a/packages/manager/src/features/Domains/DeleteDomain.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useDeleteDomainMutation } from '@linode/queries'; -import { Button, Notice, Typography } from '@linode/ui'; -import { styled } from '@mui/material/styles'; -import { useSnackbar } from 'notistack'; -import * as React from 'react'; - -import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; - -import type { APIError } from '@linode/api-v4'; -export interface DeleteDomainProps { - domainError: APIError[] | null; - domainId: number; - domainLabel: string; - // Function that is invoked after Domain has been successfully deleted. - onSuccess?: () => void; -} - -export const DeleteDomain = (props: DeleteDomainProps) => { - const { domainError, domainId, domainLabel } = props; - const { enqueueSnackbar } = useSnackbar(); - - const { - error, - isPending, - mutateAsync: deleteDomain, - } = useDeleteDomainMutation(domainId); - - const [open, setOpen] = React.useState(false); - - const onDelete = () => { - deleteDomain().then(() => { - enqueueSnackbar('Domain deleted successfully.', { - variant: 'success', - }); - if (props.onSuccess) { - props.onSuccess(); - } - }); - }; - - return ( - <> - setOpen(true)}> - Delete Domain - - setOpen(false)} - open={open} - title={`Delete Domain ${domainLabel ?? 'Unknown'}?`} - > - - - Warning: Deleting this domain is permanent and can’t be undone. - - - - - ); -}; - -const StyledButton = styled(Button, { label: 'StyledButton' })(({ theme }) => ({ - [theme.breakpoints.down('lg')]: { - marginRight: theme.spacing(), - }, -})); diff --git a/packages/manager/src/features/Domains/DeleteDomainDialog.tsx b/packages/manager/src/features/Domains/DeleteDomainDialog.tsx new file mode 100644 index 00000000000..3120797bdcb --- /dev/null +++ b/packages/manager/src/features/Domains/DeleteDomainDialog.tsx @@ -0,0 +1,68 @@ +import { useDeleteDomainMutation } from '@linode/queries'; +import { Notice, Typography } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; + +import type { APIError } from '@linode/api-v4'; +export interface DeleteDomainProps { + domainError: APIError[] | null; + domainId?: number; + domainLabel?: string; + isFetching: boolean; + onClose?: () => void; + // Function that is invoked after Domain has been successfully deleted. + onSuccess?: () => void; + open: boolean; +} + +export const DeleteDomainDialog = (props: DeleteDomainProps) => { + const { domainError, domainId, domainLabel, open, onClose, isFetching } = + props; + const { enqueueSnackbar } = useSnackbar(); + + const { + error, + isPending, + mutateAsync: deleteDomain, + } = useDeleteDomainMutation(domainId ?? 0); + + const onDelete = () => { + deleteDomain().then(() => { + enqueueSnackbar('Domain deleted successfully.', { + variant: 'success', + }); + if (props.onSuccess) { + props.onSuccess(); + } + }); + }; + + return ( + + + + Warning: Deleting this domain is permanent and can’t + be undone. + + + + ); +}; diff --git a/packages/manager/src/features/Domains/DisableDomainDialog.tsx b/packages/manager/src/features/Domains/DisableDomainDialog.tsx index ad6c75708ff..1062bd48400 100644 --- a/packages/manager/src/features/Domains/DisableDomainDialog.tsx +++ b/packages/manager/src/features/Domains/DisableDomainDialog.tsx @@ -62,7 +62,7 @@ export const DisableDomainDialog = React.memo( isFetching={isFetching} onClose={onClose} open={open} - title={`Disable Domain ${domain?.domain ?? 'Unknown'}?`} + title={`Disable Domain${domain ? ` ${domain.domain}` : ''}?`} > Are you sure you want to disable this DNS zone? diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 9b838db064c..27d2eaf56a1 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -4,6 +4,7 @@ import { useUpdateDomainMutation, } from '@linode/queries'; import { + Button, CircleProgress, ErrorState, Notice, @@ -20,7 +21,7 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; -import { DeleteDomain } from '../DeleteDomain'; +import { DeleteDomainDialog } from '../DeleteDomainDialog'; import { DownloadDNSZoneFileButton } from '../DownloadDNSZoneFileButton'; import { DomainRecords } from './DomainRecords/DomainRecords'; @@ -35,6 +36,7 @@ export const DomainDetail = () => { const { data: domain, error, + isFetching: isFetchingDomain, isLoading, } = useDomainQuery(domainId, !!domainId); const { mutateAsync: updateDomain } = useUpdateDomainMutation(); @@ -52,6 +54,8 @@ export const DomainDetail = () => { }); const [updateError, setUpdateError] = React.useState(); + const [isDeleteDomainDialogOpen, setDeleteDomainDialogOpen] = + React.useState(false); const handleLabelChange = (label: string) => { setUpdateError(undefined); @@ -146,11 +150,20 @@ export const DomainDetail = () => { /> - setDeleteDomainDialogOpen(true)} + > + Delete Domain + + setDeleteDomainDialogOpen(false)} onSuccess={() => navigate({ to: '/domains' })} + open={isDeleteDomainDialogOpen} /> @@ -209,3 +222,9 @@ const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ marginLeft: theme.spacing(), }, })); + +const StyledButton = styled(Button, { label: 'StyledButton' })(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { + marginRight: theme.spacing(), + }, +})); diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx index 9cf137664c8..1467e60bb71 100644 --- a/packages/manager/src/features/Domains/DomainsLanding.tsx +++ b/packages/manager/src/features/Domains/DomainsLanding.tsx @@ -1,18 +1,11 @@ import { - useDeleteDomainMutation, useDomainQuery, useDomainsQuery, useLinodesQuery, useProfile, useUpdateDomainMutation, } from '@linode/queries'; -import { - Button, - CircleProgress, - ErrorState, - Notice, - Typography, -} from '@linode/ui'; +import { Button, CircleProgress, ErrorState, Notice } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { @@ -33,7 +26,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; -import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -44,6 +36,7 @@ import { DOMAINS_TABLE_DEFAULT_ORDER_BY, DOMAINS_TABLE_PREFERENCE_KEY, } from './constants'; +import { DeleteDomainDialog } from './DeleteDomainDialog'; import { DisableDomainDialog } from './DisableDomainDialog'; import { DomainBanner } from './DomainBanner'; import { DomainsEmptyLandingState } from './DomainsEmptyLandingPage'; @@ -122,12 +115,6 @@ export const DomainsLanding = (props: DomainsLandingProps) => { error: domainError, } = useDomainQuery(params.domainId ?? -1, !!params.domainId); - const { - error: deleteError, - isPending: isDeleting, - mutateAsync: deleteDomain, - } = useDeleteDomainMutation(selectedDomain?.id ?? 0); - const { mutateAsync: updateDomain } = useUpdateDomainMutation(); const navigateToDomains = () => { @@ -181,12 +168,6 @@ export const DomainsLanding = (props: DomainsLandingProps) => { }); }; - const removeDomain = () => { - deleteDomain().then(() => { - navigateToDomains(); - }); - }; - const handleDisableOrEnable = ( action: 'disable' | 'enable', domain: Domain @@ -364,35 +345,20 @@ export const DomainsLanding = (props: DomainsLandingProps) => { onClose={navigateToDomains} open={params.action === 'edit'} /> - navigateToDomains()} open={params.action === 'delete'} - title={`Delete Domain ${selectedDomain?.domain ?? 'Unknown'}?`} - > - - - Warning: Deleting this domain is permanent and - can’t be undone. - - - + /> ); }; const StyledButon = styled(Button, { label: 'StyledButton' })(({ theme }) => ({ - marginLeft: `-${theme.spacing()}`, + marginLeft: `-${theme.spacingFunction()}`, whiteSpace: 'nowrap', })); diff --git a/packages/manager/src/features/Managed/Contacts/Contacts.tsx b/packages/manager/src/features/Managed/Contacts/Contacts.tsx index 20ff1fa714d..214d4b8f047 100644 --- a/packages/manager/src/features/Managed/Contacts/Contacts.tsx +++ b/packages/manager/src/features/Managed/Contacts/Contacts.tsx @@ -225,7 +225,7 @@ export const Contacts = () => { }); }} open={isDeleteContactDialogOpen} - title={`Delete Contact ${selectedContact?.name || 'Unknown'}?`} + title={`Delete Contact${selectedContact ? ` ${selectedContact.name}` : ''}?`} > diff --git a/packages/manager/src/features/Managed/Credentials/CredentialList.tsx b/packages/manager/src/features/Managed/Credentials/CredentialList.tsx index 45d2d27ff1a..eb87c8072b4 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialList.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialList.tsx @@ -252,7 +252,7 @@ export const CredentialList = () => { onClick={handleDelete} onClose={() => navigate({ to: '/managed/credentials' })} open={isDeleteDialogOpen} - title={`Delete Credential ${selectedCredential?.label || 'Unknown'}?`} + title={`Delete Credential${selectedCredential ? ` ${selectedCredential.label}` : ''}?`} > diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx index ab573f5296a..7c4fed329dd 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx @@ -228,7 +228,7 @@ export const MonitorTable = () => { onClick={handleDelete} onClose={() => navigate({ to: '/managed/monitors' })} open={isDeleteDialogOpen} - title={`Delete Monitor ${selectedMonitor?.label || 'Unknown'}?`} + title={`Delete Monitor${selectedMonitor ? ` ${selectedMonitor.label}` : ''}?`} > diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx index fb3a7fcb6a3..f2b81b3076a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx @@ -59,7 +59,7 @@ export const NodeBalancerDeleteDialog = ({ : () => navigate({ to: '/nodebalancers' }) } open={open} - title={`Delete ${label ?? 'Unknown'}?`} + title={`Delete${label ? ` ${label}` : ''}?`} typographyStyle={{ marginTop: '20px' }} > diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx index ff8f60faa1a..7cfdd722f38 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx @@ -74,7 +74,7 @@ describe('PlacementGroupsDeleteModal', () => { expect( getByRole('heading', { - name: 'Delete Placement Group PG-to-delete', + name: 'Delete Placement Group PG-to-delete?', }) ).toBeInTheDocument(); expect( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index a860060f2aa..be46a8fdb59 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -116,7 +116,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { onClick={onDelete} onClose={handleClose} open={open} - title={`Delete Placement Group ${selectedPlacementGroup?.label ?? 'Unknown'}`} + title={`Delete Placement Group${selectedPlacementGroup ? ` ${selectedPlacementGroup.label}` : ''}?`} > {error && ( { isFetching={isFetching} onClose={onClose} open={open} - title={`Delete StackScript ${stackscript?.label ?? 'Unknown'}?`} + title={`Delete StackScript${stackscript ? ` ${stackscript.label}` : ''}?`} > Are you sure you want to delete this StackScript? diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx index 5b644c3c20d..8ee083a6ea4 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx @@ -62,7 +62,7 @@ export const VPCDeleteDialog = (props: Props) => { onClick={onDeleteVPC} onClose={onClose} open={open} - title={`Delete VPC ${vpc?.label ?? 'Unknown'}`} + title={`Delete VPC${vpc ? ` ${vpc.label}` : ''}`} /> ); }; diff --git a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx index 86d0a548be8..13c4a5b65e4 100644 --- a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx @@ -49,7 +49,7 @@ export const DeleteVolumeDialog = (props: Props) => { onClick={onDelete} onClose={onClose} open={open} - title={`Delete Volume ${volume?.label ?? 'Unknown'}?`} + title={`Delete Volume${volume ? ` ${volume.label}` : ''}?`} typographyStyle={{ marginTop: '10px' }} /> ); From 3ec53b7ce4112d25007179849492eb01b5a444f3 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:50:34 -0700 Subject: [PATCH 43/88] test: [M3-10464] - Add Cypress test confirming LKE-E cluster details page shows VPC details (#12700) * Update types to include null * Add test coverage for VPC in LKE details summary * Add test coverage for VPC IP columns; improve comments * Add test coverage for case 2, standard cluster * Added changeset: Add `lke-enterprise-read` and `lke-standard-read` Cypress specs; test LKE-E VPC coverage * Added changeset: Update `KubernetesCluster` type to include `null` * Improve changeset * Mock the account capability for LKE-E to fix test failures --------- Co-authored-by: cpathipa <119517080+cpathipa@users.noreply.github.com> --- ...r-12700-upcoming-features-1755186828421.md | 5 + packages/api-v4/src/kubernetes/types.ts | 4 +- .../pr-12700-tests-1755186714746.md | 5 + .../kubernetes/lke-enterprise-read.spec.ts | 243 ++++++++++++++++++ .../core/kubernetes/lke-standard-read.spec.ts | 112 ++++++++ .../KubeEntityDetailFooter.tsx | 2 +- 6 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12700-upcoming-features-1755186828421.md create mode 100644 packages/manager/.changeset/pr-12700-tests-1755186714746.md create mode 100644 packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts create mode 100644 packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts diff --git a/packages/api-v4/.changeset/pr-12700-upcoming-features-1755186828421.md b/packages/api-v4/.changeset/pr-12700-upcoming-features-1755186828421.md new file mode 100644 index 00000000000..60d525e9f28 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12700-upcoming-features-1755186828421.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + + Update `KubernetesCluster` `vpc_id` and `subnet_id` types to include `null` ([#12700](https://github.com/linode/manager/pull/12700)) diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c04a0eaf2ce..a518d0260d7 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -39,7 +39,7 @@ export interface KubernetesCluster { * Upcoming Feature Notice - LKE-E:** this property may not be available to all customers * and may change in subsequent releases. */ - subnet_id?: number; + subnet_id?: null | number; tags: string[]; /** Marked as 'optional' in this existing interface to prevent duplicated code for beta functionality, in line with the apl_enabled approach. * @todo LKE-E - Make this field required once LKE-E is in GA. tier defaults to 'standard' in the API. @@ -50,7 +50,7 @@ export interface KubernetesCluster { * Upcoming Feature Notice - LKE-E:** this property may not be available to all customers * and may change in subsequent releases. */ - vpc_id?: number; + vpc_id?: null | number; } export interface KubeNodePoolResponse { diff --git a/packages/manager/.changeset/pr-12700-tests-1755186714746.md b/packages/manager/.changeset/pr-12700-tests-1755186714746.md new file mode 100644 index 00000000000..d0a78672300 --- /dev/null +++ b/packages/manager/.changeset/pr-12700-tests-1755186714746.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add `lke-enterprise-read` and `lke-standard-read` Cypress specs; test LKE-E VPC coverage ([#12700](https://github.com/linode/manager/pull/12700)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts new file mode 100644 index 00000000000..b5724cab3e7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts @@ -0,0 +1,243 @@ +/** + * Confirms read operations on LKE-Enterprise clusters. + */ + +import { + linodeFactory, + linodeIPFactory, + profileFactory, +} from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeIPAddresses, + mockGetLinodes, +} from 'support/intercepts/linodes'; +import { + mockGetCluster, + mockGetClusterPools, + mockGetKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { mockGetVPC } from 'support/intercepts/vpc'; + +import { + accountFactory, + kubeLinodeFactory, + kubernetesClusterFactory, + nodePoolFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; + +const mockProfile = profileFactory.build(); + +const mockVPC = vpcFactory.build({ + id: 123, + label: 'lke-e-vpc', + subnets: [subnetFactory.build()], +}); + +const mockClusterWithVPC = kubernetesClusterFactory.build({ + id: 1, + vpc_id: mockVPC.id, + subnet_id: mockVPC.subnets[0].id, + tier: 'enterprise', +}); +const mockClusterWithoutVPC = kubernetesClusterFactory.build({ + id: 2, + vpc_id: null, + tier: 'enterprise', +}); +const mockNodePools = [ + nodePoolFactory.build({ + id: 1, + nodes: [kubeLinodeFactory.build()], + count: 1, + }), +]; + +const mockLinodes = mockNodePools.map((pool, i) => + linodeFactory.build({ + id: pool.nodes[i].instance_id ?? undefined, + lke_cluster_id: mockClusterWithVPC.id, + type: pool.type, + }) +); +const mockLinodeIPs = linodeIPFactory.build({ + ipv4: { + public: [ + { + address: '192.0.2.1', + linode_id: mockLinodes[0].id, + }, + ], + private: [ + { + linode_id: mockLinodes[0].id, + }, + ], + vpc: [ + { + address: '10.0.0.1', + linode_id: mockLinodes[0].id, + vpc_id: mockVPC.id, + subnet_id: mockVPC.subnets[0].id, + }, + ], + }, + ipv6: { + slaac: { + address: '2600:abcd::efgh:ijkl:mnop:qrst', + linode_id: mockLinodes[0].id, + }, + link_local: { + linode_id: mockLinodes[0].id, + }, + vpc: [ + { + linode_id: mockLinodes[0].id, + vpc_id: mockVPC.id, + subnet_id: mockVPC.subnets[0].id, + ipv6_addresses: [ + { + slaac_address: '2600:1234::abcd:5678:efgh:9012', + }, + ], + }, + ], + }, +}); + +/** + * Confirms the expected information is displayed in the cluster summary section of the cluster details page: + * - Confirms the linked VPC is shown for an LKE-E cluster when it exists. + * - Confirms a linked VPC is not shown for an LKE-E cluster when it doesn't exist. + */ +describe('LKE-E Cluster Summary - VPC Section', () => { + beforeEach(() => { + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + }); + /* + * Confirms LKE-E summary page shows VPC info and links to the correct VPC page when a vpc_id is present. + */ + it('shows linked VPC in summary for cluster with a VPC', () => { + mockGetCluster(mockClusterWithVPC).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockClusterWithVPC.id, []).as('getNodePools'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetProfile(mockProfile).as('getProfile'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getVPC', + '@getProfile', + ]); + + // Verify VPC details appear in the summary + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label') + .should('contain.text', mockVPC.label) + .should('have.attr', 'href') + .and('include', `/vpcs/${mockVPC.id}`); + }); + + // Navigate to the VPC by clicking the link + cy.findByTestId('assigned-lke-cluster-label').click(); + + // Verify the VPC details page loads + cy.url().should('include', `/vpcs/${mockVPC.id}`); + cy.contains(mockVPC.label).should('exist'); + }); + + /* + * Confirms VPC info is not shown when cluster's vpc_id is null. + */ + it('does not show linked VPC in summary when cluster does not specify a VPC', () => { + mockGetCluster(mockClusterWithoutVPC).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockClusterWithoutVPC.id, []).as('getNodePools'); + mockGetProfile(mockProfile).as('getProfile'); + + cy.visitWithLogin( + `/kubernetes/clusters/${mockClusterWithoutVPC.id}/summary` + ); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + + // Confirm that no VPC label or link is shown in the summary section + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + }); +}); + +/** + * Confirms the expected information is shown for a cluster's node pools on the cluster details page. + */ +describe('LKE-E Node Pools', () => { + /** + * - Confirms the VPC IP address table headers are shown in the node table. + * - Confirms the IP address data is shown for a node in the node pool. + */ + it('shows VPC IPv4 and IPv6 columns for an LKE-E cluster', () => { + mockAppendFeatureFlags({ + // TODO LKE-E: Remove once feature is in GA + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetCluster(mockClusterWithVPC).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockClusterWithVPC.id, mockNodePools).as( + 'getNodePools' + ); + mockGetVPC(mockVPC).as('getVPC'); + mockGetProfile(mockProfile).as('getProfile'); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeIPAddresses(mockLinodes[0].id, mockLinodeIPs).as( + 'getLinodeIPs' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getProfile', + '@getVPC', + ]); + + // Confirm VPC IP columns are present in the table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('be.visible'); + cy.contains('th', 'VPC IPv6').should('be.visible'); + }); + + // Confirm VPC IP addresses are present in the table data + const vpcIPv6 = + mockLinodeIPs.ipv6?.vpc?.[0]?.ipv6_addresses?.[0]?.slaac_address; + const vpcIPv4 = mockLinodeIPs.ipv4?.vpc?.[0]?.address; + + cy.get('[data-qa-node-row]').within(() => { + cy.contains('td', vpcIPv6).should('be.visible'); + cy.contains('td', vpcIPv4).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts new file mode 100644 index 00000000000..07c8162b686 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts @@ -0,0 +1,112 @@ +/** + * Confirms read operations on LKE standard clusters. + */ + +import { linodeFactory, profileFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { + mockGetCluster, + mockGetClusterPools, + mockGetKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetProfile } from 'support/intercepts/profile'; + +import { + accountFactory, + kubeLinodeFactory, + kubernetesClusterFactory, + nodePoolFactory, +} from 'src/factories'; + +const mockProfile = profileFactory.build(); + +const mockCluster = kubernetesClusterFactory.build({ + id: 3, + tier: 'standard', + vpc_id: undefined, + subnet_id: undefined, +}); + +const mockNodePools = [ + nodePoolFactory.build({ + id: 2, + nodes: [kubeLinodeFactory.build()], + count: 1, + }), +]; + +const mockLinodes = mockNodePools.map((pool, i) => + linodeFactory.build({ + id: pool.nodes[i].instance_id ?? undefined, + lke_cluster_id: mockCluster.id, + type: pool.type, + }) +); + +/** + * Confirms the expected information is displayed in the cluster summary section of the cluster details page. + */ +describe('LKE Cluster Summary', () => { + it('does not show linked VPC in summary for a standard cluster', () => { + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockCluster.id, []).as('getNodePools'); + mockGetProfile(mockProfile).as('getProfile'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + + // Confirm that no VPC label or link is shown in the summary section + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + }); +}); + +/** + * Confirms the expected information is shown for a cluster's node pools on the cluster details page. + */ +describe('LKE Node Pools', () => { + /** + * Confirms standard LKE clusters do not show VPC IP columns when the LKE-Enterprise Phase 2 feature flag is enabled. + */ + it('does not show VPC IP columns for standard LKE cluster', () => { + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockGetProfile(profileFactory.build()).as('getProfile'); + mockGetLinodes(mockLinodes).as('getLinodes'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + + // Confirm VPC IP columns are not present in the node table + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('not.exist'); + cy.contains('th', 'VPC IPv6').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx index 324db47baaf..ebac0368f15 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx @@ -34,7 +34,7 @@ interface FooterProps { isLoadingKubernetesACL: boolean; setControlPlaneACLDrawerOpen: React.Dispatch>; sx?: SxProps; - vpcId: number | undefined; + vpcId: null | number | undefined; } export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { From 844abaec2e8d3f7d8bb66381f48d130937e4669c Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Sun, 17 Aug 2025 23:23:19 +0530 Subject: [PATCH 44/88] Change: [DI-26395] - Update filters to use the resources from useQuery cache in CloudPulse metrics (#12678) * DI-26395 : Initial changes for reduction in instances call * DI-26395 : Cypress changes * DI-26395 : Cypress changes * DI-26395 : Code refactoring changes * DI-26395 : Add changetset * DI-26395 : No xFilter needed for firewall service * DI-26395 : Code refactoring and updates --- .../pr-12678-changed-1754995292006.md | 5 + .../cloudpulse-dashboard-errors.spec.ts | 32 +--- .../useCreateFirewallFromTemplate.ts | 2 +- .../Alerts/AlertRegions/AlertRegions.tsx | 13 +- .../AlertsResources/AlertsResources.tsx | 4 + .../Dashboard/CloudPulseDashboard.tsx | 11 +- .../Dashboard/CloudPulseDashboardLanding.tsx | 7 +- .../Overview/GlobalFilters.test.tsx | 26 +++ .../CloudPulse/Overview/GlobalFilters.tsx | 18 +- .../CloudPulse/Utils/FilterBuilder.test.ts | 170 +++++++++++++++--- .../CloudPulse/Utils/FilterBuilder.ts | 156 +++++++++------- .../Utils/ReusableDashboardFilterUtils.ts | 4 +- .../features/CloudPulse/Utils/constants.ts | 26 +++ .../shared/CloudPulseCustomSelect.tsx | 4 +- .../CloudPulseDashboardFilterBuilder.tsx | 81 ++++++--- .../shared/CloudPulseNodeTypeFilter.test.tsx | 48 +++-- .../shared/CloudPulseNodeTypeFilter.tsx | 18 +- .../shared/CloudPulseRegionSelect.tsx | 26 ++- .../shared/CloudPulseResourcesSelect.tsx | 38 ++-- .../manager/src/queries/cloudpulse/queries.ts | 2 +- .../src/queries/cloudpulse/resources.ts | 1 + packages/queries/src/firewalls/firewalls.ts | 32 ++-- 22 files changed, 491 insertions(+), 233 deletions(-) create mode 100644 packages/manager/.changeset/pr-12678-changed-1754995292006.md diff --git a/packages/manager/.changeset/pr-12678-changed-1754995292006.md b/packages/manager/.changeset/pr-12678-changed-1754995292006.md new file mode 100644 index 00000000000..55ff0fdc05d --- /dev/null +++ b/packages/manager/.changeset/pr-12678-changed-1754995292006.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update logic in metrics filters to use the resources from `useResources` useQuery cache in CloudPulse metrics ([#12678](https://github.com/linode/manager/pull/12678)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index 1ced6eaa60d..d852296fd3d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -389,6 +389,11 @@ describe('Tests for API error handling', () => { // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); + // simulate an error on instances call before changing the region again + mockGetDatabasesError('Internal Server Error').as( + 'getDatabaseInstancesError' + ); + // Select a dashboard from the autocomplete input ui.autocomplete .findByLabel('Dashboard') @@ -400,33 +405,6 @@ describe('Tests for API error handling', () => { .should('be.visible') .click(); - // Select a Database Engine from the autocomplete input. - ui.autocomplete - .findByLabel('Database Engine') - .should('be.visible') - .type(engine); - - ui.autocompletePopper.findByTitle(engine).should('be.visible').click(); - - // Select a region from the dropdown. - ui.regionSelect.find().click(); - ui.regionSelect - .findItemByRegionId(mockRegions[0].id, mockRegions) - .should('be.visible') - .click(); - - // simulate an error on instances call before changing the region again - mockGetDatabasesError('Internal Server Error').as( - 'getDatabaseInstancesError' - ); - - // Select a region from the dropdown. - ui.regionSelect.find().click(); - ui.regionSelect - .findItemByRegionId(mockRegions[1].id, mockRegions) - .should('be.visible') - .click(); - // Wait for the intercepted request to complete cy.wait('@getDatabaseInstancesError'); diff --git a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts index 625b4887a56..4239ce7ec0f 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts +++ b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts @@ -65,7 +65,7 @@ export const createFirewallFromTemplate = async (options: { // Get firewalls and firewall template in parallel const [{ rules, slug }, firewalls] = await Promise.all([ queryClient.ensureQueryData(firewallQueries.template(templateSlug)), - queryClient.fetchQuery(firewallQueries.firewalls._ctx.all), // must fetch fresh data if generating more than one firewall + queryClient.fetchQuery(firewallQueries.firewalls._ctx.all()), // must fetch fresh data if generating more than one firewall ]); if (updateProgress) { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx index 5feb5123fb2..c15a160a41b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { RESOURCE_FILTER_MAP } from '../../Utils/constants'; import { type AlertFormMode, REGION_GROUP_INFO_MESSAGE, @@ -17,7 +18,7 @@ import { getFilteredRegions } from '../Utils/utils'; import { DisplayAlertRegions } from './DisplayAlertRegions'; import type { AlertRegion } from './DisplayAlertRegions'; -import type { CloudPulseServiceType, Filter } from '@linode/api-v4'; +import type { CloudPulseServiceType } from '@linode/api-v4'; interface AlertRegionsProps { /** @@ -48,19 +49,11 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); const [selectedRegions, setSelectedRegions] = React.useState(value); const [showSelected, setShowSelected] = React.useState(false); - - const resourceFilterMap: Record = { - dbaas: { - platform: 'rdbms-default', - }, - }; const { data: resources, isLoading: isResourcesLoading } = useResourcesQuery( Boolean(serviceType && regions?.length), serviceType === null ? undefined : serviceType, {}, - { - ...(resourceFilterMap[serviceType ?? ''] ?? {}), - } + { ...(RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {}) } ); const handleSelectionChange = React.useCallback( diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 70dcf79ce53..45271682aa6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -132,6 +132,10 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const supportedRegionIds = getSupportedRegionIds(regions, serviceType); const xFilterToBeApplied: Filter | undefined = React.useMemo(() => { + if (serviceType === 'firewall') { + return undefined; + } + const regionFilter: Filter = supportedRegionIds ? { '+or': supportedRegionIds.map((regionId) => ({ diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 5932d5ed919..d87ae94a1b1 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -9,6 +9,7 @@ import { useGetCloudPulseMetricDefinitionsByServiceType, } from 'src/queries/cloudpulse/services'; +import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; import { renderPlaceHolder, @@ -92,7 +93,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { Boolean(dashboard?.service_type), dashboard?.service_type, {}, - dashboard?.service_type === 'dbaas' ? { platform: 'rdbms-default' } : {} + RESOURCE_FILTER_MAP[dashboard?.service_type ?? ''] ?? {} ); const { @@ -131,7 +132,13 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { } if (isMetricDefinitionLoading || isDashboardLoading || isResourcesLoading) { - return ; + return ( + ({ + padding: theme.spacingFunction(16), + })} + /> + ); } if (!dashboard) { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index b11e7197842..0f7ef75ea14 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -18,11 +18,12 @@ export interface FilterData { id: { [filterKey: string]: FilterValueType }; label: { [filterKey: string]: string[] }; } +export interface CloudPulseMetricsFilter { + [key: string]: FilterValueType; +} export interface DashboardProp { dashboard?: Dashboard; - filterValue: { - [key: string]: FilterValueType; - }; + filterValue: CloudPulseMetricsFilter; timeDuration?: DateTimeWithPreset; } diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx index 1c10b05f6b2..79a911c5c22 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react'; import React from 'react'; +import { databaseInstanceFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { GlobalFilters } from './GlobalFilters'; @@ -19,6 +20,19 @@ const setup = () => { /> ); }; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + describe('Global filters component test', () => { it('Should render refresh button', () => { setup(); @@ -40,4 +54,16 @@ describe('Global filters component test', () => { expect(timeRangeSelect).toBeInTheDocument(); }); + + it('Should show circle progress if resources call is loading', async () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: [{ ...databaseInstanceFactory.build(), clusterSize: 1 }], + isLoading: true, + }); + + setup(); + + const progress = await screen.findByTestId('circle-progress'); + expect(progress).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 1c236ed2e97..69663ae83a4 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -4,13 +4,19 @@ import { GridLegacy } from '@mui/material'; import * as React from 'react'; import Reload from 'src/assets/icons/refresh.svg'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseDateTimeRangePicker } from '../shared/CloudPulseDateTimeRangePicker'; import { CloudPulseTooltip } from '../shared/CloudPulseTooltip'; import { convertToGmt } from '../Utils/CloudPulseDateTimePickerUtils'; -import { DASHBOARD_ID, REFRESH, TIME_DURATION } from '../Utils/constants'; +import { + DASHBOARD_ID, + REFRESH, + RESOURCE_FILTER_MAP, + TIME_DURATION, +} from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; @@ -88,6 +94,14 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleAnyFilterChange(REFRESH, Date.now(), []); }, []); + const { isLoading, isError } = useResourcesQuery( + selectedDashboard !== undefined, + selectedDashboard?.service_type ?? '', + {}, + + RESOURCE_FILTER_MAP[selectedDashboard?.service_type ?? ''] ?? {} + ); + return ( @@ -150,6 +164,8 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { dashboard={selectedDashboard} emitFilterChange={emitFilterChange} handleToggleAppliedFilter={handleToggleAppliedFilter} + isError={isError} + isLoading={isLoading} isServiceAnalyticsIntegration={false} preferences={preferences} /> diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 0a42100a08b..217a73b1222 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -1,16 +1,17 @@ import { databaseQueries } from '@linode/queries'; import { DateTime } from 'luxon'; -import { dashboardFactory } from 'src/factories'; +import { dashboardFactory, databaseInstanceFactory } from 'src/factories'; import { RESOURCE_ID, RESOURCES } from './constants'; import { deepEqual, + filterBasedOnConfig, + filterUsingDependentFilters, getFilters, getTextFilterProperties, } from './FilterBuilder'; import { - buildXFilter, checkIfAllMandatoryFiltersAreSelected, constructAdditionalRequestFilters, getCustomSelectProperties, @@ -22,7 +23,10 @@ import { shouldDisableFilterByFilterKey, } from './FilterBuilder'; import { FILTER_CONFIG } from './FilterConfig'; -import { CloudPulseSelectTypes } from './models'; +import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; + +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { CloudPulseServiceTypeFilters } from './models'; const mockDashboard = dashboardFactory.build(); @@ -111,7 +115,7 @@ it('test getResourceSelectionProperties method', () => { expect(handleResourcesSelection).toBeDefined(); expect(savePreferences).toEqual(false); expect(disabled).toEqual(false); - expect(JSON.stringify(xFilter)).toEqual('{"+and":[{"region":"us-east"}]}'); + expect(JSON.stringify(xFilter)).toEqual('{"region":"us-east"}'); expect(label).toEqual(name); } }); @@ -143,7 +147,7 @@ it('test getResourceSelectionProperties method with disabled true', () => { expect(handleResourcesSelection).toBeDefined(); expect(savePreferences).toEqual(false); expect(disabled).toEqual(true); - expect(JSON.stringify(xFilter)).toEqual('{"+and":[]}'); + expect(JSON.stringify(xFilter)).toEqual('{}'); expect(label).toEqual(name); } }); @@ -247,26 +251,6 @@ it('test getNodeTypeProperties with disabled true', () => { } }); -it('test buildXfilter method', () => { - const resourceSelectionConfig = linodeConfig?.filters.find( - (filterObj) => filterObj.name === 'Resources' - ); - - expect(resourceSelectionConfig).toBeDefined(); // fails if resources selection in not defined - - if (resourceSelectionConfig) { - let result = buildXFilter(resourceSelectionConfig, { - region: 'us-east', - }); - - expect(JSON.stringify(result)).toEqual('{"+and":[{"region":"us-east"}]}'); - - result = buildXFilter(resourceSelectionConfig, {}); - - expect(JSON.stringify(result)).toEqual('{"+and":[]}'); - } -}); - it('test checkIfAllMandatoryFiltersAreSelected method', () => { const resourceSelectionConfig = linodeConfig?.filters.find( (filterObj) => filterObj.name === 'Resources' @@ -501,3 +485,139 @@ it('should return the filters based on dashboard', () => { expect(filters?.length).toBe(1); }); + +describe('filterUsingDependentFilters', () => { + const mockData: CloudPulseResources[] = [ + { + ...databaseInstanceFactory.build(), + region: 'us-east', + engineType: 'mysql', + id: '1', + tags: ['test'], + }, + { + ...databaseInstanceFactory.build(), + region: 'us-west', + engineType: 'postgresql', + id: '2', + tags: ['test', 'test2'], + }, + ]; + it('should return the data passed if data or dependentFilters are undefined', () => { + expect(filterUsingDependentFilters(undefined, undefined)).toBeUndefined(); + expect(filterUsingDependentFilters(mockData, undefined)).toBe(mockData); + expect(filterUsingDependentFilters(undefined, {})).toBeUndefined(); + }); + + it('should filter based on a single key-value match', () => { + const filters = { engineType: 'mysql' }; + const result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([mockData[0]]); + }); + + it('should filter when both resource and filter value are arrays', () => { + const filters = { tags: ['test', 'test2'] }; + const result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([mockData[0], mockData[1]]); + }); + + it('should return empty array if no resource matches', () => { + const filters = { region: 'us-central' }; + const result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([]); + }); + + it('should apply multiple filters simultaneously', () => { + let filters = { + engineType: 'postgresql', + region: 'us-east', + tags: 'test', + }; + let result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([]); + + filters = { + engineType: 'postgresql', + region: 'us-east', + tags: 'test', + }; + + result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([]); + + filters = { + engineType: 'postgresql', + region: 'us-west', + tags: 'test', + }; + + result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([mockData[1]]); + }); +}); + +describe('filterBasedOnConfig', () => { + const config: CloudPulseServiceTypeFilters = { + configuration: { + dependency: [], // empty dependency + filterKey: 'resource_id', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: true, + name: 'Database Clusters', + neededInViews: [CloudPulseAvailableViews.central], + placeholder: 'Select Database Clusters', + priority: 3, + }, + name: 'Resources', + }; + it('should return empty object if config has no dependencies', () => { + const dependentFilters = { engine: 'mysql', region: 'us-east' }; + const result = filterBasedOnConfig(config, dependentFilters); + expect(result).toEqual({}); + }); + + it('should return filtered values based on dependency keys', () => { + const dependentFilters = { + engine: 'mysql', + region: 'us-east', + status: 'running', + }; + const result = filterBasedOnConfig( + { + ...config, + configuration: { + ...config.configuration, + dependency: ['engine', 'status'], + }, + }, + dependentFilters + ); + expect(result).toEqual({ + engineType: 'mysql', + status: 'running', + }); + }); + + it('should work with array values in filters', () => { + const dependentFilters = { + engine: 'mysql', + tags: ['db', 'prod'], + }; + const result = filterBasedOnConfig( + { + ...config, + configuration: { + ...config.configuration, + dependency: ['engine', 'tags'], + }, + }, + dependentFilters + ); + expect(result).toEqual({ + engineType: 'mysql', + tags: ['db', 'prod'], + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index f05ae918ec6..484cb68ea55 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -9,7 +9,10 @@ import { import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; -import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { + CloudPulseMetricsFilter, + FilterValueType, +} from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; @@ -29,7 +32,6 @@ import type { AclpConfig, Dashboard, DateTimeWithPreset, - Filter, Filters, TimeDuration, } from '@linode/api-v4'; @@ -37,21 +39,19 @@ import type { interface CloudPulseFilterProperties { config: CloudPulseServiceTypeFilters; dashboard: Dashboard; - dependentFilters?: { - [key: string]: FilterValueType; - }; + dependentFilters?: CloudPulseMetricsFilter; isServiceAnalyticsIntegration: boolean; preferences?: AclpConfig; resource_ids?: number[] | undefined; + shouldDisable?: boolean; } interface CloudPulseMandatoryFilterCheckProps { dashboard: Dashboard; - filterValue: { - [key: string]: FilterValueType; - }; + filterValue: CloudPulseMetricsFilter; timeDuration: DateTimeWithPreset | undefined; } + /** * This function helps in building the properties needed for tags selection component * @@ -115,6 +115,7 @@ export const getRegionProperties = ( preferences, dependentFilters, config, + shouldDisable, } = props; return { defaultValue: preferences?.[REGION], @@ -123,12 +124,14 @@ export const getRegionProperties = ( placeholder, savePreferences: !isServiceAnalyticsIntegration, selectedDashboard: dashboard, - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard - ), - xFilter: buildXFilter(config, dependentFilters ?? {}), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), }; }; @@ -156,21 +159,24 @@ export const getResourcesProperties = ( dependentFilters, isServiceAnalyticsIntegration, preferences, + shouldDisable, } = props; return { defaultValue: preferences?.[RESOURCES], - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard, - preferences - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard, + preferences + ), handleResourcesSelection: handleResourceChange, label, placeholder, resourceType: dashboard.service_type, savePreferences: !isServiceAnalyticsIntegration, - xFilter: buildXFilter(config, dependentFilters ?? {}), + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), }; }; @@ -189,15 +195,18 @@ export const getNodeTypeProperties = ( isServiceAnalyticsIntegration, preferences, resource_ids, + shouldDisable, } = props; return { database_ids: resource_ids, defaultValue: preferences?.[NODE_TYPE], - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), handleNodeTypeChange, label, placeholder, @@ -238,6 +247,7 @@ export const getCustomSelectProperties = ( dependentFilters, isServiceAnalyticsIntegration, preferences, + shouldDisable, } = props; return { apiResponseIdField: apiIdField, @@ -248,11 +258,13 @@ export const getCustomSelectProperties = ( dashboard ), defaultValue: preferences?.[filterKey], - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), filterKey, filterType, isOptional, @@ -323,14 +335,17 @@ export const getTextFilterProperties = ( isServiceAnalyticsIntegration, preferences, dependentFilters, + shouldDisable, } = props; return { - disabled: shouldDisableFilterByFilterKey( - props.config.configuration.filterKey, - dependentFilters ?? {}, - dashboard - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + props.config.configuration.filterKey, + dependentFilters ?? {}, + dashboard + ), defaultValue: preferences?.[props.config.configuration.filterKey], handleTextFilterChange, label, @@ -346,34 +361,29 @@ export const getTextFilterProperties = ( * * @param config - any cloudpulse service type filter config * @param dependentFilters - the filters that are selected so far - * @returns - a xFilter type of apiV4 + * @returns - filtered dependencies based on the provided config */ -export const buildXFilter = ( - config: CloudPulseServiceTypeFilters, - dependentFilters: { - [key: string]: FilterValueType | TimeDuration; +export const filterBasedOnConfig = ( + config: CloudPulseServiceTypeFilters | undefined, + dependentFilters: CloudPulseMetricsFilter +): CloudPulseMetricsFilter => { + if (!config) { + return {}; } -): Filter => { - const filters: Filter[] = []; - let orCondition: Filter[] = []; const { dependency } = config.configuration; + const filtered: CloudPulseMetricsFilter = {}; if (dependency) { dependency.forEach((key) => { const value = dependentFilters[key]; if (value !== undefined) { - if (Array.isArray(value)) { - orCondition = value.map((val) => ({ [key]: val })); - } else { - filters.push({ [key]: value }); - } + filtered[key === 'engine' ? 'engineType' : key] = value; } }); + return filtered; } - if (orCondition.length) { - return { '+and': filters, '+or': orCondition }; - } - return { '+and': filters }; + + return {}; }; /** @@ -471,9 +481,7 @@ export const checkIfAllMandatoryFiltersAreSelected = ( * @returns Constructs and returns the metrics call filters based on selected filters and service type */ export const getMetricsCallCustomFilters = ( - selectedFilters: { - [key: string]: FilterValueType; - }, + selectedFilters: CloudPulseMetricsFilter, dashboardId?: number ): CloudPulseMetricsAdditionalFilters[] => { const serviceTypeConfig = dashboardId @@ -626,10 +634,38 @@ export const getFilters = ( return FILTER_CONFIG.get(dashboard.id)?.filters.filter((config) => isServiceAnalyticsIntegration ? config.configuration.neededInViews.includes( - CloudPulseAvailableViews.service - ) + CloudPulseAvailableViews.service + ) : config.configuration.neededInViews.includes( - CloudPulseAvailableViews.central - ) + CloudPulseAvailableViews.central + ) ); }; + +/** + * @param data The resources for which the filter needs to be applied + * @param dependentFilters The selected dependent filters that will be used to filter the resources + * @returns The filtered resources + */ +export const filterUsingDependentFilters = ( + data?: CloudPulseResources[], + dependentFilters?: CloudPulseMetricsFilter +): CloudPulseResources[] | undefined => { + if (!dependentFilters || !data) { + return data; + } + + return data.filter((resource) => { + return Object.entries(dependentFilters).every(([key, filterValue]) => { + const resourceValue = resource[key as keyof CloudPulseResources]; + + if (Array.isArray(resourceValue) && Array.isArray(filterValue)) { + return filterValue.some((val) => resourceValue.includes(String(val))); + } else if (Array.isArray(resourceValue)) { + return resourceValue.includes(String(filterValue)); + } else { + return resourceValue === filterValue; + } + }); + }); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index 3cc7ed5d6f5..10c2c59258f 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -3,7 +3,7 @@ import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews } from './models'; import type { DashboardProperties } from '../Dashboard/CloudPulseDashboard'; -import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; import type { Dashboard, DateTimeWithPreset } from '@linode/api-v4'; @@ -18,7 +18,7 @@ interface ReusableDashboardFilterUtilProps { /** * The selected filter values */ - filterValue: { [key: string]: FilterValueType }; + filterValue: CloudPulseMetricsFilter; /** * The selected resource id */ diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index aa154f7c086..e159fbbb11d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -1,3 +1,5 @@ +import type { Filter } from '@linode/api-v4'; + export const DASHBOARD_ID = 'dashboardId'; export const PRIMARY_NODE = 'primary'; @@ -88,3 +90,27 @@ export const PLACEHOLDER_TEXT: Record = { [PORT]: PORTS_PLACEHOLDER_TEXT, [INTERFACE_ID]: INTERFACE_IDS_PLACEHOLDER_TEXT, }; + +export const ORDER_BY_LABLE_ASC = { + '+order': 'asc', + '+order_by': 'label', +}; + +export const RESOURCE_FILTER_MAP: Record = { + dbaas: { + platform: 'rdbms-default', + ...ORDER_BY_LABLE_ASC, + }, + linode: { + ...ORDER_BY_LABLE_ASC, + }, + nodebalancer: { + ...ORDER_BY_LABLE_ASC, + }, + firewall: { + ...ORDER_BY_LABLE_ASC, + }, + netloadbalancer: { + ...ORDER_BY_LABLE_ASC, + }, +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index ec7d18701d9..8c7555c12d3 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -165,7 +165,7 @@ export const CloudPulseCustomSelect = React.memo( }); React.useEffect(() => { - if (!selectedResource) { + if (!selectedResource && !disabled) { setResource( getInitialDefaultSelections({ defaultValue, @@ -180,7 +180,7 @@ export const CloudPulseCustomSelect = React.memo( ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [savePreferences, options, apiV4QueryKey, queriedResources]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes + }, [savePreferences, options, apiV4QueryKey, queriedResources, disabled]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes const handleChange = ( _: React.SyntheticEvent, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index b87b9bf0063..6a4498cb168 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -1,4 +1,4 @@ -import { Button, ErrorState, Typography } from '@linode/ui'; +import { Button, CircleProgress, ErrorState, Typography } from '@linode/ui'; import { GridLegacy, useTheme } from '@mui/material'; import * as React from 'react'; @@ -30,7 +30,10 @@ import { import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { type CloudPulseServiceTypeFilters } from '../Utils/models'; -import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { + CloudPulseMetricsFilter, + FilterValueType, +} from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseTags } from './CloudPulseTagsFilter'; import type { AclpConfig, Dashboard } from '@linode/api-v4'; @@ -55,6 +58,16 @@ export interface CloudPulseDashboardFilterBuilderProps { handleToggleAppliedFilter: (isVisible: boolean) => void; + /** + * Is cluster Call + */ + isError?: boolean; + + /** + * Property to disable filters + */ + isLoading?: boolean; + /** * this will handle the restrictions, if the parent of the component is going to be integrated in service analytics page */ @@ -80,19 +93,18 @@ export const CloudPulseDashboardFilterBuilder = React.memo( isServiceAnalyticsIntegration, preferences, resource_ids, + isError = false, + isLoading = false, } = props; - const [, setDependentFilters] = React.useState<{ - [key: string]: FilterValueType; - }>({}); + const [, setDependentFilters] = React.useState({}); const [showFilter, setShowFilter] = React.useState(true); const theme = useTheme(); - const dependentFilterReference: React.MutableRefObject<{ - [key: string]: FilterValueType; - }> = React.useRef({}); + const dependentFilterReference: React.MutableRefObject = + React.useRef({}); const checkAndUpdateDependentFilters = React.useCallback( (filterKey: string, value: FilterValueType) => { @@ -261,6 +273,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( dependentFilters: dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, + shouldDisable: isError || isLoading, }, handleTagsChange ); @@ -272,6 +285,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( isServiceAnalyticsIntegration, preferences, dependentFilters: dependentFilterReference.current, + shouldDisable: isError || isLoading, }, handleRegionChange ); @@ -283,6 +297,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( dependentFilters: dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, + shouldDisable: isError || isLoading, }, handleResourceChange ); @@ -301,6 +316,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( : ( dependentFilterReference.current[RESOURCE_ID] as string[] )?.map((id: string) => Number(id)), + shouldDisable: isError || isLoading, }, handleNodeTypeChange ); @@ -317,6 +333,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( dependentFilters: resource_ids?.length ? { [RESOURCE_ID]: resource_ids } : dependentFilterReference.current, + shouldDisable: isError || isLoading, }, handleTextFilterChange ); @@ -330,6 +347,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( : dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, + shouldDisable: isError || isLoading, }, handleCustomSelectChange ); @@ -345,6 +363,8 @@ export const CloudPulseDashboardFilterBuilder = React.memo( handleCustomSelectChange, isServiceAnalyticsIntegration, preferences, + isError, + isLoading, ] ); @@ -438,21 +458,32 @@ export const CloudPulseDashboardFilterBuilder = React.memo( Filters - - - + {isLoading ? ( + + + + ) : ( + + + + )} ); }, @@ -466,6 +497,8 @@ function compareProps( return ( oldProps.dashboard?.id === newProps.dashboard?.id && oldProps.preferences?.[DASHBOARD_ID] === - newProps.preferences?.[DASHBOARD_ID] + newProps.preferences?.[DASHBOARD_ID] && + oldProps.isLoading === newProps.isLoading && + oldProps.isError === newProps.isError ); } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx index 26806cf2dcb..09323742ef8 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx @@ -20,14 +20,14 @@ const props: CloudPulseNodeTypeFilterProps = { }; const queryMocks = vi.hoisted(() => ({ - useAllDatabasesQuery: vi.fn().mockReturnValue({}), + useResourcesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); return { ...actual, - useAllDatabasesQuery: queryMocks.useAllDatabasesQuery, + useResourcesQuery: queryMocks.useResourcesQuery, }; }); @@ -50,8 +50,8 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('initializes with Primary as default value when no preferences are saved', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ - data: databaseInstanceFactory.buildList(2), + queryMocks.useResourcesQuery.mockReturnValue({ + data: [{ ...databaseInstanceFactory.build(), clusterSize: 1 }], isError: false, isLoading: false, }); @@ -67,10 +67,16 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('displays correct options in dropdown in case of maximum cluster size one', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ + queryMocks.useResourcesQuery.mockReturnValue({ data: [ - databaseInstanceFactory.build({ cluster_size: 1, id: 1 }), - databaseInstanceFactory.build({ cluster_size: 1, id: 2 }), + { + ...databaseInstanceFactory.build({ id: 1 }), + clusterSize: 1, + }, + { + ...databaseInstanceFactory.build({ id: 2 }), + clusterSize: 1, + }, ], isError: false, isLoading: false, @@ -87,10 +93,16 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('displays correct options in dropdown if maximum cluster size is greater than one', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ + queryMocks.useResourcesQuery.mockReturnValue({ data: [ - databaseInstanceFactory.build({ cluster_size: 2, id: 1 }), - databaseInstanceFactory.build({ cluster_size: 3, id: 2 }), + { + ...databaseInstanceFactory.build({ id: 1 }), + clusterSize: 2, + }, + { + ...databaseInstanceFactory.build({ id: 2 }), + clusterSize: 3, + }, ], isError: false, isLoading: false, @@ -116,10 +128,16 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('maintains selected value in preferences after re-render', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ + queryMocks.useResourcesQuery.mockReturnValue({ data: [ - databaseInstanceFactory.build({ cluster_size: 1, id: 1 }), - databaseInstanceFactory.build({ cluster_size: 3, id: 2 }), + { + ...databaseInstanceFactory.build({ id: 1 }), + clusterSize: 1, + }, + { + ...databaseInstanceFactory.build({ id: 2 }), + clusterSize: 3, + }, ], isError: false, isLoading: false, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx index cda1d448278..9f26fc63a11 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx @@ -1,10 +1,11 @@ -import { useAllDatabasesQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import * as React from 'react'; -import { PRIMARY_NODE } from '../Utils/constants'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import type { DatabaseInstance, FilterValue } from '@linode/api-v4'; +import { PRIMARY_NODE, RESOURCE_FILTER_MAP } from '../Utils/constants'; + +import type { FilterValue } from '@linode/api-v4'; export interface CloudPulseNodeType { id: string; @@ -88,7 +89,12 @@ export const CloudPulseNodeTypeFilter = React.memo( data: databaseClusters, isError, isLoading, - } = useAllDatabasesQuery(); // fetch all databases + } = useResourcesQuery( + !disabled, + 'dbaas', + {}, + RESOURCE_FILTER_MAP['dbaas'] ?? {} + ); // fetch all databases const isClusterSizeGreaterThanOne = React.useMemo< boolean | undefined @@ -98,8 +104,8 @@ export const CloudPulseNodeTypeFilter = React.memo( } // check if any cluster has a size greater than 1 for selected database ids return databaseClusters.some( - (cluster: DatabaseInstance) => - database_ids.includes(cluster.id) && cluster.cluster_size > 1 + ({ id, clusterSize }) => + database_ids.includes(Number(id)) && clusterSize && clusterSize > 1 ); }, [databaseClusters, database_ids]); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index b385b4cf223..560ac92afc9 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -7,11 +7,12 @@ import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { filterRegionByServiceType } from '../Alerts/Utils/utils'; -import { NO_REGION_MESSAGE } from '../Utils/constants'; -import { deepEqual } from '../Utils/FilterBuilder'; +import { NO_REGION_MESSAGE, RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import type { Dashboard, Filter, FilterValue, Region } from '@linode/api-v4'; +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; @@ -25,7 +26,7 @@ export interface CloudPulseRegionSelectProps { placeholder?: string; savePreferences?: boolean; selectedDashboard: Dashboard | undefined; - xFilter?: Filter; + xFilter?: CloudPulseMetricsFilter; } export const CloudPulseRegionSelect = React.memo( @@ -41,24 +42,17 @@ export const CloudPulseRegionSelect = React.memo( xFilter, } = props; - const resourceFilterMap: Record = { - dbaas: { - platform: 'rdbms-default', - }, - }; - const { data: regions, isError, isLoading } = useRegionsQuery(); const { data: resources, isError: isResourcesError, isLoading: isResourcesLoading, } = useResourcesQuery( - selectedDashboard !== undefined && Boolean(regions?.length), + !disabled && selectedDashboard !== undefined && Boolean(regions?.length), selectedDashboard?.service_type, {}, { - ...(resourceFilterMap[selectedDashboard?.service_type ?? ''] ?? {}), - ...(xFilter ?? {}), // the usual xFilters + ...(RESOURCE_FILTER_MAP[selectedDashboard?.service_type ?? ''] ?? {}), } ); @@ -111,7 +105,9 @@ export const CloudPulseRegionSelect = React.memo( }, [regions, serviceType]); const supportedRegionsFromResources = supportedRegions?.filter(({ id }) => - resources?.some(({ region }) => region === id) + filterUsingDependentFilters(resources, xFilter)?.some( + ({ region }) => region === id + ) ); return ( @@ -128,7 +124,7 @@ export const CloudPulseRegionSelect = React.memo( fullWidth isGeckoLAEnabled={isGeckoLAEnabled} label={label || 'Region'} - loading={isLoading || isResourcesLoading} + loading={!disabled && (isLoading || isResourcesLoading)} noMarginTop noOptionsText={ NO_REGION_MESSAGE[selectedDashboard?.service_type ?? ''] ?? diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 034f0aec38c..c95f20d2236 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -5,15 +5,14 @@ import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { deepEqual } from '../Utils/FilterBuilder'; +import { RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; -import type { - CloudPulseServiceType, - Filter, - FilterValue, -} from '@linode/api-v4'; +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; export interface CloudPulseResources { + clusterSize?: number; engineType?: string; entities?: Record; id: string; @@ -35,7 +34,7 @@ export interface CloudPulseResourcesSelectProps { resourceType: CloudPulseServiceType | undefined; savePreferences?: boolean; tags?: string[]; - xFilter?: Filter; + xFilter?: CloudPulseMetricsFilter; } export const CloudPulseResourcesSelect = React.memo( @@ -49,20 +48,11 @@ export const CloudPulseResourcesSelect = React.memo( region, resourceType, savePreferences, - tags, xFilter, } = props; const flags = useFlags(); - const resourceFilterMap: Record = { - dbaas: { - '+order': 'asc', - '+order_by': 'label', - platform: 'rdbms-default', - }, - }; - const { data: resources, isError, @@ -71,16 +61,8 @@ export const CloudPulseResourcesSelect = React.memo( disabled !== undefined ? !disabled : Boolean(region && resourceType), resourceType, {}, - xFilter - ? { - ...(resourceFilterMap[resourceType ?? ''] ?? {}), - ...xFilter, // the usual xFilters - } - : { - ...(resourceFilterMap[resourceType ?? ''] ?? {}), - region, - ...(tags ?? []), - } + + RESOURCE_FILTER_MAP[resourceType ?? ''] ?? {} ); const [selectedResources, setSelectedResources] = @@ -94,8 +76,8 @@ export const CloudPulseResourcesSelect = React.memo( const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete const getResourcesList = React.useMemo(() => { - return resources && resources.length > 0 ? resources : []; - }, [resources]); + return filterUsingDependentFilters(resources, xFilter) ?? []; + }, [resources, xFilter]); // Maximum resource selection limit is fetched from launchdarkly const maxResourceSelectionLimit = React.useMemo(() => { diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index 9fb946629a7..d53fedbfb23 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -115,7 +115,7 @@ export const queryFactory = createQueryKeys(key, { case 'dbaas': return databaseQueries.databases._ctx.all(params, filters); case 'firewall': - return firewallQueries.firewalls._ctx.all; + return firewallQueries.firewalls._ctx.all(params, filters); case 'linode': return { queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 2cd9b1d8164..a530fe8a6cb 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -36,6 +36,7 @@ export const useResourcesQuery = ( regions: resource.regions ? resource.regions : [], tags: resource.tags, entities, + clusterSize: resource.cluster_size, }; }); }, diff --git a/packages/queries/src/firewalls/firewalls.ts b/packages/queries/src/firewalls/firewalls.ts index ceed713cfc0..09ab77e9543 100644 --- a/packages/queries/src/firewalls/firewalls.ts +++ b/packages/queries/src/firewalls/firewalls.ts @@ -62,9 +62,15 @@ const getAllFirewallDevices = ( const getAllFirewallTemplates = () => getAll(getTemplates)().then((data) => data.data); -const getAllFirewallsRequest = () => - getAll((passedParams, passedFilter) => - getFirewalls(passedParams, passedFilter), +const getAllFirewallsRequest = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getFirewalls( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), )().then((data) => data.data); export const firewallQueries = createQueryKeys('firewalls', { @@ -80,10 +86,10 @@ export const firewallQueries = createQueryKeys('firewalls', { }), firewalls: { contextQueries: { - all: { - queryFn: getAllFirewallsRequest, - queryKey: null, - }, + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllFirewallsRequest(params, filter), + queryKey: [params, filter], + }), infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => getFirewalls({ page: pageParam as number }, filter), @@ -176,7 +182,7 @@ export const useAddFirewallDeviceMutation = () => { // Append the new entity to the Firewall object in the "all firewalls" store queryClient.setQueryData( - firewallQueries.firewalls._ctx.all.queryKey, + firewallQueries.firewalls._ctx.all().queryKey, (firewalls) => { if (!firewalls) { return undefined; @@ -305,9 +311,13 @@ export const useFirewallQuery = (id: number, enabled: boolean = true) => enabled, }); -export const useAllFirewallsQuery = (enabled: boolean = true) => { +export const useAllFirewallsQuery = ( + enabled: boolean = true, + params?: Params, + filter?: Filter, +) => { return useQuery({ - ...firewallQueries.firewalls._ctx.all, + ...firewallQueries.firewalls._ctx.all(params, filter), enabled, }); }; @@ -450,7 +460,7 @@ export const useUpdateFirewallRulesMutation = (firewallId: number) => { // Update the the Firewall object in the "all firewalls" store queryClient.setQueryData( - firewallQueries.firewalls._ctx.all.queryKey, + firewallQueries.firewalls._ctx.all().queryKey, (firewalls) => { if (!firewalls) { return undefined; From 7a8417c5c12eec5583330258e916791320e85207 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Mon, 18 Aug 2025 09:53:31 +0200 Subject: [PATCH 45/88] upcoming: [DPS-34193] - Add search and select to streams and destinations table (#12679) --- ...r-12679-upcoming-features-1754997480956.md | 5 ++ .../Destinations/DestinationsLanding.test.tsx | 26 ++++-- .../Destinations/DestinationsLanding.tsx | 30 ++++++- .../DataStreamTabHeader.test.tsx | 33 ++++++- .../DataStreamTabHeader.tsx | 89 +++++++++++++++++-- .../src/features/DataStream/Shared/types.ts | 18 +++- .../DataStream/Streams/StreamTableRow.tsx | 4 +- .../Streams/StreamsLanding.test.tsx | 39 +++++--- .../DataStream/Streams/StreamsLanding.tsx | 48 +++++++++- .../manager/src/routes/datastream/index.ts | 13 +++ 10 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 packages/manager/.changeset/pr-12679-upcoming-features-1754997480956.md diff --git a/packages/manager/.changeset/pr-12679-upcoming-features-1754997480956.md b/packages/manager/.changeset/pr-12679-upcoming-features-1754997480956.md new file mode 100644 index 00000000000..fe12b63b239 --- /dev/null +++ b/packages/manager/.changeset/pr-12679-upcoming-features-1754997480956.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add search and select inputs for Streams table. Add search input for Desitnations table ([#12679](https://github.com/linode/manager/pull/12679)) diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx index 6923a63fade..b7e78477b20 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx @@ -10,7 +10,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; describe('Destinations Landing Table', () => { - it('should render destinations landing table with items PaginationFooter', async () => { + it('should render destinations landing tab header and table with items PaginationFooter', async () => { server.use( http.get('*/monitor/streams/destinations', () => { return HttpResponse.json( @@ -19,15 +19,22 @@ describe('Destinations Landing Table', () => { }) ); - const { getByText, queryByTestId, getByTestId } = renderWithTheme( - - ); + const { getByText, queryByTestId, getAllByTestId, getByPlaceholderText } = + renderWithTheme(, { + initialRoute: '/datastream/destinations', + }); const loadingElement = queryByTestId(loadingTestId); if (loadingElement) { await waitForElementToBeRemoved(loadingElement); } + // search text input + getByPlaceholderText('Search for a Destination'); + + // button + getByText('Create Destination'); + // Table column headers getByText('Name'); getByText('Type'); @@ -35,13 +42,13 @@ describe('Destinations Landing Table', () => { getByText('Last Modified'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getByTestId( + const paginationFooterSelectPageSizeInput = getAllByTestId( 'textfield-input' - ) as HTMLInputElement; + )[1] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); }); - it('should render images landing empty state', async () => { + it('should render destinations landing empty state', async () => { server.use( http.get('*/monitor/streams/destinations', () => { return HttpResponse.json(makeResourcePage([])); @@ -49,7 +56,10 @@ describe('Destinations Landing Table', () => { ); const { getByText, queryByTestId } = renderWithTheme( - + , + { + initialRoute: '/datastream/destinations', + } ); const loadingElement = queryByTestId(loadingTestId); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx index b3266c599bd..f329d54c893 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx @@ -2,7 +2,7 @@ import { useDestinationsQuery } from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; -import { useNavigate } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -20,9 +20,13 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; export const DestinationsLanding = () => { const navigate = useNavigate(); - + const destinationsUrl = '/datastream/destinations'; + const search = useSearch({ + from: destinationsUrl, + shouldThrow: false, + }); const pagination = usePaginationV2({ - currentRoute: '/datastream/destinations', + currentRoute: destinationsUrl, preferenceKey: DESTINATIONS_TABLE_PREFERENCE_KEY, }); @@ -32,7 +36,7 @@ export const DestinationsLanding = () => { order: DESTINATIONS_TABLE_DEFAULT_ORDER, orderBy: DESTINATIONS_TABLE_DEFAULT_ORDER_BY, }, - from: '/datastream/destinations', + from: destinationsUrl, }, preferenceKey: `destinations-order`, }); @@ -40,11 +44,15 @@ export const DestinationsLanding = () => { const filter = { ['+order']: order, ['+order_by']: orderBy, + ...(search?.label !== undefined && { + label: { '+contains': search?.label }, + }), }; const { data: destinations, isLoading, + isFetching, error, } = useDestinationsQuery( { @@ -54,6 +62,17 @@ export const DestinationsLanding = () => { filter ); + const onSearch = (label: string) => { + navigate({ + search: (prev) => ({ + ...prev, + page: undefined, + label: label ? label : undefined, + }), + to: destinationsUrl, + }); + }; + const navigateToCreate = () => { navigate({ to: '/datastream/destinations/create' }); }; @@ -78,8 +97,11 @@ export const DestinationsLanding = () => { <> diff --git a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx index ecdc5d03c18..d5d81c33419 100644 --- a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx +++ b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; +import { streamStatusOptions } from 'src/features/DataStream/Shared/types'; import { renderWithTheme } from 'src/utilities/testHelpers'; describe('DataStreamTabHeader', () => { @@ -8,7 +9,8 @@ describe('DataStreamTabHeader', () => { const { getByText } = renderWithTheme( null} /> ); - expect(getByText('Create Stream')).toBeInTheDocument(); + + getByText('Create Stream'); }); it('should render a disabled create button', () => { @@ -25,4 +27,33 @@ describe('DataStreamTabHeader', () => { 'true' ); }); + + it('should render a search input', () => { + const { getByPlaceholderText } = renderWithTheme( + null} + onSearch={() => null} + searchValue={''} + /> + ); + + getByPlaceholderText('Search for a Stream'); + }); + + it('should render a select input', () => { + const selectValue = streamStatusOptions[0].value; + const { getByPlaceholderText, getByLabelText } = renderWithTheme( + null} + selectList={streamStatusOptions} + selectValue={selectValue} + /> + ); + + getByLabelText('Status'); + getByPlaceholderText('Select'); + }); }); diff --git a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx index 5799da80cb5..c44571be93d 100644 --- a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx +++ b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx @@ -1,18 +1,27 @@ -import { Button } from '@linode/ui'; +import { Autocomplete, Button } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; + import type { Theme } from '@mui/material/styles'; +import type { LabelValueOption } from 'src/features/DataStream/Shared/types'; export interface DataStreamTabHeaderProps { buttonDataAttrs?: { [key: string]: boolean | string }; createButtonText?: string; disabledCreateButton?: boolean; entity?: string; + isSearching?: boolean; loading?: boolean; onButtonClick?: () => void; + onSearch?: (label: string) => void; + onSelect?: (status: string) => void; + searchValue?: string; + selectList?: LabelValueOption[]; + selectValue?: string; spacingBottom?: 0 | 4 | 16 | 24; } @@ -24,6 +33,12 @@ export const DataStreamTabHeader = ({ loading, onButtonClick, spacingBottom = 24, + isSearching, + selectList, + onSelect, + selectValue, + searchValue, + onSearch, }: DataStreamTabHeaderProps) => { const theme = useTheme(); @@ -35,6 +50,7 @@ export const DataStreamTabHeader = ({ const customSmMdBetweenBreakpoint = useMediaQuery((theme: Theme) => theme.breakpoints.between(customBreakpoint, 'md') ); + const searchLabel = `Search for a ${entity}`; return ( - { - // @TODO (DPS-34192) Search input - both streams and destinations - } + {onSearch && searchValue !== undefined && ( + + )} - { - // @TODO (DPS-34193) Select status - only streams - } + {selectList && onSelect && ( + { + onSelect(option?.value ?? ''); + }} + options={selectList} + placeholder="Select" + value={selectList.find(({ value }) => value === selectValue)} + /> + )} {onButtonClick && (
    diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts index a96d9691f58..7f421ed2fdc 100644 --- a/packages/manager/src/routes/datastream/index.ts +++ b/packages/manager/src/routes/datastream/index.ts @@ -3,6 +3,13 @@ import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { DataStreamRoute } from './DataStreamRoute'; +import type { TableSearchParams } from 'src/routes/types'; + +export interface StreamSearchParams extends TableSearchParams { + label?: string; + status?: string; +} + export const dataStreamRoute = createRoute({ component: DataStreamRoute, getParentRoute: () => rootRoute, @@ -24,6 +31,7 @@ const dataStreamLandingRoute = createRoute({ const streamsRoute = createRoute({ getParentRoute: () => dataStreamRoute, path: 'streams', + validateSearch: (search: StreamSearchParams) => search, }).lazy(() => import('src/features/DataStream/dataStreamLandingLazyRoute').then( (m) => m.dataStreamLandingLazyRoute @@ -39,9 +47,14 @@ const streamsCreateRoute = createRoute({ ).then((m) => m.streamCreateLazyRoute) ); +export interface DestinationSearchParams extends TableSearchParams { + label?: string; +} + const destinationsRoute = createRoute({ getParentRoute: () => dataStreamRoute, path: 'destinations', + validateSearch: (search: DestinationSearchParams) => search, }).lazy(() => import('src/features/DataStream/dataStreamLandingLazyRoute').then( (m) => m.dataStreamLandingLazyRoute From faad3d02e420cf7e0f02962156c9bba66e1432fd Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:04:24 +0530 Subject: [PATCH 46/88] fix: [DI-26796] - Removed error check from useIsACLPEnabled hook (#12713) * fix: [DI-26796] - Removed error check from useIsACLPEnabled hook * added changeset --- packages/manager/.changeset/pr-12713-fixed-1755507986027.md | 5 +++++ packages/manager/src/features/CloudPulse/Utils/utils.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-12713-fixed-1755507986027.md diff --git a/packages/manager/.changeset/pr-12713-fixed-1755507986027.md b/packages/manager/.changeset/pr-12713-fixed-1755507986027.md new file mode 100644 index 00000000000..a6e734b42bc --- /dev/null +++ b/packages/manager/.changeset/pr-12713-fixed-1755507986027.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +ACLP: `metrics` and `alerts` visible for restricted account ([#12713](https://github.com/linode/manager/pull/12713)) diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 9f4970cb177..6c56b0e9be6 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -63,10 +63,10 @@ interface AclpSupportedRegionProps { export const useIsACLPEnabled = (): { isACLPEnabled: boolean; } => { - const { data: account, error } = useAccount(); + const { data: account } = useAccount(); const flags = useFlags(); - if (error || !flags) { + if (!flags) { return { isACLPEnabled: false }; } From cdcf176e898dbd4bedb8d5fc23c25ee46f4b649a Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:42:09 +0200 Subject: [PATCH 47/88] change: [UIE-8836] - IAM RBAC permissions for Billing Activity (#12660) * BillingActivityPanel * Added changeset: IAM RBAC permissions for Billing Activity * list_invoice_items * cleanup * moar cleanuo * Added changeset: Implemented `disabled` parameters for payments & invoices queries * update changeset * feedback @coliu-akamai --- .../pr-12660-changed-1754915328052.md | 5 ++ .../BillingActivityPanel.tsx | 79 +++++++++++++++---- .../Billing/InvoiceDetail/InvoiceDetail.tsx | 16 ++++ .../CloudPulse/Utils/FilterBuilder.ts | 8 +- .../pr-12660-added-1754985893442.md | 5 ++ packages/queries/src/account/billing.ts | 4 + 6 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 packages/manager/.changeset/pr-12660-changed-1754915328052.md create mode 100644 packages/queries/.changeset/pr-12660-added-1754985893442.md diff --git a/packages/manager/.changeset/pr-12660-changed-1754915328052.md b/packages/manager/.changeset/pr-12660-changed-1754915328052.md new file mode 100644 index 00000000000..42fe30cd91c --- /dev/null +++ b/packages/manager/.changeset/pr-12660-changed-1754915328052.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM RBAC permissions for Billing Activity ([#12660](https://github.com/linode/manager/pull/12660)) diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index e232be017a0..bb71e6707b0 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -6,7 +6,7 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; -import { Autocomplete, Typography } from '@linode/ui'; +import { Autocomplete, Notice, Typography, WarningIcon } from '@linode/ui'; import { getAll, useSet } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; @@ -37,6 +37,7 @@ import { printInvoice, printPayment, } from 'src/features/Billing/PdfGenerator/PdfGenerator'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -199,6 +200,16 @@ export const BillingActivityPanel = React.memo((props: Props) => { preferenceKey: 'billing-activity-order', }); + const { data: permissions } = usePermissions('account', [ + 'list_billing_payments', + 'list_billing_invoices', + 'list_invoice_items', + ]); + + const canViewInvoices = permissions.list_billing_invoices; + const canViewPayments = permissions.list_billing_payments; + const canViewInvoiceDetails = permissions.list_invoice_items; + const isAkamaiCustomer = account?.billing_source === 'akamai'; const { classes } = useStyles(); const flags = useFlags(); @@ -218,13 +229,13 @@ export const BillingActivityPanel = React.memo((props: Props) => { data: payments, error: accountPaymentsError, isLoading: accountPaymentsLoading, - } = useAllAccountPayments({}, filter); + } = useAllAccountPayments({}, filter, canViewPayments); const { data: invoices, error: accountInvoicesError, isLoading: accountInvoicesLoading, - } = useAllAccountInvoices({}, filter); + } = useAllAccountInvoices({}, filter, canViewInvoices); const downloadInvoicePDF = React.useCallback( (invoiceId: number) => { @@ -356,7 +367,19 @@ export const BillingActivityPanel = React.memo((props: Props) => { return ( + {' '} + You do not have permission to view billing or payment history. + + ) + } /> ); } @@ -365,6 +388,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { const lastItem = idx === orderedPaginatedData.length - 1; return ( { { + if (!canViewInvoices) { + return options.filter((option) => option.value !== 'invoice'); + } + if (!canViewPayments) { + return options.filter((option) => option.value !== 'payment'); + } + return options; + }} label="Transaction Types" noMarginTop onChange={(_, item) => { @@ -457,6 +491,16 @@ export const BillingActivityPanel = React.memo((props: Props) => { /> + {(canViewInvoices && !canViewPayments) || + (!canViewInvoices && canViewPayments) ? ( + + ) : null}
    @@ -473,8 +517,9 @@ export const BillingActivityPanel = React.memo((props: Props) => { > Amount - - + {canViewInvoiceDetails && ( + + )} {renderTableContent()} @@ -503,6 +548,7 @@ const StyledBillingAndPaymentHistoryHeader = styled('div', { // // ============================================================================= interface ActivityFeedItemProps extends ActivityFeedItem { + canViewInvoiceDetails: boolean; downloadPDF: (id: number) => void; hasError: boolean; isLoading: boolean; @@ -515,6 +561,7 @@ export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { const { iamRbacPrimaryNavChanges } = useFlags(); const { + canViewInvoiceDetails, date, downloadPDF, hasError, @@ -544,7 +591,7 @@ export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { return ( - {type === 'invoice' ? ( + {type === 'invoice' && canViewInvoiceDetails ? ( { - - - + {canViewInvoiceDetails && ( + + + + )} ); }); diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 563fb02b99f..2f960224287 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx @@ -22,6 +22,7 @@ import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { LandingHeader } from 'src/components/LandingHeader'; import { Link } from 'src/components/Link'; import { printInvoice } from 'src/features/Billing/PdfGenerator/PdfGenerator'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useFlags } from 'src/hooks/useFlags'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -41,6 +42,9 @@ export const InvoiceDetail = () => { : '/account/billing/invoices/$invoiceId', }); const theme = useTheme(); + const { data: permissions } = usePermissions('account', [ + 'list_invoice_items', + ]); const csvRef = React.useRef(undefined); @@ -59,6 +63,10 @@ export const InvoiceDetail = () => { const shouldShowRegion = invoiceCreatedAfterDCPricingLaunch(invoice?.date); const requestData = () => { + if (!permissions.list_invoice_items) { + return; + } + setLoading(true); const getAllInvoiceItems = getAll((params, filter) => @@ -86,6 +94,14 @@ export const InvoiceDetail = () => { requestData(); }, []); + if (!permissions.list_invoice_items) { + return ( + + You do not have permission to view invoice details. + + ); + } + const printInvoicePDF = async ( account: Account, invoice: Invoice, diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 484cb68ea55..29a250dc73c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -634,11 +634,11 @@ export const getFilters = ( return FILTER_CONFIG.get(dashboard.id)?.filters.filter((config) => isServiceAnalyticsIntegration ? config.configuration.neededInViews.includes( - CloudPulseAvailableViews.service - ) + CloudPulseAvailableViews.service + ) : config.configuration.neededInViews.includes( - CloudPulseAvailableViews.central - ) + CloudPulseAvailableViews.central + ) ); }; diff --git a/packages/queries/.changeset/pr-12660-added-1754985893442.md b/packages/queries/.changeset/pr-12660-added-1754985893442.md new file mode 100644 index 00000000000..17c0c6947b7 --- /dev/null +++ b/packages/queries/.changeset/pr-12660-added-1754985893442.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Added +--- + +Implemented `enabled` parameters for payments & invoices queries ([#12660](https://github.com/linode/manager/pull/12660)) diff --git a/packages/queries/src/account/billing.ts b/packages/queries/src/account/billing.ts index acb8e670230..b4afa48f1c0 100644 --- a/packages/queries/src/account/billing.ts +++ b/packages/queries/src/account/billing.ts @@ -10,22 +10,26 @@ import type { APIError, Filter, Params } from '@linode/api-v4/lib/types'; export const useAllAccountInvoices = ( params: Params = {}, filter: Filter = {}, + enabled: boolean = true, ) => { return useQuery({ ...accountQueries.invoices(params, filter), ...queryPresets.oneTimeFetch, placeholderData: keepPreviousData, + enabled, }); }; export const useAllAccountPayments = ( params: Params = {}, filter: Filter = {}, + enabled: boolean = true, ) => { return useQuery({ ...accountQueries.payments(params, filter), ...queryPresets.oneTimeFetch, placeholderData: keepPreviousData, + enabled, }); }; From cad8e00c708634baa6f8256a218eeb4909fd0331 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:55:54 -0500 Subject: [PATCH 48/88] upcoming: [M3-10417], [M3-10418], [M3-10419] and [M3-10420] - Redirects account tabs to flat routes /login-history, /settings, /maintenance and /service-transfers (#12702) * Route accout/login-history -> /login-history * Route accout/maintenance -> /maintenance * Added changeset: Redirects account tabs to flat routes /login-history, /settings, /maintenance and /service-transfers * Route accout/settings -> /settings * Route accout/service-transfers -> /service-transfers * Fix casing: serviceTransfers -> ServiceTransfers * Update packages/manager/.changeset/pr-12702-upcoming-features-1755191140699.md Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --------- Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --- ...r-12702-upcoming-features-1755191140699.md | 5 ++ .../src/components/PrimaryNav/PrimaryNav.tsx | 8 +-- .../src/features/Account/AccountLogins.tsx | 10 +++- .../Account/Maintenance/MaintenanceTable.tsx | 8 ++- .../EntityTransfersCreate.tsx | 6 +- .../LinodeTransferTable.tsx | 7 ++- .../EntityTransfersLanding.tsx | 14 +++-- .../TransferControls.tsx | 9 ++- .../LoginHistory/LoginHistoryLanding.tsx | 38 ++++++++++++ .../loginHistoryLandingLazyRoute.ts | 7 +++ .../Maintenance/MaintenanceLanding.tsx | 38 ++++++++++++ .../maintenanceLandingLazyRoute.ts | 9 +++ .../ServiceTransfersLanding.tsx | 38 ++++++++++++ .../serviceTransfersCreateLazyRoute.ts | 9 +++ .../serviceTransfersLandingLazyRoute.ts | 9 +++ .../src/features/Settings/SettingsLanding.tsx | 38 ++++++++++++ .../Settings/settingsLandingLazyRoute.ts | 7 +++ .../TopMenu/UserMenu/UserMenuPopover.tsx | 14 +++-- packages/manager/src/routes/account/index.ts | 40 +++++++++++++ packages/manager/src/routes/index.tsx | 8 +++ .../routes/loginHistory/LoginHistoryRoute.tsx | 14 +++++ .../manager/src/routes/loginHistory/index.ts | 42 +++++++++++++ .../routes/maintenance/MaintenanceRoute.tsx | 14 +++++ .../manager/src/routes/maintenance/index.ts | 42 +++++++++++++ .../ServiceTransfersRoute.tsx | 14 +++++ .../src/routes/serviceTransfers/index.ts | 60 +++++++++++++++++++ .../src/routes/settings/SettingsRoute.tsx | 14 +++++ packages/manager/src/routes/settings/index.ts | 42 +++++++++++++ 28 files changed, 545 insertions(+), 19 deletions(-) create mode 100644 packages/manager/.changeset/pr-12702-upcoming-features-1755191140699.md create mode 100644 packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx create mode 100644 packages/manager/src/features/LoginHistory/loginHistoryLandingLazyRoute.ts create mode 100644 packages/manager/src/features/Maintenance/MaintenanceLanding.tsx create mode 100644 packages/manager/src/features/Maintenance/maintenanceLandingLazyRoute.ts create mode 100644 packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx create mode 100644 packages/manager/src/features/ServiceTransfers/serviceTransfersCreateLazyRoute.ts create mode 100644 packages/manager/src/features/ServiceTransfers/serviceTransfersLandingLazyRoute.ts create mode 100644 packages/manager/src/features/Settings/SettingsLanding.tsx create mode 100644 packages/manager/src/features/Settings/settingsLandingLazyRoute.ts create mode 100644 packages/manager/src/routes/loginHistory/LoginHistoryRoute.tsx create mode 100644 packages/manager/src/routes/loginHistory/index.ts create mode 100644 packages/manager/src/routes/maintenance/MaintenanceRoute.tsx create mode 100644 packages/manager/src/routes/maintenance/index.ts create mode 100644 packages/manager/src/routes/serviceTransfers/ServiceTransfersRoute.tsx create mode 100644 packages/manager/src/routes/serviceTransfers/index.ts create mode 100644 packages/manager/src/routes/settings/SettingsRoute.tsx create mode 100644 packages/manager/src/routes/settings/index.ts diff --git a/packages/manager/.changeset/pr-12702-upcoming-features-1755191140699.md b/packages/manager/.changeset/pr-12702-upcoming-features-1755191140699.md new file mode 100644 index 00000000000..119f9b4da3c --- /dev/null +++ b/packages/manager/.changeset/pr-12702-upcoming-features-1755191140699.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Redirect Account tabs to flat routes `/login-history`, `/settings`, `/maintenance`, and `/service-transfers` ([#12702](https://github.com/linode/manager/pull/12702)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 4766bac2a76..648a55cdae7 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -296,19 +296,19 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }, { display: 'Login History', - to: '/account/login-history', // TODO: replace with '/login-history' when flat route is added + to: '/login-history', }, { display: 'Service Transfers', - to: '/account/service-transfers', // TODO: replace with '/service-transfers' when flat route is added + to: '/service-transfers', }, { display: 'Maintenance', - to: '/account/maintenance', // TODO: replace with '/maintenance' when flat route is added + to: '/maintenance', }, { display: 'Settings', - to: '/account/settings', // TODO: replace with '/settings' when flat route is added + to: '/settings', }, ], name: 'Administration', diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index 36e1263fc76..232a99f4f13 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -15,6 +15,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 { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -43,11 +44,14 @@ const useStyles = makeStyles()((theme: Theme) => ({ const AccountLogins = () => { const { classes } = useStyles(); + const flags = useFlags(); const { data: permissions } = usePermissions('account', [ 'list_account_logins', ]); const pagination = usePaginationV2({ - currentRoute: '/account/login-history', + currentRoute: flags?.iamRbacPrimaryNavChanges + ? '/login-history' + : '/account/login-history', preferenceKey: 'account-logins-pagination', }); @@ -57,7 +61,9 @@ const AccountLogins = () => { order: 'desc', orderBy: 'datetime', }, - from: '/account/login-history', + from: flags?.iamRbacPrimaryNavChanges + ? '/login-history' + : '/account/login-history', }, preferenceKey: `${preferenceKey}-order`, }); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx index 0ae0cf35ad0..8a283a565d7 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx @@ -73,7 +73,9 @@ export const MaintenanceTable = ({ type }: Props) => { const flags = useFlags(); const pagination = usePaginationV2({ - currentRoute: `/account/maintenance`, + currentRoute: flags?.iamRbacPrimaryNavChanges + ? `/maintenance` + : `/account/maintenance`, preferenceKey: `${preferenceKey}-${type}`, queryParamsPrefix: type, }); @@ -84,7 +86,9 @@ export const MaintenanceTable = ({ type }: Props) => { order: 'desc', orderBy: 'status', }, - from: `/account/maintenance`, + from: flags?.iamRbacPrimaryNavChanges + ? `/maintenance` + : `/account/maintenance`, }, preferenceKey: `${preferenceKey}-order-${type}`, prefix: type, diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx index b459da49436..78429dfd07b 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx @@ -9,6 +9,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useFlags } from 'src/hooks/useFlags'; import { sendEntityTransferCreateEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -29,6 +30,7 @@ import type { QueryClient } from '@tanstack/react-query'; export const EntityTransfersCreate = () => { const navigate = useNavigate(); + const flags = useFlags(); const { error, isPending, mutateAsync: createTransfer } = useCreateTransfer(); const queryClient = useQueryClient(); @@ -77,7 +79,9 @@ export const EntityTransfersCreate = () => { queryKey: [entityTransfersQueryKey], }); navigate({ - to: '/account/service-transfers', + to: flags?.iamRbacPrimaryNavChanges + ? '/service-transfers' + : '/account/service-transfers', state: (prev) => ({ ...prev, transfer }), }); }, diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index c3f2c920e68..5fba61fe051 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -11,6 +11,7 @@ import * as React from 'react'; import { SelectableTableRow } from 'src/components/SelectableTableRow/SelectableTableRow'; import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; +import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { extendType } from 'src/utilities/extendType'; @@ -36,10 +37,14 @@ export const LinodeTransferTable = React.memo((props: Props) => { selectedLinodes, disabled, } = props; + const flags = useFlags(); + const [searchText, setSearchText] = React.useState(''); const pagination = usePaginationV2({ - currentRoute: '/account/service-transfers/create', + currentRoute: flags?.iamRbacPrimaryNavChanges + ? '/service-transfers/create' + : '/account/service-transfers/create', initialPage: 1, preferenceKey: 'linode-transfer-table', }); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx index 5649aacfd22..de3234f2610 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { TransfersTable } from '../TransfersTable'; @@ -21,12 +22,17 @@ export const EntityTransfersLanding = () => { const location = useLocation(); const navigate = useNavigate(); + const flags = useFlags(); + + const url = flags?.iamRbacPrimaryNavChanges + ? '/service-transfers' + : '/account/service-transfers'; const handleCloseSuccessDialog = () => { setSuccessDialogOpen(false); setTransfer(undefined); navigate({ - to: '/account/service-transfers', + to: url, state: (prev) => ({ ...prev, transfer: undefined }), }); }; @@ -48,19 +54,19 @@ export const EntityTransfersLanding = () => { const paginationPendingTransfers = usePaginationV2({ initialPage, - currentRoute: '/account/service-transfers', + currentRoute: url, preferenceKey: pendingTransfersTablePreferenceKey, queryParamsPrefix: pendingTransfersTablePreferenceKey, }); const paginationReceivedTransfers = usePaginationV2({ initialPage, - currentRoute: '/account/service-transfers', + currentRoute: url, preferenceKey: receivedTransfersTablePreferenceKey, queryParamsPrefix: receivedTransfersTablePreferenceKey, }); const paginationSentTransfers = usePaginationV2({ initialPage, - currentRoute: '/account/service-transfers', + currentRoute: url, preferenceKey: sentTransfersTablePreferenceKey, queryParamsPrefix: sentTransfersTablePreferenceKey, }); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx index 4a8e2846428..44606440ee8 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx @@ -1,6 +1,8 @@ import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { ConfirmTransferDialog } from './ConfirmTransferDialog'; import { StyledLabelWrapperGrid, @@ -22,6 +24,7 @@ interface Props { export const TransferControls = React.memo((props: Props) => { const { permissions } = props; + const flags = useFlags(); const [token, setToken] = React.useState(''); const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false); @@ -38,7 +41,11 @@ export const TransferControls = React.memo((props: Props) => { }; const handleCreateTransfer = () => - navigate({ to: '/account/service-transfers/create' }); + navigate({ + to: flags?.iamRbacPrimaryNavChanges + ? '/service-transfers/create' + : '/account/service-transfers/create', + }); return ( <> diff --git a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx new file mode 100644 index 00000000000..69ffc219779 --- /dev/null +++ b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx @@ -0,0 +1,38 @@ +import { Navigate, useLocation } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; +import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useFlags } from 'src/hooks/useFlags'; + +import AccountLogins from '../Account/AccountLogins'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; + +export const LoginHistoryLanding = () => { + const flags = useFlags(); + const location = useLocation(); + + if ( + !flags?.iamRbacPrimaryNavChanges && + location.pathname !== '/account/login-history' + ) { + return ; + } + + const landingHeaderProps: LandingHeaderProps = { + title: 'Login History', + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/LoginHistory/loginHistoryLandingLazyRoute.ts b/packages/manager/src/features/LoginHistory/loginHistoryLandingLazyRoute.ts new file mode 100644 index 00000000000..c54f358fd39 --- /dev/null +++ b/packages/manager/src/features/LoginHistory/loginHistoryLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { LoginHistoryLanding } from './LoginHistoryLanding'; + +export const loginHistoryLandingLazyRoute = createLazyRoute('/login-history')({ + component: LoginHistoryLanding, +}); diff --git a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx new file mode 100644 index 00000000000..7c4b14afe93 --- /dev/null +++ b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx @@ -0,0 +1,38 @@ +import { Navigate, useLocation } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; +import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useFlags } from 'src/hooks/useFlags'; + +import { default as AccountMaintenanceLanding } from '../Account/Maintenance/MaintenanceLanding'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; + +export const MaintenanceLanding = () => { + const flags = useFlags(); + const location = useLocation(); + + if ( + !flags?.iamRbacPrimaryNavChanges && + location.pathname !== '/account/maintenance' + ) { + return ; + } + + const landingHeaderProps: LandingHeaderProps = { + title: 'Maintenance', + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Maintenance/maintenanceLandingLazyRoute.ts b/packages/manager/src/features/Maintenance/maintenanceLandingLazyRoute.ts new file mode 100644 index 00000000000..0f2f04738d0 --- /dev/null +++ b/packages/manager/src/features/Maintenance/maintenanceLandingLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { MaintenanceLanding } from './MaintenanceLanding'; + +export const maintenanceLandingLandingLazyRoute = createLazyRoute( + '/maintenance' +)({ + component: MaintenanceLanding, +}); diff --git a/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx b/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx new file mode 100644 index 00000000000..e5dceef7ae3 --- /dev/null +++ b/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx @@ -0,0 +1,38 @@ +import { Navigate, useLocation } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; +import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useFlags } from 'src/hooks/useFlags'; + +import { EntityTransfersLanding } from '../EntityTransfers/EntityTransfersLanding/EntityTransfersLanding'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; + +export const ServiceTransfersLanding = () => { + const flags = useFlags(); + const location = useLocation(); + + if ( + !flags?.iamRbacPrimaryNavChanges && + location.pathname !== '/account/service-transfers' + ) { + return ; + } + + const landingHeaderProps: LandingHeaderProps = { + title: 'Service Transfers', + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/ServiceTransfers/serviceTransfersCreateLazyRoute.ts b/packages/manager/src/features/ServiceTransfers/serviceTransfersCreateLazyRoute.ts new file mode 100644 index 00000000000..59c2e2f9e17 --- /dev/null +++ b/packages/manager/src/features/ServiceTransfers/serviceTransfersCreateLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { EntityTransfersCreate } from '../EntityTransfers/EntityTransfersCreate/EntityTransfersCreate'; + +export const serviceTransfersCreateLazyRoute = createLazyRoute( + '/service-transfers/create' +)({ + component: EntityTransfersCreate, +}); diff --git a/packages/manager/src/features/ServiceTransfers/serviceTransfersLandingLazyRoute.ts b/packages/manager/src/features/ServiceTransfers/serviceTransfersLandingLazyRoute.ts new file mode 100644 index 00000000000..b3a7e80f66f --- /dev/null +++ b/packages/manager/src/features/ServiceTransfers/serviceTransfersLandingLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ServiceTransfersLanding } from './ServiceTransfersLanding'; + +export const serviceTransfersLandingLazyRoute = createLazyRoute( + '/service-transfers' +)({ + component: ServiceTransfersLanding, +}); diff --git a/packages/manager/src/features/Settings/SettingsLanding.tsx b/packages/manager/src/features/Settings/SettingsLanding.tsx new file mode 100644 index 00000000000..0523eb58e79 --- /dev/null +++ b/packages/manager/src/features/Settings/SettingsLanding.tsx @@ -0,0 +1,38 @@ +import { Navigate, useLocation } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; +import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useFlags } from 'src/hooks/useFlags'; + +import GlobalSettings from '../Account/GlobalSettings'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; + +export const SettingsLanding = () => { + const flags = useFlags(); + const location = useLocation(); + + if ( + !flags?.iamRbacPrimaryNavChanges && + location.pathname !== '/account/settings' + ) { + return ; + } + + const landingHeaderProps: LandingHeaderProps = { + title: 'Settings', + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts b/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts new file mode 100644 index 00000000000..e73ddbfed13 --- /dev/null +++ b/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { SettingsLanding } from './SettingsLanding'; + +export const settingsLandingLazyRoute = createLazyRoute('/settings')({ + component: SettingsLanding, +}); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index eb8c5bd4df6..7d6c120330e 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -124,23 +124,29 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { }, { display: 'Login History', - to: '/account/login-history', + to: flags?.iamRbacPrimaryNavChanges + ? '/login-history' + : '/account/login-history', }, // Restricted users can't view the Transfers tab regardless of their grants { display: 'Service Transfers', hide: isRestrictedUser, - to: '/account/service-transfers', + to: flags?.iamRbacPrimaryNavChanges + ? '/service-transfers' + : '/account/service-transfers', }, { display: 'Maintenance', - to: '/account/maintenance', + to: flags?.iamRbacPrimaryNavChanges + ? '/maintenance' + : '/account/maintenance', }, // Restricted users with read_write account access can view Settings. { display: 'Account Settings', hide: !hasFullAccountAccess, - to: '/account/settings', + to: flags?.iamRbacPrimaryNavChanges ? '/settings' : '/account/settings', }, ], [hasFullAccountAccess, isRestrictedUser, isIAMEnabled, flags] diff --git a/packages/manager/src/routes/account/index.ts b/packages/manager/src/routes/account/index.ts index eeb448cf06c..e867b05be11 100644 --- a/packages/manager/src/routes/account/index.ts +++ b/packages/manager/src/routes/account/index.ts @@ -84,6 +84,14 @@ const accountQuotasRoute = createRoute({ const accountLoginHistoryRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/login-history', + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/login-history`, + replace: true, + }); + } + }, }).lazy(() => import('src/features/Account/accountLoginsLazyRoute').then( (m) => m.accountLoginsLazyRoute @@ -93,6 +101,14 @@ const accountLoginHistoryRoute = createRoute({ const accountServiceTransfersRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/service-transfers', + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/service-transfers`, + replace: true, + }); + } + }, }).lazy(() => import( 'src/features/EntityTransfers/EntityTransfersLanding/entityTransferLandingLazyRoute' @@ -102,6 +118,14 @@ const accountServiceTransfersRoute = createRoute({ const accountMaintenanceRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/maintenance', + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/maintenance`, + replace: true, + }); + } + }, }).lazy(() => import('src/features/Account/Maintenance/maintenanceLandingLazyRoute').then( (m) => m.maintenanceLandingLazyRoute @@ -111,6 +135,14 @@ const accountMaintenanceRoute = createRoute({ const accountSettingsRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/settings', + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/settings`, + replace: true, + }); + } + }, }).lazy(() => import('src/features/Account/globalSettingsLazyRoute').then( (m) => m.globalSettingsLazyRoute @@ -194,6 +226,14 @@ const accountInvoiceDetailsRoute = createRoute({ const accountEntityTransfersCreateRoute = createRoute({ getParentRoute: () => accountRoute, path: 'service-transfers/create', + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/service-transfers/create`, + replace: true, + }); + } + }, }).lazy(() => import( 'src/features/EntityTransfers/EntityTransfersCreate/entityTransfersCreateLazyRoute' diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 9fea77012f7..58f1ccf8f24 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -24,7 +24,9 @@ import { iamRouteTree } from './IAM'; import { imagesRouteTree } from './images'; import { kubernetesRouteTree } from './kubernetes'; import { linodesRouteTree } from './linodes'; +import { loginHistoryRouteTree } from './loginHistory/'; import { longviewRouteTree } from './longview'; +import { maintenanceRouteTree } from './maintenance'; import { managedRouteTree } from './managed'; import { cloudPulseMetricsRouteTree } from './metrics'; import { nodeBalancersRouteTree } from './nodeBalancers'; @@ -34,6 +36,8 @@ import { profileRouteTree } from './profile'; import { quotasRouteTree } from './quotas'; import { rootRoute } from './root'; import { searchRouteTree } from './search'; +import { serviceTransfersRouteTree } from './serviceTransfers'; +import { settingsRouteTree } from './settings'; import { stackScriptsRouteTree } from './stackscripts'; import { supportRouteTree } from './support'; import { volumesRouteTree } from './volumes'; @@ -69,7 +73,9 @@ export const routeTree = rootRoute.addChildren([ imagesRouteTree, kubernetesRouteTree, linodesRouteTree, + loginHistoryRouteTree, longviewRouteTree, + maintenanceRouteTree, managedRouteTree, nodeBalancersRouteTree, objectStorageRouteTree, @@ -77,6 +83,8 @@ export const routeTree = rootRoute.addChildren([ profileRouteTree, quotasRouteTree, searchRouteTree, + serviceTransfersRouteTree, + settingsRouteTree, stackScriptsRouteTree, supportRouteTree, volumesRouteTree, diff --git a/packages/manager/src/routes/loginHistory/LoginHistoryRoute.tsx b/packages/manager/src/routes/loginHistory/LoginHistoryRoute.tsx new file mode 100644 index 00000000000..41743267d03 --- /dev/null +++ b/packages/manager/src/routes/loginHistory/LoginHistoryRoute.tsx @@ -0,0 +1,14 @@ +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +export const LoginHistoryRoute = () => { + return ( + }> + + + + ); +}; diff --git a/packages/manager/src/routes/loginHistory/index.ts b/packages/manager/src/routes/loginHistory/index.ts new file mode 100644 index 00000000000..4fcb408ba49 --- /dev/null +++ b/packages/manager/src/routes/loginHistory/index.ts @@ -0,0 +1,42 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { LoginHistoryRoute } from './LoginHistoryRoute'; + +const loginHistoryRoute = createRoute({ + component: LoginHistoryRoute, + getParentRoute: () => rootRoute, + path: 'login-history', +}); + +// Catch all route for login historyRoute page +const loginHistoryCatchAllRoute = createRoute({ + getParentRoute: () => loginHistoryRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/login-history' }); + }, +}); + +// Index route: /login-history (main login-history content) +const loginHistoryIndexRoute = createRoute({ + getParentRoute: () => loginHistoryRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/login-history`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/LoginHistory/loginHistoryLandingLazyRoute').then( + (m) => m.loginHistoryLandingLazyRoute + ) +); + +export const loginHistoryRouteTree = loginHistoryRoute.addChildren([ + loginHistoryIndexRoute, + loginHistoryCatchAllRoute, +]); diff --git a/packages/manager/src/routes/maintenance/MaintenanceRoute.tsx b/packages/manager/src/routes/maintenance/MaintenanceRoute.tsx new file mode 100644 index 00000000000..04f8f179be0 --- /dev/null +++ b/packages/manager/src/routes/maintenance/MaintenanceRoute.tsx @@ -0,0 +1,14 @@ +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +export const MaintenanceRoute = () => { + return ( + }> + + + + ); +}; diff --git a/packages/manager/src/routes/maintenance/index.ts b/packages/manager/src/routes/maintenance/index.ts new file mode 100644 index 00000000000..7f565775e35 --- /dev/null +++ b/packages/manager/src/routes/maintenance/index.ts @@ -0,0 +1,42 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { MaintenanceRoute } from './MaintenanceRoute'; + +const maintenanceRoute = createRoute({ + component: MaintenanceRoute, + getParentRoute: () => rootRoute, + path: 'maintenance', +}); + +// Catch all route for maintenance page +const maintenanceCatchAllRoute = createRoute({ + getParentRoute: () => maintenanceRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/maintenance' }); + }, +}); + +// Index route: /maintenance (main maintenance content) +const maintenanceIndexRoute = createRoute({ + getParentRoute: () => maintenanceRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/maintenance`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/Maintenance/maintenanceLandingLazyRoute').then( + (m) => m.maintenanceLandingLandingLazyRoute + ) +); + +export const maintenanceRouteTree = maintenanceRoute.addChildren([ + maintenanceIndexRoute, + maintenanceCatchAllRoute, +]); diff --git a/packages/manager/src/routes/serviceTransfers/ServiceTransfersRoute.tsx b/packages/manager/src/routes/serviceTransfers/ServiceTransfersRoute.tsx new file mode 100644 index 00000000000..aa8f3ea8965 --- /dev/null +++ b/packages/manager/src/routes/serviceTransfers/ServiceTransfersRoute.tsx @@ -0,0 +1,14 @@ +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +export const ServiceTransfersRoute = () => { + return ( + }> + + + + ); +}; diff --git a/packages/manager/src/routes/serviceTransfers/index.ts b/packages/manager/src/routes/serviceTransfers/index.ts new file mode 100644 index 00000000000..62056c773d9 --- /dev/null +++ b/packages/manager/src/routes/serviceTransfers/index.ts @@ -0,0 +1,60 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { ServiceTransfersRoute } from './ServiceTransfersRoute'; + +const serviceTransfersRoute = createRoute({ + component: ServiceTransfersRoute, + getParentRoute: () => rootRoute, + path: 'service-transfers', +}); + +// Catch all route for service-transfers page +const serviceTransfersCatchAllRoute = createRoute({ + getParentRoute: () => serviceTransfersRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/service-transfers' }); + }, +}); + +// Index route: /service-transfers (main service-transfers content) +const serviceTransfersIndexRoute = createRoute({ + getParentRoute: () => serviceTransfersRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/service-transfers`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/ServiceTransfers/serviceTransfersLandingLazyRoute').then( + (m) => m.serviceTransfersLandingLazyRoute + ) +); + +const serviceTransfersCreateRoute = createRoute({ + getParentRoute: () => serviceTransfersRoute, + path: 'create', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/service-transfers/create`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/ServiceTransfers/serviceTransfersCreateLazyRoute').then( + (m) => m.serviceTransfersCreateLazyRoute + ) +); + +export const serviceTransfersRouteTree = serviceTransfersRoute.addChildren([ + serviceTransfersIndexRoute, + serviceTransfersCatchAllRoute, + serviceTransfersCreateRoute, +]); diff --git a/packages/manager/src/routes/settings/SettingsRoute.tsx b/packages/manager/src/routes/settings/SettingsRoute.tsx new file mode 100644 index 00000000000..14e0369843e --- /dev/null +++ b/packages/manager/src/routes/settings/SettingsRoute.tsx @@ -0,0 +1,14 @@ +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +export const SettingsRoute = () => { + return ( + }> + + + + ); +}; diff --git a/packages/manager/src/routes/settings/index.ts b/packages/manager/src/routes/settings/index.ts new file mode 100644 index 00000000000..47cf6c5aca6 --- /dev/null +++ b/packages/manager/src/routes/settings/index.ts @@ -0,0 +1,42 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { SettingsRoute } from './SettingsRoute'; + +const settingsRoute = createRoute({ + component: SettingsRoute, + getParentRoute: () => rootRoute, + path: 'settings', +}); + +// Catch all route for settings page +const settingsCatchAllRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/settings' }); + }, +}); + +// Index route: /settings (main settings content) +const settingsIndexRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/settings`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/Settings/settingsLandingLazyRoute').then( + (m) => m.settingsLandingLazyRoute + ) +); + +export const settingsRouteTree = settingsRoute.addChildren([ + settingsIndexRoute, + settingsCatchAllRoute, +]); From 3ffc9854c70718d3c3aee7be21850c01ea27600e Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Mon, 18 Aug 2025 08:39:17 -0700 Subject: [PATCH 49/88] test: [M3-10369] - Add tests to linode alerts edit page on when "Save Changes?" dialog should appear (#12707) * init commit * cleanup * Added changeset: Add tests on confirm dialog in linode details page * Delete packages/manager/.changeset/pr-12707-tests-1755204665364.md recd an error while running the changeset cmd locally, so i didnt realize i uploaded the file * Delete packages/manager/.changeset/pr-12707-tests-1755204624282.md recd an error while running the changeset cmd locally, so i didnt realize i uploaded the file * edits after review * reverted title --- .../pr-12707-tests-1755204765568.md | 5 + .../e2e/core/linodes/alerts-edit.spec.ts | 146 +++++++++++++++--- .../AlertInformationActionTable.tsx | 7 +- .../manager/src/features/Linodes/constants.ts | 3 + 4 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-12707-tests-1755204765568.md diff --git a/packages/manager/.changeset/pr-12707-tests-1755204765568.md b/packages/manager/.changeset/pr-12707-tests-1755204765568.md new file mode 100644 index 00000000000..ca942c54824 --- /dev/null +++ b/packages/manager/.changeset/pr-12707-tests-1755204765568.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add tests on confirm dialog in linode details page ([#12707](https://github.com/linode/manager/pull/12707)) diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index 74c3ab9e3a5..0bbe2c41235 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -14,6 +14,7 @@ import { alertFactory } from 'src/factories'; import { ALERTS_BETA_MODE_BANNER_TEXT, ALERTS_BETA_MODE_BUTTON_TEXT, + ALERTS_BETA_PROMPT, ALERTS_LEGACY_MODE_BANNER_TEXT, ALERTS_LEGACY_MODE_BUTTON_TEXT, ALERTS_LEGACY_PROMPT, @@ -240,26 +241,6 @@ describe('region enables alerts', function () { .should('be.enabled') .click(); }); - - // TODO: this test passes but modal behavior may change when properly implemented in api (M3-10195) - // ui.dialog - // .findByTitle('Save Alerts?') - // .should('be.visible') - // .within(() => { - // ui.button.findByTitle('Save').should('be.visible') - // .click(); - // }); - // TODO: content of request.body not match prod, 'alerts' attribute missing here - // cy.wait('@updateLinode').then((xhr) => { - // // can save changes. new beta alerts added in assertLinodeAlertsEnabled tests - // const edits = xhr.request.body; - // expect(JSON.stringify(edits.system)).to.equal( - // JSON.stringify([]) - // ); - // expect(JSON.stringify(edits.user)).to.equal( - // JSON.stringify([]) - // ); - // }); }); it('Legacy alerts = 0, Beta alerts > 0, => beta enabled', function () { @@ -401,6 +382,131 @@ describe('region enables alerts', function () { ui.button.findByTitle('Confirm').should('be.visible').click(); }); }); + + it('in default beta mode, edits to beta alerts do not trigger confirmation modal', function () { + const mockLinode = linodeFactory.build({ + id: 2, + label: randomLabel(), + region: this.mockEnabledRegion.id, + alerts: { + ...mockEnabledBetaAlerts, + }, + }); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/alerts`); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinode']); + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('be.visible'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.contains('Alerts').should('be.visible'); + cy.get('[data-testid="notice-info"]') + .should('be.visible') + .within(() => { + cy.contains(ALERTS_BETA_MODE_BANNER_TEXT); + }); + cy.wait(['@getAlertDefinitions']); + // toggles in table are on but can be turned off + assertLinodeAlertsEnabled(this.alertDefinitions); + + mockUpdateLinode(mockLinode.id).as('updateLinode'); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // M3-10369: "Save Alerts?" prompt does not appear bc beta is already default mode + ui.dialog.find().should('not.exist'); + }); + + it('in default legacy mode, edits to beta alerts trigger confirmation modal ', function () { + const mockLinode = linodeFactory.build({ + id: 2, + label: randomLabel(), + region: this.mockEnabledRegion.id, + alerts: { + ...mockEnabledLegacyAlerts, + }, + }); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/alerts`); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinode']); + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.contains('Alerts').should('be.visible'); + cy.get('[data-testid="notice-info"]') + .should('be.visible') + .within(() => { + cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT); + }); + }); + + // upgrade from legacy alerts to ACLP alerts + ui.button + .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('be.visible'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.contains('Alerts').should('be.visible'); + cy.get('[data-testid="notice-info"]') + .should('be.visible') + .within(() => { + cy.contains(ALERTS_BETA_MODE_BANNER_TEXT); + }); + cy.wait(['@getAlertDefinitions']); + cy.get('table[data-testid="alert-table"]') + .should('be.visible') + .get('tbody > tr') + .should('have.length', 3) + .each((row, index) => { + // match alert definitions to table cell contents + cy.wrap(row).within(() => { + cy.get('td') + .eq(0) + .within(() => { + // each alert's toggle should be enabled/on/true and editable + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible') + .should('be.enabled') + .click(); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'false'); + }); + }); + }); + + mockUpdateLinode(mockLinode.id).as('updateLinode'); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // M3-10369: "Save Alerts?" prompt appears bc of edits to beta alerts but linode was previously in legacy mode + ui.dialog + .findByTitle(ALERTS_BETA_PROMPT) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Confirm').should('be.visible').click(); + }); + }); }); describe('region disables alerts. beta alerts not available regardless of linode settings', function () { diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index 1bdfc50cca3..4e6781952af 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -12,6 +12,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { ALERTS_BETA_PROMPT } from 'src/features/Linodes/constants'; import { useServiceAlertsMutation } from 'src/queries/cloudpulse/alerts'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -347,12 +348,12 @@ export const AlertInformationActionTable = ( isOpen={isDialogOpen} message={ <> - Are you sure you want to save (Beta) Alerts? Legacy settings - will be disabled and replaced by (Beta) Alerts settings. + {ALERTS_BETA_PROMPT} Legacy settings will be disabled and + replaced by (Beta) Alerts settings. } primaryButtonLabel="Confirm" - title="Are you sure you want to save (Beta) Alerts? " + title={ALERTS_BETA_PROMPT} /> ); diff --git a/packages/manager/src/features/Linodes/constants.ts b/packages/manager/src/features/Linodes/constants.ts index 574beef12a8..6c119771ca3 100644 --- a/packages/manager/src/features/Linodes/constants.ts +++ b/packages/manager/src/features/Linodes/constants.ts @@ -29,3 +29,6 @@ export const ALERTS_BETA_MODE_BUTTON_TEXT = 'Switch to legacy Alerts'; export const ALERTS_LEGACY_PROMPT = 'Are you sure you want to save legacy Alerts?'; + +export const ALERTS_BETA_PROMPT = + 'Are you sure you want to save (Beta) Alerts?'; From 53998f47bf3dd1b9215705597f91d5689c40930b Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Mon, 18 Aug 2025 08:56:26 -0700 Subject: [PATCH 50/88] test: [M3-10465, M3-10466] - Add Cypress test coverage for LKE-E Phase 2 (VPC, IP Stack) create flow (#12709) * Move shared constants out to constants/lke.ts file * Add new test file with LKE-E phase 2 networking config coverage WIP * Clean up request mocking and assertions * Correctly mock VPCs in the region capability, fixing BYO test case failures * Add changeset * Clean up for error message * Correct success test cases; punt failure validation to separate ticket/case --- .../pr-12709-tests-1755277488273.md | 5 + .../e2e/core/kubernetes/lke-create.spec.ts | 82 +------ .../kubernetes/lke-enterprise-create.spec.ts | 226 ++++++++++++++++++ .../manager/cypress/support/constants/lke.ts | 88 +++++++ 4 files changed, 328 insertions(+), 73 deletions(-) create mode 100644 packages/manager/.changeset/pr-12709-tests-1755277488273.md create mode 100644 packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts diff --git a/packages/manager/.changeset/pr-12709-tests-1755277488273.md b/packages/manager/.changeset/pr-12709-tests-1755277488273.md new file mode 100644 index 00000000000..d360544f26e --- /dev/null +++ b/packages/manager/.changeset/pr-12709-tests-1755277488273.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add `lke-enterprise-create` Cypress spec to test LKE-E Phase 2 (VPC + IP Stack) coverage ([#12709](https://github.com/linode/manager/pull/12709)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index c5942eaf35d..dca95f300a8 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -17,8 +17,15 @@ import { dcPricingPlanPlaceholder, } from 'support/constants/dc-specific-pricing'; import { + clusterPlans, + dedicatedNodeCount, + dedicatedType, latestEnterpriseTierKubernetesVersion, latestKubernetesVersion, + mockedLKEClusterTypes, + mockedLKEEnterprisePrices, + nanodeNodeCount, + nanodeType, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -50,8 +57,6 @@ import { kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, - lkeEnterpriseTypeFactory, - lkeHighAvailabilityTypeFactory, nodePoolFactory, } from 'src/factories'; import { @@ -62,12 +67,8 @@ import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kube import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import type { PriceType } from '@linode/api-v4/lib/types'; -import type { ExtendedType } from 'src/utilities/extendType'; import type { LkePlanDescription } from 'support/api/lke'; -const dedicatedNodeCount = 4; -const nanodeNodeCount = 3; - const clusterRegion = chooseRegion({ capabilities: ['Kubernetes'], exclude: ['au-mel', 'eu-west'], // Unavailable regions @@ -82,46 +83,7 @@ const nanodeMemoryPool = nodePoolFactory.build({ nodes: kubeLinodeFactory.buildList(nanodeNodeCount), type: 'g6-standard-1', }); -const dedicatedType = dedicatedTypeFactory.build({ - disk: 81920, - id: 'g6-dedicated-2', - label: 'Dedicated 4 GB', - memory: 4096, - price: { - hourly: 0.054, - monthly: 36.0, - }, - region_prices: dcPricingMockLinodeTypes.find( - (type) => type.id === 'g6-dedicated-2' - )?.region_prices, - vcpus: 2, -}) as ExtendedType; -const nanodeType = linodeTypeFactory.build({ - disk: 51200, - id: 'g6-standard-1', - label: 'Linode 2 GB', - memory: 2048, - price: { - hourly: 0.0095, - monthly: 12.0, - }, - region_prices: dcPricingMockLinodeTypes.find( - (type) => type.id === 'g6-standard-1' - )?.region_prices, - vcpus: 1, -}) as ExtendedType; -const gpuType = linodeTypeFactory.build({ - class: 'gpu', - id: 'g2-gpu-1', -}) as ExtendedType; -const highMemType = linodeTypeFactory.build({ - class: 'highmem', - id: 'g7-highmem-1', -}) as ExtendedType; -const premiumType = linodeTypeFactory.build({ - class: 'premium', - id: 'g7-premium-1', -}) as ExtendedType; + const mockedLKEClusterPrices: PriceType[] = [ { id: 'lke-sa', @@ -146,33 +108,7 @@ const mockedLKEHAClusterPrices: PriceType[] = [ transfer: 0, }, ]; -const mockedLKEEnterprisePrices = [ - lkeHighAvailabilityTypeFactory.build(), - lkeEnterpriseTypeFactory.build(), -]; -const clusterPlans: LkePlanDescription[] = [ - { - nodeCount: dedicatedNodeCount, - planName: 'Dedicated 4 GB', - size: 4, - tab: 'Dedicated CPU', - type: 'dedicated', - }, - { - nodeCount: nanodeNodeCount, - planName: 'Linode 2 GB', - size: 24, - tab: 'Shared CPU', - type: 'standard', - }, -]; -const mockedLKEClusterTypes = [ - dedicatedType, - nanodeType, - gpuType, - highMemType, - premiumType, -]; + const validEnterprisePlanTabs = [ 'Dedicated CPU', 'Shared CPU', diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts new file mode 100644 index 00000000000..bc84016b588 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -0,0 +1,226 @@ +/** + * Confirms create operations on LKE-Enterprise clusters. + */ + +import { regionFactory } from '@linode/utilities'; +import { + clusterPlans, + latestEnterpriseTierKubernetesVersion, + latestKubernetesVersion, + mockedLKEClusterTypes, + mockedLKEEnterprisePrices, +} from 'support/constants/lke'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodeTypes } from 'support/intercepts/linodes'; +import { + mockCreateCluster, + mockGetKubernetesVersions, + mockGetLKEClusterTypes, + mockGetTieredKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVPCs } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + +import { + accountFactory, + kubernetesClusterFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; + +describe('LKE Cluster Creation with LKE-E', () => { + describe('LKE-E Phase 2 Networking Configurations', () => { + const clusterLabel = randomLabel(); + const selectedVpcId = 1; + const selectedSubnetId = 1; + + const mockEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', + }); + + const mockVpcs = [ + { + ...vpcFactory.build(), + id: selectedVpcId, + label: 'test-vpc', + region: 'us-iad', + subnets: [ + subnetFactory.build({ + id: selectedSubnetId, + label: 'subnet-a', + ipv4: '10.0.0.0/13', + }), + ], + }, + ]; + + // Accounts for the different combination of IP Networking and VPC/Subnet radio selections + const possibleNetworkingConfigurations = [ + { + description: + 'Successfully creates cluster with auto-generated dual-stack VPC and IPv4+IPv6 stack', + isUsingOwnVPC: false, + stackType: 'ipv4-ipv6', + }, + { + description: + 'Successfully creates cluster with auto-generated dual-stack VPC and IPv4 stack', + isUsingOwnVPC: false, + stackType: 'ipv4', + }, + { + description: + 'Successfully creates cluster with existing (BYO) dual-stack VPC and IPv4+IPv6 stack', + isUsingOwnVPC: true, + stackType: 'ipv4-ipv6', + }, + { + description: + 'Successfully creates cluster with existing (BYO) dual-stack VPC and IPv4 stack', + isUsingOwnVPC: true, + stackType: 'ipv4', + }, + ]; + + beforeEach(() => { + // TODO LKE-E: Remove feature flag mocks once we're in GA + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); + mockCreateCluster(mockEnterpriseCluster).as('createCluster'); + + mockGetRegions([ + regionFactory.build({ + capabilities: [ + 'Linodes', + 'Kubernetes', + 'Kubernetes Enterprise', + 'VPCs', + ], + id: 'us-iad', + label: 'Washington, DC', + }), + ]).as('getRegions'); + + mockGetVPCs(mockVpcs).as('getVPCs'); + + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); + + ui.button.findByTitle('Create Cluster').click(); + cy.url().should('endWith', '/kubernetes/create'); + cy.wait([ + '@getKubernetesVersions', + '@getTieredKubernetesVersions', + '@getLinodeTypes', + ]); + }); + + possibleNetworkingConfigurations.forEach( + ({ description, isUsingOwnVPC, stackType }) => { + it(`${description}`, () => { + // Select the enterprise tier and available region + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.wait('@getVPCs'); + + // Select either the autogenerated or existing (BYO) VPC radio button + if (isUsingOwnVPC) { + cy.findByTestId('isUsingOwnVpc').within(() => { + cy.findByLabelText('Use an existing VPC').click(); + }); + + // Select the existing VPC and Subnet to use + ui.autocomplete.findByLabel('VPC').click(); + cy.findByText('test-vpc').click(); + ui.autocomplete.findByLabel('Subnet').click(); + cy.findByText(/subnet-a/).click(); + } + + // Select either the IPv4 or IPv4 + IPv6 (dual-stack) IP Networking radio button + cy.findByLabelText( + stackType === 'ipv4' ? 'IPv4' : 'IPv4 + IPv6' + ).click(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Bypass ACL validation error + cy.get('input[name="acl-acknowledgement"]').check(); + + // Create LKE-E cluster + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm request payload + cy.wait('@createCluster').then((intercept) => { + const payload = intercept.request.body; + + expect(payload.stack_type).to.eq(stackType); + // Confirm existing (BYO) VPC selection passes the vpc_id and subnet_id; + // else, confirm undefined is passed for an autogenerated VPC + if (isUsingOwnVPC) { + expect(payload.vpc_id).to.eq(selectedVpcId); + expect(payload.subnet_id).to.eq(selectedSubnetId); + } else { + expect(payload.vpc_id).to.be.undefined; + expect(payload.subnet_id).to.be.undefined; + } + }); + }); + } + ); + }); +}); diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index 9be3b2780e9..e8ad5e735f6 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -1,6 +1,16 @@ +import { dedicatedTypeFactory, linodeTypeFactory } from '@linode/utilities'; import { getLatestKubernetesVersion } from 'support/util/lke'; +import { + lkeEnterpriseTypeFactory, + lkeHighAvailabilityTypeFactory, +} from 'src/factories'; + +import { dcPricingMockLinodeTypes } from './dc-specific-pricing'; + import type { KubernetesTieredVersion } from '@linode/api-v4'; +import type { ExtendedType } from 'src/utilities/extendType'; +import type { LkePlanDescription } from 'support/api/lke'; /** * Kubernetes versions available for cluster creation via Cloud Manager. @@ -33,3 +43,81 @@ export const latestEnterpriseTierKubernetesVersion: KubernetesTieredVersion = { id: getLatestKubernetesVersion(enterpriseKubernetesVersions), tier: 'enterprise', }; + +/** + * The following constants are shared between lke-create and lke-enterprise-create specs. + */ + +export const dedicatedType = dedicatedTypeFactory.build({ + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + price: { + hourly: 0.054, + monthly: 36.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-2' + )?.region_prices, + vcpus: 2, +}) as ExtendedType; +export const nanodeType = linodeTypeFactory.build({ + disk: 51200, + id: 'g6-standard-1', + label: 'Linode 2 GB', + memory: 2048, + price: { + hourly: 0.0095, + monthly: 12.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-standard-1' + )?.region_prices, + vcpus: 1, +}) as ExtendedType; +const gpuType = linodeTypeFactory.build({ + class: 'gpu', + id: 'g2-gpu-1', +}) as ExtendedType; +const highMemType = linodeTypeFactory.build({ + class: 'highmem', + id: 'g7-highmem-1', +}) as ExtendedType; +const premiumType = linodeTypeFactory.build({ + class: 'premium', + id: 'g7-premium-1', +}) as ExtendedType; + +export const mockedLKEClusterTypes = [ + dedicatedType, + nanodeType, + gpuType, + highMemType, + premiumType, +]; + +export const mockedLKEEnterprisePrices = [ + lkeHighAvailabilityTypeFactory.build(), + lkeEnterpriseTypeFactory.build(), +]; + +export const dedicatedNodeCount = 4; +export const nanodeNodeCount = 3; + +export const clusterPlans: LkePlanDescription[] = [ + { + nodeCount: dedicatedNodeCount, + planName: 'Dedicated 4 GB', + size: 4, + tab: 'Dedicated CPU', + type: 'dedicated', + }, + { + nodeCount: nanodeNodeCount, + planName: 'Linode 2 GB', + size: 24, + tab: 'Shared CPU', + type: 'standard', + }, +]; From d2190e6f3d712c70a1016f2bb08ef8042ab14661 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Mon, 18 Aug 2025 19:41:04 +0200 Subject: [PATCH 51/88] feat: [STORIF-62] - Update Quotas informational banner (#12595) * Reapply "new: [STORIF-62] - Quotas informational banner created. (#12531)" This reverts commit 0a0fecc13212d2f7c7335648f569252d4d0ee188. * ref: [STORIF-62] - Quotas informational banner updated. * Added changeset: Quotas informational banner updated to use DismissibleBanner --- .../pr-12595-changed-1753861597401.md | 5 +++++ .../BucketDetail/BucketDetail.tsx | 2 ++ .../BucketLanding/CreateBucketDrawer.tsx | 2 ++ .../BucketLanding/OMC_CreateBucketDrawer.tsx | 2 ++ .../ObjectStorage/QuotasInfoNotice.tsx | 20 +++++++++++++++++++ 5 files changed, 31 insertions(+) create mode 100644 packages/manager/.changeset/pr-12595-changed-1753861597401.md create mode 100644 packages/manager/src/features/ObjectStorage/QuotasInfoNotice.tsx diff --git a/packages/manager/.changeset/pr-12595-changed-1753861597401.md b/packages/manager/.changeset/pr-12595-changed-1753861597401.md new file mode 100644 index 00000000000..60ebd1c7f27 --- /dev/null +++ b/packages/manager/.changeset/pr-12595-changed-1753861597401.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Quotas informational banner updated to use DismissibleBanner ([#12595](https://github.com/linode/manager/pull/12595)) diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx index e1ac49c4ac3..fbb79c18d48 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx @@ -30,6 +30,7 @@ import { import { fetchBucketAndUpdateCache } from 'src/queries/object-storage/utilities'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { QuotasInfoNotice } from '../QuotasInfoNotice'; import { deleteObject as _deleteObject } from '../requests'; import { displayName, @@ -356,6 +357,7 @@ export const BucketDetail = () => { <> + { return ( + {isRestrictedUser && ( { return ( + {isRestrictedUser && ( { + return ( + + + Did you know you can check your usage and quotas before {action}?{' '} + View Quotas. + + + ); +}; From 53faa5a00b979233bf0dacab4f9697fd8fd07f49 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:25:24 +0530 Subject: [PATCH 52/88] fix: [DI-26675] - Alerts List cache bug fix for Edit Alert use-case (#12699) * fix: [DI-26675] - Conditionally setting List query data in alert edit use-case * add changeset * upcoming: [DI-26675] - using single setQuery instead of get,set and update changeset --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- .../pr-12699-changed-1755183068769.md | 5 ++++ .../manager/src/queries/cloudpulse/alerts.ts | 23 ++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-12699-changed-1755183068769.md diff --git a/packages/manager/.changeset/pr-12699-changed-1755183068769.md b/packages/manager/.changeset/pr-12699-changed-1755183068769.md new file mode 100644 index 00000000000..bbab27669a9 --- /dev/null +++ b/packages/manager/.changeset/pr-12699-changed-1755183068769.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +ACLP-Alerting: Conditionally set the query data on successful edit alert operation ([#12699](https://github.com/linode/manager/pull/12699)) diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 04fc6de3a03..1a1bdac1fc2 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -106,14 +106,21 @@ export const useEditAlertDefinition = () => { editAlertDefinition(data, serviceType, alertId), onSuccess(data) { - const allAlertsQueryKey = queryFactory.alerts._ctx.all().queryKey; - queryClient.cancelQueries({ queryKey: allAlertsQueryKey }); - queryClient.setQueryData(allAlertsQueryKey, (oldData) => { - return ( - oldData?.map((alert) => { - return alert.id === data.id ? data : alert; - }) ?? [data] - ); + const allAlertsKey = queryFactory.alerts._ctx.all().queryKey; + + queryClient.setQueryData(allAlertsKey, (prev) => { + // nothing cached yet + if (!prev) return prev; + + const idx = prev.findIndex((a) => a.id === data.id); + if (idx === -1) return prev; + + // if no change keep referential equality + if (prev[idx] === data) return prev; + + const next = prev.slice(); + next[idx] = data; + return next; }); queryClient.setQueryData( From c3125cf0680571ec8b862cf3416266083424dc1c Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:26:00 +0530 Subject: [PATCH 53/88] fix: [DI-26824] - Add aclpAlerting flag to featureFlag.ts (#12715) * fix: [DI-26824] - Add aclpAlerting flag to featureFlag.ts * add changeset --- .../manager/.changeset/pr-12715-fixed-1755521920912.md | 5 +++++ packages/manager/src/factories/featureFlags.ts | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/manager/.changeset/pr-12715-fixed-1755521920912.md diff --git a/packages/manager/.changeset/pr-12715-fixed-1755521920912.md b/packages/manager/.changeset/pr-12715-fixed-1755521920912.md new file mode 100644 index 00000000000..8e08b2f4e2c --- /dev/null +++ b/packages/manager/.changeset/pr-12715-fixed-1755521920912.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +ACLP-Alerting: Add aclpAlerting flag object to flagsFactor in featureFlag.ts ([#12715](https://github.com/linode/manager/pull/12715)) diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index 128355a483d..d77be1679e6 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -18,6 +18,13 @@ export const productInformationBannerFactory = export const flagsFactory = Factory.Sync.makeFactory>({ aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + recentActivity: false, + notificationChannels: false, + }, aclpServices: { linode: { alerts: { beta: true, enabled: true }, From c0f595f9de79887fd0be0ec657beba2c13aaf4c9 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:26:16 +0530 Subject: [PATCH 54/88] refactor: [DI-26413] - Refactor the DimensionValue component (#12697) * refactor: [DI-26413] - Refactor the DimensionValue component to be driven by config * add changeset * refactor: [DI-26413] - Fix comment and changeset --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../pr-12697-changed-1755168906400.md | 5 + .../Criteria/DimensionFilterField.test.tsx | 122 +++------- .../Criteria/DimensionFilterField.tsx | 184 +++------------ .../DimensionFilterAutocomplete.test.tsx | 126 +++++++++++ .../DimensionFilterAutocomplete.tsx | 97 ++++++++ .../ValueFieldRenderer.test.tsx | 128 +++++++++++ .../ValueFieldRenderer.tsx | 156 +++++++++++++ .../DimensionFilterValue/ValueSchemas.ts | 209 ++++++++++++++++++ .../DimensionFilterValue/constants.ts | 207 +++++++++++++++++ .../DimensionFilterValue/utils.test.ts | 101 +++++++++ .../Criteria/DimensionFilterValue/utils.ts | 87 ++++++++ .../CloudPulse/Alerts/CreateAlert/schemas.ts | 207 +---------------- .../features/CloudPulse/Alerts/constants.ts | 8 +- 13 files changed, 1192 insertions(+), 445 deletions(-) create mode 100644 packages/manager/.changeset/pr-12697-changed-1755168906400.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts diff --git a/packages/manager/.changeset/pr-12697-changed-1755168906400.md b/packages/manager/.changeset/pr-12697-changed-1755168906400.md new file mode 100644 index 00000000000..322c7405e38 --- /dev/null +++ b/packages/manager/.changeset/pr-12697-changed-1755168906400.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Consolidate DimensionFilterValue logic, utils, schemas & tests; added configMap to drive use-cases ([#12697](https://github.com/linode/manager/pull/12697)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx index be308c26241..94a5b6d05ec 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx @@ -176,56 +176,58 @@ describe('Dimension filter field component', () => { }); it('should render the Value component with options happy path and select an option', async () => { - const container = - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - rule_criteria: { - rules: [mockData[0]], - }, - serviceType: 'linode', + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], }, + serviceType: 'linode', }, - }); - const dataFieldContainer = container.getByTestId(dataFieldId); + }, + }); + // selecting data field + const dataFieldContainer = screen.getByTestId(dataFieldId); const dataFieldInput = within(dataFieldContainer).getByRole('button', { name: 'Open', }); - const valueLabel = capitalize(dimensionFieldMockData[1].values[0]); await user.click(dataFieldInput); await user.click( - await container.findByRole('option', { + await screen.findByRole('option', { name: dimensionFieldMockData[1].label, }) ); - const valueContainer = container.getByTestId('value'); - const valueInput = within(valueContainer).getByRole('button', { - name: 'Open', - }); - - user.click(valueInput); - expect( - await container.findByRole('option', { - name: valueLabel, + // selecting operator + const operatorContainer = screen.getByTestId('operator'); + await user.click( + within(operatorContainer).getByRole('button', { + name: 'Open', }) ); - - expect( - await container.findByRole('option', { - name: valueLabel, + await user.click( + await screen.findByRole('option', { + name: 'Equal', }) ); + // selecting value + const valueLabel = capitalize(dimensionFieldMockData[1].values[0]); + const valueContainer = screen.getByTestId('value'); await user.click( - container.getByRole('option', { + within(valueContainer).getByRole('button', { + name: 'Open', + }) + ); + await user.click( + await screen.findByRole('option', { name: valueLabel, }) ); @@ -309,56 +311,4 @@ describe('Dimension filter field component', () => { expect(within(valueContainer).getByText(userLabel)).toBeInTheDocument(); expect(within(valueContainer).getByText(idleLabel)).toBeInTheDocument(); }); - it('should render a TextField for the Value input when the selected dimension has no values (for all operators)', async () => { - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - rule_criteria: { - rules: [mockData[0]], - }, - serviceType: 'linode', - }, - }, - }); - - const dataFieldContainer = screen.getByTestId('data-field'); - const dataFieldInput = within(dataFieldContainer).getByRole('button', { - name: 'Open', - }); - - await user.click(dataFieldInput); - await user.click( - await screen.findByRole('option', { - name: dimensionFieldMockData[0].label, - }) - ); - - const operatorContainer = screen.getByTestId('operator'); - const operatorInput = within(operatorContainer).getByRole('button', { - name: 'Open', - }); - - await user.click(operatorInput); - await user.click( - screen.getByRole('option', { - name: 'Equal', - }) - ); - - const valueContainer = screen.getByTestId('value'); - - expect(within(valueContainer).getByRole('textbox')).toBeInTheDocument(); - - await user.click(operatorInput); - await user.click(screen.getByRole('option', { name: 'In' })); - expect(within(valueContainer).getByRole('textbox')).toBeInTheDocument(); - }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx index bbc25087515..42babd42afa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -1,20 +1,13 @@ -import { Autocomplete, Box, TextField } from '@linode/ui'; +import { Autocomplete, Box } from '@linode/ui'; import { GridLegacy } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; -import { transformDimensionValue } from 'src/features/CloudPulse/Alerts/Utils/utils'; - -import { - dimensionOperatorOptions, - HELPER_TEXT_MAP, - PLACEHOLDER_TEXT_MAP, - textFieldOperators, -} from '../../constants'; +import { dimensionOperatorOptions } from '../../constants'; import { ClearIconButton } from './ClearIconButton'; +import { ValueFieldRenderer } from './DimensionFilterValue/ValueFieldRenderer'; -import type { Item } from '../../constants'; import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; import type { Dimension, DimensionFilterOperatorType } from '@linode/api-v4'; @@ -79,9 +72,14 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { name: `${name}.operator`, }); - const dimensionValueWatcher = useWatch({ control, name: `${name}.value` }); - const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); - + const entities = useWatch({ + control, + name: 'entity_ids', + }); + const serviceType = useWatch({ + control, + name: 'serviceType', + }); const selectedDimension = dimensionOptions && dimensionFieldWatcher ? (dimensionOptions.find( @@ -89,78 +87,6 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { ) ?? null) : null; - const valueOptions = () => { - if (selectedDimension !== null && selectedDimension.values) { - return selectedDimension.values.map((val) => ({ - label: transformDimensionValue( - serviceTypeWatcher, - selectedDimension.dimension_label, - val - ), - value: val, - })); - } - return []; - }; - const isValueMultiple = - valueOptions().length > 0 && dimensionOperatorWatcher === 'in'; - - const isTextField = - !valueOptions().length || - (dimensionOperatorWatcher - ? textFieldOperators.includes(dimensionOperatorWatcher) - : false); - - const valuePlaceholder = `${isTextField ? 'Enter' : 'Select'} a Value`; - - const customPlaceholderDimension = - PLACEHOLDER_TEXT_MAP[dimensionFieldWatcher ?? '']; - const customPlaceholderText = - customPlaceholderDimension?.[ - dimensionOperatorWatcher === 'in' ? 'in' : 'default' - ] ?? valuePlaceholder; - - const customHelperDimension = HELPER_TEXT_MAP[dimensionFieldWatcher ?? '']; - const customHelperText = - customHelperDimension?.[ - dimensionOperatorWatcher === 'in' ? 'in' : 'default' - ] ?? undefined; - - const resolveSelectedValues = ( - options: Item[], - value: null | string - ): Item | Item[] | null => { - if (!value) return isValueMultiple ? [] : null; - - if (isValueMultiple) { - const splitValues = value.split(','); - return options.filter((option) => splitValues.includes(option.value)); - } - - return options.find((option) => option.value === value) ?? null; - }; - - const handleValueChange = ( - selected: Item | Item[] | null, - operation: string - ): string => { - if (!['removeOption', 'selectOption'].includes(operation)) { - return ''; - } - - if (isValueMultiple && Array.isArray(selected)) { - return selected.map((item) => item.value).join(','); - } - - if (!isValueMultiple && selected && !Array.isArray(selected)) { - return selected.value; - } - - return ''; - }; - - const isCustomValueDimension = - dimensionFieldWatcher === 'port' || dimensionFieldWatcher === 'config_id'; return ( { )} /> - + { /> - - - isTextField ? ( - field.onChange(event.target.value)} - placeholder={customPlaceholderText} - sx={{ flex: 1, width: '256px' }} - type={ - isCustomValueDimension && dimensionOperatorWatcher !== 'in' - ? 'number' - : 'text' - } - value={field.value ?? ''} - /> - ) : ( - - value.value === option.value - } - label="Value" - limitTags={1} - multiple={isValueMultiple} - onBlur={field.onBlur} - onChange={(_, selected, operation) => { - field.onChange(handleValueChange(selected, operation)); - }} - options={valueOptions()} - placeholder={ - dimensionValueWatcher && - (!Array.isArray(dimensionValueWatcher) || - dimensionValueWatcher.length) - ? '' - : valuePlaceholder - } - sx={{ flex: 1 }} - value={resolveSelectedValues(valueOptions(), field.value)} - /> - ) - } - /> - - - + ( + + )} + /> + + + + diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx new file mode 100644 index 00000000000..c1de69eebc2 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx @@ -0,0 +1,126 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DimensionFilterAutocomplete } from './DimensionFilterAutocomplete'; + +import type { Item } from '../../../constants'; + +const mockOptions: Item[] = [ + { label: 'TCP', value: 'tcp' }, + { label: 'UDP', value: 'udp' }, +]; + +describe('', () => { + const defaultProps = { + name: `rule_criteria.rules.${0}.dimension_filters.%{0}.value`, + disabled: false, + errorText: '', + fieldOnBlur: vi.fn(), + fieldOnChange: vi.fn(), + fieldValue: 'tcp', + multiple: false, + placeholderText: 'Select a value', + values: mockOptions, + }; + + it('renders with label and placeholder', () => { + renderWithTheme(); + expect(screen.getByLabelText(/Value/i)).toBeVisible(); + expect(screen.getByPlaceholderText('Select a value')).toBeVisible(); + }); + + it('calls fieldOnBlur when input is blurred', async () => { + const user = userEvent.setup(); + renderWithTheme(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.tab(); // move focus away + expect(defaultProps.fieldOnBlur).toHaveBeenCalled(); + }); + + it('disables the Autocomplete when disabled is true', () => { + renderWithTheme(); + const input = screen.getByRole('combobox'); + expect(input).toBeDisabled(); + }); + + it('renders error text when provided', () => { + renderWithTheme( + + ); + expect(screen.getByText('Invalid protocol')).toBeVisible(); + }); + + it('calls fieldOnChange with correct value when selecting TCP (single)', async () => { + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: mockOptions[0].label }) + ).toBeVisible(); + await user.click( + screen.getByRole('option', { name: mockOptions[0].label }) + ); + expect(fieldOnChange).toHaveBeenCalledWith(mockOptions[0].value); + }); + + it('should select multiple options when multiple prop is true', async () => { + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: mockOptions[1].label }) + ).toBeVisible(); + await user.click( + screen.getByRole('option', { name: mockOptions[1].label }) + ); + expect(fieldOnChange).toHaveBeenCalledWith(mockOptions[1].value); + + // Rerender with updated form state + rerender( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: mockOptions[0].label }) + ).toBeVisible(); + await user.click( + screen.getByRole('option', { name: mockOptions[0].label }) + ); + + // Assert both values were selected + expect(fieldOnChange).toHaveBeenCalledWith( + `${mockOptions[1].value},${mockOptions[0].value}` + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx new file mode 100644 index 00000000000..beb54d0ffe5 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx @@ -0,0 +1,97 @@ +import { Autocomplete } from '@linode/ui'; +import React from 'react'; + +import { handleValueChange, resolveSelectedValues } from './utils'; + +import type { Item } from '../../../constants'; + +interface DimensionFilterAutocompleteProps { + /** + * Whether the autocomplete input should be disabled. + */ + disabled: boolean; + + /** + * Optional error message to display beneath the input. + */ + errorText?: string; + + /** + * Handler function called on input blur. + */ + fieldOnBlur: () => void; + + /** + * Callback triggered when the user selects a new value(s). + */ + fieldOnChange: (newValue: string | string[]) => void; + + /** + * Current raw string value (or null) from the form state. + */ + fieldValue: null | string; + + /** + * To control single-select/multi-select in the Autocomplete. + */ + multiple?: boolean; + /** + * Name of the field set in the form. + */ + name: string; + /** + * Placeholder text to display when no selection is made. + */ + placeholderText: string; + + /** + * The full list of selectable options for the autocomplete input. + */ + values: Item[]; +} + +/** + * Renders an Autocomplete input field for the DimensionFilter value field. + * This component supports both single and multiple selection based on config. + */ +export const DimensionFilterAutocomplete = ( + props: DimensionFilterAutocompleteProps +) => { + const { + multiple, + name, + fieldOnChange, + values, + disabled, + fieldOnBlur, + placeholderText, + errorText, + fieldValue, + } = props; + + return ( + value.value === option.value} + label="Value" + limitTags={1} + multiple={multiple} + onBlur={fieldOnBlur} + onChange={(_, selected, operation) => { + const newValue = handleValueChange( + selected, + operation, + multiple ?? false + ); + fieldOnChange(newValue); + }} + options={values} + placeholder={placeholderText} + sx={{ flex: 1 }} + value={resolveSelectedValues(values, fieldValue, multiple ?? false)} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx new file mode 100644 index 00000000000..373f1a5867d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx @@ -0,0 +1,128 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ValueFieldRenderer } from './ValueFieldRenderer'; + +import type { + CloudPulseServiceType, + DimensionFilterOperatorType, +} from '@linode/api-v4'; + +vi.mock('./useFetchOptions', () => ({ + useFetchOptions: () => [ + { label: 'TCP', value: 'tcp' }, + { label: 'UDP', value: 'udp' }, + ], +})); + +const EQ: DimensionFilterOperatorType = 'eq'; +const IN: DimensionFilterOperatorType = 'in'; +const NB: CloudPulseServiceType = 'nodebalancer'; +describe('', () => { + const defaultProps = { + serviceType: NB, + name: `rule_criteria.rules.${0}.dimension_filters.${0}`, + dimensionLabel: 'protocol', + disabled: false, + entities: [], + errorText: '', + onBlur: vi.fn(), + onChange: vi.fn(), + operator: EQ, + value: null, + values: null, + }; + + it('renders a TextField if config type is textfield', () => { + const props = { + ...defaultProps, + dimensionLabel: 'port', // assuming this maps to textfield in valueFieldConfig + operator: EQ, + }; + + renderWithTheme(); + expect(screen.getByLabelText('Value')).toBeVisible(); + expect(screen.getByTestId('textfield-input')).toBeVisible(); + }); + + it('renders an Autocomplete if config type is autocomplete', () => { + const props = { + ...defaultProps, + dimensionLabel: 'protocol', // assuming this maps to autocomplete + operator: IN, + values: ['tcp', 'udp'], + }; + + renderWithTheme(); + expect(screen.getByRole('combobox')).toBeVisible(); + }); + + it('calls onChange when typing into TextField', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const props = { + ...defaultProps, + dimensionLabel: 'port', + operator: EQ, + onChange, + }; + + renderWithTheme(); + const input = screen.getByLabelText('Value'); + await user.type(input, '8080'); + expect(onChange).toHaveBeenLastCalledWith('8080'); + }); + + it('calls onBlur from TextField', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + const props = { + ...defaultProps, + dimensionLabel: 'port', + operator: IN, + onBlur, + }; + + renderWithTheme(); + const input = screen.getByLabelText('Value'); + await user.click(input); + await user.tab(); // blur + expect(onBlur).toHaveBeenCalled(); + }); + + it('calls onChange from Autocomplete', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const props = { + ...defaultProps, + dimensionLabel: 'protocol', + operator: IN, + onChange, + values: ['tcp', 'udp'], + }; + + renderWithTheme(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'TCP'); + await user.click(await screen.findByText('TCP')); + + expect(onChange).toHaveBeenLastCalledWith('tcp'); + }); + + it('returns TextField when no config and no operator is found', () => { + // fallback case + const props = { + ...defaultProps, + dimensionLabel: 'nonexistent', + operator: null, + }; + + renderWithTheme(); + expect(screen.getByTestId('textfield-input')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx new file mode 100644 index 00000000000..a75c2bc1bf6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -0,0 +1,156 @@ +import { TextField } from '@linode/ui'; +import React, { useMemo } from 'react'; + +import { + MULTISELECT_PLACEHOLDER_TEXT, + SINGLESELECT_PLACEHOLDER_TEXT, + TEXTFIELD_PLACEHOLDER_TEXT, + valueFieldConfig, +} from './constants'; +import { DimensionFilterAutocomplete } from './DimensionFilterAutocomplete'; +import { getOperatorGroup, getStaticOptions } from './utils'; + +import type { OperatorGroup, ValueFieldConfig } from './constants'; +import type { + CloudPulseServiceType, + DimensionFilterOperatorType, +} from '@linode/api-v4'; + +interface ValueFieldRendererProps { + /** + * The dimension label extracted from the Dimension Data. + */ + dimensionLabel: null | string; + + /** + * Disables the input field when set to true. + */ + disabled: boolean; + + /** + * List of entity IDs used to filter resources like firewalls. + */ + entities?: string[]; + /** + * Error message to be displayed under the input field, if any. + */ + errorText: string | undefined; + + /** + * The name of the field set in the form. + */ + name: string; + + /** + * Triggered when the input field loses focus. + */ + onBlur: () => void; + + /** + * Callback fired when the value changes. + */ + onChange: (value: string | string[]) => void; + + /** + * The operator used in the current filter. Used to determine the type of input to show. + */ + operator: DimensionFilterOperatorType | null; + /** + * Service type of the alert + */ + serviceType?: CloudPulseServiceType | null; + + /** + * The currently selected value for the input field. + */ + value: null | string; + + /** + * List of pre-defined values, used for static autocomplete options. + */ + values: null | string[]; +} + +export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { + const { + serviceType, + dimensionLabel, + disabled, + errorText, + name, + onBlur, + onChange, + operator, + value, + values, + } = props; + // Use operator group for config lookup + const operatorGroup = getOperatorGroup(operator); + let dimensionConfig: Record; + + if (dimensionLabel && valueFieldConfig[dimensionLabel]) { + // 1. Use dimension-specific config if available + dimensionConfig = valueFieldConfig[dimensionLabel]; + } else if (!values || values.length === 0) { + // 2. No dimension-specific config & no values → use emptyValue + dimensionConfig = valueFieldConfig['emptyValue']; + } else { + // 3. No dimension-specific config & values present → use * + dimensionConfig = valueFieldConfig['*']; + } + const config = dimensionConfig[operatorGroup]; + const staticOptions = useMemo( + () => + getStaticOptions( + serviceType ?? undefined, + dimensionLabel ?? '', + values ?? [] + ), + [dimensionLabel, serviceType, values] + ); + if (!config) return null; + + if (config.type === 'textfield') { + return ( + onChange(e.target.value)} + placeholder={config.placeholder ?? TEXTFIELD_PLACEHOLDER_TEXT} + sx={{ flex: 1 }} + type={config.inputType} + value={value ?? ''} + /> + ); + } + + if (config.type === 'autocomplete') { + const autocompletePlaceholder = config.multiple + ? MULTISELECT_PLACEHOLDER_TEXT + : SINGLESELECT_PLACEHOLDER_TEXT; + const items = staticOptions; + return ( + + ); + } + + return null; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts new file mode 100644 index 00000000000..26e9c868b7a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts @@ -0,0 +1,209 @@ +import { string } from 'yup'; + +import { + PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + PORTS_ERROR_MESSAGE, + PORTS_HELPER_TEXT, + PORTS_LEADING_COMMA_ERROR_MESSAGE, + PORTS_LEADING_ZERO_ERROR_MESSAGE, + PORTS_LIMIT_ERROR_MESSAGE, + PORTS_RANGE_ERROR_MESSAGE, +} from 'src/features/CloudPulse/Utils/constants'; + +import { + CONFIG_ERROR_MESSAGE, + CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + CONFIGS_ERROR_MESSAGE, + CONFIGS_HELPER_TEXT, + PORT_HELPER_TEXT, + PORTS_TRAILING_COMMA_ERROR_MESSAGE, +} from '../../../constants'; + +const fieldErrorMessage = 'This field is required.'; +const DECIMAL_PORT_REGEX = /^[1-9]\d{0,4}$/; +const LEADING_ZERO_PORT_REGEX = /^0\d+/; +const CONFIG_NUMBER_REGEX = /^\d+$/; + +// Validation schema for a single input port +const singlePortSchema = string().test( + 'validate-single-port', + PORT_HELPER_TEXT, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (LEADING_ZERO_PORT_REGEX.test(value)) { + return this.createError({ + message: PORTS_LEADING_ZERO_ERROR_MESSAGE, + }); + } + + if (!DECIMAL_PORT_REGEX.test(value)) { + return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); + } + const num = Number(value); + if (!Number.isInteger(num) || num < 1 || num > 65535) { + return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); + } + + return true; + } +); + +// Validation schema for a multiple comma-separated ports +const commaSeparatedPortListSchema = string().test( + 'validate-port-list', + PORTS_HELPER_TEXT, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (value.includes(' ')) { + return this.createError({ message: PORTS_ERROR_MESSAGE }); + } + + if (value.trim().endsWith(',')) { + return this.createError({ message: PORTS_TRAILING_COMMA_ERROR_MESSAGE }); + } + + if (value.trim().startsWith(',')) { + return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); + } + + if (value.includes('.')) { + return this.createError({ message: PORTS_HELPER_TEXT }); + } + + const rawSegments = value.split(','); + + // Check for empty segments (consecutive commas, or commas with just spaces) + if (rawSegments.some((segment) => segment.trim() === '')) { + return this.createError({ + message: PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + + const ports = rawSegments.map((p) => p.trim()); + + if (ports.length > 15) { + return this.createError({ + message: PORTS_LIMIT_ERROR_MESSAGE, + }); + } + for (const port of ports) { + const trimmedPort = port.trim(); + + if (LEADING_ZERO_PORT_REGEX.test(trimmedPort)) { + return this.createError({ + message: PORTS_LEADING_ZERO_ERROR_MESSAGE, + }); + } + if (!DECIMAL_PORT_REGEX.test(trimmedPort)) { + return this.createError({ message: PORTS_HELPER_TEXT }); + } + + const num = Number(trimmedPort); + if (!Number.isInteger(num) || num < 1 || num > 65535) { + return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); + } + } + + return true; + } +); +const singleConfigSchema = string() + .max(100, 'Value must be 100 characters or less.') + .test( + 'validate-single-config-schema', + CONFIG_ERROR_MESSAGE, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (!CONFIG_NUMBER_REGEX.test(value)) { + return this.createError({ message: CONFIG_ERROR_MESSAGE }); + } + return true; + } + ); + +const multipleConfigSchema = string() + .max(100, 'Value must be 100 characters or less.') + .test( + 'validate-multi-config-schema', + CONFIGS_ERROR_MESSAGE, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + if (value.includes(' ')) { + return this.createError({ message: CONFIGS_ERROR_MESSAGE }); + } + + if (value.trim().endsWith(',')) { + return this.createError({ + message: PORTS_TRAILING_COMMA_ERROR_MESSAGE, + }); + } + + if (value.trim().startsWith(',')) { + return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); + } + + if (value.trim().includes(',,')) { + return this.createError({ + message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + if (value.includes('.')) { + return this.createError({ message: CONFIGS_HELPER_TEXT }); + } + + const rawSegments = value.split(','); + // Check for empty segments + if (rawSegments.some((segment) => segment.trim() === '')) { + return this.createError({ + message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + for (const configId of rawSegments) { + const trimmedConfigId = configId.trim(); + + if (!CONFIG_NUMBER_REGEX.test(trimmedConfigId)) { + return this.createError({ message: CONFIG_ERROR_MESSAGE }); + } + } + return true; + } + ); + +const baseValueSchema = string() + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null); + +interface GetValueSchemaParams { + dimensionLabel: string; + operator: string; +} + +export const getDimensionFilterValueSchema = ({ + dimensionLabel, + operator, +}: GetValueSchemaParams) => { + if (dimensionLabel === 'port') { + const portSchema = + operator === 'in' ? commaSeparatedPortListSchema : singlePortSchema; + return portSchema.concat(baseValueSchema); + } + if (dimensionLabel === 'config_id') { + const configSchema = + operator === 'in' ? multipleConfigSchema : singleConfigSchema; + return configSchema.concat(baseValueSchema); + } + + return baseValueSchema; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts new file mode 100644 index 00000000000..2a9304ff064 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -0,0 +1,207 @@ +import { PORTS_HELPER_TEXT } from 'src/features/CloudPulse/Utils/constants'; + +import { + CONFIG_ERROR_MESSAGE, + CONFIG_ID_PLACEHOLDER_TEXT, + CONFIGS_HELPER_TEXT, + CONFIGS_ID_PLACEHOLDER_TEXT, + PORT_HELPER_TEXT, + PORT_PLACEHOLDER_TEXT, + PORTS_PLACEHOLDER_TEXT, +} from '../../../constants'; + +export const MULTISELECT_PLACEHOLDER_TEXT = 'Select Values'; +export const TEXTFIELD_PLACEHOLDER_TEXT = 'Enter a Value'; +export const SINGLESELECT_PLACEHOLDER_TEXT = 'Select a Value'; + +/** + * Type definition for the value field renderer props. + * - 'autocomplete': Renders a select/multi-select dropdown. + * - 'textfield': Renders a free-form input field. + */ +export type ValueFieldType = 'autocomplete' | 'textfield'; + +/** + * Base configuration interface for the Value input components. + */ +export interface BaseConfig { + /** + * Specifies which type of input component to render. + */ + type: ValueFieldType; +} + +/** + * Configuration interface for the TextField-based Value input. + */ +export interface TextFieldConfig extends BaseConfig { + /** + * Optional helper text to render below the input field (e.g., hints or constraints). + */ + helperText?: string; + + /** + * - 'number': Renders an input that only accepts numeric values. + * - 'text': Accepts any textual input. + */ + inputType: 'number' | 'text'; + + /** + * Optional upper bound for numeric inputs (used with inputType: 'number'). + */ + max?: number; + + /** + * Optional lower bound for numeric inputs (used with inputType: 'number'). + */ + min?: number; + + /** + * Placeholder text to show in the field before a value is entered. + */ + placeholder?: string; + + /** + * Enforces that this config is for a textfield input. + */ + type: 'textfield'; +} + +/** + * Configuration interface for the Autocomplete-based Value input. + */ +export interface AutocompleteConfig extends BaseConfig { + /** + * Indicates whether the Autocomplete supports selecting multiple options. + */ + multiple: boolean; + + /** + * Optional placeholder to display when no value is selected. + */ + placeholder?: string; + + /** + * Enforces that this config is for an autocomplete input. + */ + type: 'autocomplete'; + + /** + * Flag to use a custom fetch function instead of the static options. + */ + useCustomFetch?: boolean; +} + +/** + * Union of configuration types used to dynamically render + * either a TextField or Autocomplete input component. + */ +export type ValueFieldConfig = AutocompleteConfig | TextFieldConfig; + +/** + * Operator grouping categories used to map to appropriate config. + */ +export type OperatorGroup = '*' | 'eq_neq' | 'in' | 'startswith_endswith'; + +/** + * Configuration map that defines the input UI to render + * based on a given dimension and the operator type. + */ +export type ValueFieldConfigMap = Record< + string, + Record +>; + +/** + * Full config for each dimension, operator group pair. + */ +export const valueFieldConfig: ValueFieldConfigMap = { + port: { + eq_neq: { + type: 'textfield', + inputType: 'number', + placeholder: PORT_PLACEHOLDER_TEXT, + min: 1, + max: 65535, + helperText: PORT_HELPER_TEXT, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'number', + placeholder: PORT_PLACEHOLDER_TEXT, + helperText: PORT_HELPER_TEXT, + min: 1, + max: 65535, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: PORTS_PLACEHOLDER_TEXT, + helperText: PORTS_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'number', + }, + }, + config_id: { + eq_neq: { + type: 'textfield', + inputType: 'number', + placeholder: CONFIG_ID_PLACEHOLDER_TEXT, + helperText: CONFIG_ERROR_MESSAGE, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'number', + placeholder: CONFIG_ID_PLACEHOLDER_TEXT, + helperText: CONFIG_ERROR_MESSAGE, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: CONFIGS_ID_PLACEHOLDER_TEXT, + helperText: CONFIGS_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'number', + }, + }, + emptyValue: { + eq_neq: { + type: 'textfield', + inputType: 'text', + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + }, + in: { + type: 'textfield', + inputType: 'text', + }, + '*': { + type: 'textfield', + inputType: 'text', + }, + }, + '*': { + eq_neq: { + type: 'autocomplete', + multiple: false, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + }, + in: { + type: 'autocomplete', + multiple: true, + }, + '*': { + type: 'textfield', + inputType: 'text', + }, + }, +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts new file mode 100644 index 00000000000..220af8e2654 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -0,0 +1,101 @@ +import { transformDimensionValue } from '../../../Utils/utils'; +import { + getOperatorGroup, + getStaticOptions, + handleValueChange, + resolveSelectedValues, +} from './utils'; + +describe('Utils', () => { + describe('resolveSelectedValues', () => { + const options = [ + { label: 'Option One', value: 'one' }, + { label: 'Option Two', value: 'two' }, + { label: 'Option Three', value: 'three' }, + ]; + + it('should return null if value is null and not multiple', () => { + expect(resolveSelectedValues(options, null, false)).toBeNull(); + }); + + it('should return empty array if value is null and multiple', () => { + expect(resolveSelectedValues(options, null, true)).toEqual([]); + }); + + it('should return matched option for single value', () => { + expect(resolveSelectedValues(options, 'two', false)).toEqual(options[1]); + }); + + it('should return matched options for multiple values', () => { + expect(resolveSelectedValues(options, 'one,two', true)).toEqual([ + options[0], + options[1], + ]); + }); + }); + + describe('handleValueChange', () => { + const selectedSingle = { label: 'One', value: 'one' }; + const selectedMultiple = [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ]; + + it('should return empty string if operation is not selectOption/removeOption', () => { + expect(handleValueChange(selectedSingle, 'blur', false)).toBe(''); + }); + + it('should return single value string', () => { + expect(handleValueChange(selectedSingle, 'selectOption', false)).toBe( + 'one' + ); + }); + + it('should return comma-separated string for multiple', () => { + expect(handleValueChange(selectedMultiple, 'selectOption', true)).toBe( + 'one,two' + ); + }); + }); + + describe('getOperatorGroup', () => { + it('should return correct group for eq/neq', () => { + expect(getOperatorGroup('eq')).toBe('eq_neq'); + expect(getOperatorGroup('neq')).toBe('eq_neq'); + }); + + it('should return correct group for startswith/endswith', () => { + expect(getOperatorGroup('startswith')).toBe('startswith_endswith'); + expect(getOperatorGroup('endswith')).toBe('startswith_endswith'); + }); + + it('should return in for operator in', () => { + expect(getOperatorGroup('in')).toBe('in'); + }); + + it('should return * for unknown/null operators', () => { + expect(getOperatorGroup(null)).toBe('*'); + }); + }); + + describe('getStaticOptions', () => { + it('should return transformed label/value pairs', () => { + expect( + getStaticOptions('nodebalancer', 'protocol', ['tcp', 'udp']) + ).toEqual([ + { + label: transformDimensionValue('nodebalancer', 'protocol', 'tcp'), + value: 'tcp', + }, + { + label: transformDimensionValue('nodebalancer', 'protocol', 'udp'), + value: 'udp', + }, + ]); + }); + + it('should return empty array if input is null', () => { + expect(getStaticOptions('linode', 'dim', null)).toEqual([]); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts new file mode 100644 index 00000000000..3d2be8eceff --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -0,0 +1,87 @@ +import { transformDimensionValue } from '../../../Utils/utils'; + +import type { Item } from '../../../constants'; +import type { OperatorGroup } from './constants'; +import type { + CloudPulseServiceType, + DimensionFilterOperatorType, +} from '@linode/api-v4'; + +/** + * Resolves the selected value(s) for the Autocomplete component from raw string. + * @param options - List of selectable options. + * @param value - The selected value(s) in raw string format. + * @param isMultiple - Whether multiple values are allowed. + * @returns - Matched option(s) for the Autocomplete input. + */ +export const resolveSelectedValues = ( + options: Item[], + value: null | string, + isMultiple: boolean +): Item | Item[] | null => { + if (!value) return isMultiple ? [] : null; + + if (isMultiple) { + return options.filter((option) => value.split(',').includes(option.value)); + } + + return options.find((option) => option.value === value) ?? null; +}; + +/** + * Converts selected option(s) from Autocomplete into a raw value string. + * @param selected - Currently selected value(s) from Autocomplete. + * @param operation - The triggered Autocomplete action (e.g., 'selectOption'). + * @param isMultiple - Whether multiple selections are enabled. + * @returns - Comma-separated string or single value. + */ +export const handleValueChange = ( + selected: Item | Item[] | null, + operation: string, + isMultiple: boolean +): string => { + if (!['removeOption', 'selectOption'].includes(operation)) return ''; + + if (isMultiple && Array.isArray(selected)) { + return selected.map((item) => item.value).join(','); + } + + if (!isMultiple && selected && !Array.isArray(selected)) { + return selected.value; + } + + return ''; +}; + +/** + * Resolves the operator into a corresponding group key. + * @param operator - The dimension filter operator. + * @returns - Mapped operator group used for config lookup. + */ +export const getOperatorGroup = ( + operator: DimensionFilterOperatorType | null +): OperatorGroup => { + if (operator === 'eq' || operator === 'neq') return 'eq_neq'; + if (operator === 'startswith' || operator === 'endswith') + return 'startswith_endswith'; + if (operator === 'in') return 'in'; + return '*'; // fallback for null/undefined/other +}; + +/** + * Converts a list of raw values to static options for Autocomplete. + * @param values - List of raw string values. + * @returns - List of label/value option objects. + */ +export const getStaticOptions = ( + serviceType: CloudPulseServiceType | undefined, + dimensionLabel: string, + values: null | string[] +): Item[] => { + return ( + values?.map((val: string) => ({ + label: transformDimensionValue(serviceType ?? null, dimensionLabel, val), + value: val, + })) ?? [] + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 3a059f268db..26d64142211 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -6,216 +6,11 @@ import { } from '@linode/validation'; import { array, lazy, mixed, number, object, string } from 'yup'; -import { - PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - PORTS_ERROR_MESSAGE, - PORTS_HELPER_TEXT, - PORTS_LEADING_COMMA_ERROR_MESSAGE, - PORTS_LEADING_ZERO_ERROR_MESSAGE, - PORTS_LIMIT_ERROR_MESSAGE, - PORTS_RANGE_ERROR_MESSAGE, -} from '../../Utils/constants'; -import { - CONFIG_ERROR_MESSAGE, - CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - CONFIGS_ERROR_MESSAGE, - CONFIGS_HELPER_TEXT, - PORT_HELPER_TEXT, - PORTS_TRAILING_COMMA_ERROR_MESSAGE, -} from '../constants'; +import { getDimensionFilterValueSchema } from './Criteria/DimensionFilterValue/ValueSchemas'; import type { AlertSeverityType, CloudPulseServiceType } from '@linode/api-v4'; - const fieldErrorMessage = 'This field is required.'; -const DECIMAL_PORT_REGEX = /^[1-9]\d{0,4}$/; -const LEADING_ZERO_PORT_REGEX = /^0\d+/; -const CONFIG_NUMBER_REGEX = /^\d+$/; - -// Validation schema for a single input port -const singlePortSchema = string().test( - 'validate-single-port', - PORT_HELPER_TEXT, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - - if (LEADING_ZERO_PORT_REGEX.test(value)) { - return this.createError({ - message: PORTS_LEADING_ZERO_ERROR_MESSAGE, - }); - } - - if (!DECIMAL_PORT_REGEX.test(value)) { - return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); - } - const num = Number(value); - if (!Number.isInteger(num) || num < 1 || num > 65535) { - return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); - } - - return true; - } -); - -// Validation schema for a multiple comma-separated ports -const commaSeparatedPortListSchema = string().test( - 'validate-port-list', - PORTS_HELPER_TEXT, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - - if (value.includes(' ')) { - return this.createError({ message: PORTS_ERROR_MESSAGE }); - } - - if (value.trim().endsWith(',')) { - return this.createError({ message: PORTS_TRAILING_COMMA_ERROR_MESSAGE }); - } - - if (value.trim().startsWith(',')) { - return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); - } - - if (value.includes('.')) { - return this.createError({ message: PORTS_HELPER_TEXT }); - } - - const rawSegments = value.split(','); - - // Check for empty segments (consecutive commas, or commas with just spaces) - if (rawSegments.some((segment) => segment.trim() === '')) { - return this.createError({ - message: PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - }); - } - - const ports = rawSegments.map((p) => p.trim()); - - if (ports.length > 15) { - return this.createError({ - message: PORTS_LIMIT_ERROR_MESSAGE, - }); - } - for (const port of ports) { - const trimmedPort = port.trim(); - - if (LEADING_ZERO_PORT_REGEX.test(trimmedPort)) { - return this.createError({ - message: PORTS_LEADING_ZERO_ERROR_MESSAGE, - }); - } - if (!DECIMAL_PORT_REGEX.test(trimmedPort)) { - return this.createError({ message: PORTS_HELPER_TEXT }); - } - - const num = Number(trimmedPort); - if (!Number.isInteger(num) || num < 1 || num > 65535) { - return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); - } - } - - return true; - } -); - -const singleConfigSchema = string() - .max(100, 'Value must be 100 characters or less.') - .test( - 'validate-single-config-schema', - CONFIG_ERROR_MESSAGE, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - - if (!CONFIG_NUMBER_REGEX.test(value)) { - return this.createError({ message: CONFIG_ERROR_MESSAGE }); - } - return true; - } - ); - -const multipleConfigSchema = string() - .max(100, 'Value must be 100 characters or less.') - .test( - 'validate-multi-config-schema', - CONFIGS_ERROR_MESSAGE, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - if (value.includes(' ')) { - return this.createError({ message: CONFIGS_ERROR_MESSAGE }); - } - - if (value.trim().endsWith(',')) { - return this.createError({ - message: PORTS_TRAILING_COMMA_ERROR_MESSAGE, - }); - } - - if (value.trim().startsWith(',')) { - return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); - } - - if (value.trim().includes(',,')) { - return this.createError({ - message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - }); - } - if (value.includes('.')) { - return this.createError({ message: CONFIGS_HELPER_TEXT }); - } - - const rawSegments = value.split(','); - // Check for empty segments - if (rawSegments.some((segment) => segment.trim() === '')) { - return this.createError({ - message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - }); - } - for (const configId of rawSegments) { - const trimmedConfigId = configId.trim(); - - if (!CONFIG_NUMBER_REGEX.test(trimmedConfigId)) { - return this.createError({ message: CONFIGS_ERROR_MESSAGE }); - } - } - return true; - } - ); - -const baseValueSchema = string() - .required(fieldErrorMessage) - .nullable() - .test('nonNull', fieldErrorMessage, (value) => value !== null); - -interface GetValueSchemaParams { - dimensionLabel: string; - operator: string; -} - -export const getDimensionFilterValueSchema = ({ - dimensionLabel, - operator, -}: GetValueSchemaParams) => { - if (dimensionLabel === 'port') { - const portSchema = - operator === 'in' ? commaSeparatedPortListSchema : singlePortSchema; - return portSchema.concat(baseValueSchema); - } - if (dimensionLabel === 'config_id') { - const configIdSchema = - operator === 'in' ? multipleConfigSchema : singleConfigSchema; - - return configIdSchema.concat(baseValueSchema); - } - return baseValueSchema; -}; export const dimensionFiltersSchema = dimensionFilters.concat( object({ dimension_label: string() diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 6021bd1067f..d2c8aa7b743 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -90,10 +90,6 @@ export const dimensionOperatorOptions: Item< label: 'Equal', value: 'eq', }, - { - label: 'Ends with', - value: 'endswith', - }, { label: 'Not Equal', value: 'neq', @@ -102,6 +98,10 @@ export const dimensionOperatorOptions: Item< label: 'Starts with', value: 'startswith', }, + { + label: 'Ends with', + value: 'endswith', + }, { label: 'In', value: 'in', From 7787a1913b274b29a33d8e1a7b796e41f667ddef Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Tue, 19 Aug 2025 09:39:42 +0530 Subject: [PATCH 55/88] upcoming: [DI-26793] - CloudPulse metric label support for Linode Interface firewall entities (#12716) * upcoming: [DI-26793] - Add parent entity for linode_interface types for firewall * upcoming: [DI-26793] - Updated mock data * upcoming: [DI-26793] - Updated logic for linode id to label mappings * upcoming: [DI-26793] - Updated logic * Reverted changes * Added Changeset * Updated changeset --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../pr-12716-added-1755528765131.md | 5 +++++ packages/manager/src/mocks/serverHandlers.ts | 18 +++++++++++++++++- .../src/queries/cloudpulse/resources.ts | 19 ++++++++++++------- 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-12716-added-1755528765131.md diff --git a/packages/manager/.changeset/pr-12716-added-1755528765131.md b/packages/manager/.changeset/pr-12716-added-1755528765131.md new file mode 100644 index 00000000000..0e42f571af5 --- /dev/null +++ b/packages/manager/.changeset/pr-12716-added-1755528765131.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming +--- + +CloudPulse metric label support for Linode Interface firewall entities ([#12716](https://github.com/linode/manager/pull/12716)) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index fb2e38bb5a6..6d7ce7a4f91 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -56,6 +56,7 @@ import { entityTransferFactory, eventFactory, firewallDeviceFactory, + firewallEntityfactory, firewallFactory, imageFactory, incidentResponseFactory, @@ -1100,7 +1101,21 @@ export const handlers = [ return HttpResponse.json({}); }), http.get('*/v4beta/networking/firewalls', () => { - const firewalls = firewallFactory.buildList(10); + const firewalls = [ + ...firewallFactory.buildList(10), + firewallFactory.build({ + entities: [ + firewallEntityfactory.build({ + type: 'linode_interface', + parent_entity: firewallEntityfactory.build({ + type: 'linode', + id: 123, + label: 'Linode-123', + }), + }), + ], + }), + ]; firewallFactory.resetSequenceNumber(); return HttpResponse.json(makeResourcePage(firewalls)); }), @@ -3360,6 +3375,7 @@ export const handlers = [ metric: { entity_id: '456', metric_name: 'average_cpu_usage', + linode_id: '123', node_id: 'primary-2', }, values: [ diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index a530fe8a6cb..4fa2e79d4eb 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { queryFactory } from './queries'; -import type { Filter, Params } from '@linode/api-v4'; +import type { Filter, FirewallDeviceEntity, Params } from '@linode/api-v4'; import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; export const useResourcesQuery = ( @@ -20,13 +20,18 @@ export const useResourcesQuery = ( // handle separately for firewall resource type if (resourceType === 'firewall') { - resource.entities?.forEach( - (entity: { id: number; label: string; type: string }) => { - if (entity.type === 'linode') { - entities[String(entity.id)] = entity.label; - } + resource.entities?.forEach((entity: FirewallDeviceEntity) => { + if (entity.type === 'linode' && entity.label) { + entities[String(entity.id)] = entity.label; } - ); + if ( + entity.type === 'linode_interface' && + entity.parent_entity?.label + ) { + entities[String(entity.parent_entity.id)] = + entity.parent_entity.label; + } + }); } return { engineType: resource.engine, From 51e7e1999b5a7963d668b9429927c5e633cd486a Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Tue, 19 Aug 2025 11:29:57 +0530 Subject: [PATCH 56/88] change: [M3-10449] - Enhance Linode alerts input validation messages behavior (#12703) * Update legacy alerts input validation messages behavior * More changes and fix validation * Added changeset: Enhance Linode alerts input validation messages behavior * Added changeset: Updated `alertsSchema` to require numeric fields when empty and changed the validation messages * Fix changeset, validation schema and linting --- .../pr-12703-changed-1755190934686.md | 5 ++ .../LinodeAlerts/AlertSection.tsx | 11 +++- .../LinodeAlerts/AlertsPanel.tsx | 53 +++++++++++++++---- .../pr-12703-changed-1755191361914.md | 5 ++ packages/validation/src/linodes.schema.ts | 16 +++--- 5 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-12703-changed-1755190934686.md create mode 100644 packages/validation/.changeset/pr-12703-changed-1755191361914.md diff --git a/packages/manager/.changeset/pr-12703-changed-1755190934686.md b/packages/manager/.changeset/pr-12703-changed-1755190934686.md new file mode 100644 index 00000000000..9536e3b10f9 --- /dev/null +++ b/packages/manager/.changeset/pr-12703-changed-1755190934686.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Enhance Linode alerts input validation messages behavior ([#12703](https://github.com/linode/manager/pull/12703)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertSection.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertSection.tsx index 3a66fbe25ce..13368227697 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertSection.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertSection.tsx @@ -11,10 +11,15 @@ import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; -interface Props { +export interface AlertSectionProps { copy: string; endAdornment: string; + /** + * Error message to display for the field, + * from client-side validation or API response. + */ error?: string; + onBlur: () => void; onStateChange: (e: React.ChangeEvent<{}>, checked: boolean) => void; onValueChange: (e: React.ChangeEvent) => void; radioInputLabel: string; @@ -26,7 +31,7 @@ interface Props { value: number; } -export const AlertSection = (props: Props) => { +export const AlertSection = (props: AlertSectionProps) => { const theme = useTheme(); const { copy, @@ -34,6 +39,7 @@ export const AlertSection = (props: Props) => { error, onStateChange, onValueChange, + onBlur, readOnly, state, textTitle, @@ -127,6 +133,7 @@ export const AlertSection = (props: Props) => { label={textTitle} max={Infinity} min={0} + onBlur={onBlur} onChange={onValueChange} sx={{ '.MuiInput-root': { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx index 3004b34f2f9..07dfbb8fd21 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx @@ -5,6 +5,7 @@ import { } from '@linode/queries'; import { useIsLinodeAclpSubscribed } from '@linode/shared'; import { ActionsPanel, Divider, Notice, Paper, Typography } from '@linode/ui'; +import { alertsSchema } from '@linode/validation'; import { styled } from '@mui/material/styles'; import { useBlocker } from '@tanstack/react-router'; import { useFormik } from 'formik'; @@ -17,6 +18,7 @@ import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { AlertSection } from './AlertSection'; +import type { AlertSectionProps } from './AlertSection'; import type { Linode } from '@linode/api-v4'; interface Props { @@ -75,6 +77,8 @@ export const AlertsPanel = (props: Props) => { const formik = useFormik({ enableReinitialize: true, initialValues, + validateOnChange: true, + validationSchema: alertsSchema, async onSubmit({ cpu, io, network_in, network_out, transfer_quota }) { await updateLinode({ alerts: { @@ -98,7 +102,7 @@ export const AlertsPanel = (props: Props) => { }, }); - const hasErrorFor = getAPIErrorFor( + const hasAPIErrorFor = getAPIErrorFor( { 'alerts.cpu': 'CPU', 'alerts.io': 'Disk I/O rate', @@ -109,11 +113,15 @@ export const AlertsPanel = (props: Props) => { error ?? undefined ); - const alertSections = [ + const generalError = hasAPIErrorFor('none'); + + const alertSections: AlertSectionProps[] = [ { copy: 'Average CPU usage over 2 hours exceeding this value triggers this alert.', endAdornment: '%', - error: hasErrorFor('alerts.cpu'), + error: + (formik.touched.cpu ? formik.errors.cpu : undefined) || + hasAPIErrorFor('alerts.cpu'), hidden: isBareMetalInstance, onStateChange: ( e: React.ChangeEvent, @@ -127,11 +135,15 @@ export const AlertsPanel = (props: Props) => { : 90 * (linode?.specs.vcpus ?? 1) : 0 ), - onValueChange: (e: React.ChangeEvent) => + onValueChange: (e: React.ChangeEvent) => { formik.setFieldValue( 'cpu', !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : '' - ), + ); + }, + onBlur: () => { + formik.setFieldTouched('cpu'); + }, radioInputLabel: 'cpu_usage_state', state: formik.values.cpu === ('' as unknown) || Boolean(formik.values.cpu), @@ -143,7 +155,9 @@ export const AlertsPanel = (props: Props) => { { copy: 'Average Disk I/O ops/sec over 2 hours exceeding this value triggers this alert.', endAdornment: 'IOPS', - error: hasErrorFor('alerts.io'), + error: + (formik.touched.io ? formik.errors.io : undefined) || + hasAPIErrorFor('alerts.io'), hidden: isBareMetalInstance, onStateChange: ( e: React.ChangeEvent, @@ -158,6 +172,9 @@ export const AlertsPanel = (props: Props) => { 'io', !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : '' ), + onBlur: () => { + formik.setFieldTouched('io'); + }, radioInputLabel: 'disk_io_state', state: formik.values.io === ('' as unknown) || Boolean(formik.values.io), textInputLabel: 'disk_io_threshold', @@ -169,7 +186,9 @@ export const AlertsPanel = (props: Props) => { copy: `Average incoming traffic over a 2 hour period exceeding this value triggers this alert.`, endAdornment: 'Mb/s', - error: hasErrorFor('alerts.network_in'), + error: + (formik.touched.network_in ? formik.errors.network_in : undefined) || + hasAPIErrorFor('alerts.network_in'), onStateChange: ( e: React.ChangeEvent, checked: boolean @@ -187,6 +206,9 @@ export const AlertsPanel = (props: Props) => { 'network_in', !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : '' ), + onBlur: () => { + formik.setFieldTouched('network_in'); + }, radioInputLabel: 'incoming_traffic_state', state: formik.values.network_in === ('' as unknown) || @@ -200,7 +222,9 @@ export const AlertsPanel = (props: Props) => { copy: `Average outbound traffic over a 2 hour period exceeding this value triggers this alert.`, endAdornment: 'Mb/s', - error: hasErrorFor('alerts.network_out'), + error: + (formik.touched.network_out ? formik.errors.network_out : undefined) || + hasAPIErrorFor('alerts.network_out'), onStateChange: ( e: React.ChangeEvent, checked: boolean @@ -218,6 +242,9 @@ export const AlertsPanel = (props: Props) => { 'network_out', !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : '' ), + onBlur: () => { + formik.setFieldTouched('network_out'); + }, radioInputLabel: 'outbound_traffic_state', state: formik.values.network_out === ('' as unknown) || @@ -231,7 +258,10 @@ export const AlertsPanel = (props: Props) => { copy: `Percentage of network transfer quota used being greater than this value will trigger this alert.`, endAdornment: '%', - error: hasErrorFor('alerts.transfer_quota'), + error: + (formik.touched.transfer_quota + ? formik.errors.transfer_quota + : undefined) || hasAPIErrorFor('alerts.transfer_quota'), onStateChange: ( e: React.ChangeEvent, checked: boolean @@ -249,6 +279,9 @@ export const AlertsPanel = (props: Props) => { 'transfer_quota', !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : '' ), + onBlur: () => { + formik.setFieldTouched('transfer_quota'); + }, radioInputLabel: 'transfer_quota_state', state: formik.values.transfer_quota === ('' as unknown) || @@ -260,8 +293,6 @@ export const AlertsPanel = (props: Props) => { }, ].filter((thisAlert) => !thisAlert.hidden); - const generalError = hasErrorFor('none'); - const hasUnsavedChanges = formik.dirty; const { proceed, reset, status } = useBlocker({ diff --git a/packages/validation/.changeset/pr-12703-changed-1755191361914.md b/packages/validation/.changeset/pr-12703-changed-1755191361914.md new file mode 100644 index 00000000000..faf3cee26ad --- /dev/null +++ b/packages/validation/.changeset/pr-12703-changed-1755191361914.md @@ -0,0 +1,5 @@ +--- +'@linode/validation': Changed +--- + +Update `alertsSchema` to require numeric fields when empty and change the validation messages ([#12703](https://github.com/linode/manager/pull/12703)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index c545d3763a6..70c9d9fb1b7 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -360,16 +360,16 @@ const DiskEncryptionSchema = string() .oneOf(['enabled', 'disabled']) .notRequired(); -const alerts = object({ +export const alertsSchema = object({ cpu: number() - .typeError('CPU Usage must be a number') + .required('CPU Usage is required.') .min(0, 'Must be between 0 and 4800') .max(4800, 'Must be between 0 and 4800'), - network_in: number().typeError('Incoming Traffic must be a number'), - network_out: number().typeError('Outbound Traffic must be a number'), - transfer_quota: number().typeError('Transfer Quota must be a number'), - io: number().typeError('Disk I/O Rate must be a number'), -}).notRequired(); + network_in: number().required('Incoming Traffic is required.'), + network_out: number().required('Outbound Traffic is required.'), + transfer_quota: number().required('Transfer Quota is required.'), + io: number().required('Disk I/O Rate is required.'), +}); const schedule = object({ day: mixed().oneOf( @@ -417,7 +417,7 @@ export const UpdateLinodeSchema = object({ .max(64, LINODE_LABEL_CHAR_REQUIREMENT), tags: array().of(string()).notRequired(), watchdog_enabled: boolean().notRequired(), - alerts, + alerts: alertsSchema.notRequired().default(undefined), backups, }); From 3788573e30538f49a9077d1bed70b57b46ff73f6 Mon Sep 17 00:00:00 2001 From: Ankita Date: Tue, 19 Aug 2025 13:13:19 +0530 Subject: [PATCH 57/88] upcoming:[DI-26661]: Add new filter - 'linode region' for firewalls (#12704) * upcoming:[DI-26661]: Add reusable hook and utils for computation of linode regions * [DI-26661] - Add new linode region filter in firewalls * [DI-26661] - Fix test case * [DI-26661] - Revert regionsData changes * test:fixing firewall widget * [DI-26661] - Update filterkey for linode region * [DI-26661] - Fix linting * [DI-26661] - Add changesets * [DI-26661] - Fix linting * [DI-26661] - Move into const * [DI-26661] - Fetch only if there are supported region ids --------- Co-authored-by: agorthi-akamai Co-authored-by: dmcintyr-akamai Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- ...r-12704-upcoming-features-1755191793190.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 1 + ...r-12704-upcoming-features-1755191839299.md | 5 + .../cypress/support/constants/widgets.ts | 45 +++++++ .../DimensionFilterValue/useFetchOptions.ts | 122 ++++++++++++++++++ .../DimensionFilterValue/utils.test.ts | 94 ++++++++++++++ .../Criteria/DimensionFilterValue/utils.ts | 55 ++++++++ .../Dashboard/CloudPulseDashboard.tsx | 7 + .../Dashboard/CloudPulseDashboardRenderer.tsx | 14 +- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 8 +- .../CloudPulse/Utils/FilterBuilder.ts | 7 +- .../features/CloudPulse/Utils/FilterConfig.ts | 52 +++++++- .../features/CloudPulse/Utils/constants.ts | 2 + .../CloudPulse/Widget/CloudPulseWidget.tsx | 7 + .../Widget/CloudPulseWidgetRenderer.tsx | 3 + .../shared/CloudPulseComponentRenderer.tsx | 1 + .../CloudPulseDashboardFilterBuilder.tsx | 33 ++++- .../shared/CloudPulseRegionSelect.test.tsx | 9 +- .../shared/CloudPulseRegionSelect.tsx | 46 +++++-- 19 files changed, 488 insertions(+), 28 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12704-upcoming-features-1755191793190.md create mode 100644 packages/manager/.changeset/pr-12704-upcoming-features-1755191839299.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts diff --git a/packages/api-v4/.changeset/pr-12704-upcoming-features-1755191793190.md b/packages/api-v4/.changeset/pr-12704-upcoming-features-1755191793190.md new file mode 100644 index 00000000000..2ba1f71ba62 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12704-upcoming-features-1755191793190.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulse: Update cloud pulse metrics request payload type at `types.ts` ([#12704](https://github.com/linode/manager/pull/12704)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index a52690d818f..837c0503bbe 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -146,6 +146,7 @@ export interface Metric { export interface CloudPulseMetricsRequest { absolute_time_duration: DateTimeWithPreset | undefined; + associated_entity_region?: string; entity_ids: number[]; filters?: Filters[]; group_by: string[]; diff --git a/packages/manager/.changeset/pr-12704-upcoming-features-1755191839299.md b/packages/manager/.changeset/pr-12704-upcoming-features-1755191839299.md new file mode 100644 index 00000000000..f71b4c8bae3 --- /dev/null +++ b/packages/manager/.changeset/pr-12704-upcoming-features-1755191839299.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse: Add linode region filter in `filterconfig.ts`, refactor `CloudPulseRegionSelect.tsx`, add `useFetchOptions.ts` hook ([#12704](https://github.com/linode/manager/pull/12704)) diff --git a/packages/manager/cypress/support/constants/widgets.ts b/packages/manager/cypress/support/constants/widgets.ts index a169454b36f..42c9c818f19 100644 --- a/packages/manager/cypress/support/constants/widgets.ts +++ b/packages/manager/cypress/support/constants/widgets.ts @@ -145,4 +145,49 @@ export const widgetDetails = { port: 1, protocols: ['TCP', 'UDP'], }, + firewall: { + dashboardName: 'Firewall Dashboard', + id: 4, + metrics: [ + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_cpu_utilization_percent', + title: 'CPU Utilization', + unit: '%', + yLabel: 'system_cpu_utilization_ratio', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_memory_usage_by_resource', + title: 'Memory Usage', + unit: 'B', + yLabel: 'system_memory_usage_bytes', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_network_io_by_resource', + title: 'Network Traffic', + unit: 'B', + yLabel: 'system_network_io_bytes_total', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_disk_OPS_total', + title: 'Disk I/O', + unit: 'OPS', + yLabel: 'system_disk_operations_total', + }, + ], + firewalls: 'Firewall-resource', + serviceType: 'firewall', + region: 'Newark', + }, }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts new file mode 100644 index 00000000000..6597f8280de --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts @@ -0,0 +1,122 @@ +import { useAllLinodesQuery } from '@linode/queries'; +import { useMemo } from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { filterRegionByServiceType } from '../../../Utils/utils'; +import { + getFilteredFirewallResources, + getFirewallLinodes, + getLinodeRegions, +} from './utils'; + +import type { Item } from '../../../constants'; +import type { CloudPulseServiceType, Filter, Region } from '@linode/api-v4'; + +interface FetchOptionsProps { + /** + * The dimension label determines the filtering logic and return type. + */ + dimensionLabel: null | string; + /** + * List of firewall entity IDs to filter on. + */ + entities?: string[]; + /** + * List of regions to filter on. + */ + regions?: Region[]; + /** + * Service to apply specific transformations to dimension values. + */ + serviceType?: CloudPulseServiceType | null; + /** + * The type of monitoring to filter on. + */ + type: 'alerts' | 'metrics'; +} +/** + * Custom hook to return selectable options based on the dimension type. + * Handles fetching and transforming data for edge-cases. + */ +export function useFetchOptions( + props: FetchOptionsProps +): Item[] { + const { dimensionLabel, regions, entities, serviceType, type } = props; + + const supportedRegionIds = + (serviceType && + regions && + filterRegionByServiceType(type, regions, serviceType).map( + ({ id }) => id + )) || + []; + + // Create a filter for regions based on suppoerted region IDs + const regionFilter: Filter = + supportedRegionIds && supportedRegionIds.length > 0 + ? { + '+or': supportedRegionIds.map((regionId) => ({ + region: regionId, + })), + } + : {}; + + const filterLabels: string[] = [ + 'parent_vm_entity_id', + 'region_id', + 'associated_entity_region', + ]; + + // Fetch all firewall resources when dimension requires it + const { data: firewallResources } = useResourcesQuery( + filterLabels.includes(dimensionLabel ?? ''), + 'firewall' + ); + + // Filter firewall resources by the given entities list + const filteredFirewallResourcesIds = useMemo( + () => getFilteredFirewallResources(firewallResources, entities), + [firewallResources, entities] + ); + + const idFilter = filteredFirewallResourcesIds.length + ? { '+or': filteredFirewallResourcesIds.map((id) => ({ id })) } + : []; + + const combinedFilter: Filter = { + '+and': [idFilter, regionFilter].filter(Boolean) as Filter[], + }; + // Fetch all linodes with the combined filter + const { data: linodes } = useAllLinodesQuery( + {}, + combinedFilter, + filterLabels.includes(dimensionLabel ?? '') && + filteredFirewallResourcesIds.length > 0 && + supportedRegionIds.length > 0 + ); + + // Extract linodes from filtered firewall resources + const firewallLinodes = useMemo( + () => getFirewallLinodes(linodes ?? []), + [linodes] + ); + + // Extract unique regions from linodes + const linodeRegions = useMemo( + () => getLinodeRegions(linodes ?? []), + [linodes] + ); + + // Determine what options to return based on the dimension label + switch (dimensionLabel) { + case 'associated_entity_region': + return linodeRegions; + case 'parent_vm_entity_id': + return firewallLinodes; + case 'region_id': + return linodeRegions; + default: + return []; + } +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts index 220af8e2654..014736e58fa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -1,11 +1,19 @@ +import { linodeFactory } from '@linode/utilities'; + import { transformDimensionValue } from '../../../Utils/utils'; import { + getFilteredFirewallResources, + getFirewallLinodes, + getLinodeRegions, getOperatorGroup, getStaticOptions, handleValueChange, resolveSelectedValues, } from './utils'; +import type { Linode } from '@linode/api-v4'; +import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; + describe('Utils', () => { describe('resolveSelectedValues', () => { const options = [ @@ -98,4 +106,90 @@ describe('Utils', () => { expect(getStaticOptions('linode', 'dim', null)).toEqual([]); }); }); + + describe('getFilteredFirewallResources', () => { + const resources: CloudPulseResources[] = [ + { + id: '1', + entities: { a: 'linode-1' }, + label: 'firewall-1', + }, + { + id: '2', + entities: { b: 'linode-2' }, + label: 'firewall-2', + }, + ]; + + it('should return matched resources by entity IDs', () => { + expect(getFilteredFirewallResources(resources, ['1'])).toEqual(['a']); + }); + + it('should return empty array if no match', () => { + expect(getFilteredFirewallResources(resources, ['3'])).toEqual([]); + }); + + it('should handle undefined inputs', () => { + expect(getFilteredFirewallResources(undefined, ['1'])).toEqual([]); + expect(getFilteredFirewallResources(resources, undefined)).toEqual([]); + }); + }); + + describe('getFirewallLinodes', () => { + const linodes: Linode[] = linodeFactory.buildList(2); + + it('should return linode options with transformed labels', () => { + expect(getFirewallLinodes(linodes)).toEqual([ + { + label: transformDimensionValue( + 'firewall', + 'parent_vm_entity_id', + linodes[0].label + ), + value: linodes[0].id.toString(), + }, + { + label: transformDimensionValue( + 'firewall', + 'parent_vm_entity_id', + linodes[1].label + ), + value: linodes[1].id.toString(), + }, + ]); + }); + + it('should handle empty linode list', () => { + expect(getFirewallLinodes([])).toEqual([]); + }); + }); + + describe('getLinodeRegions', () => { + it('should extract and deduplicate regions', () => { + const linodes = linodeFactory.buildList(3, { + region: 'us-east', + }); + linodes[1].region = 'us-west'; // introduce a second unique region + + const result = getLinodeRegions(linodes); + expect(result).toEqual([ + { + label: transformDimensionValue( + 'firewall', + 'region_id', + linodes[0].region + ), + value: 'us-east', + }, + { + label: transformDimensionValue( + 'firewall', + 'region_id', + linodes[1].region + ), + value: 'us-west', + }, + ]); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts index 3d2be8eceff..914f3c012fb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -5,7 +5,9 @@ import type { OperatorGroup } from './constants'; import type { CloudPulseServiceType, DimensionFilterOperatorType, + Linode, } from '@linode/api-v4'; +import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; /** * Resolves the selected value(s) for the Autocomplete component from raw string. @@ -85,3 +87,56 @@ export const getStaticOptions = ( })) ?? [] ); }; + +/** + * Filters firewall resources and returns matching entity IDs. + * @param firewallResources - List of firewall resource objects. + * @param entities - List of target firewall entity IDs. + * @returns - Flattened array of matching entity IDs. + */ +export const getFilteredFirewallResources = ( + firewallResources: CloudPulseResources[] | undefined, + entities: string[] | undefined +): string[] => { + if (!(firewallResources?.length && entities?.length)) return []; + + return firewallResources + .filter((firewall) => entities.includes(firewall.id)) + .flatMap((firewall) => + firewall.entities ? Object.keys(firewall.entities) : [] + ); +}; + +/** + * Extracts linode items from firewall resources by merging entities. + * @param resources - List of firewall resources with entity mappings. + * @returns - Flattened list of linode ID/label pairs as options. + */ +export const getFirewallLinodes = ( + linodes: Linode[] +): Item[] => { + if (!linodes) return []; + return linodes.map((linode) => ({ + label: transformDimensionValue( + 'firewall', + 'parent_vm_entity_id', + linode.label + ), + value: String(linode.id), + })); +}; + +/** + * Extracts unique region values from a list of linodes. + * @param linodes - Linode objects with region information. + * @returns - Deduplicated list of regions as options. + */ +export const getLinodeRegions = (linodes: Linode[]): Item[] => { + if (!linodes) return []; + const regions = new Set(); + linodes.forEach(({ region }) => region && regions.add(region)); + return Array.from(regions).map((region) => ({ + label: transformDimensionValue('firewall', 'region_id', region), + value: region, + })); +}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index d87ae94a1b1..36dcf2c988b 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -35,6 +35,11 @@ export interface DashboardProperties { */ duration: DateTimeWithPreset; + /** + * Selected linode region for the dashboard + */ + linodeRegion?: string; + /** * optional timestamp to pass as react query param to forcefully re-fetch data */ @@ -69,6 +74,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { manualRefreshTimeStamp, resources, savePref, + linodeRegion, } = props; const { preferences } = useAclpPreference(); @@ -154,6 +160,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { duration={duration} isJweTokenFetching={isJweTokenFetching} jweToken={jweToken} + linodeRegion={linodeRegion} manualRefreshTimeStamp={manualRefreshTimeStamp} metricDefinitions={metricDefinitions} preferences={preferences} diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index ae4eed38d37..c1f0d881df9 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; -import { REFRESH, REGION, RESOURCE_ID, TAGS } from '../Utils/constants'; +import { + LINODE_REGION, + REFRESH, + REGION, + RESOURCE_ID, + TAGS, +} from '../Utils/constants'; import { checkIfAllMandatoryFiltersAreSelected, getMetricsCallCustomFilters, @@ -57,6 +63,12 @@ export const CloudPulseDashboardRenderer = React.memo( additionalFilters={getMetricsCall} dashboardId={dashboard.id} duration={timeDuration} + linodeRegion={ + filterValue[LINODE_REGION] && + typeof filterValue[LINODE_REGION] === 'string' + ? (filterValue[LINODE_REGION] as string) + : undefined + } manualRefreshTimeStamp={ filterValue[REFRESH] && typeof filterValue[REFRESH] === 'number' ? filterValue[REFRESH] diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index b0f07b122eb..b90ef39de90 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -103,6 +103,11 @@ interface MetricRequestProps { */ entityIds: string[]; + /** + * selected linode region for the widget + */ + linodeRegion?: string; + /** * list of CloudPulse resources available */ @@ -283,7 +288,7 @@ export const generateMaxUnit = ( export const getCloudPulseMetricRequest = ( props: MetricRequestProps ): CloudPulseMetricsRequest => { - const { duration, entityIds, resources, widget } = props; + const { duration, entityIds, resources, widget, linodeRegion } = props; const preset = duration.preset; return { @@ -310,6 +315,7 @@ export const getCloudPulseMetricRequest = ( unit: widget.time_granularity.unit, value: widget.time_granularity.value, }, + associated_entity_region: linodeRegion, }; }; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 29a250dc73c..ff4448f5dd4 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -103,8 +103,9 @@ export const getTagsProperties = ( export const getRegionProperties = ( props: CloudPulseFilterProperties, handleRegionChange: ( + filterKey: string, region: string | undefined, - labels: [], + labels: string[], savePref?: boolean ) => void ): CloudPulseRegionSelectProps => { @@ -118,8 +119,9 @@ export const getRegionProperties = ( shouldDisable, } = props; return { - defaultValue: preferences?.[REGION], + defaultValue: preferences?.[filterKey], handleRegionChange, + filterKey, label, placeholder, savePreferences: !isServiceAnalyticsIntegration, @@ -132,6 +134,7 @@ export const getRegionProperties = ( dashboard ), xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + selectedEntities: (dependentFilters?.[RESOURCE_ID] ?? []) as string[], }; }; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 4dda7780f3e..c93438a7e29 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -1,6 +1,10 @@ import { capabilityServiceTypeMapping } from '@linode/api-v4'; -import { INTERFACE_IDS_PLACEHOLDER_TEXT, RESOURCE_ID } from './constants'; +import { + INTERFACE_IDS_PLACEHOLDER_TEXT, + LINODE_REGION, + RESOURCE_ID, +} from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; @@ -194,6 +198,20 @@ export const NODEBALANCER_CONFIG: Readonly = { }, name: 'Ports', }, + { + configuration: { + filterKey: 'relative_time_duration', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: TIME_DURATION, + neededInViews: [], // we will have a static time duration component, no need render from filter builder + placeholder: 'Select a Duration', + priority: 4, + }, + name: TIME_DURATION, + }, ], serviceType: 'nodebalancer', }; @@ -215,6 +233,24 @@ export const FIREWALL_CONFIG: Readonly = { }, name: 'Firewalls', }, + { + configuration: { + dependency: ['resource_id'], + filterKey: LINODE_REGION, + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: 'Linode Region', + neededInViews: [ + CloudPulseAvailableViews.central, + CloudPulseAvailableViews.service, + ], + placeholder: 'Select a Linode Region', + priority: 2, + }, + name: 'Linode Region', + }, { configuration: { filterKey: 'interface_type', @@ -261,6 +297,20 @@ export const FIREWALL_CONFIG: Readonly = { }, name: 'Interface IDs', }, + { + configuration: { + filterKey: 'relative_time_duration', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: TIME_DURATION, + neededInViews: [], // we will have a static time duration component, no need render from filter builder + placeholder: 'Select a Duration', + priority: 4, + }, + name: TIME_DURATION, + }, ], serviceType: 'firewall', }; diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index e159fbbb11d..bac20ace791 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -8,6 +8,8 @@ export const SECONDARY_NODE = 'secondary'; export const REGION = 'region'; +export const LINODE_REGION = 'associated_entity_region'; + export const RESOURCES = 'resources'; export const INTERVAL = 'interval'; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index f945a6bef1e..660b488713e 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -80,6 +80,11 @@ export interface CloudPulseWidgetProperties { */ isJweTokenFetching: boolean; + /** + * Selected linode region for the widget + */ + linodeRegion?: string; + /** * List of resources available of selected service type */ @@ -150,6 +155,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { timeStamp, unit, widget: widgetProp, + linodeRegion, } = props; const timezone = @@ -245,6 +251,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { entityIds, resources, widget, + linodeRegion, }), filters, // any additional dimension filters will be constructed and passed here }, diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index d362b492d98..571b67c69e6 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -32,6 +32,7 @@ interface WidgetProps { duration: DateTimeWithPreset; isJweTokenFetching: boolean; jweToken?: JWEToken | undefined; + linodeRegion?: string; manualRefreshTimeStamp?: number; metricDefinitions: ResourcePage | undefined; preferences?: AclpConfig; @@ -64,6 +65,7 @@ export const RenderWidgets = React.memo( resourceList, resources, savePref, + linodeRegion, } = props; const getCloudPulseGraphProperties = ( @@ -166,6 +168,7 @@ export const RenderWidgets = React.memo( authToken={jweToken?.token} availableMetrics={availMetrics} isJweTokenFetching={isJweTokenFetching} + linodeRegion={linodeRegion} resources={resourceList!} savePref={savePref} /> diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index 4bd0b7e3918..220b36f03bb 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -53,6 +53,7 @@ const Components: { relative_time_duration: CloudPulseDateTimeRangePicker, resource_id: CloudPulseResourcesSelect, tags: CloudPulseTagsSelect, + associated_entity_region: CloudPulseRegionSelect, }; const buildComponent = (props: CloudPulseComponentRendererProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 6a4498cb168..9ee7727b24b 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -11,6 +11,7 @@ import RenderComponent from '../shared/CloudPulseComponentRenderer'; import { DASHBOARD_ID, INTERFACE_ID, + LINODE_REGION, NODE_TYPE, PORT, REGION, @@ -224,17 +225,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( const handleRegionChange = React.useCallback( ( + filterKey: string, region: string | undefined, labels: string[], savePref: boolean = false ) => { - const updatedPreferenceData = { - [REGION]: region, - [RESOURCES]: undefined, - [TAGS]: undefined, - }; + const updatedPreferenceData = + filterKey === REGION + ? { + [filterKey]: region, + [RESOURCES]: undefined, + [TAGS]: undefined, + } + : { + [filterKey]: region, + }; emitFilterChangeByFilterKey( - REGION, + filterKey, region, labels, savePref, @@ -289,6 +296,20 @@ export const CloudPulseDashboardFilterBuilder = React.memo( }, handleRegionChange ); + } else if (config.configuration.filterKey === LINODE_REGION) { + return getRegionProperties( + { + config, + dashboard, + isServiceAnalyticsIntegration, + preferences, + dependentFilters: resource_ids?.length + ? { [RESOURCE_ID]: resource_ids } + : dependentFilterReference.current, + shouldDisable: isError || isLoading, + }, + handleRegionChange + ); } else if (config.configuration.filterKey === RESOURCE_ID) { return getResourcesProperties( { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index feb1bcc38c2..77a3e608ddb 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -14,6 +14,8 @@ import type { Region } from '@linode/api-v4'; import type { useRegionsQuery } from '@linode/queries'; const props: CloudPulseRegionSelectProps = { + filterKey: 'region', + selectedEntities: [], handleRegionChange: vi.fn(), label: 'Region', selectedDashboard: undefined, @@ -152,13 +154,6 @@ describe('CloudPulseRegionSelect', () => { }); renderWithTheme(); - expect(queryMocks.useResourcesQuery).toHaveBeenLastCalledWith( - false, - undefined, - {}, - {} - ); // use resources should have called with enabled false since the region call failed - const errorMessage = screen.getByText('Failed to fetch Region.'); // should show regions failure only expect(errorMessage).not.toBeNull(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 560ac92afc9..36760f146d0 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -6,18 +6,26 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { useFetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions'; import { filterRegionByServiceType } from '../Alerts/Utils/utils'; -import { NO_REGION_MESSAGE, RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { + LINODE_REGION, + NO_REGION_MESSAGE, + RESOURCE_FILTER_MAP, +} from '../Utils/constants'; import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; +import type { Item } from '../Alerts/constants'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; disabled?: boolean; + filterKey: string; handleRegionChange: ( + filterKey: string, region: string | undefined, labels: string[], savePref?: boolean @@ -26,6 +34,7 @@ export interface CloudPulseRegionSelectProps { placeholder?: string; savePreferences?: boolean; selectedDashboard: Dashboard | undefined; + selectedEntities: string[]; xFilter?: CloudPulseMetricsFilter; } @@ -33,11 +42,13 @@ export const CloudPulseRegionSelect = React.memo( (props: CloudPulseRegionSelectProps) => { const { defaultValue, + filterKey, handleRegionChange, label, placeholder, savePreferences, selectedDashboard, + selectedEntities, disabled = false, xFilter, } = props; @@ -86,13 +97,13 @@ export const CloudPulseRegionSelect = React.memo( ? regions.find((regionObj) => regionObj.id === defaultValue) : undefined; // Notify parent and set internal state - handleRegionChange(region?.id, region ? [region.label] : []); + handleRegionChange(filterKey, region?.id, region ? [region.label] : []); setSelectedRegion(region?.id); } else { if (selectedRegion !== undefined) { setSelectedRegion(''); } - handleRegionChange(undefined, []); + handleRegionChange(filterKey, undefined, []); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -100,15 +111,29 @@ export const CloudPulseRegionSelect = React.memo( regions, // Function to call on change ]); - const supportedRegions = React.useMemo(() => { + const linodeRegionIds = useFetchOptions({ + dimensionLabel: filterKey, + entities: selectedEntities, + regions, + serviceType, + type: 'metrics', + }).map((option: Item) => option.value); + + const supportedLinodeRegions = + regions?.filter((region) => linodeRegionIds?.includes(region.id)) ?? []; + + const supportedRegions = React.useMemo(() => { return filterRegionByServiceType('metrics', regions, serviceType); }, [regions, serviceType]); - const supportedRegionsFromResources = supportedRegions?.filter(({ id }) => - filterUsingDependentFilters(resources, xFilter)?.some( - ({ region }) => region === id - ) - ); + const supportedRegionsFromResources = + filterKey === LINODE_REGION + ? supportedLinodeRegions + : supportedRegions.filter(({ id }) => + filterUsingDependentFilters(resources, xFilter)?.some( + ({ region }) => region === id + ) + ); return ( { setSelectedRegion(region?.id ?? ''); handleRegionChange( + filterKey, region?.id, region ? [region.label] : [], savePreferences ); }} placeholder={placeholder ?? 'Select a Region'} - regions={supportedRegionsFromResources ?? []} + regions={supportedRegionsFromResources} value={ supportedRegionsFromResources?.length ? selectedRegion : undefined } From f7cdf5426c660b4093c48f6e7e853d067b116e70 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Tue, 19 Aug 2025 08:31:20 -0400 Subject: [PATCH 58/88] fix: [M3-10482] - Add missing `firewall_apply` event messages (#12685) * firewall event message fixes * Added changeset: Missing `firewall_apply` event messages * other firewall messages * add todo comment * spacing * Added changeset: Temporarily fix Linode Interface `firewall_device_add` event message * update messages based on feedback * address feedback @cpathipa * update type --- .../pr-12685-fixed-1755272623309.md | 5 ++ ...r-12685-upcoming-features-1755276113969.md | 5 ++ .../features/Events/factories/firewall.tsx | 51 ++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-12685-fixed-1755272623309.md create mode 100644 packages/manager/.changeset/pr-12685-upcoming-features-1755276113969.md diff --git a/packages/manager/.changeset/pr-12685-fixed-1755272623309.md b/packages/manager/.changeset/pr-12685-fixed-1755272623309.md new file mode 100644 index 00000000000..0019fdf96a7 --- /dev/null +++ b/packages/manager/.changeset/pr-12685-fixed-1755272623309.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Missing `firewall_apply` event messages ([#12685](https://github.com/linode/manager/pull/12685)) diff --git a/packages/manager/.changeset/pr-12685-upcoming-features-1755276113969.md b/packages/manager/.changeset/pr-12685-upcoming-features-1755276113969.md new file mode 100644 index 00000000000..e04093dbbbb --- /dev/null +++ b/packages/manager/.changeset/pr-12685-upcoming-features-1755276113969.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Temporarily fix Linode Interface `firewall_device_add` event message ([#12685](https://github.com/linode/manager/pull/12685)) diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx index 140dd563b4d..14ec53f3264 100644 --- a/packages/manager/src/features/Events/factories/firewall.tsx +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -1,3 +1,4 @@ +import { capitalize } from '@linode/utilities'; import * as React from 'react'; import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/constants'; @@ -5,16 +6,50 @@ import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/co import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; +import type { Event } from '@linode/api-v4'; import type { FirewallDeviceEntityType } from '@linode/api-v4'; +const entityPrefix = (e: Event) => { + const type = e?.entity?.type ? capitalize(e.entity.type) : null; + + return type ? ( + <> + {type} {' '} + + ) : null; +}; + export const firewall: PartialEventMap<'firewall'> = { firewall_apply: { - notification: (e) => ( - <> - Firewall has been{' '} - applied. - - ), + failed: (e) => { + return ( + <> + {entityPrefix(e)} Firewall update could not be{' '} + applied. + + ); + }, + finished: (e) => { + return ( + <> + {entityPrefix(e)} Firewall update has been applied. + + ); + }, + scheduled: (e) => { + return ( + <> + {entityPrefix(e)} Firewall update is scheduled. + + ); + }, + started: (e) => { + return ( + <> + {entityPrefix(e)} Firewall update has started. + + ); + }, }, firewall_create: { notification: (e) => ( @@ -34,8 +69,10 @@ export const firewall: PartialEventMap<'firewall'> = { firewall_device_add: { notification: (e) => { if (e.secondary_entity?.type) { + // TODO - Linode Interfaces [M3-10447] - clean this up when API ticket [VPC-3359] is completed const secondaryEntityName = - formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType]; + formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType] ?? + 'Linode Interface'; return ( <> {secondaryEntityName} {' '} From 1eca8839b71ffc99c0b0b9565e5d39881cc95931 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Tue, 19 Aug 2025 16:40:25 +0200 Subject: [PATCH 59/88] feat: [UIE-9083] - IAM RBAC: add the missing permission checks for profile (#12698) * feat: [UIE-9083] - IAM RBAC: add the missing permission checks for profile clients * Added changeset: IAM RBAC: add the missing permission checks for Profile OAuth Apps * fix e2e tests * fix the mapping for oauth perm to grants --------- Co-authored-by: Conal Ryan <136115382+corya-akamai@users.noreply.github.com> --- packages/api-v4/src/iam/types.ts | 3 +- .../pr-12698-changed-1755182466488.md | 5 + .../e2e/core/account/oauth-apps.spec.ts | 6 +- .../adapters/accountGrantsToPermissions.ts | 5 + .../OAuthClients/EditOAuthClientDrawer.tsx | 2 +- .../OAuthClients/OAuthClientActionMenu.tsx | 10 +- .../OAuthClients/OAuthClients.test.tsx | 91 ++++++++++++++++++- .../Profile/OAuthClients/OAuthClients.tsx | 10 ++ 8 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-12698-changed-1755182466488.md diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 7259c9885af..1b262dc3ca5 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -101,7 +101,8 @@ export type AccountAdmin = | 'view_user_preferences' | AccountBillingAdmin | AccountFirewallAdmin - | AccountLinodeAdmin; + | AccountLinodeAdmin + | AccountOauthClientAdmin; /** Permissions associated with the "account_billing_admin" role. */ export type AccountBillingAdmin = diff --git a/packages/manager/.changeset/pr-12698-changed-1755182466488.md b/packages/manager/.changeset/pr-12698-changed-1755182466488.md new file mode 100644 index 00000000000..0dd42507b90 --- /dev/null +++ b/packages/manager/.changeset/pr-12698-changed-1755182466488.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM RBAC: add the missing permission checks for Profile OAuth Apps ([#12698](https://github.com/linode/manager/pull/12698)) diff --git a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts index f18d980d3ea..821da0ac719 100644 --- a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts @@ -280,7 +280,7 @@ describe('OAuth Apps', () => { cy.findByText('Edit').should('be.visible').click(); }); ui.drawer - .findByTitle('Create OAuth App') + .findByTitle('Edit OAuth App') .should('be.visible') .within(() => { ui.buttonGroup @@ -299,7 +299,7 @@ describe('OAuth Apps', () => { .within(() => { cy.findByText('Edit').should('be.visible').click(); }); - ui.drawer.findByTitle('Create OAuth App').should('be.visible'); + ui.drawer.findByTitle('Edit OAuth App').should('be.visible'); ui.drawerCloseButton.find().click(); // Confirm edition. @@ -317,7 +317,7 @@ describe('OAuth Apps', () => { mockGetOAuthApps(updatedApps).as('getUpdatedOAuthApps'); mockUpdateOAuthApps(updatedApps[0].id, updatedApps).as('updateOAuthApp'); ui.drawer - .findByTitle('Create OAuth App') + .findByTitle('Edit OAuth App') .should('be.visible') .within(() => { // If there is no changes, the 'save' button should disabled diff --git a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts index cdd4e796982..66271e99d61 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts @@ -67,5 +67,10 @@ export const accountGrantsToPermissions = ( create_firewall: unrestricted || globalGrants?.add_firewalls, // AccountLinodeAdmin create_linode: unrestricted || globalGrants?.add_linodes, + // AccountOAuthClientAdmin + create_oauth_client: true, + update_oauth_client: true, + delete_oauth_client: true, + reset_oauth_client_secret: true, } as Record; }; diff --git a/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx b/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx index bc1f8423a81..03873e5cddf 100644 --- a/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx @@ -53,7 +53,7 @@ export const EditOAuthClientDrawer = ({ client, onClose, open }: Props) => { const hasErrorFor = getAPIErrorFor(errorResources, error ?? undefined); return ( - + {hasErrorFor('none') && ( )} diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx index f57853e57f7..c201a319858 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import type { PermissionType } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -13,24 +14,30 @@ interface Props { onOpenDeleteDialog: () => void; onOpenEditDrawer: () => void; onOpenResetDialog: () => void; + permissions: Partial>; } export const OAuthClientActionMenu = (props: Props) => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const { permissions, label } = props; + const actions: Action[] = [ { onClick: props.onOpenEditDrawer, title: 'Edit', + disabled: !permissions.update_oauth_client, }, { onClick: props.onOpenResetDialog, title: 'Reset', + disabled: !permissions.reset_oauth_client_secret, }, { onClick: props.onOpenDeleteDialog, title: 'Delete', + disabled: !permissions.delete_oauth_client, }, ]; @@ -40,13 +47,14 @@ export const OAuthClientActionMenu = (props: Props) => { {matchesSmDown ? ( ) : ( actions.map((action) => { return ( diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx index 75f45c7fa3e..665311ddf51 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx @@ -1,4 +1,4 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import React from 'react'; import { oauthClientFactory } from 'src/factories/accountOAuth'; @@ -8,6 +8,21 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import OAuthClients from './OAuthClients'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + create_oauth_client: false, + update_oauth_client: false, + delete_oauth_client: false, + reset_oauth_client_secret: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + describe('Maintenance Table Row', () => { const clients = oauthClientFactory.buildList(3); @@ -27,4 +42,78 @@ describe('Maintenance Table Row', () => { getByText(client.id); } }); + + it('should disable "Add an OAuth App" button if user does not have create_oauth_client permission', () => { + const { getByRole } = renderWithTheme(); + + const button = getByRole('button', { name: /add an oauth app/i }); + expect(button).toBeDisabled(); + }); + + it('should disable menu buttons if user does not have permission', async () => { + const { getAllByRole } = renderWithTheme(); + + server.use( + http.get('*/account/oauth-clients', () => { + return HttpResponse.json(makeResourcePage(clients)); + }) + ); + + await waitFor(() => { + const editBtn = getAllByRole('button', { name: 'Edit' })[0]; + expect(editBtn).toBeDisabled(); + + const resetBtn = getAllByRole('button', { name: 'Reset' })[0]; + expect(resetBtn).toBeDisabled(); + + const deleteBtn = getAllByRole('button', { name: 'Delete' })[0]; + expect(deleteBtn).toBeDisabled(); + }); + }); + + it('should enable "Add an OAuth App" button if user has create_oauth_client permission', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_oauth_client: true, + update_oauth_client: false, + delete_oauth_client: false, + reset_oauth_client_secret: false, + }, + }); + + const { getByRole } = renderWithTheme(); + + const button = getByRole('button', { name: /add an oauth app/i }); + expect(button).toBeEnabled(); + }); + + it('should enable menu buttons if user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_oauth_client: true, + update_oauth_client: true, + delete_oauth_client: true, + reset_oauth_client_secret: true, + }, + }); + + const { getAllByRole } = renderWithTheme(); + + server.use( + http.get('*/account/oauth-clients', () => { + return HttpResponse.json(makeResourcePage(clients)); + }) + ); + + await waitFor(() => { + const editBtn = getAllByRole('button', { name: 'Edit' })[0]; + expect(editBtn).toBeEnabled(); + + const resetBtn = getAllByRole('button', { name: 'Reset' })[0]; + expect(resetBtn).toBeEnabled(); + + const deleteBtn = getAllByRole('button', { name: 'Delete' })[0]; + expect(deleteBtn).toBeEnabled(); + }); + }); }); diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx index aac8feca417..9e6b9969739 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx @@ -14,6 +14,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 { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -55,6 +56,13 @@ const OAuthClients = () => { } ); + const { data: permissions } = usePermissions('account', [ + 'create_oauth_client', + 'update_oauth_client', + 'delete_oauth_client', + 'reset_oauth_client_secret', + ]); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [isResetDialogOpen, setIsResetDialogOpen] = React.useState(false); const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); @@ -120,6 +128,7 @@ const OAuthClients = () => { setSelectedOAuthClientId(id); setIsResetDialogOpen(true); }} + permissions={permissions} /> @@ -137,6 +146,7 @@ const OAuthClients = () => { > diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index f0a4015309c..a31a827bcf6 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -13,6 +13,8 @@ import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { StyledGrid } from './EmailBounce.styles'; import type { Theme } from '@mui/material/styles'; @@ -26,6 +28,7 @@ export const EmailBounceNotificationSection = React.memo(() => { const { data: profile } = useProfile(); const { mutateAsync: updateProfile } = useMutateProfile(); const { data: notifications } = useNotificationsQuery(); + const flags = useFlags(); const navigate = useNavigate(); @@ -54,7 +57,9 @@ export const EmailBounceNotificationSection = React.memo(() => { navigate({ - to: '/account/billing', + to: flags?.iamRbacPrimaryNavChanges + ? '/billing' + : '/account/billing', search: { contactDrawerOpen: true, focusEmail: true }, }) } diff --git a/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx b/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx index 962a3394519..daf7e336b1c 100644 --- a/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx @@ -55,7 +55,10 @@ export const TaxCollectionBanner = () => { diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx index 36b10d67f4f..81b1b48871f 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { NO_PERMISSION_TOOLTIP_TEXT } from 'src/constants'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { isMTCPlan } from 'src/features/components/PlansPanel/utils'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; @@ -20,8 +21,6 @@ import type { LinodeHandlers } from '../LinodesLanding'; import type { LinodeBackups, LinodeType } from '@linode/api-v4'; import type { ActionType } from 'src/features/Account/utils'; -const NO_PERMISSION_TOOLTIP_TEXT = - 'You do not have permission to perform this action.'; const MAINTENANCE_TOOLTIP_TEXT = 'This action is unavailable while your Linode is undergoing host maintenance.'; const DISTRIBUTED_REGION_TOOLTIP_TEXT = From f6b5361562c5cde13322d6898a3ff3cac3d99399 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 20 Aug 2025 19:00:00 +0200 Subject: [PATCH 70/88] change: [UIE-8977] - RBAC IAM Users Permissions (#12714) * implementing basic permissions * more permissions * fix units * more permissions * changesets * few more tweaks * few more tweaks * feedback @coliu-akamai * feedback @coliu-akamai * feedback @corya-akamai --- packages/api-v4/src/iam/types.ts | 2 + .../pr-12714-changed-1755514005125.md | 5 +++ .../src/features/IAM/Roles/Roles.test.tsx | 33 +++++++++++++-- .../manager/src/features/IAM/Roles/Roles.tsx | 15 ++++++- .../IAM/Roles/RolesTable/RolesTable.test.tsx | 19 +++++++++ .../IAM/Roles/RolesTable/RolesTable.tsx | 12 +++++- .../RolesTable/RolesTableActionMenu.test.tsx | 4 +- .../Roles/RolesTable/RolesTableActionMenu.tsx | 12 +++++- .../AssignedRolesActionMenu.test.tsx | 2 + .../AssignedRolesActionMenu.tsx | 24 +++++++++++ .../AssignedRolesTable/AssignedRolesTable.tsx | 9 ++++ .../NoAssignedRoles/NoAssignedRoles.tsx | 8 ++++ .../UserDetails/DeleteUserPanel.test.tsx | 27 ++++++++++-- .../IAM/Users/UserDetails/DeleteUserPanel.tsx | 15 +++++-- .../Users/UserDetails/UserEmailPanel.test.tsx | 25 +++++++++-- .../IAM/Users/UserDetails/UserEmailPanel.tsx | 10 ++++- .../IAM/Users/UserDetails/UserProfile.tsx | 42 ++++++++++++++++--- .../Users/UserDetails/UsernamePanel.test.tsx | 34 +++++++++++++-- .../IAM/Users/UserDetails/UsernamePanel.tsx | 10 ++++- .../UserEntities/AssignedEntitiesTable.tsx | 10 +++++ .../Users/UserEntities/UserEntities.test.tsx | 27 ++++++++++++ .../IAM/Users/UserEntities/UserEntities.tsx | 19 +++++++-- .../IAM/Users/UserRoles/UserRoles.test.tsx | 14 +++++++ .../IAM/Users/UserRoles/UserRoles.tsx | 18 +++++++- .../IAM/Users/UsersTable/ProxyUserTable.tsx | 6 +-- .../features/IAM/Users/UsersTable/UserRow.tsx | 18 ++++++-- .../features/IAM/Users/UsersTable/Users.tsx | 12 +++--- .../Users/UsersTable/UsersActionMenu.test.tsx | 12 ++++++ .../IAM/Users/UsersTable/UsersActionMenu.tsx | 35 ++++++++++++++-- .../UsersTable/UsersLandingTableBody.tsx | 22 +++++++++- .../pr-12714-added-1755514068310.md | 5 +++ packages/queries/src/account/users.ts | 4 +- packages/queries/src/iam/iam.ts | 4 +- 33 files changed, 455 insertions(+), 59 deletions(-) create mode 100644 packages/manager/.changeset/pr-12714-changed-1755514005125.md create mode 100644 packages/queries/.changeset/pr-12714-added-1755514068310.md diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 1b262dc3ca5..677cb9cafb5 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -358,3 +358,5 @@ export interface Roles { } export type IamAccessType = keyof IamAccountRoles; + +export type PickPermissions = T; diff --git a/packages/manager/.changeset/pr-12714-changed-1755514005125.md b/packages/manager/.changeset/pr-12714-changed-1755514005125.md new file mode 100644 index 00000000000..cd52ed18b37 --- /dev/null +++ b/packages/manager/.changeset/pr-12714-changed-1755514005125.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update RBAC IAM Users Permissions ([#12714](https://github.com/linode/manager/pull/12714)) diff --git a/packages/manager/src/features/IAM/Roles/Roles.test.tsx b/packages/manager/src/features/IAM/Roles/Roles.test.tsx index f5a08f19996..0fbfccaf172 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.test.tsx @@ -8,10 +8,11 @@ import { RolesLanding } from './Roles'; const queryMocks = vi.hoisted(() => ({ useAccountRoles: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccountRoles: queryMocks.useAccountRoles, @@ -19,15 +20,21 @@ vi.mock('@linode/queries', async () => { }); vi.mock('src/features/IAM/Shared/utilities', async () => { - const actual = await vi.importActual( - 'src/features/IAM/Shared/utilities' - ); + const actual = await vi.importActual('src/features/IAM/Shared/utilities'); return { ...actual, mapAccountPermissionsToRoles: vi.fn(), }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + beforeEach(() => { vi.clearAllMocks(); }); @@ -46,6 +53,11 @@ describe('RolesLanding', () => { it('renders roles table when permissions are loaded', async () => { const mockPermissions = accountRolesFactory.build(); + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: true, + }, + }); queryMocks.useAccountRoles.mockReturnValue({ data: mockPermissions, isLoading: false, @@ -55,4 +67,17 @@ describe('RolesLanding', () => { // RolesTable has a textbox at the top expect(screen.getByRole('textbox')).toBeInTheDocument(); }); + + it('should show an error message if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: false, + }, + }); + + renderWithTheme(); + expect( + screen.getByText('You do not have permission to view roles.') + ).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 9b3c0ef197f..9324c61113d 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -1,12 +1,17 @@ import { useAccountRoles } from '@linode/queries'; -import { CircleProgress, Paper, Typography } from '@linode/ui'; +import { CircleProgress, Notice, Paper, Typography } from '@linode/ui'; import React from 'react'; import { RolesTable } from 'src/features/IAM/Roles/RolesTable/RolesTable'; import { mapAccountPermissionsToRoles } from 'src/features/IAM/Shared/utilities'; +import { usePermissions } from '../hooks/usePermissions'; + export const RolesLanding = () => { - const { data: accountRoles, isLoading } = useAccountRoles(); + const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: accountRoles, isLoading } = useAccountRoles( + permissions?.is_account_admin + ); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -20,6 +25,12 @@ export const RolesLanding = () => { return ; } + if (!permissions?.is_account_admin) { + return ( + You do not have permission to view roles. + ); + } + return ( ({ marginTop: theme.tokens.spacing.S16 })}> Roles 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 e2ae276e7a7..5c469650dfb 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx @@ -7,6 +7,10 @@ import { RolesTable } from './RolesTable'; import type { RoleView } from '../../Shared/types'; +const queryMocks = { + usePermissions: vi.fn(), +}; + vi.mock('src/features/IAM/Shared/utilities', async () => { const actual = await vi.importActual( 'src/features/IAM/Shared/utilities' @@ -17,6 +21,16 @@ vi.mock('src/features/IAM/Shared/utilities', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/usePermissions' + ); + return { + ...actual, + usePermissions: vi.fn().mockReturnValue({}), + }; +}); + const mockRoles: RoleView[] = [ { access: 'account_access', @@ -40,6 +54,11 @@ const mockRoles: RoleView[] = [ beforeEach(() => { vi.clearAllMocks(); + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: true, + }, + }); }); describe('RolesTable', () => { diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 59903a32489..f06fdaaa32d 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -26,6 +26,7 @@ import { } from 'src/features/IAM/Shared/utilities'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { usePermissions } from '../../hooks/usePermissions'; import { ROLES_LEARN_MORE_LINK, ROLES_TABLE_PREFERENCE_KEY, @@ -43,6 +44,7 @@ interface Props { roles?: RoleView[]; } const DEFAULT_PAGE_SIZE = 10; + export const RolesTable = ({ roles = [] }: Props) => { // Filter string for the search bar const [filterString, setFilterString] = React.useState(''); @@ -54,6 +56,9 @@ export const RolesTable = ({ roles = [] }: Props) => { const [selectedRows, setSelectedRows] = useState([]); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { data: permissions } = usePermissions('account', ['is_account_admin']); + const isAccountAdmin = permissions?.is_account_admin; + const pagination = usePaginationV2({ currentRoute: '/iam/roles', initialPage: 1, @@ -194,12 +199,14 @@ export const RolesTable = ({ roles = [] }: Props) => { diff --git a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx index f8a85c53368..f02ca9cfa18 100644 --- a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx +++ b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx @@ -3,6 +3,7 @@ import React from 'react'; import EmptyState from 'src/assets/icons/empty-state-cloud.svg'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignNewRoleDrawer } from '../../Users/UserRoles/AssignNewRoleDrawer'; interface Props { @@ -13,6 +14,7 @@ interface Props { export const NoAssignedRoles = (props: Props) => { const { text, hasAssignNewRoleDrawer } = props; const theme = useTheme(); + const { data: permissions } = usePermissions('account', ['is_account_admin']); const [isAssignNewRoleDrawerOpen, setIsAssignNewRoleDrawerOpen] = React.useState(false); @@ -42,7 +44,13 @@ export const NoAssignedRoles = (props: Props) => { {hasAssignNewRoleDrawer && ( diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx index 5126852229f..cc1874b1778 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx @@ -26,7 +26,9 @@ describe('DeleteUserPanel', () => { username: 'current_user', }); - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme( + + ); const deleteButton = getByTestId('button'); expect(deleteButton).toBeDisabled(); @@ -42,7 +44,9 @@ describe('DeleteUserPanel', () => { username: 'current_user', }); - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme( + + ); const deleteButton = getByTestId('button'); expect(deleteButton).toBeDisabled(); @@ -58,7 +62,9 @@ describe('DeleteUserPanel', () => { username: 'user', }); - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme( + + ); const deleteButton = getByTestId('button'); expect(deleteButton).toBeEnabled(); @@ -75,7 +81,7 @@ describe('DeleteUserPanel', () => { }); const { getByTestId, getByText } = renderWithTheme( - + ); const deleteButton = getByTestId('button'); @@ -85,4 +91,17 @@ describe('DeleteUserPanel', () => { getByText('The user will be deleted permanently.') ).toBeInTheDocument(); }); + + it('disables the delete button when the user does not have delete_user permission', async () => { + const user = accountUserFactory.build({ + username: 'my-linode-username', + }); + + const { getByTestId } = renderWithTheme( + + ); + + const deleteButton = getByTestId('button'); + expect(deleteButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx index 3f0adbee15d..cb5513ee45f 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx @@ -10,10 +10,11 @@ import { UserDeleteConfirmation } from './UserDeleteConfirmation'; import type { User } from '@linode/api-v4'; interface Props { + canDeleteUser: boolean; user: User; } -export const DeleteUserPanel = ({ user }: Props) => { +export const DeleteUserPanel = ({ canDeleteUser, user }: Props) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); @@ -35,9 +36,17 @@ export const DeleteUserPanel = ({ user }: Props) => { diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx index 18250bc1c34..6b7ed5453f9 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx @@ -25,7 +25,9 @@ describe('UserEmailPanel', () => { it("initializes the form with the user's email", async () => { const user = accountUserFactory.build(); - const { getByLabelText } = renderWithTheme(); + const { getByLabelText } = renderWithTheme( + + ); const emailTextField = getByLabelText('Email'); @@ -43,7 +45,7 @@ describe('UserEmailPanel', () => { ); const { findByLabelText, getByLabelText, getByText } = renderWithTheme( - + ); const warning = await findByLabelText( @@ -68,7 +70,7 @@ describe('UserEmailPanel', () => { }); const { getByLabelText, getByText } = renderWithTheme( - + ); const warning = getByLabelText('This field can’t be modified.'); @@ -92,7 +94,7 @@ describe('UserEmailPanel', () => { username: 'user-1', }); - renderWithTheme(); + renderWithTheme(); const emailInput = screen.getByLabelText('Email'); @@ -105,4 +107,19 @@ describe('UserEmailPanel', () => { const errorText = screen.getByText(/invalid email address/i); expect(errorText).toBeInTheDocument(); }); + + it('disables the save button when the user does not have update_user permission', async () => { + const user = accountUserFactory.build({ + email: 'my-linode-email', + }); + + const { getByRole, findByDisplayValue } = renderWithTheme( + + ); + + await findByDisplayValue(user.email); + + const saveButton = getByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index 860bb8a5e7c..fe6c003ee84 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -9,10 +9,11 @@ import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import type { User } from '@linode/api-v4'; interface Props { + canUpdateUser: boolean; user: User; } -export const UserEmailPanel = ({ user }: Props) => { +export const UserEmailPanel = ({ canUpdateUser, user }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); @@ -73,9 +74,14 @@ export const UserEmailPanel = ({ user }: Props) => { /> - + ); }); diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx index 38c168c5591..0a5614a4fc2 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx @@ -3,9 +3,9 @@ import Grid from '@mui/material/Grid'; import * as React from 'react'; import EnabledIcon from 'src/assets/icons/checkmark-enabled.svg'; +import AkamaiWaveOnlyIcon from 'src/assets/icons/providers/akamai-logo-rgb-waveOnly.svg'; import GitHubIcon from 'src/assets/icons/providers/github-logo.svg'; import GoogleIcon from 'src/assets/icons/providers/google-logo.svg'; -import AkamaiWaveIcon from 'src/assets/logo/akamai-wave.svg'; import { Link } from 'src/components/Link'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { useFlags } from 'src/hooks/useFlags'; @@ -21,13 +21,13 @@ interface Props { const icons: Record = { github: GitHubIcon, google: GoogleIcon, - password: AkamaiWaveIcon, + password: AkamaiWaveOnlyIcon, }; const linode = { displayName: 'Cloud Manager', href: '', - icon: AkamaiWaveIcon, + icon: AkamaiWaveOnlyIcon, name: 'password' as TPAProvider, }; diff --git a/packages/manager/src/routes/auth/index.ts b/packages/manager/src/routes/auth/index.ts index fde7eff1a12..8b91f930a0d 100644 --- a/packages/manager/src/routes/auth/index.ts +++ b/packages/manager/src/routes/auth/index.ts @@ -1,4 +1,4 @@ -import { createRoute, redirect } from '@tanstack/react-router'; +import { createRoute } from '@tanstack/react-router'; import { CancelLanding } from 'src/features/CancelLanding/CancelLanding'; import { LoginAsCustomerCallback } from 'src/OAuth/LoginAsCustomerCallback'; @@ -7,22 +7,15 @@ import { OAuthCallback } from 'src/OAuth/OAuthCallback'; import { rootRoute } from '../root'; +interface CancelLandingSearch { + survey_link?: string; +} + const cancelLandingRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cancel', component: CancelLanding, - onError() { - throw redirect({ to: '/' }); - }, - validateSearch(search) { - if (!search.survey_link) { - throw new Error('No survey in search params!'); - } - if (typeof search.survey_link !== 'string') { - throw new Error('Expected survey_link to be a string but it was not...'); - } - return { survey_link: search.survey_link }; - }, + validateSearch: (search: CancelLandingSearch) => search, }); const logoutRoute = createRoute({ diff --git a/packages/search/README.md b/packages/search/README.md index 0666ef7c50f..bed7cb4d000 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -2,7 +2,7 @@ Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). -The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Cloud Manager. +The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. ## Example diff --git a/packages/shared/README.md b/packages/shared/README.md index 16ebac5c8c2..33cd2c58897 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -1,6 +1,6 @@ # Shared Feature Component Library -`@linode/shared` contains definitions for React-based feature components and hooks that are used frequently across Akamai Cloud Manager. +`@linode/shared` contains definitions for React-based feature components and hooks that are used frequently across Akamai Connected Cloud Manager. In contrast to the other libraries, [`@linode/ui`](../ui/) and [`@linode/utilities`](../utilities/) in this repository, components and hooks in this package make use of [`@linode/api-v4`](../api-v4/), [`@linode/queries`](../queries/) and other dependencies to implement common, opinionated and complex components to enable a seamless experience for users as they navigate between features of the app. From 9b78375d64a2acb3f678198aa806130e6449b66d Mon Sep 17 00:00:00 2001 From: Bill Coloe Date: Wed, 20 Aug 2025 21:36:50 -0500 Subject: [PATCH 81/88] Update changelog with manual changes --- packages/manager/CHANGELOG.md | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 5961bbf3260..2cbb328ecbf 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -6,7 +6,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-08-26] - v1.149.0 - ### Added: - Copyable Node Pool ID ([#12619](https://github.com/linode/manager/pull/12619)) @@ -20,15 +19,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Node Pool headers from `h2` to `h3` ([#12619](https://github.com/linode/manager/pull/12619)) - ACLP: all the instances of service type property has `CloudPulseServiceType` type ([#12646](https://github.com/linode/manager/pull/12646)) - IAM permissions for billing payment methods ([#12654](https://github.com/linode/manager/pull/12654)) -- IAM RBAC block non-beta route access ([#12656](https://github.com/linode/manager/pull/12656)) +- IAM RBAC block non-beta route access ([#12656](https://github.com/linode/manager/pull/12656)) - IAM RBAC: add a permission check in Account Service Transfers ([#12658](https://github.com/linode/manager/pull/12658)) - IAM RBAC permissions for Billing Activity ([#12660](https://github.com/linode/manager/pull/12660)) -- IAM RBAC: add the missing permission checks for creating a disk in the drawer ([#12667](https://github.com/linode/manager/pull/12667)) +- IAM RBAC: add the missing permission checks for creating a disk in the drawer ([#12667](https://github.com/linode/manager/pull/12667)) - Enable action buttons for VPCs autogenerated for LKE-E ([#12675](https://github.com/linode/manager/pull/12675)) - Update logic in metrics filters to use the resources from `useResources` useQuery cache in CloudPulse metrics ([#12678](https://github.com/linode/manager/pull/12678)) -- IAM RBAC: fix permission check for rebuilding and resizing linode ([#12680](https://github.com/linode/manager/pull/12680)) +- IAM RBAC: fix permission check for rebuilding and resizing linode ([#12680](https://github.com/linode/manager/pull/12680)) - Enable view all payments query based on new permissions ([#12682](https://github.com/linode/manager/pull/12682)) -- Update app with latest Akamai branding and logos ([#12694](https://github.com/linode/manager/pull/12694)) - Consolidate DimensionFilterValue logic, utils, schemas & tests; added configMap to drive use-cases ([#12697](https://github.com/linode/manager/pull/12697)) - IAM RBAC: add the missing permission checks for Profile OAuth Apps ([#12698](https://github.com/linode/manager/pull/12698)) - ACLP-Alerting: Conditionally set the query data on successful edit alert operation ([#12699](https://github.com/linode/manager/pull/12699)) @@ -90,7 +88,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Show legacy 'Save Alerts' confirmation modal only if user has already opted into Beta Alerts mode ([#12683](https://github.com/linode/manager/pull/12683)) - Update `smoke-linode-landing-table.spec.ts` to account for removal of `/dashboard` ([#12690](https://github.com/linode/manager/pull/12690)) - Fix `qemu-reboot-upgrade-notice.spec.ts` test failure due to incorrect assertion ([#12691](https://github.com/linode/manager/pull/12691)) -- Add `lke-enterprise-read` and `lke-standard-read` Cypress specs; test LKE-E VPC coverage ([#12700](https://github.com/linode/manager/pull/12700)) +- Add `lke-enterprise-read` and `lke-standard-read` Cypress specs; test LKE-E VPC coverage ([#12700](https://github.com/linode/manager/pull/12700)) - Fix failing test in linode-storage.spec.ts ([#12705](https://github.com/linode/manager/pull/12705)) - Add tests on confirm dialog in linode details page ([#12707](https://github.com/linode/manager/pull/12707)) - Add `lke-enterprise-create` Cypress spec to test LKE-E Phase 2 (VPC + IP Stack) coverage ([#12709](https://github.com/linode/manager/pull/12709)) @@ -153,7 +151,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Premature validation of Linode Alert numeric input ([#12626](https://github.com/linode/manager/pull/12626)) - DBaaS Create and Resize node selector options for premium plans and region disabling behavior and handling not being applied in Resize ([#12634](https://github.com/linode/manager/pull/12634)) - ACLP: `loading` screen on auto refetch in edit alert page ([#12636](https://github.com/linode/manager/pull/12636)) -- ACLP: not display `/s` with *PS units on initial widget loading ([#12647](https://github.com/linode/manager/pull/12647)) +- ACLP: not display `/s` with \*PS units on initial widget loading ([#12647](https://github.com/linode/manager/pull/12647)) ### Tech Stories: @@ -161,12 +159,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add MSW Crud support for Linode Config profiles and prevent deletion of vpcs/subnets with resources ([#12574](https://github.com/linode/manager/pull/12574)) - Revise PR template to recommend video previews ([#12608](https://github.com/linode/manager/pull/12608)) - Update PR template to hide a section; add a Scope subsection for confirmation of customer-facing changes ([#12609](https://github.com/linode/manager/pull/12609)) -- ACLP: `filterRegionByServiceType` method to alerts/utils/utils.ts, remove `supportedRegionIds` property from `CloudPulseResourceTypeMapFlag` feature flag ([#12573](https://github.com/linode/manager/pull/12573)) +- ACLP: `filterRegionByServiceType` method to alerts/utils/utils.ts, remove `supportedRegionIds` property from `CloudPulseResourceTypeMapFlag` feature flag ([#12573](https://github.com/linode/manager/pull/12573)) ### Tests: - Add Cypress tests for ACLP alerts in Linode create flow ([#12540](https://github.com/linode/manager/pull/12540)) -- Add Cypress tests for QEMU Upgrade Notice ([#12564](https://github.com/linode/manager/pull/12564)) +- Add Cypress tests for QEMU Upgrade Notice ([#12564](https://github.com/linode/manager/pull/12564)) - Add Cypress verification tests for CloudPulse NodeBalancer widget ([#12568](https://github.com/linode/manager/pull/12568)) - Improve stability of Linode create password field tests ([#12576](https://github.com/linode/manager/pull/12576)) - Mock LKE versions in `lke-create.spec.ts` to fix test failure due to LKE version 1.31 being deprecated ([#12597](https://github.com/linode/manager/pull/12597)) @@ -186,14 +184,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - IAM RBAC: add a permission check in Account Settings Tab ([#12630](https://github.com/linode/manager/pull/12630)) - Add subnet IPv6 to VPC create page ([#12563](https://github.com/linode/manager/pull/12563)) - Add/update inline docs for ACLP Alerts logic ([#12578](https://github.com/linode/manager/pull/12578)) -- ACLP: add checkbox functionality in `AlertRegions`. ([#12582](https://github.com/linode/manager/pull/12582)) +- ACLP: add checkbox functionality in `AlertRegions`. ([#12582](https://github.com/linode/manager/pull/12582)) - Add Linode Interface support for Linode CLI codesnippets tool ([#12591](https://github.com/linode/manager/pull/12591)) - Add IP Version (IPv4/IPv6) support to LKE-E cluster create flow ([#12594](https://github.com/linode/manager/pull/12594)) - Add VPC IPv4 and IPv6 columns to node pools table on LKE-E cluster details page ([#12600](https://github.com/linode/manager/pull/12600)) - Add VPC IPv6 address in Linode Detail > Summary panel ([#12610](https://github.com/linode/manager/pull/12610)) - Update the usePermissions hook to return consistent with the other queries ([#12617](https://github.com/linode/manager/pull/12617)) - Integrate RBAC permission checks in edit billing info ([#12618](https://github.com/linode/manager/pull/12618)) -- ACLP-Alerting: change the `aclpAlerting` to include alert and metric limits and other relevant changes ([#12624](https://github.com/linode/manager/pull/12624)) +- ACLP-Alerting: change the `aclpAlerting` to include alert and metric limits and other relevant changes ([#12624](https://github.com/linode/manager/pull/12624)) - ACLP-Alerting: add custom config_id validation, dynamic schema resolver, helperText map, TextField logic, and mock API for nodebalancer metrics added ([#12629](https://github.com/linode/manager/pull/12629)) - Update VM Host Maintenance GPU Notice Text ([#12632](https://github.com/linode/manager/pull/12632)) - Improve maintenance banner datetime display and formatting ([#12663](https://github.com/linode/manager/pull/12663)) @@ -258,7 +256,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add save legacy alerts confirmation modal ([#12516](https://github.com/linode/manager/pull/12516)) - Implement the new RBAC permission hook in Linode Create flow ([#12522](https://github.com/linode/manager/pull/12522)) - Add streams list for datastream page and GET, POST mock handlers for streams requests ([#12524](https://github.com/linode/manager/pull/12524)) -- IAM RBAC: Integrate a new hook to fetch permissions for a list of entities ([#12529](https://github.com/linode/manager/pull/12529)) +- IAM RBAC: Integrate a new hook to fetch permissions for a list of entities ([#12529](https://github.com/linode/manager/pull/12529)) - Implement the new RBAC permission hook in Firewalls Rules flow ([#12534](https://github.com/linode/manager/pull/12534)) - IAM RBAC permission hook: update checks for sub-entities in Linodes Storage, Configuration, and Settings tabs ([#12535](https://github.com/linode/manager/pull/12535)) - IAM RBAC: fix error message and styles issues ([#12542](https://github.com/linode/manager/pull/12542)) @@ -270,7 +268,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Show GPU warning notice conditionally based on policy type - display for "migrate" policy but hide for "power-off-on" policy ([#12512](https://github.com/linode/manager/pull/12512)) - IAM RBAC: Implement the new RBAC permission hook in Firewall Linodes tab ([#12500](https://github.com/linode/manager/pull/12500)) - ## [2025-07-21] - v1.146.2 ### Fixed: @@ -279,14 +276,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-07-16] - v1.146.1 - ### Fixed: - IAM RBAC: Fix a permission check for notification banner in Linode details component ([#12525](https://github.com/linode/manager/pull/12525)) ## [2025-07-15] - v1.146.0 - ### Added: - Unsaved Changes modal for Legacy Alerts on Linode Details page ([#12385](https://github.com/linode/manager/pull/12385)) @@ -367,7 +362,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-07-01] - v1.145.0 - ### Changed: - Kubernetes cluster details to show restricted access warnings and disabled actions ([#12360](https://github.com/linode/manager/pull/12360)) @@ -430,7 +424,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-06-17] - v1.144.0 - ### Added: - Subheading Support to MUI Accordion Component ([#12286](https://github.com/linode/manager/pull/12286)) @@ -440,7 +433,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed: - Hide `gb-lon`, `au-mel`, `sg-sin-2`, and `jp-tyo-3` for Image upload and replication ([#12257](https://github.com/linode/manager/pull/12257)) -- Replace node pool autoscaler dialog with drawer ([#12325](https://github.com/linode/manager/pull/12325)) +- Replace node pool autoscaler dialog with drawer ([#12325](https://github.com/linode/manager/pull/12325)) - Disable "Reuse user data previously provided" checkbox in Linode rebuild dialog if the Linode does not have existing user data ([#12352](https://github.com/linode/manager/pull/12352)) - Expand "Add User Data" section by default in the Linode rebuild dialog if the Linode has existing user data ([#12352](https://github.com/linode/manager/pull/12352)) @@ -545,7 +538,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - ACL Revision ID being set to empty string on LKE clusters ([#12210](https://github.com/linode/manager/pull/12210)) - NodeBalancer label and connection throttle not updating until page refresh ([#12217](https://github.com/linode/manager/pull/12217)) - Inconsistent restricted user notices on landing pages ([#12223](https://github.com/linode/manager/pull/12223)) -- `linode_resize` started event referencing the wrong linode ([#12252](https://github.com/linode/manager/pull/12252)) +- `linode_resize` started event referencing the wrong linode ([#12252](https://github.com/linode/manager/pull/12252)) - Image Select overflows off screen on mobile viewports ([#12269](https://github.com/linode/manager/pull/12269)) - LinodeCreateError notice not spanning full width ([#12276](https://github.com/linode/manager/pull/12276)) - Manual clearing of default Alerts fields now resets values to zero, preventing empty string/NaN and ensuring consistency with toggle off state ([#12215](https://github.com/linode/manager/pull/12215)) @@ -555,7 +548,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Reduce api requests made for every keystroke in Volume attach drawer ([#12052](https://github.com/linode/manager/pull/12052)) - Add support for NB-VPC related /v4/vpcs changes in CRUD mocks ([#12201](https://github.com/linode/manager/pull/12201)) - Move images related queries and dependencies to shared `queries` package ([#12205](https://github.com/linode/manager/pull/12205)) -- Move domain related queries and dependencies to shared `queries` package ([#12204](https://github.com/linode/manager/pull/12204)) +- Move domain related queries and dependencies to shared `queries` package ([#12204](https://github.com/linode/manager/pull/12204)) - Move quotas related queries and dependencies to shared `queries` package ([#12221](https://github.com/linode/manager/pull/12221)) - Add MSW presets for Events, Maintenance, and Notifications ([#12212](https://github.com/linode/manager/pull/12212)) - Upgrade @sentry/react to v9 ([#12219](https://github.com/linode/manager/pull/12219)) @@ -568,7 +561,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fix erroneous Sentry error in useAdobeAnalytics hook ([#12265](https://github.com/linode/manager/pull/12265)) - Re-add `eslint-plugin-react-refresh` eslint plugin ([#12267](https://github.com/linode/manager/pull/12267)) - Switch to self-hosting the Pendo agent with Adobe Launch ([#12203](https://github.com/linode/manager/pull/12203)) -- Fix bug in loadScript function not resolving promise if script already existed ([#12203](https://github.com/linode/manager/pull/12203)) +- Fix bug in loadScript function not resolving promise if script already existed ([#12203](https://github.com/linode/manager/pull/12203)) - Make quota_id a string ([#12272](https://github.com/linode/manager/pull/12272)) ### Tests: From 7c9344de7f9d3f4ba5dd2a76082a535d4673d1a8 Mon Sep 17 00:00:00 2001 From: Bill Coloe Date: Wed, 20 Aug 2025 21:38:54 -0500 Subject: [PATCH 82/88] Update changelog with manual changes --- packages/manager/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 2cbb328ecbf..2f0957e7a2b 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -83,7 +83,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Tests: -- M3-10293 Allow action menu items to be selected in 'within' blocks in Cypress ([#12625](https://github.com/linode/manager/pull/12625)) +- Allow action menu items to be selected in 'within' blocks in Cypress ([#12625](https://github.com/linode/manager/pull/12625)) - Remove clean up from longview.spec.ts ([#12651](https://github.com/linode/manager/pull/12651)) - Show legacy 'Save Alerts' confirmation modal only if user has already opted into Beta Alerts mode ([#12683](https://github.com/linode/manager/pull/12683)) - Update `smoke-linode-landing-table.spec.ts` to account for removal of `/dashboard` ([#12690](https://github.com/linode/manager/pull/12690)) From d7f910760e970eb2edaecf4bba9ae44529a376c1 Mon Sep 17 00:00:00 2001 From: Bill Coloe Date: Thu, 21 Aug 2025 09:22:19 -0500 Subject: [PATCH 83/88] Update changelog with manual changes --- packages/validation/CHANGELOG.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 1e89033f648..b0d3e30ad83 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,7 +1,6 @@ ## [2025-08-26] - v0.73.0 - -### : +### Changed: - Update `alertsSchema` to require numeric fields when empty and change the validation messages ([#12703](https://github.com/linode/manager/pull/12703)) @@ -11,7 +10,6 @@ ## [2025-08-12] - v0.72.0 - ### Changed: - Update `createVPCSchema` to support IPv6 subnets ([#12563](https://github.com/linode/manager/pull/12563)) @@ -31,7 +29,6 @@ ## [2025-07-29] - v0.71.0 - ### Changed: - Update `VPCIPv6Schema` and `VPCIPv6SubnetSchema` ([#12309](https://github.com/linode/manager/pull/12309)) @@ -43,7 +40,6 @@ ## [2025-07-15] - v0.70.0 - ### Upcoming Features: - Update validation schemas for the changes in endpoints /v4/nodebalancers & /v4/nodebalancers/configs/{configId}/nodes for NB Dual Stack Support ([#12421](https://github.com/linode/manager/pull/12421)) @@ -51,7 +47,6 @@ ## [2025-07-01] - v0.69.0 - ### Added: - IAM RBAC: email validation ([#12395](https://github.com/linode/manager/pull/12395)) @@ -68,7 +63,6 @@ ## [2025-06-17] - v0.68.0 - ### Added: - Validation schema for database PrivateNetwork property via updatePrivateNetworkSchema ([#12354](https://github.com/linode/manager/pull/12354)) @@ -81,7 +75,7 @@ ### Added: -- Method to retrieve dynamic validation for Create database schema ([#12281](https://github.com/linode/manager/pull/12281)) +- Method to retrieve dynamic validation for Create database schema ([#12281](https://github.com/linode/manager/pull/12281)) ### Fixed: From 4f3b05b12180f780521292577e819b6bdb037131 Mon Sep 17 00:00:00 2001 From: Bill Coloe Date: Thu, 21 Aug 2025 09:25:04 -0500 Subject: [PATCH 84/88] Revert Shared and Utilities version bumps since no changes --- packages/shared/package.json | 2 +- packages/utilities/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 1f2a1290c64..efabcbf6772 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@linode/shared", - "version": "0.8.0", + "version": "0.7.0", "description": "Linode shared feature component library", "main": "src/index.ts", "module": "src/index.ts", diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 7f6d6751fde..a4eafb72b00 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -1,6 +1,6 @@ { "name": "@linode/utilities", - "version": "0.8.0", + "version": "0.7.0", "description": "Linode Utility functions library", "main": "src/index.ts", "module": "src/index.ts", From 099d492f711dca6e19717b7af5acacfd9fe6d5a8 Mon Sep 17 00:00:00 2001 From: Bill Coloe Date: Thu, 21 Aug 2025 09:52:15 -0500 Subject: [PATCH 85/88] Update changelog with manual changes --- packages/manager/CHANGELOG.md | 11 ++++------- packages/queries/CHANGELOG.md | 8 +------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 2f0957e7a2b..a7ca5ee9fa9 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -41,7 +41,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Fixed: - Wrong stackScriptID used when clicking Deploy New Linode during an active search ([#12623](https://github.com/linode/manager/pull/12623)) -- Fix ImageSelect onChange rendering bug as well as other console errors ([#12638](https://github.com/linode/manager/pull/12638)) +- ImageSelect onChange rendering bug as well as other console errors ([#12638](https://github.com/linode/manager/pull/12638)) - Console error from `hasBorder` prop in `StyledFlag` component ([#12657](https://github.com/linode/manager/pull/12657)) - IAM RBAC: Accidental row expansion in Roles table when selecting roles via checkbox ([#12659](https://github.com/linode/manager/pull/12659)) - Correct maintenance status from `in-progress` to `in_progress` for consistency. Update components to handle nullable time fields with proper fallbacks ([#12665](https://github.com/linode/manager/pull/12665)) @@ -70,10 +70,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - DBaaS drawers and dialogs in resize, configuration, and settings not resetting errors and validation state on close ([#12733](https://github.com/linode/manager/pull/12733)) - Navigating to "/account" redirects to "/billing" when IAM navigation is enabled ([#12735](https://github.com/linode/manager/pull/12735)) - Navigating to "account/users" shows tabs for administration pages when IAM nav is enabled ([#12735](https://github.com/linode/manager/pull/12735)) +- IAM RBAC: User Detail UI fix, add missing tooltips to Linode Storage Action Menu ([#12722](https://github.com/linode/manager/pull/12722)) ### Tech Stories: -- Routing: remove `react-router-dom` and fully switch to tanstack router ([#12602](https://github.com/linode/manager/pull/12602)) +- Routing: remove `react-router-dom` and fully switch to TanStack router ([#12602](https://github.com/linode/manager/pull/12602)) - Clean up types for `LinodeCreateFormValues` interface ([#12612](https://github.com/linode/manager/pull/12612)) - Refactor single disk encryption status component into two separate components (Node Pool and Linodes) ([#12619](https://github.com/linode/manager/pull/12619)) - Refactor the Add Node Pool drawer to use `react-hook-form` ([#12631](https://github.com/linode/manager/pull/12631)) @@ -103,16 +104,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Redirect /account/billing → /billing when feature flag is enabled ([#12670](https://github.com/linode/manager/pull/12670)) - CloudPulse: Add new flag - 'aclpServices', filter services at `CloudPulseDashboardSelect.tsx`, `AlertListing.tsx`, `ServiceTypeSelect.tsx` ([#12671](https://github.com/linode/manager/pull/12671)) - CloudPulse: Add dimension filter value label transformation config at `DimensionTransform.ts` and update labels in metrics and alerts ([#12676](https://github.com/linode/manager/pull/12676)) -- Add search and select inputs for Streams table. Add search input for Desitnations table ([#12679](https://github.com/linode/manager/pull/12679)) +- Add search and select inputs for Streams table. Add search input for Destinations table ([#12679](https://github.com/linode/manager/pull/12679)) - Temporarily fix Linode Interface `firewall_device_add` event message ([#12685](https://github.com/linode/manager/pull/12685)) - Restrict access to the Identity & Access link from the Primary Nav for non-beta users ([#12692](https://github.com/linode/manager/pull/12692)) - Redirect Account tabs to flat routes `/login-history`, `/settings`, `/maintenance`, and `/service-transfers` ([#12702](https://github.com/linode/manager/pull/12702)) - CloudPulse: Add linode region filter in `filterconfig.ts`, refactor `CloudPulseRegionSelect.tsx`, add `useFetchOptions.ts` hook ([#12704](https://github.com/linode/manager/pull/12704)) - Add node pool firewall selection to LKE-E create flow ([#12712](https://github.com/linode/manager/pull/12712)) -- IAM RBAC: User Detail UI fix, add missing tooltips to Linode Storage Action Menu ([#12722](https://github.com/linode/manager/pull/12722)) - -### Upcoming: - - CloudPulse metric label support for Linode Interface firewall entities ([#12716](https://github.com/linode/manager/pull/12716)) ## [2025-08-12] - v1.148.0 diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index 61ec4465c86..e4ecd0a8703 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,6 +1,5 @@ ## [2025-08-26] - v0.12.0 - ### Added: - Implemented `enabled` parameters for payments & invoices queries ([#12660](https://github.com/linode/manager/pull/12660)) @@ -8,7 +7,7 @@ ### Changed: -- Replace deprecated quieries from /account/entity-transfers to /account/service-transfers ([#12658](https://github.com/linode/manager/pull/12658)) +- Replace deprecated queries from /account/entity-transfers to /account/service-transfers ([#12658](https://github.com/linode/manager/pull/12658)) ### Upcoming Features: @@ -16,14 +15,12 @@ ## [2025-08-12] - v0.11.0 - ### Upcoming Features: - Add GET queries for destinations endpoints ([#12559](https://github.com/linode/manager/pull/12559)) ## [2025-07-29] - v0.10.0 - ### Changed: - Fetch all nodebalancers query to accept Params and Filter ([#12510](https://github.com/linode/manager/pull/12510)) @@ -34,7 +31,6 @@ ## [2025-07-15] - v0.9.0 - ### Added: - `entitytransfers/` directory and migrated relevant query keys and hooks ([#12406](https://github.com/linode/manager/pull/12406)) @@ -43,7 +39,6 @@ ## [2025-07-01] - v0.8.0 - ### Added: - Created `iam/` directory and migrated relevant query keys and hooks ([#12370](https://github.com/linode/manager/pull/12370)) @@ -56,7 +51,6 @@ ## [2025-06-17] - v0.7.0 - ### Added: - Created `types/` directory and migrated relevant query keys and hooks ([#12330](https://github.com/linode/manager/pull/12330)) From 07ede02995e67fd0b1f7df846c0b003db4e5c135 Mon Sep 17 00:00:00 2001 From: Bill Coloe Date: Thu, 21 Aug 2025 09:54:57 -0500 Subject: [PATCH 86/88] Update changelog with manual changes --- packages/queries/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index e4ecd0a8703..f636aee44ed 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -11,7 +11,7 @@ ### Upcoming Features: -- Add queries for destinations enpoints (paginated GET, POST) ([#12627](https://github.com/linode/manager/pull/12627)) +- Add queries for destinations endpoints (paginated GET, POST) ([#12627](https://github.com/linode/manager/pull/12627)) ## [2025-08-12] - v0.11.0 From 0b6a4ab88c123da012e9c6ccbde55fa5c69fc563 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:48:04 +0200 Subject: [PATCH 87/88] staging hotfix: [UIE-9137] - AssignSelectedRolesDrawer user selection state issue (#12748) * fix AssignSelectedRolesDrawer user state selection * cleanup --- .../RolesTable/AssignSelectedRolesDrawer.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 406e9a48d9b..76491bb22af 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -45,11 +45,27 @@ export const AssignSelectedRolesDrawer = ({ }: Props) => { const theme = useTheme(); - const [usernameInput, setUsernameInput] = useState(''); - const debouncedUsernameInput = useDebouncedValue(usernameInput); + const values = { + roles: selectedRoles.map((r) => ({ + role: { + access: r.access, + entity_type: r.entity_type, + label: r.name, + value: r.name, + }, + entities: null, + })), + username: null, + }; - const [username, setUsername] = useState(''); + const form = useForm({ + defaultValues: values, + values, + }); + const [usernameInput, setUsernameInput] = useState(''); + const debouncedUsernameInput = useDebouncedValue(usernameInput); + const username = form.watch('username'); const userSearchFilter = debouncedUsernameInput ? { ['+or']: [ @@ -79,30 +95,12 @@ export const AssignSelectedRolesDrawer = ({ })); }, [accountUsers]); + const { handleSubmit, reset, control, formState, setError } = form; + const { data: accountRoles } = useAccountRoles(); const { data: existingRoles } = useUserRoles(username ?? ''); - const values = { - roles: selectedRoles.map((r) => ({ - role: { - access: r.access, - entity_type: r.entity_type, - label: r.name, - value: r.name, - }, - entities: null, - })), - username: null, - }; - - const form = useForm({ - defaultValues: values, - values, - }); - - const { handleSubmit, reset, control, formState, setError } = form; - const [areDetailsHidden, setAreDetailsHidden] = useState(false); const { mutateAsync: updateUserRoles, isPending } = useUserRolesMutation( @@ -139,8 +137,7 @@ export const AssignSelectedRolesDrawer = ({ const handleClose = () => { reset(); - setUsername(null); - setUsernameInput(''); + onClose(); }; @@ -193,10 +190,13 @@ export const AssignSelectedRolesDrawer = ({ loading={isLoadingAccountUsers || isFetchingAccountUsers} noMarginTop onChange={(_, option) => { - setUsername(option?.label || null); - onChange(username); + onChange(option?.label || null); + // Form now has the username, so we can clear the input + // This will prevent refetching all users with an existing user as a filter + setUsernameInput(''); }} onInputChange={(_, value) => { + // We set an input state separately for when we query the API setUsernameInput(value); }} options={getUserOptions() || []} From 7079401bfb5886304c062f4e56e0f2e22b695f59 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 25 Aug 2025 20:20:41 +0200 Subject: [PATCH 88/88] hotfix: [UIE-9134] Increase page size for RBAC IAM entities call (#12762) * fix: [UIE-9134] Increase page size for entities call as temporary workaround * changesets * changelog cleanup * changelog cleanup --------- Co-authored-by: Richard O'Donnell --- packages/api-v4/CHANGELOG.md | 1 + packages/api-v4/src/entities/entities.ts | 7 ++++--- packages/manager/src/queries/entities/queries.ts | 3 ++- packages/queries/CHANGELOG.md | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 5a73a8dbbcc..87635e8b4fa 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed: - Replace deprecated apis from /account/entity-transfers to /account/service-transfers ([#12658](https://github.com/linode/manager/pull/12658)) +- IAM RBAC Update `getAccountEntities` API call with params ([#12762](https://github.com/linode/manager/pull/12762)) ### Removed: diff --git a/packages/api-v4/src/entities/entities.ts b/packages/api-v4/src/entities/entities.ts index 8c2471fe30c..794a13f80f3 100644 --- a/packages/api-v4/src/entities/entities.ts +++ b/packages/api-v4/src/entities/entities.ts @@ -1,8 +1,8 @@ import { BETA_API_ROOT } from '../constants'; -import Request, { setMethod, setURL } from '../request'; +import Request, { setMethod, setParams, setURL } from '../request'; import type { AccountEntity } from './types'; -import type { ResourcePage } from 'src/types'; +import type { Params, ResourcePage } from 'src/types'; /** * getAccountEntities @@ -10,9 +10,10 @@ import type { ResourcePage } from 'src/types'; * Return all entities for account. * */ -export const getAccountEntities = () => { +export const getAccountEntities = (params?: Params) => { return Request>( setURL(`${BETA_API_ROOT}/entities`), setMethod('GET'), + setParams(params), ); }; diff --git a/packages/manager/src/queries/entities/queries.ts b/packages/manager/src/queries/entities/queries.ts index 871e963045b..e5d237dbe58 100644 --- a/packages/manager/src/queries/entities/queries.ts +++ b/packages/manager/src/queries/entities/queries.ts @@ -3,7 +3,8 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; export const entitiesQueries = createQueryKeys('entities', { entities: { - queryFn: getAccountEntities, + queryFn: ({ pageParam }) => + getAccountEntities({ page: pageParam as number, page_size: 500 }), queryKey: null, }, }); diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index f636aee44ed..e2246edc104 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed: - Replace deprecated queries from /account/entity-transfers to /account/service-transfers ([#12658](https://github.com/linode/manager/pull/12658)) +- IAM RBAC - Increase getAccountEntities page size to 500 ([#12762](https://github.com/linode/manager/pull/12762)) ### Upcoming Features: