diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md index 3cd5f883d7b..ea48182cfe4 100644 --- a/docs/tooling/analytics.md +++ b/docs/tooling/analytics.md @@ -12,19 +12,20 @@ Pendo is configured in [`usePendo.js`](https://github.com/linode/manager/blob/de Important notes: -- Pendo is only loaded if the user has enabled Performance Cookies via OneTrust *and* if a valid `PENDO_API_KEY` is configured as an environment variable. In our development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments. -- We load the Pendo agent from the CDN, rather than [self-hosting](https://support.pendo.io/hc/en-us/articles/360038969692-Self-hosting-the-Pendo-agent), and we have configured a [CNAME](https://support.pendo.io/hc/en-us/articles/360043539891-CNAME-for-Pendo). +- Pendo is only loaded if a valid `PENDO_API_KEY` is configured as an environment variable. In our preview, development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments. +- We [self-host](https://support.pendo.io/hc/en-us/articles/360038969692-Self-hosting-the-Pendo-agent) and load the Pendo agent from Adobe Launch, rather than from the CDN, and we have configured a [CNAME](https://support.pendo.io/hc/en-us/articles/360043539891-CNAME-for-Pendo). +- As configured by Adobe Launch, Pendo will respect OneTrust cookie preferences in development, staging, and production environments and does not check cookie preferences in preview environments. Pendo will not run on localhost:3000 because it needs a Optanon cookie with the linode.com domain for consent. - At initialization, we do string transformation on select URL patterns to **remove sensitive data**. When new URL patterns are added to Cloud Manager, verify that existing transforms remove sensitive data; if not, update the transforms. -- Pendo will respect OneTrust cookie preferences in development, staging, and production environments and does not check cookie preferences in the local environment. - Pendo makes use of the existing `data-testid` properties, used in our automated testing, for tagging elements. They are more persistent and reliable than CSS properties, which are liable to change. ### Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo 1. Set the `REACT_APP_PENDO_API_KEY` environment variable in `.env`. -2. Use the browser tools Network tab, filter requests by "psp.cloud", and check that successful network requests have been made to load Pendo scripts (also visible in the browser tools Sources tab). -3. In the browser console, type `pendo.validateEnvironment()`. -4. You should see command output in the console, and it should include an `accountId` and a `visitorId` that correspond with your APIv4 account `euuid` and profile `uid`, respectively. Each page view change or custom event that fires should be visible as a request in the Network tab. -5. If the console does not output the expected ids and instead outputs something like `Cookies are disabled in Pendo config. Is this expected?` in response to the above command, clear app storage with the browser tools. Once redirected back to Login, update the OneTrust cookie settings to enable cookies via "Manage Preferences" in the banner at the bottom of the screen. Log back into Cloud Manager and Pendo should load. +2. Confirm the Adobe Launch script has loaded. (View it in the browser console Sources tab under the assets.adobedtm.com directory.) +3. Use the browser tools Network tab, filter requests by "psp.cloud", and check that successful network requests have been made to load Pendo scripts (also visible in the browser tools Sources tab). +4. In the browser console, type `pendo.validateEnvironment()`. +5. You should see command output in the console, and it should include an `accountId` and a `visitorId` that correspond with your APIv4 account `euuid` and profile `uid`, respectively. Each page view change or custom event that fires should be visible as a request in the Network tab. +6. If the console does not output the expected ids and instead outputs something like `Cookies are disabled in Pendo config. Is this expected?` in response to the above command, clear app storage with the browser tools. Once redirected back to Login, update the OneTrust cookie settings to enable cookies via "Manage Preferences" in the banner at the bottom of the screen. Log back into Cloud Manager and Pendo should load. ## Adobe Analytics diff --git a/package.json b/package.json index b60be5abe3c..3c6edaaa5ef 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "eslint-plugin-prettier": "~5.2.6", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "0.4.20", "eslint-plugin-sonarjs": "^3.0.2", "eslint-plugin-testing-library": "^7.1.1", "eslint-plugin-xss": "^0.1.12", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 4beb99843f3..f136d5f743b 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,23 @@ +## [2025-06-03] - v0.141.0 + +### Added: + +- Notification type for QEMU maintenance ([#12231](https://github.com/linode/manager/pull/12231)) +- PrivateNetwork type for Use in DBaaS requests ([#12281](https://github.com/linode/manager/pull/12281)) + +### Changed: + +- Make `lke_cluster` and `type` defined in the `NodeBalancer` type ([#12217](https://github.com/linode/manager/pull/12217)) +- Mark `markEventRead` as deprecated ([#12274](https://github.com/linode/manager/pull/12274)) + +### Fixed: + +- Make quota_id a string ([#12272](https://github.com/linode/manager/pull/12272)) + +### Removed: + +- `add_buckets` from `GlobalGrantTypes` ([#12223](https://github.com/linode/manager/pull/12223)) + ## [2025-05-20] - v0.140.0 ### Upcoming Features: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 0a96986b3ae..8f71898954d 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.140.0", + "version": "0.141.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -56,6 +56,7 @@ "lib" ], "devDependencies": { + "@linode/tsconfig": "workspace:*", "axios-mock-adapter": "^1.22.0", "concurrently": "^9.0.1", "tsup": "^8.4.0" diff --git a/packages/api-v4/src/account/events.ts b/packages/api-v4/src/account/events.ts index 1cf4934a3f3..4f8c8d21e4b 100644 --- a/packages/api-v4/src/account/events.ts +++ b/packages/api-v4/src/account/events.ts @@ -33,7 +33,7 @@ export const getEvent = (eventId: number) => /** * markEventSeen * - * Set the "seen" property of an event to true + * Marks all events up to and including the referenced event ID as "seen" * * @param eventId { number } ID of the event to designate as seen */ @@ -50,6 +50,10 @@ export const markEventSeen = (eventId: number) => * * @param eventId { number } ID of the event to designate as read * + * @deprecated As of `5/20/2025`, this endpoint is deprecated. It will be sunset on `6/17/2025`. + * + * If you depend on using `read`, you may be able to use `markEventSeen` and `seen` instead. + * Please note that the `seen` endpoint functions differently and will mark all events up to and including the referenced event ID as "seen" rather than individual events. */ export const markEventRead = (eventId: number) => Request<{}>( diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index e112e3ac1ea..3920a8820e6 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -199,7 +199,6 @@ export interface Grant { } export type GlobalGrantTypes = | 'account_access' - | 'add_buckets' | 'add_databases' | 'add_domains' | 'add_firewalls' @@ -286,6 +285,7 @@ export type NotificationType = | 'payment_due' | 'promotion' | 'reboot_scheduled' + | 'security_reboot_maintenance_scheduled' | 'tax_id_verifying' | 'ticket_abuse' | 'ticket_important' @@ -574,7 +574,7 @@ export interface AccountMaintenance { entity: { id: number; label: string; - type: string; + type: 'linode' | 'volume'; url: string; }; maintenance_policy_set: MaintenancePolicyType; diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 05d0e4076a4..c70d7726d8b 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -127,6 +127,12 @@ export interface DatabaseInstance { export type ClusterSize = 1 | 2 | 3; +export interface PrivateNetwork { + public_access: boolean; + subnet_id: null | number; + vpc_id: null | number; +} + type ReadonlyCount = 0 | 2; /** @deprecated TODO (UIE-8214) remove POST GA */ @@ -139,6 +145,7 @@ export interface CreateDatabasePayload { encrypted?: boolean; engine?: Engine; label: string; + private_network?: null | PrivateNetwork; // TODO (UIE-8831): Remove optional (?) post VPC release, since it will always be in create payload region: string; /** @Deprecated used by rdbms-legacy only */ replication_type?: MySQLReplicationType | PostgresReplicationType; diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index da9f3cb5c6b..a6bc665202a 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -22,7 +22,7 @@ import type { CreateNodeBalancerPayload, NodeBalancer, NodeBalancerStats, - NodebalancerVpcConfig, + NodeBalancerVpcConfig, } from './types'; /** @@ -193,7 +193,7 @@ export const getNodeBalancerVPCConfigsBeta = ( params?: Params, filter?: Filter, ) => - Request>( + Request>( setURL( `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( nodeBalancerId, @@ -214,7 +214,7 @@ export const getNodeBalancerVPCConfigBeta = ( nodeBalancerId: number, nbVpcConfigId: number, ) => - Request( + Request( setURL( `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( nodeBalancerId, diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 22069a32f5f..77d577d33c6 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -42,11 +42,11 @@ export interface NodeBalancer { * If the NB is associated with a cluster (active or deleted), return its info * If the NB is not associated with a cluster, return null */ - lke_cluster?: LKEClusterInfo | null; + lke_cluster: LKEClusterInfo | null; region: string; tags: string[]; transfer: BalancerTransfer; - type?: NodeBalancerType; + type: NodeBalancerType; updated: string; } @@ -134,7 +134,13 @@ export interface NodeBalancerStats { title: string; } -export interface NodebalancerVpcConfig { +export interface NodeBalancerVpcPayload { + ipv4_range?: string; + ipv6_range?: string; + subnet_id: number; +} + +export interface NodeBalancerVpcConfig { id: number; ipv4_range: null | string; ipv6_range: null | string; @@ -247,9 +253,5 @@ export interface CreateNodeBalancerPayload { label?: string; region?: string; tags?: string[]; - vpcs?: { - ipv4_range: string; - ipv6_range?: string; - subnet_id: number; - }[]; + vpcs?: NodeBalancerVpcPayload[]; } diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts index 2f7a7303f2b..25f1ee8f34d 100644 --- a/packages/api-v4/src/quotas/quotas.ts +++ b/packages/api-v4/src/quotas/quotas.ts @@ -45,9 +45,9 @@ export const getQuotas = ( * Returns the usage for a single quota within a particular service specified by `type`. * * @param type { QuotaType } retrieve a quota within this service type. - * @param id { number } the quota ID to look up. + * @param id { string } the quota ID to look up. */ -export const getQuotaUsage = (type: QuotaType, id: number) => +export const getQuotaUsage = (type: QuotaType, id: string) => Request( setURL(`${BETA_API_ROOT}/${type}/quotas/${id}/usage`), setMethod('GET'), diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index 93719ac53c3..f7f9f37d773 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -20,7 +20,7 @@ export interface Quota { /** * A unique identifier for the quota. */ - quota_id: number; + quota_id: string; /** * The account-wide limit for this service, measured in units diff --git a/packages/api-v4/tsconfig.json b/packages/api-v4/tsconfig.json index 58df0f1dff7..22d69fd4250 100644 --- a/packages/api-v4/tsconfig.json +++ b/packages/api-v4/tsconfig.json @@ -1,18 +1,8 @@ { + "extends": ["@linode/tsconfig/package", "@linode/tsconfig/emit-types"], "compilerOptions": { - "target": "esnext", - "module": "esnext", - "emitDeclarationOnly": true, - "declaration": true, "outDir": "./lib", - "esModuleInterop": true, - "moduleResolution": "bundler", - "skipLibCheck": true, - "strict": true, "baseUrl": ".", - "noUnusedLocals": true, - "declarationMap": true, - "incremental": true }, "include": [ "src" diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 01b777aca2c..e9f168eec09 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,78 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-06-03] - v1.143.0 + +### Changed: + +- Remove the `Accordion` wrapper from the default Alerts tab and replace it with `Paper` on the Linode details page ([#12215](https://github.com/linode/manager/pull/12215)) +- Update LKE flows for APL General Availability ([#12268](https://github.com/linode/manager/pull/12268)) +- Copy for premium plan recommendation for LKE ([#12300](https://github.com/linode/manager/pull/12300)) + +### Fixed: + +- Bug where first pageview of landing page was not fired in Adobe Analytics ([#12203](https://github.com/linode/manager/pull/12203)) +- Formatting of the volume status and broken spacing in MultipleIPInput component ([#12207](https://github.com/linode/manager/pull/12207)) +- 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)) +- 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)) + +### Tech Stories: + +- 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 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)) +- Remove `useAccountManagement` hook ([#12223](https://github.com/linode/manager/pull/12223)) +- Remove recompose from Longview ([#12239](https://github.com/linode/manager/pull/12239)) +- Reroute Support & Help features ([#12242](https://github.com/linode/manager/pull/12242)) +- Use `unstable_createBreakpoints` to define our MUI breakpoints ([#12244](https://github.com/linode/manager/pull/12244)) +- Reroute search feature ([#12258](https://github.com/linode/manager/pull/12258)) +- Stop MSW and DevTools from existing in production bundles ([#12263](https://github.com/linode/manager/pull/12263)) +- 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)) +- Make quota_id a string ([#12272](https://github.com/linode/manager/pull/12272)) + +### Tests: + +- Unskip Cypress Firewall end-to-end tests ([#12218](https://github.com/linode/manager/pull/12218)) +- Exclude distributed regions when selecting regions for API operations ([#12226](https://github.com/linode/manager/pull/12226)) +- Add Cypress test for Longview create page for restricted users ([#12230](https://github.com/linode/manager/pull/12230)) +- Add test for firewall create page for restricted users ([#12237](https://github.com/linode/manager/pull/12237)) +- Add VPC tests for restricted user ([#12238](https://github.com/linode/manager/pull/12238)) +- Add Cypress test for Account quotas navigation and permissions ([#12250](https://github.com/linode/manager/pull/12250)) +- Add integration test for Upgrade to new Linode Interface flow ([#12259](https://github.com/linode/manager/pull/12259)) + +### Upcoming Features: + +- DataStream: routes, feature flag, tabs ([#12155](https://github.com/linode/manager/pull/12155)) +- Show VPC details in the Nodebalancer summary page ([#12162](https://github.com/linode/manager/pull/12162)) +- Show Linode Interface firewalls in `LinodeEntityDetail` ([#12176](https://github.com/linode/manager/pull/12176)) +- Add VPC Section in the Nodebalancer create flow ([#12181](https://github.com/linode/manager/pull/12181)) +- IAM RBAC: fix bugs in the assign new roles drawer ([#12227](https://github.com/linode/manager/pull/12227)) +- QEMU reboot notices ([#12231](https://github.com/linode/manager/pull/12231)) +- Add NodeBalancer Table under VPC Subnets Table and rename "Linodes" column to "Resources" ([#12232](https://github.com/linode/manager/pull/12232)) +- IAM RBAC: fix UI issues in Users and Roles tabs, including button styling, layout, and permissions toggle ([#12233](https://github.com/linode/manager/pull/12233)) +- DataStream: add Streams empty state and Create Stream views ([#12235](https://github.com/linode/manager/pull/12235)) +- Add beta ACLP contextual alerts to the Alerts tab on the Linode details page ([#12236](https://github.com/linode/manager/pull/12236)) +- Event message tweaks for Linode Interfaces ([#12243](https://github.com/linode/manager/pull/12243)) +- Fix newest LKE-E kubernetes version not being selected by default in create flow ([#12246](https://github.com/linode/manager/pull/12246)) +- IAM RBAC: Fix bugs for the assigned roles table ([#12249](https://github.com/linode/manager/pull/12249)) +- Update estimated time for LKE-E node pool pending creation message ([#12251](https://github.com/linode/manager/pull/12251)) +- Add VPC column to the Nodebalancer Landing table ([#12256](https://github.com/linode/manager/pull/12256)) +- IAM RBAC: add pagination to the Roles table ([#12264](https://github.com/linode/manager/pull/12264)) +- Disable the Kubernetes Dashboard request for LKE-E clusters ([#12266](https://github.com/linode/manager/pull/12266)) +- Configure Networking section and VPC functionality for DBaaS Create view ([#12281](https://github.com/linode/manager/pull/12281)) + ## [2025-05-20] - v1.142.1 ### Fixed: diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index ab9bbaca527..a4ced12e972 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -2,7 +2,7 @@ * @file Integration tests for Cloud Manager account cancellation flows. */ -import { profileFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { cancellationDataLossWarning, cancellationDialogTitle, @@ -14,7 +14,10 @@ import { mockGetAccount, } from 'support/intercepts/account'; import { mockWebpageUrl } from 'support/intercepts/general'; -import { mockGetProfile } from 'support/intercepts/profile'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomDomainName, @@ -170,14 +173,16 @@ describe('Account cancellation', () => { email: 'mock-user@linode.com', restricted: true, }); + const mockGrants = grantsFactory.build(); mockGetAccount(mockAccount).as('getAccount'); mockGetProfile(mockProfile).as('getProfile'); + mockGetProfileGrants(mockGrants).as('getGrants'); mockCancelAccountError('Unauthorized', 403).as('cancelAccount'); // Navigate to Account Settings page, click "Close Account" button. cy.visitWithLogin('/account/settings'); - cy.wait(['@getAccount', '@getProfile']); + cy.wait(['@getAccount', '@getProfile', '@getGrants']); cy.findByTestId('close-account') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts index 163164c7ee3..c9466939e48 100644 --- a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -2,7 +2,11 @@ * @file Integration tests for Cloud Manager account enable Linode Managed flows. */ -import { linodeFactory, profileFactory } from '@linode/utilities'; +import { + grantsFactory, + linodeFactory, + profileFactory, +} from '@linode/utilities'; import { visitUrlWithManagedDisabled, visitUrlWithManagedEnabled, @@ -17,7 +21,10 @@ import { mockGetAccount, } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import { mockGetProfile } from 'support/intercepts/profile'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; @@ -100,16 +107,18 @@ describe('Account Linode Managed', () => { restricted: true, username: 'mock-restricted-user', }); + const mockGrants = grantsFactory.build(); const errorMessage = 'Unauthorized'; mockGetLinodes([]); mockGetAccount(mockAccount).as('getAccount'); mockGetProfile(mockProfile).as('getProfile'); + mockGetProfileGrants(mockGrants).as('getGrants'); mockEnableLinodeManagedError(errorMessage, 403).as('enableLinodeManaged'); // Navigate to Account Settings page, click "Add Linode Managed" button. visitUrlWithManagedDisabled('/account/settings'); - cy.wait(['@getAccount', '@getProfile']); + cy.wait(['@getAccount', '@getProfile', '@getGrants']); ui.button .findByTitle('Add Linode Managed') @@ -164,7 +173,7 @@ describe('Account Linode Managed', () => { // Navigate to the 'Open a Support Ticket' page. cy.findByText('Support Ticket').should('be.visible').click(); - cy.url().should('endWith', '/support/tickets'); + cy.url().should('endWith', '/support/tickets/open?dialogOpen=true'); // Confirm that title and category are related to cancelling Linode Managed. cy.findByLabelText('Title (required)').should( diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index e18840149e9..c0fed1236d1 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -2,13 +2,16 @@ * @file Integration tests for Cloud Manager account login history flows. */ -import { profileFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { loginEmptyStateMessageText, loginHelperText, } from 'support/constants/account'; import { mockGetAccountLogins } from 'support/intercepts/account'; -import { mockGetProfile } from 'support/intercepts/profile'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { accountLoginFactory } from 'src/factories/accountLogin'; import { PARENT_USER } from 'src/features/Account/constants'; @@ -100,12 +103,14 @@ describe('Account login history', () => { user_type: 'child', username: 'mock-child-user', }); + const mockGrants = grantsFactory.build(); mockGetProfile(mockProfile).as('getProfile'); + mockGetProfileGrants(mockGrants).as('getGrants'); // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getProfile']); + cy.wait(['@getProfile', '@getGrants']); // Confirm helper text above table and table are not visible. cy.findByText(loginHelperText).should('not.exist'); @@ -149,12 +154,14 @@ describe('Account login history', () => { user_type: 'default', username: 'mock-restricted-user', }); + const mockGrants = grantsFactory.build(); + mockGetProfileGrants(mockGrants).as('getGrants'); mockGetProfile(mockProfile).as('getProfile'); // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getProfile']); + cy.wait(['@getProfile', '@getGrants']); // Confirm helper text above table and table are not visible. cy.findByText(loginHelperText).should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index e5b0e1655c6..3ed8e8724d1 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,7 +1,10 @@ -import { profileFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { getProfile } from 'support/api/account'; import { mockUpdateUsername } from 'support/intercepts/account'; -import { interceptGetProfile } from 'support/intercepts/profile'; +import { + interceptGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomString } from 'support/util/random'; @@ -96,6 +99,9 @@ describe('Display Settings', () => { username: 'restricted-proxy-user', }); + const mockGrants = grantsFactory.build(); + mockGetProfileGrants(mockGrants); + verifyUsernameAndEmail( mockRestrictedProxyProfile, RESTRICTED_FIELD_TOOLTIP, @@ -123,6 +129,9 @@ describe('Display Settings', () => { username: 'regular-restricted-user', }); + const mockGrants = grantsFactory.build(); + mockGetProfileGrants(mockGrants); + verifyUsernameAndEmail( mockRegularRestrictedProfile, 'Restricted users cannot update their username. Please contact an account administrator.', diff --git a/packages/manager/cypress/e2e/core/account/quotas.spec.ts b/packages/manager/cypress/e2e/core/account/quotas.spec.ts new file mode 100644 index 00000000000..bbfc38d8bb2 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/quotas.spec.ts @@ -0,0 +1,89 @@ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +describe('Quotas accessible when limitsEvolution feature flag enabled', () => { + beforeEach(() => { + // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. + mockAppendFeatureFlags({ + limitsEvolution: { + enabled: true, + }, + }).as('getFeatureFlags'); + }); + it('can navigate directly to Quotas page', () => { + cy.visitWithLogin('/account/quotas'); + cy.wait('@getFeatureFlags'); + cy.url().should('endWith', '/quotas'); + cy.contains( + 'View your Object Storage quotas by applying the endpoint filter below' + ).should('be.visible'); + }); + + it('can navigate to the Quotas page via the User Menu', () => { + cy.visitWithLogin('/'); + cy.wait('@getFeatureFlags'); + // Open user menu + ui.userMenuButton.find().click(); + ui.userMenu.find().within(() => { + cy.get('[data-testid="menu-item-Quotas"]').should('be.visible').click(); + cy.url().should('endWith', '/quotas'); + }); + }); + + it('Quotas tab is visible from all other tabs in Account tablist', () => { + cy.visitWithLogin('/account/billing'); + cy.wait('@getFeatureFlags'); + ui.tabList.find().within(() => { + cy.get('a').each(($link) => { + cy.wrap($link).click(); + cy.get('[data-testid="Quotas"]').should('be.visible'); + }); + }); + cy.get('[data-testid="Quotas"]').should('be.visible').click(); + cy.url().should('endWith', '/quotas'); + }); +}); + +describe('Quotas inaccessible when limitsEvolution feature flag disabled', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + limitsEvolution: { + enabled: false, + }, + }).as('getFeatureFlags'); + }); + it('Quotas page is inaccessible', () => { + cy.visitWithLogin('/account/quotas'); + cy.wait('@getFeatureFlags'); + cy.url().should('endWith', '/billing'); + }); + + it('cannot navigate to the Quotas tab via the Users & Grants link in the User Menu', () => { + cy.visitWithLogin('/'); + cy.wait('@getFeatureFlags'); + // Open user menu + ui.userMenuButton.find().click(); + ui.userMenu.find().within(() => { + cy.get('[data-testid="menu-item-Quotas"]').should('not.exist'); + cy.get('[data-testid="menu-item-Users & Grants"]') + .should('be.visible') + .click(); + }); + cy.url().should('endWith', '/users'); + cy.get('[data-testid="Quotas"]').should('not.exist'); + }); + + it('cannot navigate to the Quotas tab via the Billing link in the User Menu', () => { + cy.visitWithLogin('/'); + cy.wait('@getFeatureFlags'); + ui.userMenuButton.find().click(); + ui.userMenu.find().within(() => { + cy.get('[data-testid="menu-item-Quotas"]').should('not.exist'); + cy.get('[data-testid="menu-item-Billing & Contact Information"]') + .should('be.visible') + .click(); + }); + cy.url().should('endWith', '/billing'); + cy.get('[data-testid="Quotas"]').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index ea9cbb8c793..1a55c533762 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -249,10 +249,6 @@ describe('restricted user details pages', () => { cy.visitWithLogin(`/images`); cy.wait(['@getCustomImages', '@getProfile', '@getRecoveryImages']); - cy.findByText( - `You don't have permissions to create Images. Please contact your ${ADMINISTRATOR} to request the necessary permissions.` - ); - // Confirm that the "Create Image" button is visible and disabled ui.button .findByTitle('Create Image') @@ -307,11 +303,6 @@ describe('restricted user details pages', () => { cy.wait(['@getProfile', '@getVolumes']); - // Confirm that a warning message is displayed - cy.findByText( - `You don't have permissions to create or edit Volumes. Please contact your ${ADMINISTRATOR} to request the necessary permissions.` - ); - // Confirm that the "Create Volume" button is disabled ui.button .findByTitle('Create Volume') @@ -587,11 +578,6 @@ describe('restricted user details pages', () => { cy.visitWithLogin('/longview'); cy.wait(['@getProfile', '@getLongviewClients']); - // Confirm that the warning message is displayed - cy.findByText( - `You don't have permissions to create Longview Clients. Please contact your ${ADMINISTRATOR} to request the necessary permissions.` - ); - // Confirm that the "Add Client" button is disabled ui.button .findByTitle('Add Client') diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 1012c840caf..620e66bb792 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -546,7 +546,7 @@ describe('Account service transfers', () => { .within(() => { cy.get(`[data-qa-panel-summary="${transfer}"]`).click(); // Error Icon should shows up. - cy.findByTestId('ErrorOutlineIcon').should('be.visible'); + cy.findByTestId('error-state').should('be.visible'); // Error message should be visible. cy.findByText(serviceTransferErrorMessage, { exact: false }).should( 'be.visible' diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index b35718a3fac..314a12465b7 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -279,7 +279,6 @@ describe('User permission management', () => { ...mockUserGrants, global: { account_access: 'read_only', - add_buckets: true, add_databases: true, add_domains: true, add_firewalls: true, diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 43822b48fa3..f8a0a34eb79 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -1,15 +1,22 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; +import { mockGetUser } from 'support/intercepts/account'; import { interceptCreateFirewall } from 'support/intercepts/firewalls'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; + +import { accountUserFactory } from 'src/factories'; authenticate(); -// Firewall GET API request performance issues need to be addressed in order to unskip this test -// See M3-9619 -describe.skip('create firewall', () => { + +describe('create firewall', () => { before(() => { cleanUp(['lke-clusters', 'linodes', 'firewalls']); }); @@ -101,7 +108,7 @@ describe.skip('create firewall', () => { .should('be.visible') .click(); - cy.findByLabelText('Linodes').should('be.visible').click(); + cy.focused().type('{esc}'); ui.buttonGroup .findButtonByTitle('Create Firewall') @@ -124,3 +131,65 @@ describe.skip('create firewall', () => { }); }); }); + +describe('restricted user cannot create firewall', () => { + beforeEach(() => { + cleanUp(['lke-clusters', 'linodes', 'firewalls']); + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_firewalls: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + }); + + /* + * - Verifies that restricted user cannot create firewall on landing page + */ + it('confirms the create button is disabled on the Firewall Landing page', () => { + cy.visitWithLogin('/firewalls'); + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.disabled'); + }); + + /* + * - Verifies that restricted user cannot create firewall in drawer + */ + it('confirms the Create Firewall button is disabled in create drawer', () => { + cy.visitWithLogin('/firewalls/create'); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByText( + "You don't have permissions to create a new Firewall. Please contact an account administrator for details." + ); + ui.buttonGroup.findButtonByTitle('Create Firewall').scrollIntoView(); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.disabled'); + // all form inputs are disabled + cy.get('input').each((input) => { + cy.wrap(input).should('be.disabled'); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index f0037b28ed8..a99b70fc142 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -7,6 +7,7 @@ import { mockGetFirewallSettings, } from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; import { accountFactory, firewallFactory } from 'src/factories'; @@ -15,13 +16,11 @@ import { DEFAULT_FIREWALL_TOOLTIP_TEXT } from 'src/features/Firewalls/FirewallLa import type { Firewall } from '@linode/api-v4'; authenticate(); -// Firewall GET API request performance issues need to be addressed in order to unskip this test -// See M3-9619 describe('delete firewall', () => { - // TODO Restore clean-up when `deletes a firewall` test is unskipped. - // before(() => { - // cleanUp('firewalls'); - // }); + before(() => { + cleanUp('firewalls'); + }); + beforeEach(() => { cy.tag('method:e2e'); }); @@ -32,7 +31,7 @@ describe('delete firewall', () => { * - Confirms that firewall is still in landing page list after canceled operation. * - Confirms that firewall is removed from landing page list after confirmed operation. */ - it.skip('deletes a firewall', () => { + it('deletes a firewall', () => { const firewallRequest = firewallFactory.build({ label: randomLabel(), }); diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index b7d797faaf2..0f174987390 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -189,8 +189,8 @@ describe('Migrate Linode With Firewall', () => { .should('be.visible') .click(); - // Click on the Select again to dismiss the autocomplete popper. - cy.findByLabelText('Linodes').should('be.visible').click(); + // Dismiss the autocomplete popper. + cy.focused().type('{esc}'); ui.buttonGroup .findButtonByTitle('Create Firewall') diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index e90d64d3c02..a7b47ce4906 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -1,4 +1,4 @@ -import { createFirewall, createLinode } from '@linode/api-v4'; +import { createFirewall } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { @@ -7,6 +7,7 @@ import { } from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; import { randomItem, randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -149,7 +150,7 @@ const addLinodesToFirewall = (firewall: Firewall, linode: Linode) => { .should('be.visible') .click(); - cy.findByLabelText('Linodes').should('be.visible').click(); + cy.focused().type('{esc}'); ui.button.findByTitle('Add').should('be.visible').click(); }); @@ -160,8 +161,7 @@ const createLinodeAndFirewall = async ( firewallRequestPayload: CreateFirewallPayload ) => { return Promise.all([ - // eslint-disable-next-line @linode/cloud-manager/no-createLinode - createLinode(linodeRequestPayload), + createTestLinode(linodeRequestPayload, { securityMethod: 'powered_off' }), createFirewall(firewallRequestPayload), ]); }; @@ -169,7 +169,7 @@ const createLinodeAndFirewall = async ( authenticate(); // Firewall GET API request performance issues need to be addressed in order to unskip this test // See M3-9619 -describe.skip('update firewall', () => { +describe('update firewall', () => { before(() => { cleanUp('firewalls'); }); diff --git a/packages/manager/cypress/e2e/core/general/analytics.spec.ts b/packages/manager/cypress/e2e/core/general/analytics.spec.ts index c20555a720b..3ac17080cc3 100644 --- a/packages/manager/cypress/e2e/core/general/analytics.spec.ts +++ b/packages/manager/cypress/e2e/core/general/analytics.spec.ts @@ -2,9 +2,8 @@ import { ui } from 'support/ui'; const ADOBE_LAUNCH_URLS = [ 'https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-92311d9d9637-development.min.js', // New dev Launch script - 'https://assets.adobedtm.com/fcfd3580c848/795fdfec4a0e/launch-09b7ca9d43ad-development.min.js', // Existing dev Launch script - 'https://assets.adobedtm.com/fcfd3580c848/795fdfec4a0e/launch-a50be9afbe1d-staging.min.js', // Existing staging Launch script - 'https://assets.adobedtm.com/fcfd3580c848/795fdfec4a0e/launch-de0ca78667e7.min.js', // Existing prod Launch script + 'https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-5bda4b7a1db9-staging.min.js', // New staging Launch script + 'https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-9ea21650035a.min.js', // New prod Launch script ]; describe('Script loading and user interaction test', () => { 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 2e50480cbf5..9c4438e070d 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -426,9 +426,8 @@ describe('LKE Cluster Creation with APL enabled', () => { nanodeType, ]; mockAppendFeatureFlags({ - apl: { - enabled: true, - }, + apl: true, + aplGeneralAvailability: false, }).as('getFeatureFlags'); mockGetAccountBeta({ description: @@ -726,7 +725,6 @@ describe('LKE Cluster Creation with ACL', () => { const mockACL = kubernetesControlPlaneACLFactory.build({ acl: { enabled: false, - 'revision-id': '', }, }); const mockCluster = kubernetesClusterFactory.build({ @@ -825,9 +823,7 @@ describe('LKE Cluster Creation with ACL', () => { * - Confirms LKE summary page shows that ACL is enabled */ it('creates an LKE cluster with ACL enabled', () => { - const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - 'revision-id': '', - }); + const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build(); const mockACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, @@ -945,7 +941,15 @@ describe('LKE Cluster Creation with ACL', () => { ui.button .findByTitle('Enabled (3 IP Addresses)') .should('be.visible') - .should('be.enabled'); + .should('be.enabled') + .click(); + + // Confirm that the Revision ID has been auto-generated by the API + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByLabelText('Revision ID').within(() => { + cy.get('input').should('not.be.empty'); + }); + }); }); /** @@ -953,7 +957,7 @@ describe('LKE Cluster Creation with ACL', () => { * - Confirms at least one IP must be provided for ACL unless acknowledgement is checked * - Confirms the cluster details page shows ACL is enabled */ - it('creates an LKE cluster with ACL enabled by default and handles IP address validation', () => { + it('creates an LKE-E cluster with ACL enabled by default and handles IP address validation', () => { const clusterLabel = randomLabel(); const mockedEnterpriseCluster = kubernetesClusterFactory.build({ k8s_version: latestEnterpriseTierKubernetesVersion.id, @@ -969,7 +973,6 @@ describe('LKE Cluster Creation with ACL', () => { ipv6: [], }, enabled: true, - 'revision-id': '', }, }); mockGetControlPlaneACL(mockedEnterpriseCluster.id, mockACL).as( @@ -1008,7 +1011,6 @@ describe('LKE Cluster Creation with ACL', () => { mockedEnterpriseCluster.id, mockedEnterpriseClusterPools ).as('getClusterPools'); - mockGetDashboardUrl(mockedEnterpriseCluster.id).as('getDashboardUrl'); mockGetApiEndpoints(mockedEnterpriseCluster.id).as('getApiEndpoints'); cy.visitWithLogin('/kubernetes/clusters'); @@ -1148,7 +1150,6 @@ describe('LKE Cluster Creation with ACL', () => { '@createCluster', '@getLKEEnterpriseClusterTypes', '@getLinodeTypes', - '@getDashboardUrl', '@getApiEndpoints', '@getControlPlaneACL', ]); @@ -1163,7 +1164,15 @@ describe('LKE Cluster Creation with ACL', () => { ui.button .findByTitle('Enabled (0 IP Addresses)') .should('be.visible') - .should('be.enabled'); + .should('be.enabled') + .click(); + + // Confirm that the Revision ID has been auto-generated by the API + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByLabelText('Revision ID').within(() => { + cy.get('input').should('not.be.empty'); + }); + }); }); /** @@ -1347,7 +1356,6 @@ describe('LKE Cluster Creation with LKE-E', () => { ipv6: [], }, enabled: true, - 'revision-id': '', }, }); @@ -1398,7 +1406,6 @@ describe('LKE Cluster Creation with LKE-E', () => { mockedEnterpriseCluster.id, mockedEnterpriseClusterPools ).as('getClusterPools'); - mockGetDashboardUrl(mockedEnterpriseCluster.id).as('getDashboardUrl'); mockGetApiEndpoints(mockedEnterpriseCluster.id).as('getApiEndpoints'); cy.visitWithLogin('/kubernetes/clusters'); @@ -1583,7 +1590,6 @@ describe('LKE Cluster Creation with LKE-E', () => { '@createCluster', '@getLKEEnterpriseClusterTypes', '@getLinodeTypes', - '@getDashboardUrl', '@getApiEndpoints', '@getControlPlaneACL', ]); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index bf83aaccb33..095fcfe241f 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -2728,7 +2728,7 @@ describe('LKE ACL updates', () => { acl: mockUpdatedACLOptions2, }); mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL2).as( - 'updateControlPlaneACL' + 'updateControlPlaneACL2' ); // confirm data within drawer is updated and edit IPs again @@ -2755,6 +2755,9 @@ describe('LKE ACL updates', () => { mockRevisionId ); + // clear Revision ID + cy.findByLabelText('Revision ID').clear(); + // update IPv6 addresses cy.findByDisplayValue('10.0.0.0/24').should('be.visible'); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') @@ -2779,7 +2782,7 @@ describe('LKE ACL updates', () => { .click(); }); - cy.wait(['@updateControlPlaneACL']); + cy.wait(['@updateControlPlaneACL2']); // confirm summary panel updates cy.contains('Control Plane ACL').should('be.visible'); @@ -2795,6 +2798,11 @@ describe('LKE ACL updates', () => { .findByTitle(`Control Plane ACL for ${mockCluster.label}`) .should('be.visible') .within(() => { + // confirm Revision ID was "regenerated" after being cleared instead of displaying an empty string + cy.findByLabelText('Revision ID').should( + 'have.value', + mockRevisionId + ); // confirm updated IPv6 addresses display cy.findByDisplayValue( '8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e' diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index c2a7fce210c..c4d8ad31c55 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -3,7 +3,9 @@ import { linodeConfigInterfaceFactory, linodeConfigInterfaceFactoryWithVPC, linodeFactory, + linodeInterfaceFactoryPublic, regionFactory, + upgradeLinodeInterfaceFactory, } from '@linode/utilities'; import { accountFactory, @@ -15,6 +17,12 @@ import { } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { + dryRunButtonText, + upgradeInterfacesButtonText, + upgradeTooltipText1, + upgradeTooltipText2, +} from 'support/constants/linode-interfaces'; import { LINODE_CLONE_TIMEOUT } from 'support/constants/linodes'; import { mockGetAccount } from 'support/intercepts/account'; import { @@ -36,13 +44,21 @@ import { mockGetLinodeKernel, mockGetLinodeKernels, mockGetLinodeVolumes, + mockUpgradeNewLinodeInterface, + mockUpgradeNewLinodeInterfaceError, } from 'support/intercepts/linodes'; +import { mockGetRegion, mockGetRegions } from 'support/intercepts/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { fetchAllKernels, findKernelById } from 'support/util/kernels'; -import { createTestLinode, fetchLinodeConfigs } from 'support/util/linodes'; +import { + assertPromptDialogContent, + assertUpgradeSummary, + createTestLinode, + fetchLinodeConfigs, +} from 'support/util/linodes'; import { randomIp, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -466,7 +482,7 @@ describe('Linode Config management', () => { describe('Mocked', () => { const region: Region = regionFactory.build({ - capabilities: ['Linodes'], + capabilities: ['Linodes', 'Linode Interfaces'], country: 'us', id: 'us-southeast', }); @@ -497,101 +513,6 @@ describe('Linode Config management', () => { const mockVLANs: VLAN[] = VLANFactory.buildList(2); - /* - * - Confirms that config dialog interfaces section is absent on Linodes that use new interfaces. - * - Confirms absence on edit and add config dialog. - */ - it('Does not show interfaces section when managing configs using new Linode interfaces', () => { - // TODO M3-9775: Remove mock when `linodeInterfaces` feature flag is removed. - mockAppendFeatureFlags({ - linodeInterfaces: { - enabled: true, - }, - }); - - // TODO Remove account mock when 'Linode Interfaces' capability is generally available. - mockGetAccount( - accountFactory.build({ - capabilities: ['Linodes', 'Linode Interfaces'], - }) - ); - - const mockLinode = linodeFactory.build({ - id: randomNumber(1000, 99999), - label: randomLabel(), - region: chooseRegion().id, - interface_generation: 'linode', - }); - - const mockConfig = configFactory.build({ - label: randomLabel(), - id: randomNumber(1000, 99999), - interfaces: null, - }); - - mockGetLinodeDetails(mockLinode.id, mockLinode); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]); - mockGetLinodeConfig(mockLinode.id, mockConfig); - - cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); - - cy.findByLabelText('List of Configurations') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Edit') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm absence of the interfaces section when editing an existing config. - ui.dialog - .findByTitle('Edit Configuration') - .should('be.visible') - .within(() => { - // Scroll "Networking" section into view, and confirm that Interfaces - // options are absent and informational text is shown instead. - cy.findByText('Networking').scrollIntoView(); - cy.contains( - "Go to Network to view your Linode's Network interfaces." - ).should('be.visible'); - cy.findByText('Primary Interface (Default Route)').should( - 'not.exist' - ); - cy.findByText('eth0').should('not.exist'); - cy.findByText('eth1').should('not.exist'); - cy.findByText('eth2').should('not.exist'); - - ui.button.findByTitle('Cancel').click(); - }); - - // Confirm asbence of the interfaces section when adding a new config. - ui.button - .findByTitle('Add Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Add Configuration') - .should('be.visible') - .within(() => { - // Scroll "Networking" section into view, and confirm that Interfaces - // options are absent and informational text is shown instead. - cy.findByText('Networking').scrollIntoView(); - cy.contains( - "Go to Network to view your Linode's Network interfaces." - ).should('be.visible'); - cy.findByText('Primary Interface (Default Route)').should( - 'not.exist' - ); - cy.findByText('eth0').should('not.exist'); - cy.findByText('eth1').should('not.exist'); - cy.findByText('eth2').should('not.exist'); - }); - }); - /* * - Tests Linode config create and VPC interface assignment UI flows using mock API data. * - Confirms that VPC can be assigned as eth0, eth1, and eth2. @@ -939,5 +860,286 @@ describe('Linode Config management', () => { cy.findByText('REBOOT NEEDED').should('be.visible'); }); + + describe('Upgrade new Linode Interfaces flow', () => { + beforeEach(() => { + // TODO M3-9775: Remove mock when `linodeInterfaces` feature flag is removed. + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: true, + }, + }); + + // TODO Remove account mock when 'Linode Interfaces' capability is generally available. + mockGetAccount( + accountFactory.build({ + capabilities: ['Linodes', 'Linode Interfaces'], + }) + ); + + const mockRegion: Region = regionFactory.build({ + id: 'us-east', + label: 'Newark, NJ', + capabilities: ['Linodes', 'Linode Interfaces'], + }); + mockGetRegions([mockRegion]); + mockGetRegion(mockRegion); + }); + + /* + * - Confirms that config dialog interfaces section is absent on Linodes that use new interfaces. + * - Confirms absence on edit and add config dialog. + */ + it('Does not show interfaces section when managing configs using new Linode interfaces', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: 'us-east', + interface_generation: 'linode', + }); + + const mockConfig = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]); + mockGetLinodeConfig(mockLinode.id, mockConfig); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); + + cy.findByLabelText('List of Configurations') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm absence of the interfaces section when editing an existing config. + ui.dialog + .findByTitle('Edit Configuration') + .should('be.visible') + .within(() => { + // Scroll "Networking" section into view, and confirm that Interfaces + // options are absent and informational text is shown instead. + cy.findByText('Networking').scrollIntoView(); + cy.contains( + "Go to Network to view your Linode's Network interfaces." + ).should('be.visible'); + cy.findByText('Primary Interface (Default Route)').should( + 'not.exist' + ); + cy.findByText('eth0').should('not.exist'); + cy.findByText('eth1').should('not.exist'); + cy.findByText('eth2').should('not.exist'); + + ui.button.findByTitle('Cancel').click(); + }); + + // Confirm absence of the interfaces section when adding a new config. + ui.button + .findByTitle('Add Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + // Scroll "Networking" section into view, and confirm that Interfaces + // options are absent and informational text is shown instead. + cy.findByText('Networking').scrollIntoView(); + cy.contains( + "Go to Network to view your Linode's Network interfaces." + ).should('be.visible'); + cy.findByText('Primary Interface (Default Route)').should( + 'not.exist' + ); + cy.findByText('eth0').should('not.exist'); + cy.findByText('eth1').should('not.exist'); + cy.findByText('eth2').should('not.exist'); + }); + }); + + /* + * - Confirm button appears in Details footer for linodes with legacy interfaces. + * - Confirm clicking 'Upgrade Interfaces' button flow. + */ + it('Upgrades from legacy configuration interfaces to new Linode interfaces (Public)', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: 'us-east', + }); + + const mockConfig = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + const mockPublicInterface = linodeInterfaceFactoryPublic.build({ + id: randomNumber(1000, 99999), + }); + + const mockUpgradeLinodeInterface = upgradeLinodeInterfaceFactory.build({ + config_id: mockConfig.id, + dry_run: true, + interfaces: [mockPublicInterface], + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]); + mockGetLinodeConfig(mockLinode.id, mockConfig); + mockUpgradeNewLinodeInterface( + mockLinode.id, + mockUpgradeLinodeInterface + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); + + // Confirm the tooltip shows up + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .trigger('mouseover'); + cy.findByText(upgradeTooltipText1, { exact: false }).should( + 'be.visible' + ); + cy.findByText(upgradeTooltipText2, { exact: false }).should( + 'be.visible' + ); + + // Confirm the "Upgrade Interfaces" button appears and works as expected. + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + // Assert the prompt dialog content. + assertPromptDialogContent(); + + // Check "Dry Run" flow + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + ui.button + .findByTitle(dryRunButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, true); + + ui.button + .findByTitle('Continue to Upgrade') + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, false); + + ui.button.findByTitle('Close').should('be.visible').click(); + }); + + // Check "Upgrade Interfaces" flow + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, false); + + ui.button + .findByTitle('View Network Settings') + .should('be.visible') + .click(); + }); + + // Confirm can navigate to linode/networking after success + cy.url().should('endWith', `linodes/${mockLinode.id}/networking`); + }); + + /* + * - Confirm upgrade error flow. + * - Confirm the error message shows up. + */ + it('Displays error message when having upgrade issue', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: 'us-east', + }); + + const mockConfig = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + const mockErrorMessage = 'Custom Error'; + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]); + mockGetLinodeConfig(mockLinode.id, mockConfig); + mockUpgradeNewLinodeInterfaceError( + mockLinode.id, + mockErrorMessage, + 500 + ).as('upgradeError'); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); + + // Confirm the "Upgrade Interfaces" button appears. + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + // Check error flow + ui.button + .findByTitle(dryRunButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@upgradeError'); + cy.findByText(mockErrorMessage).should('be.visible'); + + // Confirm "Return to Overview" button back to the dialog. + ui.button + .findByTitle('Return to Overview') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + assertPromptDialogContent(); + }); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/upgrade-linode-interface.spec.ts b/packages/manager/cypress/e2e/core/linodes/upgrade-linode-interface.spec.ts new file mode 100644 index 00000000000..95c1dbcd082 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/upgrade-linode-interface.spec.ts @@ -0,0 +1,441 @@ +import { + configFactory, + linodeFactory, + linodeInterfaceFactoryPublic, + upgradeLinodeInterfaceFactory, +} from '@linode/utilities'; +import { accountFactory } from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { + configSelectSharedText, + dryRunButtonText, + errorDryRunText, + upgradeInterfacesButtonText, + upgradeInterfacesWarningText, +} from 'support/constants/linode-interfaces'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetLinodeConfig, + mockGetLinodeConfigs, +} from 'support/intercepts/configs'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockUpgradeNewLinodeInterface, + mockUpgradeNewLinodeInterfaceError, +} from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; +import { + assertPromptDialogContent, + assertUpgradeSummary, +} from 'support/util/linodes'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +authenticate(); +describe('upgrade to new Linode Interface flow', () => { + beforeEach(() => { + cleanUp(['linodes']); + cy.tag('method:e2e'); + + // TODO M3-9775: Remove mock when `linodeInterfaces` feature flag is removed. + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: true, + }, + }); + + // TODO Remove account mock when 'Linode Interfaces' capability is generally available. + mockGetAccount( + accountFactory.build({ + capabilities: ['Linodes', 'Linode Interfaces'], + }) + ); + }); + + /* + * - Confirms that config dialog interfaces section is absent on Linodes that use new interfaces. + * - Confirms absence on edit and add config dialog. + */ + it('does not show interfaces section in the config dialog for Linodes using new interfaces', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: chooseRegion().id, + interface_generation: 'linode', + }); + + const mockConfig = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]); + mockGetLinodeConfig(mockLinode.id, mockConfig); + + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + + // "UPGRADE" button is absent + cy.findByText('Linode').should('be.visible'); + cy.findByText('Configuration Profile').should('not.exist'); + cy.findByText('UPGRADE').should('not.exist'); + + cy.get('[data-testid="Configurations"]').should('be.visible').click(); + + cy.findByLabelText('List of Configurations') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm absence of the interfaces section when editing an existing config. + ui.dialog + .findByTitle('Edit Configuration') + .should('be.visible') + .within(() => { + // Scroll "Networking" section into view, and confirm that Interfaces + // options are absent and informational text is shown instead. + cy.findByText('Networking').scrollIntoView(); + cy.contains( + "Go to Network to view your Linode's Network interfaces." + ).should('be.visible'); + cy.findByText('Primary Interface (Default Route)').should('not.exist'); + cy.findByText('eth0').should('not.exist'); + cy.findByText('eth1').should('not.exist'); + cy.findByText('eth2').should('not.exist'); + + ui.button.findByTitle('Cancel').click(); + }); + + // Confirm absence of the interfaces section when adding a new config. + ui.button + .findByTitle('Add Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + // Scroll "Networking" section into view, and confirm that Interfaces + // options are absent and informational text is shown instead. + cy.findByText('Networking').scrollIntoView(); + cy.contains( + "Go to Network to view your Linode's Network interfaces." + ).should('be.visible'); + cy.findByText('Primary Interface (Default Route)').should('not.exist'); + cy.findByText('eth0').should('not.exist'); + cy.findByText('eth1').should('not.exist'); + cy.findByText('eth2').should('not.exist'); + }); + }); + + /* + * - Confirm button appears in Details footer for linodes with legacy interfaces. + * - Confirm clicking 'UPGRADE' button flow. + */ + it('upgrades from a single legacy configuration to new Linode interfaces (Public) from details page', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockConfig = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + const mockPublicInterface = linodeInterfaceFactoryPublic.build({ + id: randomNumber(1000, 99999), + }); + + const mockUpgradeLinodeInterface = upgradeLinodeInterfaceFactory.build({ + config_id: mockConfig.id, + dry_run: true, + interfaces: [mockPublicInterface], + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]); + mockGetLinodeConfig(mockLinode.id, mockConfig); + mockUpgradeNewLinodeInterface(mockLinode.id, mockUpgradeLinodeInterface); + + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + + // "UPGRADE" button appears and works as expected. + cy.findByText('Configuration Profile').should('be.visible'); + cy.findByText('UPGRADE').should('be.visible').click({ force: true }); + + // Assert the prompt dialog content. + assertPromptDialogContent(); + + // Check "Dry Run" flow + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + ui.button + .findByTitle(dryRunButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, true); + + ui.button + .findByTitle('Continue to Upgrade') + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, false); + + ui.button.findByTitle('Close').should('be.visible').click(); + }); + + // Check "Upgrade Interfaces" flow + cy.findByText('UPGRADE').should('be.visible').click({ force: true }); + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, false); + + ui.button + .findByTitle('View Network Settings') + .should('be.visible') + .click(); + }); + + // Confirm can navigate to linode/networking after success + cy.url().should('endWith', `linodes/${mockLinode.id}/networking`); + }); + + /* + * - Confirm Linode with multiple configurations can be upgraded to new Linode Interfaces. + */ + it('upgrades from multiple legacy configurations to new Linode interfaces from details page', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockConfig1 = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + const mockConfig2 = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + const mockPublicInterface = linodeInterfaceFactoryPublic.build({ + id: randomNumber(1000, 99999), + }); + + const mockUpgradeLinodeInterface = upgradeLinodeInterfaceFactory.build({ + config_id: mockConfig1.id, + dry_run: true, + interfaces: [mockPublicInterface], + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig1, mockConfig2]); + mockGetLinodeConfig(mockLinode.id, mockConfig1); + mockUpgradeNewLinodeInterface(mockLinode.id, mockUpgradeLinodeInterface); + + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + + // "UPGRADE" button appears and works as expected. + cy.findByText('Configuration Profile').should('be.visible'); + cy.findByText('UPGRADE').should('be.visible').click({ force: true }); + + // Assert the prompt dialog content. + assertPromptDialogContent(); + + // Check "Dry Run" flow + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + ui.button + .findByTitle(dryRunButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + // Find the config select and open it + cy.get('[placeholder="Select Configuration Profile"]') + .should('be.visible') + .click(); + cy.focused().type(`${mockConfig1.label}{enter}`); + + // Select the config + ui.autocompletePopper + .findByTitle(mockConfig1.label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm config select text for multiple configurations + cy.findByText(configSelectSharedText, { exact: false }).should( + 'be.visible' + ); + + cy.findAllByText(dryRunButtonText) + .last() + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, true); + + ui.button + .findByTitle('Continue to Upgrade') + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, false); + + ui.button.findByTitle('Close').should('be.visible').click(); + }); + + // Check "Upgrade Interfaces" flow + cy.findByText('UPGRADE').should('be.visible').click({ force: true }); + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText(configSelectSharedText).should('be.visible'); + + // Find the config select and open it + cy.get('[placeholder="Select Configuration Profile"]') + .should('be.visible') + .click(); + cy.focused().type(`${mockConfig1.label}{enter}`); + + // Select the config + ui.autocompletePopper + .findByTitle(mockConfig1.label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm multiple configuration warning text for multiple configurations + cy.findByText(upgradeInterfacesWarningText).should('be.visible'); + + cy.findAllByText(upgradeInterfacesButtonText) + .last() + .should('be.visible') + .should('be.enabled') + .click(); + + assertUpgradeSummary(mockPublicInterface, false); + + ui.button.findByTitle('Close').should('be.visible').click(); + }); + + // Confirm can navigate to Linode after success + cy.url().should('endWith', `linodes/${mockLinode.id}`); + }); + + /* + * - Confirm upgrade error flow. + * - Confirm "Return to Overview" works. + * - Confirm the error message shows up. + */ + it('Displays error message when having upgrade issue', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(1000, 99999), + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockConfig = configFactory.build({ + label: randomLabel(), + id: randomNumber(1000, 99999), + interfaces: null, + }); + + const mockErrorMessage = 'Custom Error'; + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]); + mockGetLinodeConfig(mockLinode.id, mockConfig); + mockUpgradeNewLinodeInterfaceError(mockLinode.id, mockErrorMessage, 500).as( + 'upgradeError' + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + + // "UPGRADE" button appears and works as expected. + cy.findByText('Configuration Profile').should('be.visible'); + cy.findByText('UPGRADE').should('be.visible').click({ force: true }); + + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + // Check error flow + ui.button + .findByTitle(dryRunButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm the error message shows up. + cy.wait('@upgradeError'); + cy.findByText(mockErrorMessage).should('be.visible'); + cy.findByText(errorDryRunText).should('be.visible'); + + // Confirm "Return to Overview" button back to the dialog. + ui.button + .findByTitle('Return to Overview') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + assertPromptDialogContent(); + }); +}); diff --git a/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts index 84977989f93..2f5241ca83d 100644 --- a/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts @@ -1,11 +1,22 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; +import { longviewEmptyStateMessage } from 'support/constants/longview'; +import { mockGetUser } from 'support/intercepts/account'; import { + mockGetLongviewClients, mockGetLongviewPlan, + mockUnauthorizedLongviewPlanRequest, mockUpdateLongviewPlan, } from 'support/intercepts/longview'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { randomLabel } from 'support/util/random'; +import { accountUserFactory } from 'src/factories'; import { longviewActivePlanFactory } from 'src/factories'; import type { ActiveLongviewPlan } from '@linode/api-v4'; @@ -60,3 +71,57 @@ describe('longview plan', () => { .should('be.disabled'); }); }); + +describe('restricted user does not have permission to create plan', () => { + before(() => { + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_longview: false, + longview_subscription: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + }); + + /* + * - Verifies restricted user cannot view or edit plans + */ + it('restricted user cannot create plan on empty landing page', () => { + mockGetLongviewClients([]).as('getLongviewClients'); + mockUnauthorizedLongviewPlanRequest().as('getLongviewPlan'); + cy.visitWithLogin('/longview'); + cy.wait(['@getLongviewClients', '@getLongviewPlan']); + // Confirm that the "Add Client" button is disabled + ui.button + .findByTitle('Add Client') + .should('be.visible') + .should('be.disabled') + .trigger('mouseover'); + ui.tooltip.findByText( + "You don't have permissions to create Longview Clients. Please contact your account administrator to request the necessary permissions." + ); + + // Confirms that a landing page empty state message is displayed + cy.findByText(longviewEmptyStateMessage).should('be.visible'); + + ui.tabList.findTabByTitle('Plan Details').should('be.visible').click(); + ui.tabList.findTabByTitle('Plan Details').within(() => { + cy.get('table').should('not.exist'); + cy.get('imput').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts new file mode 100644 index 00000000000..7c4278d2f67 --- /dev/null +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts @@ -0,0 +1,136 @@ +import { linodeFactory, nodeBalancerFactory } from '@linode/utilities'; +import { authenticate } from 'support/api/authentication'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { + mockCreateNodeBalancer, + mockGetNodeBalancer, +} from 'support/intercepts/nodebalancers'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { subnetFactory, vpcFactory } from 'src/factories'; + +authenticate(); +describe('Create a NodeBalancer with VPCs', () => { + /* + * - Confirms UI flow to create a Nodebalancer with an existing VPC assigned using mock API data. + * - Confirms that VPC assignment is reflected in create summary section. + * - Confirms that outgoing API request contains expected VPC interface data. + */ + it('creates a NodeBalancer with a VPC', () => { + const region = chooseRegion({ + capabilities: ['VPCs', 'NodeBalancers'], + }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + ipv4: `10.0.0.0/24`, + label: randomLabel(), + linodes: [], + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: region.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: region.id, + }); + + const mockNodeBalancer = nodeBalancerFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: region.id, + ipv4: mockLinode.ipv4[1], + }); + + const mockUpdatedSubnet = { + ...mockSubnet, + linodes: [ + { + id: mockLinode.id, + interfaces: [], + }, + ], + nodebalancers: [ + { + id: mockNodeBalancer.id, + ipv4_range: '10.0.0.4/30', + }, + ], + }; + + mockAppendFeatureFlags({ + nodebalancerVpc: true, + }).as('getFeatureFlags'); + + mockGetVPCs([mockVPC]).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetLinodes([mockLinode]).as('getLinodes'); + mockCreateNodeBalancer(mockNodeBalancer).as('createNodeBalancer'); + mockGetNodeBalancer(mockNodeBalancer); + + cy.visitWithLogin('/nodebalancers/create'); + cy.wait('@getFeatureFlags'); + + cy.get('[id="nodebalancer-label"]').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(mockNodeBalancer.label); + + // this will create the NB in newark, where the default Linode was created + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(region.label).click(); + + // Confirm that mocked VPC is shown in the Autocomplete, and then select it. + cy.findByText('Assign VPC').click(); + cy.focused().type(mockVPC.label); + + ui.autocompletePopper + .findByTitle(mockVPC.label) + .should('be.visible') + .click(); + + // Confirm that VPC's subnet gets selected + cy.findByLabelText('Subnet').should( + 'have.value', + `${mockSubnet.label} (${mockSubnet.ipv4})` + ); + + // Confirm that the auto-assign for VPC IPv4 range is checked + cy.get('[data-testid="vpc-ipv4-checkbox"]') + .find('[type="checkbox"]') + .should('be.checked') + .click(); + + cy.findByText(`NodeBalancer IPv4 CIDR for ${mockSubnet.label}`).click(); + cy.focused().clear(); + cy.focused().type(`${mockUpdatedSubnet.nodebalancers[0].ipv4_range}`); + // node backend config + cy.findByText('Label').click(); + cy.focused().type(randomLabel()); + cy.findByLabelText('IP Address').should('be.visible').click(); + cy.focused().type(mockNodeBalancer.ipv4); + ui.autocompletePopper + .findByTitle(mockNodeBalancer.ipv4) + .should('be.visible') + .click(); + cy.findByLabelText('Weight').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type('100'); + + cy.get('[data-qa-summary="true"]').within(() => { + cy.contains(`Nodes 1`).should('be.visible'); + }); + + cy.get('[data-qa-deploy-nodebalancer]').click(); + cy.wait('@createNodeBalancer').its('response.statusCode').should('eq', 200); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts index fb44ca5d08a..11216d9a3c1 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts @@ -1,11 +1,18 @@ -import { profileFactory, regionFactory } from '@linode/utilities'; +import { + grantsFactory, + profileFactory, + regionFactory, +} from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccessKeys, mockGetObjectStorageEndpoints, } from 'support/intercepts/object-storage'; -import { mockGetProfile } from 'support/intercepts/profile'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; @@ -206,6 +213,7 @@ describe('Object Storage Gen2 create access key modal has disabled fields for re restricted: true, }) ).as('getProfile'); + mockGetProfileGrants(grantsFactory.build()); }); // access keys creation diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts index 5592400f299..86e15bcdc28 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts @@ -1,4 +1,8 @@ -import { profileFactory, regionFactory } from '@linode/utilities'; +import { + grantsFactory, + profileFactory, + regionFactory, +} from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -9,7 +13,10 @@ import { mockGetBuckets, mockGetObjectStorageEndpoints, } from 'support/intercepts/object-storage'; -import { mockGetProfile } from 'support/intercepts/profile'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { checkRateLimitsTable } from 'support/util/object-storage-gen2'; @@ -756,12 +763,13 @@ describe('Object Storage Gen2 create bucket modal has disabled fields for restri restricted: true, }) ).as('getProfile'); + mockGetProfileGrants(grantsFactory.build()).as('getGrants'); }); // bucket creation it('create bucket form', () => { cy.visitWithLogin('/object-storage/buckets/create'); - cy.wait(['@getFeatureFlags', '@getAccount', '@getProfile']); + cy.wait(['@getFeatureFlags', '@getAccount', '@getProfile', '@getGrants']); // error message ui.drawer diff --git a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts index 5547bd1a262..377cfdebd4a 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts @@ -1,4 +1,4 @@ -import { profileFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountFactory, appTokenFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { DateTime } from 'luxon'; @@ -12,6 +12,7 @@ import { mockGetAppTokens, mockGetPersonalAccessTokens, mockGetProfile, + mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; @@ -25,6 +26,8 @@ const mockParentProfile = profileFactory.build({ username: randomLabel(), }); +const mockGrants = grantsFactory.build(); + const mockParentUser = accountUserFactory.build({ user_type: 'parent', username: mockParentProfile.username, @@ -55,6 +58,7 @@ describe('Token scopes', () => { mockGetAccount(mockParentAccount); mockGetChildAccounts([mockChildAccount]); mockGetProfile({ ...mockParentProfile, restricted: true }); + mockGetProfileGrants(mockGrants); mockGetUser(mockParentUser); mockGetPersonalAccessTokens([]).as('getTokens'); diff --git a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts index 8288734266a..37f33e292f3 100644 --- a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts @@ -49,7 +49,7 @@ describe('volume clone flow', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); cy.findByLabelText( `Action menu for Volume ${volume.label}` ).click(); diff --git a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts index a34ca3cc7ec..a197a9f376b 100644 --- a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts @@ -68,7 +68,7 @@ describe('volume resize flow', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); cy.findByText(`${oldSize} GB`).should('be.visible'); cy.findByLabelText( `Action menu for Volume ${volume.label}` diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index 9b1b021897c..78ffb0dbdc7 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -45,7 +45,7 @@ describe('volume update flow', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); }); ui.actionMenu .findByTitle(`Action menu for Volume ${volume.label}`) @@ -106,7 +106,7 @@ describe('volume update flow', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); }); ui.actionMenu diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index f6ff9e71a2d..5e3cc477dd7 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -78,7 +78,7 @@ describe('volume upgrade/migration', () => { cy.wait('@getEvents'); - cy.findByText(`migrating (${percentage}%)`).should('be.visible'); + cy.findByText(`Migrating (${percentage}%)`).should('be.visible'); } const mockFinishedMigrationEvent = eventFactory.build({ @@ -94,7 +94,7 @@ describe('volume upgrade/migration', () => { mockGetEvents([]); - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); @@ -173,7 +173,7 @@ describe('volume upgrade/migration', () => { cy.wait('@getEvents'); - cy.findByText(`migrating (${percentage}%)`).should('be.visible'); + cy.findByText(`Migrating (${percentage}%)`).should('be.visible'); } const mockFinishedMigrationEvent = eventFactory.build({ @@ -189,7 +189,7 @@ describe('volume upgrade/migration', () => { mockGetEvents([]); - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); @@ -264,7 +264,7 @@ describe('volume upgrade/migration', () => { cy.wait('@getEvents'); - cy.findByText(`migrating (${percentage}%)`).should('be.visible'); + cy.findByText(`Migrating (${percentage}%)`).should('be.visible'); } const mockFinishedMigrationEvent = eventFactory.build({ @@ -280,7 +280,7 @@ describe('volume upgrade/migration', () => { mockGetEvents([]); - cy.findByText('active').should('be.visible'); + cy.findByText('Active').should('be.visible'); ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index a355f77a294..954ea4fe5f5 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -1,9 +1,14 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; +import { subnetFactory, vpcFactory } from '@src/factories'; +import { mockGetUser } from 'support/intercepts/account'; /** * @file Integration tests for VPC create flow. */ - -import { linodeFactory, regionFactory } from '@linode/utilities'; -import { subnetFactory, vpcFactory } from '@src/factories'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateVPC, @@ -21,6 +26,7 @@ import { } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; +import { accountUserFactory } from 'src/factories'; import { getUniqueLinodesFromSubnets } from 'src/features/VPCs/utils'; import type { Subnet, VPC } from '@linode/api-v4'; @@ -328,3 +334,59 @@ describe('VPC create flow', () => { cy.findByText('No Subnets are assigned.').should('be.visible'); }); }); + +describe('restricted user cannot create vpc', () => { + beforeEach(() => { + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_vpcs: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + }); + + /* + * - Verifies that restricted user cannot create vpc on landing page + */ + it('create vpc is disabled on landing page', () => { + cy.visitWithLogin('/vpcs'); + ui.button + .findByTitle('Create VPC') + .should('be.visible') + .should('be.disabled'); + }); + + /* + * - Verifies that restricted user cannot create vpc in Create page + */ + it('create vpc create page is disabled', () => { + cy.visitWithLogin('/vpcs/create'); + cy.findByText( + "You don't have permissions to create a new VPC. Please contact an account administrator for details." + ); + cy.get('[data-testid="formVpcCreate"]').within(() => { + ui.buttonGroup + .findButtonByTitle('Create VPC') + .should('be.visible') + .should('be.disabled'); + // all form inputs are disabled + cy.get('input').each((input) => { + cy.wrap(input).should('be.disabled'); + }); + }); + }); +}); diff --git a/packages/manager/cypress/support/constants/linode-interfaces.ts b/packages/manager/cypress/support/constants/linode-interfaces.ts new file mode 100644 index 00000000000..8f5c8212b7e --- /dev/null +++ b/packages/manager/cypress/support/constants/linode-interfaces.ts @@ -0,0 +1,31 @@ +export const dryRunButtonText = 'Perform Dry Run'; + +export const upgradeInterfacesButtonText = 'Upgrade Interfaces'; + +export const upgradeTooltipText1 = 'Configuration Profile interfaces from a single profile can be upgraded to Linode Interfaces.'; + +export const upgradeTooltipText2 = 'After the upgrade, the Linode can only use Linode Interfaces and cannot revert to Configuration Profile interfaces. Use the dry-run feature to review the changes before committing.'; + +export const promptDialogDescription1 = 'Upgrading allows interface connections to be associated directly with the Linode, rather than its configuration profile.'; + +export const promptDialogDescription2 = 'We recommend performing a dry run before upgrading to identify and resolve any potential conflicts.'; + +export const promptDialogUpgradeWhatHappensTitle = 'What happens after the upgrade:'; + +export const promptDialogUpgradeDetails = [ + 'New Linode Interfaces are created to match the existing Configuration Profile Interfaces.', + 'The Linode will only use Linode Interfaces and cannot revert to Configuration Profile Interfaces.', + 'Private IPv4 addresses are not supported on public Linode Interfaces—services relying on a private IPv4 will no longer function.', + 'All firewalls are removed from the Linode. Any previously attached firewalls are reassigned to the new public and VPC interfaces. Default firewalls are not applied if none were originally attached.', + 'Public interfaces retain the Linode’s existing MAC address and SLAAC IPv6 address.', + 'Configuration Profile Interfaces are removed from the Configurations tab. The new Linode Interfaces will appear in the Network tab.', +]; + +export const configSelectSharedText = + 'This Linode has multiple configuration profiles. Choose one to continue.'; + +export const upgradeInterfacesWarningText = + 'After upgrading, the Linode will use only Linode Interfaces and cannot revert back to Configuration Profile Interfaces. Private IPv4 addresses are not supported on public Linode Interfaces. Services depending on a private IPv4 will no longer function.'; + +export const errorDryRunText = + 'The dry run found the following issues. After correcting them, perform another dry run.'; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 7f2a01d2d67..7ee9fb7f817 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -17,6 +17,7 @@ import type { LinodeInterfaces, LinodeIPsResponse, LinodeType, + UpgradeInterfaceData, Volume, } from '@linode/api-v4'; @@ -701,3 +702,42 @@ export const mockCreateLinodeInterfaceError = ( makeErrorResponse(errorMessage, statusCode) ); }; + +/** + * Intercepts POST request to create a Linode Interface. + * + * @param linodeId - the Linodes ID to add the interface to. + * @param linodeInterface - a mock upgrade linode interface object. + * + * @returns Cypress chainable. + */ +export const mockUpgradeNewLinodeInterface = ( + linodeId: number, + linodeInterface: UpgradeInterfaceData +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/upgrade-interfaces`), + makeResponse(linodeInterface) + ); +}; + +/** + * Intercepts POST request to create a Linode Interface and mocks an error response. + * + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockUpgradeNewLinodeInterfaceError = ( + linodeId: number, + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/upgrade-interfaces`), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index 283491a1ed0..335369127c6 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -1,3 +1,4 @@ +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; @@ -100,6 +101,14 @@ export const mockGetLongviewPlan = ( return cy.intercept('GET', apiMatcher('longview/plan'), makeResponse(plan)); }; +export const mockUnauthorizedLongviewPlanRequest = + (): Cypress.Chainable => { + return cy.intercept( + apiMatcher('longview/plan'), + makeErrorResponse('Unauthorized', 403) + ); + }; + export const mockUpdateLongviewPlan = ( newPlan: ActiveLongviewPlan ): Cypress.Chainable => { diff --git a/packages/manager/cypress/support/intercepts/nodebalancers.ts b/packages/manager/cypress/support/intercepts/nodebalancers.ts index 75cf8771abd..72eb85dda8d 100644 --- a/packages/manager/cypress/support/intercepts/nodebalancers.ts +++ b/packages/manager/cypress/support/intercepts/nodebalancers.ts @@ -4,6 +4,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; import type { Firewall, NodeBalancer } from '@linode/api-v4'; @@ -68,3 +69,20 @@ export const mockGetNodeBalancerFirewalls = ( export const interceptCreateNodeBalancer = (): Cypress.Chainable => { return cy.intercept('POST', apiMatcher('nodebalancers')); }; + +/** + * Intercepts POST request to create a nodeBalancer. + * + * @param nodebalancer - a mock nodeBalancer object + * + * @returns Cypress chainable. + */ +export const mockCreateNodeBalancer = ( + nodebalancer: NodeBalancer +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('nodebalancers'), + makeResponse(nodebalancer) + ); +}; diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index 34046c5fb5b..e4bb5251d6a 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -11,8 +11,8 @@ export const routes = { profile: '/profile', support: '/support', supportTickets: '/support/tickets', - supportTicketsClosed: '/support/tickets?type=closed', - supportTicketsOpen: '/support/tickets?type=open', + supportTicketsClosed: '/support/tickets/closed', + supportTicketsOpen: '/support/tickets/open', }; /** * due 2 rerender of the page that i could not deterministically check i added this wait diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 6c3d5407f61..816cf6c5990 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -3,7 +3,16 @@ import { createLinodeRequestFactory } from '@linode/utilities'; import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; import { findOrCreateDependencyVlan } from 'support/api/vlans'; import { pageSize } from 'support/constants/api'; +import { + dryRunButtonText, + promptDialogDescription1, + promptDialogDescription2, + promptDialogUpgradeDetails, + promptDialogUpgradeWhatHappensTitle, + upgradeInterfacesButtonText, +} from 'support/constants/linode-interfaces'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { ui } from 'support/ui'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomString } from 'support/util/random'; @@ -16,6 +25,7 @@ import type { CreateLinodeRequest, InterfacePayload, Linode, + LinodeInterface, } from '@linode/api-v4'; /** @@ -121,6 +131,7 @@ export const createTestLinode = async ( const resolvedCreatePayload = { ...createLinodeRequestFactory.build({ interface_generation: 'legacy_config', + firewall_id: null, booted: false, image: 'linode/ubuntu24.04', label: randomLabel(), @@ -212,3 +223,89 @@ export const fetchLinodeConfigs = async ( getLinodeConfigs(linodeId, { page, page_size: pageSize }) ); }; + +/** + * Check the content of prompt dialog + */ +export const assertPromptDialogContent = () => { + ui.dialog + .findByTitle('Upgrade to Linode Interfaces') + .should('be.visible') + .within(() => { + cy.findByText(promptDialogDescription1, { exact: false }).should( + 'be.visible' + ); + cy.findByText(promptDialogDescription2, { exact: false }).should( + 'be.visible' + ); + cy.findByText(promptDialogUpgradeWhatHappensTitle, { + exact: false, + }).should('be.visible'); + promptDialogUpgradeDetails.forEach((item) => { + cy.findByText(item).should('be.visible'); + }); + + ui.button + .findByTitle(dryRunButtonText) + .should('be.visible') + .should('be.enabled'); + ui.button + .findByTitle(upgradeInterfacesButtonText) + .should('be.visible') + .should('be.enabled'); + }); +}; + +/** + * Check the upgrade summary + * + * @param linodeInterface - Linode interface to check. + * @param isDryRun - Boolean to indicate if the upgrade performs dry run. + * + */ +export const assertUpgradeSummary = ( + linodeInterface: LinodeInterface, + isDryRun: boolean = false +) => { + if (isDryRun) { + // Confirm that dry run status is successful + cy.findByText('Dry run successful').should('be.visible'); + cy.findByText( + 'No issues were found. You can proceed with upgrading to Linode Interfaces.' + ).should('be.visible'); + + // Confirm that dry run summary details display. + cy.findByText('Dry Run Summary').should('be.visible'); + cy.findByText('Interface Meta Info').should('be.visible'); + cy.findByText(`MAC Address: ${linodeInterface.mac_address}`).should( + 'be.visible' + ); + cy.findByText(`Created: ${linodeInterface.created}`).should('be.visible'); + cy.findByText(`Updated: ${linodeInterface.updated}`).should('be.visible'); + cy.findByText(`Version: ${linodeInterface.version}`).should('be.visible'); + cy.findByText('Public Interface dry run successful.').should('be.visible'); + } else { + // Confirm that upgrade status is successful + cy.findByText('Upgrade successful').should('be.visible'); + cy.findByText( + 'Your Linode now uses Linode Interfaces. Existing interfaces were migrated, firewalls reassigned, and changes are visible', + { exact: false } + ).should('be.visible'); + + // Confirm that upgrade summary details display. + cy.findByText('Upgrade Summary').should('be.visible'); + cy.findByText( + `Interface Meta Info: Interface #${linodeInterface.id}` + ).should('be.visible'); + cy.findByText(`ID: ${linodeInterface.id}`).should('be.visible'); + cy.findByText(`MAC Address: ${linodeInterface.mac_address}`).should( + 'be.visible' + ); + cy.findByText(`Created: ${linodeInterface.created}`).should('be.visible'); + cy.findByText(`Updated: ${linodeInterface.updated}`).should('be.visible'); + cy.findByText(`Version: ${linodeInterface.version}`).should('be.visible'); + cy.findByText('Public Interface successfully upgraded.').should( + 'be.visible' + ); + } +}; diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index 9dab6756dbb..506577723dd 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -228,7 +228,7 @@ export const getRegionByLabel = (label: string, searchRegions?: Region[]) => { interface ChooseRegionOptions { /** * If specified, the region returned will support the defined capabilities - * @example 'Managed Databases' + * @example ['Managed Databases'] */ capabilities?: Capabilities[]; @@ -237,6 +237,11 @@ interface ChooseRegionOptions { */ exclude?: string[]; + /** + * Whether or not to include distributed regions in potential output. + */ + includeDistributed?: boolean; + /** * Regions from which to choose. If unspecified, Regions exposed by the API will be used. */ @@ -323,7 +328,17 @@ const resolveSearchRegions = ( const capableRegions = regionsWithCapabilities( options?.regions ?? regions, requiredCapabilities - ).filter((region: Region) => !allDisallowedRegionIds.includes(region.id)); + ).filter((region: Region) => { + const isDisallowed = !allDisallowedRegionIds.includes(region.id); + const isDistributed = region.site_type === 'distributed'; + + // Exclude distributed regions in output if `options.distributed` is not true. + if (isDistributed && options?.includeDistributed !== true) { + return false; + } + + return isDisallowed; + }); if (!capableRegions.length) { throw new Error( diff --git a/packages/manager/eslint.config.js b/packages/manager/eslint.config.js index 1f82e028d70..2c0c9c7b8fc 100644 --- a/packages/manager/eslint.config.js +++ b/packages/manager/eslint.config.js @@ -9,6 +9,7 @@ import perfectionist from 'eslint-plugin-perfectionist'; import prettier from 'eslint-plugin-prettier'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; import sonarjs from 'eslint-plugin-sonarjs'; import testingLibrary from 'eslint-plugin-testing-library'; import xss from 'eslint-plugin-xss'; @@ -18,7 +19,6 @@ import tseslint from 'typescript-eslint'; // Shared import restrictions between different rule contexts const restrictedImportPaths = [ - 'rxjs', '@mui/core', '@mui/system', '@mui/icons-material', @@ -136,11 +136,12 @@ export const baseConfig = [ }, }, - // 5. React and React Hooks + // 5. React, React Hooks, and React Refresh { files: ['**/*.{ts,tsx}'], plugins: { react, + 'react-refresh': reactRefresh, }, rules: { 'react-hooks/exhaustive-deps': 'warn', @@ -152,6 +153,7 @@ export const baseConfig = [ 'react/no-unescaped-entities': 'warn', 'react/prop-types': 'off', 'react/self-closing-comp': 'warn', + 'react-refresh/only-export-components': 'warn', // @todo make this error once we fix all occurrences }, }, @@ -403,14 +405,20 @@ export const baseConfig = [ // for each new features added to the migration router, add its directory here 'src/features/Betas/**/*', 'src/features/Domains/**/*', + 'src/features/DataStream/**/*', 'src/features/Firewalls/**/*', + 'src/features/Help/**/*', 'src/features/Images/**/*', 'src/features/Longview/**/*', 'src/features/Managed/**/*', 'src/features/NodeBalancers/**/*', 'src/features/ObjectStorage/**/*', 'src/features/PlacementGroups/**/*', + 'src/features/Search/**/*', + 'src/features/TopMenu/SearchBar/**/*', + 'src/components/Tag/**/*', 'src/features/StackScripts/**/*', + 'src/features/Support/**/*', 'src/features/Volumes/**/*', 'src/features/VPCs/**/*', ], diff --git a/packages/manager/package.json b/packages/manager/package.json index 0a06cca2a2b..1e3b318fdfc 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.142.1", + "version": "1.143.0", "private": true, "type": "module", "bugs": { @@ -32,13 +32,13 @@ "@linode/utilities": "workspace:*", "@linode/validation": "workspace:*", "@lukemorales/query-key-factory": "^1.3.4", - "@mui/icons-material": "^6.4.5", - "@mui/material": "^6.4.5", - "@mui/utils": "^6.4.3", + "@mui/icons-material": "^7.1.0", + "@mui/material": "^7.1.0", + "@mui/utils": "^7.1.0", "@mui/x-date-pickers": "^7.27.0", "@paypal/react-paypal-js": "^8.8.3", "@reach/tabs": "^0.18.0", - "@sentry/react": "^7.119.1", + "@sentry/react": "^9.19.0", "@shikijs/langs": "^3.1.0", "@shikijs/themes": "^3.1.0", "@tanstack/react-query": "5.51.24", @@ -112,7 +112,7 @@ "cy:component": "cypress open --component", "cy:component:run": "cypress run --component --headless -b chrome", "cy:rec-snap": "cypress run --headless -b chrome --env visualRegMode=record --spec ./cypress/integration/**/*visual*.spec.ts", - "typecheck": "tsc --noEmit && tsc -p cypress --noEmit", + "typecheck": "tsc && tsc -p cypress", "coverage": "vitest run --coverage && open coverage/index.html", "coverage:summary": "vitest run --coverage.enabled --reporter=junit --coverage.reporter=json-summary" }, diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index 41c2f2f97bc..d3187103a34 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,17 +1,25 @@ +import { useAccountSettings, useGrants, useProfile } from '@linode/queries'; import { Dialog, Select } from '@linode/ui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; -import { useAccountManagement } from './hooks/useAccountManagement'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; import type { SelectOption } from '@linode/ui'; export const GoTo = React.memo(() => { const routerHistory = useHistory(); - const { _hasAccountAccess, _isManagedAccount } = useAccountManagement(); + + const { data: accountSettings } = useAccountSettings(); + const { data: grants } = useGrants(); + const { data: profile } = useProfile(); + + const isManagedAccount = accountSettings?.managed ?? false; + + const hasAccountAccess = + !profile?.restricted || Boolean(grants?.global.account_access); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); @@ -30,7 +38,7 @@ export const GoTo = React.memo(() => { () => [ { display: 'Managed', - hide: !_isManagedAccount, + hide: !isManagedAccount, href: '/managed', }, { @@ -97,7 +105,7 @@ export const GoTo = React.memo(() => { }, { display: 'Account', - hide: !_hasAccountAccess, + hide: !hasAccountAccess, href: '/account/billing', }, { @@ -109,7 +117,7 @@ export const GoTo = React.memo(() => { href: '/profile/display', }, ], - [_hasAccountAccess, _isManagedAccount, isPlacementGroupsEnabled] + [hasAccountAccess, isManagedAccount, isPlacementGroupsEnabled] ); const options: SelectOption[] = React.useMemo( diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 0b39ef1a519..ffb41959941 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -6,14 +6,13 @@ import { } from '@linode/queries'; import { Box } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +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 Logo from 'src/assets/logo/akamai-logo.svg'; import { MainContentBanner } from 'src/components/MainContentBanner'; import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; import { @@ -129,32 +128,11 @@ const Profile = React.lazy(() => default: module.Profile, })) ); -const SupportTickets = React.lazy( - () => import('src/features/Support/SupportTickets') -); -const SupportTicketDetail = React.lazy(() => - import('src/features/Support/SupportTicketDetail/SupportTicketDetail').then( - (module) => ({ - default: module.SupportTicketDetail, - }) - ) -); -const Help = React.lazy(() => - import('./features/Help/index').then((module) => ({ - default: module.HelpAndSupport, - })) -); -const SearchLanding = React.lazy( - () => import('src/features/Search/SearchLanding') -); const EventsLanding = React.lazy(() => import('src/features/Events/EventsLanding').then((module) => ({ default: module.EventsLanding, })) ); -const AccountActivationLanding = React.lazy( - () => import('src/components/AccountActivation/AccountActivationLanding') -); const Databases = React.lazy(() => import('src/features/Databases')); const CloudPulseMetrics = React.lazy(() => @@ -226,6 +204,13 @@ export const MainContent = () => { 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, @@ -235,34 +220,13 @@ export const MainContent = () => { */ if (globalErrors.account_unactivated) { return ( -
-
- - - - - - - - - -
-
+ <> + + + ); } @@ -374,11 +338,6 @@ export const MainContent = () => { )} - - ({ + useLocation: vi.fn().mockReturnValue({ + state: { + supportTicketFormFields: {}, + }, + }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + }; +}); + describe('AccountActivationLanding', () => { it('renders the AccountActivationLanding component', () => { const { getByText, queryByText } = renderWithTheme( diff --git a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx index 86b3702eab5..2e40219d44f 100644 --- a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx +++ b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx @@ -1,14 +1,14 @@ -import { ErrorState, StyledLinkButton, Typography } from '@linode/ui'; +import { Box, ErrorState, StyledLinkButton, Typography } from '@linode/ui'; import Warning from '@mui/icons-material/CheckCircle'; -import { createLazyRoute } 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'; import type { AttachmentError } from 'src/features/Support/SupportTicketDetail/SupportTicketDetail'; -const AccountActivationLanding = () => { +export const AccountActivationLanding = () => { const history = useHistory(); const [supportDrawerIsOpen, toggleSupportDrawer] = @@ -27,49 +27,50 @@ const AccountActivationLanding = () => { }; return ( - - ({ marginBottom: theme.spacing(2) })} - variant="h2" - > - Your account is currently being reviewed. - - - Thanks for signing up! You’ll receive an email from us once - our review is complete, so hang tight. If you have questions during - this process{' '} - toggleSupportDrawer(true)}> - please open a Support ticket - - . - - toggleSupportDrawer(false)} - onSuccess={handleTicketSubmitSuccess} - open={supportDrawerIsOpen} - prefilledTitle="Help me activate my account" - /> - - } - /> + + + + ({ marginBottom: theme.spacingFunction(16) })} + variant="h2" + > + Your account is currently being reviewed. + + + Thanks for signing up! You’ll receive an email from us once + our review is complete, so hang tight. If you have questions + during this process{' '} + toggleSupportDrawer(true)}> + please open a Support ticket + + . + + toggleSupportDrawer(false)} + onSuccess={handleTicketSubmitSuccess} + open={supportDrawerIsOpen} + prefilledTitle="Help me activate my account" + /> + + } + /> + ); }; - -export const accountActivationLandingLazyRoute = createLazyRoute( - '/account-activation' -)({ - component: AccountActivationLanding, -}); - -export default React.memo(AccountActivationLanding); diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx index e4faf58a24f..e047b6096d0 100644 --- a/packages/manager/src/components/Avatar/Avatar.tsx +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -8,7 +8,7 @@ import AkamaiWave from 'src/assets/logo/akamai-wave.svg'; import type { SxProps, Theme } from '@mui/material'; -export const DEFAULT_AVATAR_SIZE = 28; +const DEFAULT_AVATAR_SIZE = 28; export interface AvatarProps { /** diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index 9a0c6439668..8cfb23885ed 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -1,6 +1,6 @@ import { Paper, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid2 from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -71,7 +71,7 @@ const StyledHeading = styled(Typography)(({ theme }) => ({ marginBottom: theme.spacing(3), })); -const StyledSummary = styled(Grid2)(({ theme }) => ({ +const StyledSummary = styled(Grid)(({ theme }) => ({ [theme.breakpoints.up('md')]: { '& > div': { '&:first-child': { diff --git a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx index 307eec624f3..7240c30b795 100644 --- a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx +++ b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid2 from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -27,7 +27,7 @@ export const SummaryItem = ({ details, title }: Props) => { ); }; -const StyledGrid = styled(Grid2)(({ theme }) => ({ +const StyledGrid = styled(Grid)(({ theme }) => ({ marginBottom: `${theme.spacing()} !important`, marginTop: `${theme.spacing()} !important`, paddingBottom: '0 !important', diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index f9017a88e21..a7ee81b07c0 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -1,7 +1,7 @@ import { Typography as FontTypography } from '@linode/design-language-system'; import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 4adab2e34d2..c4ab9722276 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -2,7 +2,7 @@ import { ActionsPanel, InputAdornment, TextField } from '@linode/ui'; import { Divider } from '@linode/ui'; import { Box } from '@linode/ui'; import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; -import { Grid, Popover } from '@mui/material'; +import { GridLegacy, Popover } from '@mui/material'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; @@ -220,13 +220,13 @@ export const DateTimePicker = ({ borderWidth: '0px', })} /> - {showTime && ( - + - + )} {showTimeZone && ( - + - + )} - + diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index 9d83f98c726..1ebd94a0cce 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -52,28 +52,15 @@ describe('DateTimeRangePicker Component', () => { vi.setSystemTime(vi.getRealSystemTime()); renderWithTheme(); - const now = DateTime.now().set({ second: 0 }); + // Open start date picker await userEvent.click(screen.getByLabelText('Start Date and Time')); await userEvent.click(screen.getByRole('gridcell', { name: '10' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - const expectedStartTime = now - .set({ - day: 10, - month: now.month, - year: now.year, - }) - .minus({ minutes: 30 }) - .toISO(); - - // Check if the onChange function is called with the expected value - expect(onChangeMock).toHaveBeenCalledWith({ - end: now.toISO(), - preset: 'custom_range', - start: expectedStartTime, - timeZone: null, - }); + + // Check if the onChange function is called + expect(onChangeMock).toHaveBeenCalled(); }); it('should disable the end date-time which is before the selected start date-time', async () => { diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx index c2fdfaeee14..da43b4ae11a 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { formatDate } from 'src/utilities/formatDate'; +import type { SxProps, Theme } from '@linode/ui'; import type { TimeInterval } from 'src/utilities/formatDate'; export interface DateTimeDisplayProps { @@ -23,6 +24,10 @@ export interface DateTimeDisplayProps { * If the date and time provided is within the designated time frame then the date is displayed as a relative date */ humanizeCutoff?: TimeInterval; + /** + * Styles to pass through to the sx prop. + */ + sx?: SxProps; /** * The date and time string to display */ @@ -30,10 +35,10 @@ export interface DateTimeDisplayProps { } const DateTimeDisplay = (props: DateTimeDisplayProps) => { - const { className, displayTime, format, humanizeCutoff, value } = props; + const { className, displayTime, format, humanizeCutoff, value, sx } = props; const { data: profile } = useProfile(); return ( - + {formatDate(value, { displayTime, format, diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts b/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts index 9a602e3ff2d..60e826b298a 100644 --- a/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts +++ b/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts @@ -1,13 +1,13 @@ import { omittedProps, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import type { DescriptionListProps } from './DescriptionList'; import type { TypographyProps } from '@mui/material'; -import type { Grid2Props } from '@mui/material/Grid2'; +import type { GridProps } from '@mui/material/Grid'; interface StyledDLProps extends Omit { - component: Grid2Props['component']; + component: GridProps['component']; gridColumns?: number; isStacked: boolean; } diff --git a/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx b/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx index c669f1f7508..3eccdc51055 100644 --- a/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx +++ b/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.tsx index 7d6524ae473..4a9b86e113f 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.tsx @@ -1,5 +1,5 @@ import { omittedProps } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx b/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx index b66e56752a6..0c7d23271d2 100644 --- a/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx +++ b/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import React from 'react'; diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 650543f1a39..aeb6393656d 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,3 +1,4 @@ +import { useAllImagesQuery } from '@linode/queries'; import { Autocomplete, Box, @@ -10,7 +11,6 @@ import { DateTime } from 'luxon'; import React, { useMemo } from 'react'; import { imageFactory } from 'src/factories/images'; -import { useAllImagesQuery } from 'src/queries/images'; import { formatDate } from 'src/utilities/formatDate'; import { OSIcon } from '../OSIcon'; @@ -157,7 +157,7 @@ export const ImageSelect = (props: Props) => { } return ( - + { const { maintenanceEnd, maintenanceStart, type } = props; - const { data: accountMaintenanceData } = useAllAccountMaintenanceQuery( + const { data: rawAccountMaintenanceData } = useAllAccountMaintenanceQuery( {}, PENDING_MAINTENANCE_FILTER ); + // Filter out platform maintenance, since that is handled separately + const accountMaintenanceData = rawAccountMaintenanceData?.filter( + (maintenance) => !isPlatformMaintenance(maintenance) + ); + const { data: profile, error: profileError, @@ -82,7 +88,7 @@ export const MaintenanceBanner = React.memo((props: Props) => { } For more information, please see your{' '} - open support tickets. + open support tickets. ); @@ -105,8 +111,8 @@ const generateIntroText = ( This Linode’s physical host is currently undergoing maintenance.{' '} {maintenanceActionTextMap[type]} Please refer to - your Support tickets for more - information. + your Support tickets for + more information. ); } diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 96dc1fa8cc3..c6a66c6a3aa 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -1,13 +1,15 @@ import { Button, CloseIcon, + IconButton, InputLabel, Notice, + Stack, TextField, TooltipIcon, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -245,54 +247,58 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { {helperText} )} {error && } - {ips.map((thisIP, idx) => ( - - - handleBlur(e, idx)} - onChange={(e: React.ChangeEvent) => - handleChange(e, idx) - } - placeholder={placeholder} - value={thisIP.address} - /> - - {/** Don't show the button for the first input since it won't do anything, unless this component is - * used in DBaaS or for Linode VPC interfaces - */} - - {(idx > 0 || forDatabaseAccessControls || forVPCIPv4Ranges) && ( - - )} + + {ips.map((thisIP, idx) => ( + + + handleBlur(e, idx)} + onChange={(e: React.ChangeEvent) => + handleChange(e, idx) + } + placeholder={placeholder} + value={thisIP.address} + /> + + {/** Don't show the button for the first input since it won't do anything, unless this component is + * used in DBaaS or for Linode VPC interfaces + */} + + {(idx > 0 || forDatabaseAccessControls || forVPCIPv4Ranges) && ( + removeInput(idx)} + > + + + )} + - - ))} + ))} + {addIPButton} ); diff --git a/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx b/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx index ee1613bce39..b794ab30d4b 100644 --- a/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx +++ b/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx b/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx index 2e19905b2fa..b513bfc088b 100644 --- a/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx +++ b/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx @@ -1,5 +1,5 @@ import { ActionsPanel } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx new file mode 100644 index 00000000000..a7a5ee043b4 --- /dev/null +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx @@ -0,0 +1,127 @@ +/* eslint-disable testing-library/prefer-screen-queries */ +import { linodeFactory } from '@linode/utilities'; +import React from 'react'; + +import { accountMaintenanceFactory, notificationFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { LinodePlatformMaintenanceBanner } from './LinodePlatformMaintenanceBanner'; + +const queryMocks = vi.hoisted(() => ({ + useNotificationsQuery: vi.fn().mockReturnValue({}), + useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}), + useLinodeQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + ...queryMocks, + }; +}); + +beforeEach(() => { + vi.stubEnv('TZ', 'UTC'); +}); + +describe('LinodePlatformMaintenanceBanner', () => { + it("doesn't render when there is no platform maintenance", () => { + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: accountMaintenanceFactory.buildList(3, { + type: 'reboot', + entity: { + type: 'linode', + }, + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: [], + }); + + const { queryByText } = renderWithTheme( + + ); + + expect( + queryByText('needs to be rebooted for critical platform maintenance.') + ).not.toBeInTheDocument(); + }); + + it('does not render if there is a notification but not a maintenance item', () => { + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: accountMaintenanceFactory.buildList(3, { + type: 'reboot', + entity: { + type: 'linode', + }, + reason: 'Unrelated maintenance', + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { queryByText } = renderWithTheme( + + ); + + expect( + queryByText('needs to be rebooted for critical platform maintenance.') + ).not.toBeInTheDocument(); + }); + + it('renders when a maintenance item is returned', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(2, { + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Your Linode needs a critical security update', + when: '2020-01-01T00:00:00', + start_time: '2020-01-01T00:00:00', + }); + const mockMaintenance = [ + ...mockPlatformMaintenance, + accountMaintenanceFactory.build({ + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Unrelated maintenance item', + }), + ]; + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockMaintenance, + }); + + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: mockPlatformMaintenance[0].entity.id, + label: 'linode-with-platform-maintenance', + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('linode-with-platform-maintenance')).toBeVisible(); + expect( + getByText((el) => + el.includes('needs to be rebooted for critical platform maintenance.') + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx new file mode 100644 index 00000000000..7e5151163c5 --- /dev/null +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx @@ -0,0 +1,112 @@ +import { useLinodeQuery } from '@linode/queries'; +import { Notice } from '@linode/ui'; +import { Box, Button, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; +import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; + +import { DateTimeDisplay } from '../DateTimeDisplay'; +import { Link } from '../Link'; + +import type { AccountMaintenance, Linode } from '@linode/api-v4'; + +export const LinodePlatformMaintenanceBanner = (props: { + linodeId: Linode['id']; +}) => { + const { linodeId } = props; + + const { linodesWithPlatformMaintenance, platformMaintenanceByLinode } = + usePlatformMaintenance(); + + const { data: linode } = useLinodeQuery( + linodeId, + linodesWithPlatformMaintenance.has(linodeId) + ); + + const [isRebootDialogOpen, setIsRebootDialogOpen] = React.useState(false); + + if (!linodesWithPlatformMaintenance.has(linodeId)) return null; + + const earliestMaintenance = platformMaintenanceByLinode[linodeId].reduce( + (earliest, current) => { + const currentMaintenanceStartTime = getMaintenanceStartTime(current); + const earliestMaintenanceStartTime = getMaintenanceStartTime(earliest); + + if (currentMaintenanceStartTime && earliestMaintenanceStartTime) { + return currentMaintenanceStartTime < earliestMaintenanceStartTime + ? current + : earliest; + } + + return earliest; + }, + platformMaintenanceByLinode[linodeId][0] + ); + + const startTime = getMaintenanceStartTime(earliestMaintenance); + + return ( + <> + + + + + Linode{' '} + + {linode?.label ?? linodeId} + {' '} + needs to be rebooted for critical platform maintenance.{' '} + {startTime && ( + <> + A reboot is scheduled for{' '} + + ({ + fontWeight: theme.tokens.font.FontWeight.Bold, + })} + value={startTime} + />{' '} + at{' '} + ({ + fontWeight: theme.tokens.font.FontWeight.Bold, + })} + value={startTime} + /> + + . + + )} + + + + + + setIsRebootDialogOpen(false)} + /> + + ); +}; + +// The 'start_time' field might not be available, so fallback to 'when' +const getMaintenanceStartTime = ( + maintenance: AccountMaintenance +): null | string | undefined => maintenance.start_time ?? maintenance.when; diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx new file mode 100644 index 00000000000..250b3cc821f --- /dev/null +++ b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx @@ -0,0 +1,108 @@ +/* eslint-disable testing-library/prefer-screen-queries */ +import React from 'react'; + +import { accountMaintenanceFactory, notificationFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PlatformMaintenanceBanner } from './PlatformMaintenanceBanner'; + +const queryMocks = vi.hoisted(() => ({ + useNotificationsQuery: vi.fn().mockReturnValue({}), + useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + ...queryMocks, + }; +}); + +describe('PlatformMaintenanceBanner', () => { + it("doesn't render when there is no platform maintenance", () => { + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: accountMaintenanceFactory.buildList(3, { + type: 'reboot', + entity: { + type: 'linode', + }, + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: [], + }); + + const { queryByText } = renderWithTheme(); + + expect(queryByText('One or more Linodes')).not.toBeInTheDocument(); + expect( + queryByText('needs to be rebooted for critical platform maintenance.') + ).not.toBeInTheDocument(); + }); + + it('renders with generic message when there is a notification', () => { + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: accountMaintenanceFactory.buildList(3, { + type: 'reboot', + entity: { + type: 'linode', + }, + reason: 'Unrelated maintenance', + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { getByText } = renderWithTheme(); + + expect(getByText('One or more Linodes')).toBeVisible(); + expect( + getByText((el) => + el.includes('need to be rebooted for critical platform maintenance.') + ) + ).toBeVisible(); + }); + + it('renders with count of affected linodes', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(2, { + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Your Linode needs a critical security update', + }); + const mockMaintenance = [ + ...mockPlatformMaintenance, + accountMaintenanceFactory.build({ + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Unrelated maintenance item', + }), + ]; + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockMaintenance, + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { getByText } = renderWithTheme(); + + expect(getByText('2 Linodes')).toBeVisible(); + expect( + getByText((el) => + el.includes('need to be rebooted for critical platform maintenance.') + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx new file mode 100644 index 00000000000..57c08bca624 --- /dev/null +++ b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx @@ -0,0 +1,37 @@ +import { Notice, Typography } from '@linode/ui'; +import React from 'react'; + +import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; + +import { Link } from '../Link'; + +/** + * This banner will be used in the event of VM platform maintenance, + * that requires a reboot, e.g., QEMU upgrades. Since these are + * urgent and affect a large portion of Linodes, we are displaying + * them separately from the standard MaintenanceBanner. + */ + +export const PlatformMaintenanceBanner = () => { + const { accountHasPlatformMaintenance, linodesWithPlatformMaintenance } = + usePlatformMaintenance(); + + if (!accountHasPlatformMaintenance) return null; + + return ( + + + + {linodesWithPlatformMaintenance.size > 0 + ? linodesWithPlatformMaintenance.size + : 'One or more'}{' '} + Linode{linodesWithPlatformMaintenance.size !== 1 && 's'} + {' '} + need{linodesWithPlatformMaintenance.size === 1 && 's'} to be rebooted + for critical platform maintenance. See which Linodes are scheduled for + reboot on the Account Maintenance{' '} + page. + + + ); +}; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index fc90a238d65..cbc9573592e 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -38,6 +38,7 @@ export type NavEntity = | 'Cloud Load Balancers' | 'Dashboard' | 'Databases' + | 'DataStream' | 'Domains' | 'Firewalls' | 'Help & Support' @@ -239,6 +240,12 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Longview', href: '/longview', }, + { + display: 'DataStream', + hide: !flags.aclpLogs?.enabled, + href: '/datastream', + isBeta: flags.aclpLogs?.beta, + }, ], name: 'Monitor', }, diff --git a/packages/manager/src/components/SelectionCard/CardBase.styles.ts b/packages/manager/src/components/SelectionCard/CardBase.styles.ts index e67a01b2643..c4ba1d46fc4 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.styles.ts +++ b/packages/manager/src/components/SelectionCard/CardBase.styles.ts @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import type { CardBaseProps } from './CardBase'; diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index fd659ce617b..6798ada6f8f 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -1,12 +1,12 @@ import { Tooltip } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { CardBase } from './CardBase'; import type { TooltipProps } from '@linode/ui'; -import type { Grid2Props } from '@mui/material/Grid2'; +import type { GridProps } from '@mui/material/Grid'; import type { SxProps, Theme } from '@mui/material/styles'; export interface SelectionCardProps { @@ -33,7 +33,7 @@ export interface SelectionCardProps { * Optionally override the grid item's size * @default { lg: 4, sm: 6, xl: 3, xs: 12 } */ - gridSize?: Grid2Props['size']; + gridSize?: GridProps['size']; /** * The heading of the card. * @example Linode 1GB diff --git a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx index ca429c7a94a..b6c97cc3ba5 100644 --- a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx +++ b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx @@ -3,6 +3,7 @@ import { useSnackbar } from 'notistack'; import React from 'react'; import { Snackbar } from 'src/components/Snackbar/Snackbar'; +import { eventFactory } from 'src/factories'; import { getEventMessage } from 'src/features/Events/utils'; import type { Meta, StoryObj } from '@storybook/react'; @@ -97,18 +98,20 @@ export const WithEventMessage: Story = { render: (args) => { const WithEventMessage = () => { const { enqueueSnackbar } = useSnackbar(); - const message = getEventMessage({ - action: 'placement_group_assign', - entity: { - label: 'Entity', - url: 'https://google.com', - }, - secondary_entity: { - label: 'Secondary Entity', - url: 'https://google.com', - }, - status: 'notification', - }); + const message = getEventMessage( + eventFactory.build({ + action: 'placement_group_assign', + entity: { + label: 'Entity', + url: 'https://google.com', + }, + secondary_entity: { + label: 'Secondary Entity', + url: 'https://google.com', + }, + status: 'notification', + }) + ); return ( + + )} + + {isEnabled && isEditing && ( + setIsEditing(false)} + open={isEditing} + title={`Edit ${preset.label}`} + > +

+ {formData.length} custom item{formData.length !== 1 && 's'} +

+ + +
+ + + + +
+ + +
+ String(index))} // we have to use a string bc Sortable doesn't like id=0 + strategy={verticalListSortingStrategy} + > + {formData.map((item, index) => + renderItemBox({ + item, + index, + onDelete: () => + handleInputChange( + formData.filter((_, i) => i !== index) + ), + onEdit: (updater) => + handleInputChange( + formData.map((item, i) => + i === index ? updater(item) : item + ) + ), + }) + )} + +
+
+
+ +
+
+
+ )} + + ); +}; + +const FieldWrapper = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +interface ItemBoxProps { + editItem: (updater: (prev: T) => T) => void; + formFields: ( + onChange: ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => void + ) => JSX.Element[]; + id: string; + item: T; + onDelete: () => void; + summary: JSX.Element; +} + +export const ItemBox = ({ + onDelete, + editItem, + id, + summary, + formFields, +}: ItemBoxProps) => { + const [isCollapsed, setIsCollapsed] = React.useState(true); + + const { + active, + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id }); + + const isActive = Boolean(active); + + // dnd-kit styles + const dndStyles = { + position: 'relative', + transform: CSS.Translate.toString(transform), + transition: isActive ? transition : 'none', + zIndex: isDragging ? 9999 : 0, + } as const; + + const handleInputChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => { + const { name, value } = e.target; + + const checkboxValue: boolean | undefined = + e.target.type === 'checkbox' && 'checked' in e.target + ? e.target.checked + : undefined; + + editItem((prev) => ({ + ...prev, + [name]: checkboxValue ?? (value || null), + })); + }; + + return ( +
+
+
+ +
+ +
+ {summary} +
+
+ +
+
+ {!isCollapsed && ( +
+ {formFields(handleInputChange).map((field, index) => ( + {field} + ))} +
+ )} +
+ ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx new file mode 100644 index 00000000000..c6489317505 --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -0,0 +1,184 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + +import { accountMaintenanceFactory } from 'src/factories'; +import { extraMockPresets } from 'src/mocks/presets'; +import { setCustomMaintenanceData } from 'src/mocks/presets/extra/account/customMaintenance'; + +import { saveCustomMaintenanceData } from '../utils'; +import { ExtraPresetList, ItemBox } from './ExtraPresetList'; +import { JsonTextArea } from './JsonTextArea'; + +import type { AccountMaintenance } from '@linode/api-v4'; + +const MAINTENANCE_PRESET_ID = 'maintenance:custom' as const; + +const maintenancePreset = extraMockPresets.find( + (p) => p.id === MAINTENANCE_PRESET_ID +); + +interface ExtraPresetMaintenanceProps { + customMaintenanceData: AccountMaintenance[] | null | undefined; + handlers: string[]; + onFormChange?: (data: AccountMaintenance[] | null | undefined) => void; + onTogglePreset: ( + e: React.ChangeEvent, + presetId: string + ) => void; +} + +export const ExtraPresetMaintenance = ({ + customMaintenanceData, + handlers, + onFormChange, + onTogglePreset, +}: ExtraPresetMaintenanceProps) => { + if (!maintenancePreset) return null; + + const isEnabled = handlers.includes(MAINTENANCE_PRESET_ID); + + return ( + ( + + )} + saveDataToLocalStorage={saveCustomMaintenanceData} + setMSWData={setCustomMaintenanceData} + /> + ); +}; + +interface MaintenanceBoxProps { + editMaintenance: ( + updater: (prev: AccountMaintenance) => AccountMaintenance + ) => void; + id: string; + maintenance: AccountMaintenance; + onDelete: () => void; +} + +const MaintenanceBox = (props: MaintenanceBoxProps) => { + const { maintenance, onDelete, editMaintenance, id } = props; + + return ( + renderMaintenanceFields(maintenance, onChange)} + id={id} + item={maintenance} + onDelete={onDelete} + summary={ + + Entity {maintenance.entity?.label} |{' '} + Type {maintenance.type} | Status{' '} + {maintenance.status} | Reason {maintenance.reason} + + } + /> + ); +}; + +const renderMaintenanceFields = ( + maintenance: AccountMaintenance, + onChange: ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => void +) => [ + , + + , + + , + + , + + , +]; + +const maintenanceTemplates = { + Default: () => accountMaintenanceFactory.build(), + Canceled: () => accountMaintenanceFactory.build({ status: 'canceled' }), + Completed: () => accountMaintenanceFactory.build({ status: 'completed' }), + 'In Progress': () => + accountMaintenanceFactory.build({ status: 'in-progress' }), + Pending: () => accountMaintenanceFactory.build({ status: 'pending' }), + Scheduled: () => accountMaintenanceFactory.build({ status: 'scheduled' }), + Started: () => accountMaintenanceFactory.build({ status: 'started' }), + 'Platform Maintenance': () => + accountMaintenanceFactory.build({ + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + status: 'scheduled', + type: 'reboot', + reason: + "In this case we must apply a critical security update to your Linode's host.", + }), +} as const; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetNotifications.tsx b/packages/manager/src/dev-tools/components/ExtraPresetNotifications.tsx new file mode 100644 index 00000000000..8f908038f2e --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetNotifications.tsx @@ -0,0 +1,264 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + +import { notificationFactory } from 'src/factories'; +import { extraMockPresets } from 'src/mocks/presets'; +import { setCustomNotificationsData } from 'src/mocks/presets/extra/account/customNotifications'; + +import { saveCustomNotificationsData } from '../utils'; +import { ExtraPresetList, ItemBox } from './ExtraPresetList'; +import { JsonTextArea } from './JsonTextArea'; + +import type { Notification } from '@linode/api-v4'; + +const NOTIFICATIONS_PRESET_ID = 'notifications:custom' as const; + +const notificationsPreset = extraMockPresets.find( + (p) => p.id === NOTIFICATIONS_PRESET_ID +); + +interface ExtraPresetNotificationsProps { + customNotificationsData: Notification[] | null | undefined; + handlers: string[]; + onFormChange?: (data: Notification[] | null | undefined) => void; + onTogglePreset: ( + e: React.ChangeEvent, + presetId: string + ) => void; +} + +export const ExtraPresetNotifications = ({ + customNotificationsData, + handlers, + onFormChange, + onTogglePreset, +}: ExtraPresetNotificationsProps) => { + if (!notificationsPreset) return null; + + const isEnabled = handlers.includes(NOTIFICATIONS_PRESET_ID); + + return ( + ( + + )} + saveDataToLocalStorage={saveCustomNotificationsData} + setMSWData={setCustomNotificationsData} + /> + ); +}; + +interface NotificationBoxProps { + editNotification: (updater: (prev: Notification) => Notification) => void; + id: string; + notification: Notification; + onDelete: () => void; +} + +const NotificationBox = (props: NotificationBoxProps) => { + const { notification, onDelete, editNotification, id } = props; + + return ( + + renderNotificationFields(notification, onChange) + } + id={id} + item={notification} + onDelete={onDelete} + summary={ + + Entity {notification.entity?.label} |{' '} + Label {notification.label} | Type{' '} + {notification.type} + + } + /> + ); +}; + +const renderNotificationFields = ( + notification: Notification, + onChange: ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => void +) => [ + , + + , + + , + + , + + , + + , + + , + + , +]; + +const notificationTemplates = { + Default: () => notificationFactory.build(), + 'Migration Notification': () => + notificationFactory.build({ + entity: { id: 0, label: 'linode-0', type: 'linode' }, + label: 'You have a migration pending!', + message: + 'You have a migration pending! Your Linode must be offline before starting the migration.', + severity: 'major', + type: 'migration_pending', + }), + 'Minor Severity Notification': () => + notificationFactory.build({ + message: 'Testing for minor notification', + severity: 'minor', + type: 'notice', + }), + 'Critical Severity Notification': () => + notificationFactory.build({ + message: 'Testing for critical notification', + severity: 'critical', + type: 'notice', + }), + 'Balance Notification': () => + notificationFactory.build({ + message: 'You have an overdue balance!', + severity: 'major', + type: 'payment_due', + }), + 'Block Storage Migration Scheduled Notification': () => + notificationFactory.build({ + body: 'Your volumes in us-east will be upgraded to NVMe.', + entity: { + id: 20, + label: 'eligibleNow', + type: 'volume', + url: '/volumes/20', + }, + label: 'You have a scheduled Block Storage volume upgrade pending!', + message: + 'The Linode that the volume is attached to will shut down in order to complete the upgrade and reboot once it is complete. Any other volumes attached to the same Linode will also be upgraded.', + severity: 'critical', + type: 'volume_migration_scheduled', + until: '2021-10-16T04:00:00', + when: '2021-09-30T04:00:00', + }), + 'Platform Maintenance Scheduled': () => + notificationFactory.build({ + label: + 'One or more of your Linodes has a scheduled reboot for a critical platform security update.', + message: + 'One or more of your Linodes has a scheduled reboot for a critical platform security update.', + severity: 'major', + type: 'security_reboot_maintenance_scheduled', + }), +} as const; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx index 31e788dc0ca..a2d9613f864 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -4,17 +4,36 @@ import { getMockPresetGroups } from 'src/mocks/mockPreset'; import { extraMockPresets } from 'src/mocks/presets'; import { ExtraPresetAccount } from './ExtraPresetAccount'; +import { ExtraPresetEvents } from './ExtraPresetEvents'; +import { ExtraPresetMaintenance } from './ExtraPresetMaintenance'; +import { ExtraPresetNotifications } from './ExtraPresetNotifications'; import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; import { ExtraPresetProfile } from './ExtraPresetProfile'; -import type { Account, Profile } from '@linode/api-v4'; +import type { + Account, + AccountMaintenance, + Event, + Notification, + Profile, +} from '@linode/api-v4'; export interface ExtraPresetOptionsProps { customAccountData?: Account | null; + customEventsData?: Event[] | null; + customMaintenanceData?: AccountMaintenance[] | null; + customNotificationsData?: Notification[] | null; customProfileData?: null | Profile; handlers: string[]; onCustomAccountChange?: (data: Account | null | undefined) => void; + onCustomEventsChange?: (data: Event[] | null | undefined) => void; + onCustomMaintenanceChange?: ( + data: AccountMaintenance[] | null | undefined + ) => void; + onCustomNotificationsChange?: ( + data: Notification[] | null | undefined + ) => void; onCustomProfileChange?: (data: null | Profile | undefined) => void; onPresetCountChange: (e: React.ChangeEvent, presetId: string) => void; onSelectChange: (e: React.ChangeEvent, presetId: string) => void; @@ -28,9 +47,15 @@ export interface ExtraPresetOptionsProps { export const ExtraPresetOptions = ({ customAccountData, customProfileData, + customEventsData, + customMaintenanceData, + customNotificationsData, handlers, onCustomAccountChange, onCustomProfileChange, + onCustomEventsChange, + onCustomMaintenanceChange, + onCustomNotificationsChange, onPresetCountChange, onSelectChange, onTogglePreset, @@ -88,6 +113,30 @@ export const ExtraPresetOptions = ({ onTogglePreset={onTogglePreset} /> )} + {currentGroupType === 'events' && ( + + )} + {currentGroupType === 'maintenance' && ( + + )} + {currentGroupType === 'notifications' && ( + + )} ); })} diff --git a/packages/manager/src/dev-tools/constants.ts b/packages/manager/src/dev-tools/constants.ts index d2b7a66d212..b1159189bed 100644 --- a/packages/manager/src/dev-tools/constants.ts +++ b/packages/manager/src/dev-tools/constants.ts @@ -13,3 +13,11 @@ export const LOCAL_STORAGE_PRESETS_MAP_KEY = 'msw-preset-count-map'; export const LOCAL_STORAGE_ACCOUNT_FORM_DATA_KEY = 'msw-account-form-data'; export const LOCAL_STORAGE_PROFILE_FORM_DATA_KEY = 'msw-profile-form-data'; + +export const LOCAL_STORAGE_EVENTS_FORM_DATA_KEY = 'msw-events-form-data'; + +export const LOCAL_STORAGE_MAINTENANCE_FORM_DATA_KEY = + 'msw-maintenance-form-data'; + +export const LOCAL_STORAGE_NOTIFICATIONS_FORM_DATA_KEY = + 'msw-notifications-form-data'; diff --git a/packages/manager/src/dev-tools/dev-tools.css b/packages/manager/src/dev-tools/dev-tools.css index 0cd87da2296..feec0feee2a 100644 --- a/packages/manager/src/dev-tools/dev-tools.css +++ b/packages/manager/src/dev-tools/dev-tools.css @@ -351,6 +351,10 @@ overflow-y: auto; padding-right: 24px; + .dev-tools__modal-form__no-max-height { + max-height: initial; + } + label { display: flex; flex-direction: column; @@ -378,6 +382,48 @@ font-family: monospace; white-space: pre; } + + input[type="checkbox"] { + align-self: start; + } +} + +.dev-tools__modal__rectangle-group { + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.5); + border-radius: 2px; + padding: 10px; + + .dev-tools__modal__controls { + background-color: white; + top: 0; + position: sticky; + display: flex; + flex-direction: row; + margin: -11px; + margin-bottom: -10px; + padding: 10px; + border-radius: 2px; + border: 1px solid rgba(0, 0, 0, 0.5); + border-bottom: none; + + button { + white-space: nowrap; + } + + > div:first-child { + width: calc(100% - 55px); + } + + > div:last-child { + flex-grow: 1; + text-align: right; + } + } + + form { + margin-top: 10px; + } } /* diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index a796e64717b..489e4989c09 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -1,4 +1,3 @@ -import { ENABLE_DEV_TOOLS } from 'src/constants'; import { mswDB } from 'src/mocks/indexedDB'; import { resolveMockPreset } from 'src/mocks/mockPreset'; import { createInitialMockStore, emptyStore } from 'src/mocks/mockState'; @@ -12,9 +11,7 @@ import { isMSWEnabled, } from './utils'; -import type { QueryClient } from '@tanstack/react-query'; import type { MockPresetExtra, MockSeeder, MockState } from 'src/mocks/types'; -import type { ApplicationStore } from 'src/store'; export let mockState: MockState; @@ -24,12 +21,7 @@ export let mockState: MockState; * * @param store Redux store to control */ -export async function loadDevTools( - store: ApplicationStore, - client: QueryClient -) { - const devTools = await import('./DevTools'); - +export async function loadDevTools() { if (isMSWEnabled) { const { worker: mswWorker } = await import('../mocks/mswWorkers'); const mswPresetId = getBaselinePreset() ?? defaultBaselineMockPreset.id; @@ -163,15 +155,4 @@ export async function loadDevTools( const worker = mswWorker(extraHandlers, baseHandlers); await worker.start({ onUnhandledRequest: 'bypass' }); } - - devTools.install(store, client); } - -/** - * Defaults to `true` for development - * Default to `false` in production builds - * - * Define `REACT_APP_ENABLE_DEV_TOOLS` to explicitly enable or disable dev tools - */ -export const shouldLoadDevTools = - ENABLE_DEV_TOOLS !== undefined ? ENABLE_DEV_TOOLS : import.meta.env.DEV; diff --git a/packages/manager/src/dev-tools/utils.ts b/packages/manager/src/dev-tools/utils.ts index dfc8234a345..9fd14ac0bd1 100644 --- a/packages/manager/src/dev-tools/utils.ts +++ b/packages/manager/src/dev-tools/utils.ts @@ -2,7 +2,10 @@ import { defaultBaselineMockPreset, extraMockPresets } from 'src/mocks/presets'; import { LOCAL_STORAGE_ACCOUNT_FORM_DATA_KEY, + LOCAL_STORAGE_EVENTS_FORM_DATA_KEY, LOCAL_STORAGE_KEY, + LOCAL_STORAGE_MAINTENANCE_FORM_DATA_KEY, + LOCAL_STORAGE_NOTIFICATIONS_FORM_DATA_KEY, LOCAL_STORAGE_PRESET_EXTRAS_KEY, LOCAL_STORAGE_PRESET_KEY, LOCAL_STORAGE_PRESETS_MAP_KEY, @@ -11,7 +14,13 @@ import { LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY, } from './constants'; -import type { Account, Profile } from '@linode/api-v4'; +import type { + Account, + AccountMaintenance, + Event, + Notification, + Profile, +} from '@linode/api-v4'; import type { MockPresetBaselineId, MockPresetExtraId, @@ -189,3 +198,67 @@ export const saveCustomProfileData = (data: null | Profile): void => { ); } }; + +/** + * Retrieves the custom events form data from local storage. + */ +export const getCustomEventsData = (): Event[] | null => { + const data = localStorage.getItem(LOCAL_STORAGE_EVENTS_FORM_DATA_KEY); + return data ? JSON.parse(data) : null; +}; + +/** + * Saves the custom events form data to local storage. + */ +export const saveCustomEventsData = (data: Event[] | null): void => { + if (data) { + localStorage.setItem( + LOCAL_STORAGE_EVENTS_FORM_DATA_KEY, + JSON.stringify(data) + ); + } +}; + +/** + * Retrieves the custom maintenance form data from local storage. + */ +export const getCustomMaintenanceData = (): AccountMaintenance[] | null => { + const data = localStorage.getItem(LOCAL_STORAGE_MAINTENANCE_FORM_DATA_KEY); + return data ? JSON.parse(data) : null; +}; + +/** + * Saves the custom maintenance form data to local storage. + */ +export const saveCustomMaintenanceData = ( + data: AccountMaintenance[] | null +): void => { + if (data) { + localStorage.setItem( + LOCAL_STORAGE_MAINTENANCE_FORM_DATA_KEY, + JSON.stringify(data) + ); + } +}; + +/** + * Retrieves the custom notifications form data from local storage. + */ +export const getCustomNotificationsData = (): Notification[] | null => { + const data = localStorage.getItem(LOCAL_STORAGE_NOTIFICATIONS_FORM_DATA_KEY); + return data ? JSON.parse(data) : null; +}; + +/** + * Saves the custom notifications form data to local storage. + */ +export const saveCustomNotificationsData = ( + data: Notification[] | null +): void => { + if (data) { + localStorage.setItem( + LOCAL_STORAGE_NOTIFICATIONS_FORM_DATA_KEY, + JSON.stringify(data) + ); + } +}; diff --git a/packages/manager/src/env.d.ts b/packages/manager/src/env.d.ts index 9f68e1692eb..8e981d9ad2c 100644 --- a/packages/manager/src/env.d.ts +++ b/packages/manager/src/env.d.ts @@ -48,3 +48,6 @@ declare module '*?raw' { const src: string; export default src; } + +declare module 'logic-query-parser'; +declare module 'search-string'; diff --git a/packages/manager/src/exceptionReporting.ts b/packages/manager/src/exceptionReporting.ts index be9a79cad30..6915347d1ca 100644 --- a/packages/manager/src/exceptionReporting.ts +++ b/packages/manager/src/exceptionReporting.ts @@ -1,4 +1,4 @@ -import { captureException, configureScope, withScope } from '@sentry/react'; +import { captureException, getCurrentScope, withScope } from '@sentry/react'; import { SENTRY_URL } from 'src/constants'; import { initSentry } from 'src/initSentry'; @@ -58,10 +58,8 @@ export const configureErrorReportingUser = ( userId: string, username: string ) => { - configureScope((scope) => { - scope.setUser({ - user_id: userId, - username, - }); + getCurrentScope().setUser({ + user_id: userId, + username, }); }; diff --git a/packages/manager/src/factories/accountMaintenance.ts b/packages/manager/src/factories/accountMaintenance.ts index 66501b16bed..23b4c1d268a 100644 --- a/packages/manager/src/factories/accountMaintenance.ts +++ b/packages/manager/src/factories/accountMaintenance.ts @@ -45,8 +45,16 @@ export const accountMaintenanceFactory = type: Factory.each(() => pickRandom(['cold_migration', 'live_migration', 'reboot']) ), - when: Factory.each(() => randomDate().toISOString()), - not_before: Factory.each(() => randomDate().toISOString()), - start_time: Factory.each(() => randomDate().toISOString()), - complete_time: Factory.each(() => randomDate().toISOString()), + when: Factory.each( + () => randomDate().toISO({ includeOffset: false }) ?? '' + ), + not_before: Factory.each( + () => randomDate().toISO({ includeOffset: false }) ?? '' + ), + start_time: Factory.each( + () => randomDate().toISO({ includeOffset: false }) ?? '' + ), + complete_time: Factory.each( + () => randomDate().toISO({ includeOffset: false }) ?? '' + ), }); diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index f2a7f34ecbe..e8a2a7ae4e9 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -258,7 +258,7 @@ export const databaseBackupFactory = Factory.Sync.makeFactory({ created: Factory.each(() => { const now = new Date(); const tenDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); - return randomDate(tenDaysAgo, now).toISOString(); + return randomDate(tenDaysAgo, now).toISO() ?? ''; }), id: Factory.each((i) => i), label: Factory.each(() => `backup-${crypto.randomUUID()}`), diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts index 5e81fae6533..f6aa63c4586 100644 --- a/packages/manager/src/factories/kubernetesCluster.ts +++ b/packages/manager/src/factories/kubernetesCluster.ts @@ -110,13 +110,13 @@ export const kubernetesVersionFactory = export const kubernetesStandardTierVersionFactory = Factory.Sync.makeFactory({ - id: Factory.each((id) => `v1.3${id}`), + id: Factory.each((id) => `1.3${id}`), tier: 'standard', }); export const kubernetesEnterpriseTierVersionFactory = Factory.Sync.makeFactory({ - id: Factory.each((id) => `v1.31.${id}+lke1`), + id: Factory.each((id) => `v1.31.${id}+lke${id}`), tier: 'enterprise', }); diff --git a/packages/manager/src/factories/notification.ts b/packages/manager/src/factories/notification.ts index 82907bec271..2c2b2eb5e40 100644 --- a/packages/manager/src/factories/notification.ts +++ b/packages/manager/src/factories/notification.ts @@ -21,7 +21,7 @@ export const notificationFactory = Factory.Sync.makeFactory({ severity: 'critical', type: 'maintenance', until: null, - when: DateTime.local().plus({ days: 7 }).toISODate(), + when: DateTime.local().plus({ days: 7 }).toISO({ includeOffset: false }), }); export const abuseTicketNotificationFactory = notificationFactory.extend({ diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts index 3b47cbf82b8..9fdbde146be 100644 --- a/packages/manager/src/factories/quotas.ts +++ b/packages/manager/src/factories/quotas.ts @@ -4,7 +4,7 @@ import type { Quota, QuotaUsage } from '@linode/api-v4/lib/quotas/types'; export const quotaFactory = Factory.Sync.makeFactory({ description: 'Maximimum number of vCPUs allowed', - quota_id: Factory.each((id) => id), + quota_id: Factory.each((id) => id.toString()), quota_limit: 50, quota_name: 'Linode Dedicated vCPUs', region_applied: 'us-east', diff --git a/packages/manager/src/factories/subnets.ts b/packages/manager/src/factories/subnets.ts index f06f91a0adf..d81bf06a94c 100644 --- a/packages/manager/src/factories/subnets.ts +++ b/packages/manager/src/factories/subnets.ts @@ -3,6 +3,7 @@ import { Factory } from '@linode/utilities'; import type { Subnet, SubnetAssignedLinodeData, + SubnetAssignedNodeBalancerData, } from '@linode/api-v4/lib/vpcs/types'; // NOTE: Changing to fixed array length for the interfaces and linodes fields of the @@ -20,6 +21,12 @@ export const subnetAssignedLinodeDataFactory = ), }); +export const subnetAssignedNodebalancerDataFactory = + Factory.Sync.makeFactory({ + id: Factory.each((i) => i), + ipv4_range: Factory.each((i) => `192.168.${i}.0/30`), + }); + export const subnetFactory = Factory.Sync.makeFactory({ created: '2023-07-12T16:08:53', id: Factory.each((i) => i), @@ -32,6 +39,12 @@ export const subnetFactory = Factory.Sync.makeFactory({ }) ) ), - nodebalancers: [], + nodebalancers: Factory.each((i) => + Array.from({ length: 3 }, (_, arrIdx) => + subnetAssignedNodebalancerDataFactory.build({ + id: i * 10 + arrIdx, + }) + ) + ), updated: '2023-07-12T16:08:53', }); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 4fc91fd8873..ddccd63a06e 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -110,17 +110,20 @@ export interface Flags { aclpAlerting: AclpAlerting; aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[]; aclpIntegration: boolean; + aclpLogs: BetaFeatureFlag; aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; apicliButtonCopy: string; apiMaintenance: APIMaintenance; apl: boolean; + aplGeneralAvailability: boolean; blockStorageEncryption: boolean; cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; databaseAdvancedConfig: boolean; databaseBeta: boolean; databaseResize: boolean; databases: boolean; + databaseVpc: boolean; dbaasV2: BetaFeatureFlag; dbaasV2MonitorMetrics: BetaFeatureFlag; disableLargestGbPlans: boolean; @@ -244,6 +247,7 @@ export type ProductInformationBannerLocation = | 'Account' | 'Betas' | 'Databases' + | 'DataStream' | 'Domains' | 'Firewalls' | 'Identity and Access' diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 9247d2de527..04df40cba27 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -17,6 +17,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; import AccountLogins from './AccountLogins'; import { SwitchAccountButton } from './SwitchAccountButton'; import { SwitchAccountDrawer } from './SwitchAccountDrawer'; @@ -193,6 +194,7 @@ const AccountLanding = () => { return ( + diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index f3ae90e98fa..86d7b7c05b6 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -1,7 +1,7 @@ import { useProfile } from '@linode/queries'; import { Tooltip } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import { capitalize, truncate } from '@linode/utilities'; +import { capitalize, getFormattedStatus, truncate } from '@linode/utilities'; import * as React from 'react'; import { Link } from 'src/components/Link'; @@ -70,7 +70,7 @@ export const MaintenanceTableRow = (props: AccountMaintenance) => { - {capitalize(type.replace('_', ' '))} + {getFormattedStatus(type)} diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index 70841a9ecfa..4c3c3658999 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -1,3 +1,11 @@ export const PENDING_MAINTENANCE_FILTER = Object.freeze({ - status: { '+or': ['pending', 'started'] }, + status: { '+or': ['pending', 'started', 'scheduled'] }, }); + +export const PLATFORM_MAINTENANCE_TYPE = + 'security_reboot_maintenance_scheduled'; + +export const PLATFORM_MAINTENANCE_REASON_MATCH = [ + 'critical platform update', + 'critical security update', +]; diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx index d92b9b40e8b..8d87327e4ff 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx @@ -19,8 +19,8 @@ const queryMocks = vi.hoisted(() => ({ useQuotasQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/quotas/quotas', () => { - const actual = vi.importActual('src/queries/quotas/quotas'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, quotaQueries: queryMocks.quotaQueries, diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index f8d63cd910d..2b11dc11276 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -1,3 +1,4 @@ +import { quotaQueries, useQuotasQuery } from '@linode/queries'; import { Dialog, ErrorState } from '@linode/ui'; import { useQueries } from '@tanstack/react-query'; import * as React from 'react'; @@ -12,8 +13,6 @@ import { TableRow } from 'src/components/TableRow/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { usePagination } from 'src/hooks/usePagination'; -import { useQuotasQuery } from 'src/queries/quotas/quotas'; -import { quotaQueries } from 'src/queries/quotas/quotas'; import { QuotasIncreaseForm } from './QuotasIncreaseForm'; import { QuotasTableRow } from './QuotasTableRow'; diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index dab10c24980..50b0ff6c636 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -4,7 +4,7 @@ import { useProfile, } from '@linode/queries'; import { Button, CircleProgress, ErrorState } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { styled } from '@mui/material/styles'; import { PayPalScriptProvider } from '@paypal/react-paypal-js'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index e6ea37695cf..a3dd9d6612b 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -8,7 +8,7 @@ import { } from '@linode/queries'; import { Autocomplete, Typography } from '@linode/ui'; import { getAll, useSet } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx index de438dd6bb4..e9dec69febc 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -1,12 +1,16 @@ -import { useGrants, useNotificationsQuery } from '@linode/queries'; +import { + useAccount, + useGrants, + useNotificationsQuery, + useProfile, +} from '@linode/queries'; import { Box, Button, Divider, TooltipIcon, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { Currency } from 'src/components/Currency'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { isWithinDays } from 'src/utilities/date'; import { BillingPaper } from '../../BillingDetail'; @@ -26,8 +30,12 @@ interface BillingSummaryProps { export const BillingSummary = (props: BillingSummaryProps) => { const theme = useTheme(); + const { data: notifications } = useNotificationsQuery(); - const { _isRestrictedUser, account } = useAccountManagement(); + const { data: account } = useAccount(); + const { data: profile } = useProfile(); + + const isRestrictedUser = profile?.restricted; const [isPromoDialogOpen, setIsPromoDialogOpen] = React.useState(false); @@ -140,7 +148,7 @@ export const BillingSummary = (props: BillingSummaryProps) => { const showAddPromoLink = balance <= 0 && - !_isRestrictedUser && + !isRestrictedUser && isWithinDays(90, account?.active_since) && promotions?.length === 0; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx index c6375a3030d..a48830919e5 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx @@ -1,7 +1,7 @@ import { useAccount, useClientToken } from '@linode/queries'; import { CircleProgress, Tooltip } from '@linode/ui'; import { useScript } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx index 0e32dbad49c..d2e0f998c08 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx @@ -1,7 +1,7 @@ import { makePayment } from '@linode/api-v4/lib/account/payments'; import { accountQueries, useAccount, useClientToken } from '@linode/queries'; import { CircleProgress, Tooltip } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { BraintreePayPalButtons, DISPATCH_ACTION, diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx index 6ca41bb1804..af36073255d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx @@ -12,7 +12,7 @@ import { TooltipIcon, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index 7242acb834d..7af5ef663f2 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -1,6 +1,6 @@ import { useNotificationsQuery, usePreferences } from '@linode/queries'; import { Box, TooltipIcon, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { allCountries } from 'country-region-data'; import * as React from 'react'; import { useState } from 'react'; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 81124135dec..6a538ab6215 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -14,7 +14,7 @@ import { TextField, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; import { allCountries } from 'country-region-data'; import { useFormik } from 'formik'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx index e2b23107b55..06fa6b3d9a4 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx @@ -1,7 +1,7 @@ import { useAddPaymentMethodMutation } from '@linode/queries'; import { ActionsPanel, Notice, TextField } from '@linode/ui'; import { CreditCardSchema } from '@linode/validation'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useFormik, yupToFormErrors } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -12,7 +12,7 @@ import { makeStyles } from 'tss-react/mui'; import { parseExpiryYear } from 'src/utilities/creditCard'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; -import type { InputBaseComponentProps } from '@mui/material/InputBase/InputBase'; +import type { InputBaseComponentProps } from '@mui/material'; import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()((theme: Theme) => ({ diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx index 751431ab6d8..0d61a2b520d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx @@ -7,7 +7,7 @@ import { TooltipIcon, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { LinearProgress } from 'src/components/LinearProgress'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index 20e2f11308f..81ef5829f33 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -1,7 +1,7 @@ import { deletePaymentMethod } from '@linode/api-v4/lib/account'; import { accountQueries } from '@linode/queries'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { useHistory, useRouteMatch } from 'react-router-dom'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx index 72115b9823b..efbc179be8b 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx @@ -1,5 +1,5 @@ import { CircleProgress, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { PaymentMethodRow } from 'src/components/PaymentMethodRow/PaymentMethodRow'; diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 2f97fdac2cb..aa018b056f9 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx @@ -3,7 +3,7 @@ import { useAccount, useRegionsQuery } from '@linode/queries'; import { Box, Button, IconButton, Notice, Paper, Typography } from '@linode/ui'; import { getAll } from '@linode/utilities'; import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx index 2a1d2c2a9c2..80c8a4b32d3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import { Grid, useTheme } from '@mui/material'; +import { GridLegacy, useTheme } from '@mui/material'; import React from 'react'; import { convertSecondsToMinutes } from '../Utils/utils'; @@ -30,15 +30,15 @@ export const AlertDetailCriteria = React.memo((props: CriteriaProps) => { const renderTriggerCriteria = React.useMemo( () => ( <> - + Trigger Alert When: - - + { consecutive occurrences. - + ), [theme, triggerOccurrences] @@ -78,7 +78,7 @@ export const AlertDetailCriteria = React.memo((props: CriteriaProps) => { Criteria - { values={[convertSecondsToMinutes(evaluationPeriod)]} /> {renderTriggerCriteria} {/** Render the trigger criteria */} - + ); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx index bb7a407a361..7289424ab01 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx @@ -1,5 +1,5 @@ import { CircleProgress, ErrorState, Stack, Typography } from '@linode/ui'; -import { Divider, Grid } from '@mui/material'; +import { Divider, GridLegacy } from '@mui/material'; import React from 'react'; import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; @@ -58,7 +58,7 @@ export const AlertDetailNotification = React.memo( Notification Channels - { const { channel_type, id, label } = notificationChannel; return ( - + - + - + {channels.length > 1 && index !== channels.length - 1 && ( - + - + )} - + ); })} - + ); } diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx index f82e12f3655..23b02468c37 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx @@ -1,4 +1,4 @@ -import { Grid, useTheme } from '@mui/material'; +import { GridLegacy, useTheme } from '@mui/material'; import React from 'react'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; @@ -45,13 +45,13 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { const theme = useTheme(); return ( - - + + {label}: - - + + {status && ( { /> )} {value} - - + + ); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx index 411257efa94..a774100456a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx @@ -1,4 +1,4 @@ -import { Grid, useTheme } from '@mui/material'; +import { GridLegacy, useTheme } from '@mui/material'; import React from 'react'; import { getAlertChipBorderRadius } from '../Utils/utils'; @@ -49,18 +49,18 @@ export const DisplayAlertDetailChips = React.memo( : []; const theme = useTheme(); return ( - + {chipValues.map((value, index) => ( - + {index === 0 && ( {label}: )} - - - + + {value.map((label, index) => ( - - + ))} - - + + ))} -
+ ); } ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx index e217eb7151b..6daade31a0e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx @@ -1,6 +1,6 @@ import { Divider } from '@linode/ui'; import { capitalize } from '@linode/utilities'; -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import NullComponent from 'src/components/NullComponent'; @@ -44,7 +44,7 @@ export const RenderAlertMetricsAndDimensions = React.memo( index ) => ( - + - + {dimensionFilters && dimensionFilters.length > 0 && ( - + - + )} - + - + ) ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index 563b3c183d3..d5cb3d20e25 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -6,7 +6,7 @@ import { } from '@linode/api-v4'; import { Notice, Typography } from '@linode/ui'; import { groupByTags, sortGroups } from '@linode/utilities'; -import { Grid2, TableBody, TableHead, TableRow } from '@mui/material'; +import { GridLegacy, TableBody, TableHead, TableRow } from '@mui/material'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -239,7 +239,7 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { return ( <> - + { /> )}
-
+ {!isGroupedByTag && ( { alert for.
)} - - + { }} xs={12} > - + { }} value={searchText || ''} /> - + {/* Dynamically render service type based filters */} {filtersToRender.map(({ component, filterKey }, index) => ( - + { ), })} /> - + ))} - + {isSelectionsNeeded && ( - + { text="Show Selected Only" value="Show Selected" /> - + )} {errorText?.length && ( - + - + )} {maxSelectionCount !== undefined && ( - + - + )} {isSelectionsNeeded && !isDataLoadingError && resources && resources.length > 0 && ( - + - + )} - + { selectionsRemaining={selectionsRemaining} serviceType={serviceType} /> - - + + ); }); 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 79e18bc088d..2b30ccb746d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -1,6 +1,6 @@ import { Autocomplete, Box, TextField } from '@linode/ui'; import { capitalize } from '@linode/utilities'; -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; @@ -92,14 +92,14 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { ? textFieldOperators.includes(dimensionOperatorWatcher) : false; return ( - - + { /> )} /> - - + + { /> )} /> - - + + {
- - + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index 06c38e61bee..b638c61691c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -1,6 +1,6 @@ import { Autocomplete, Box } from '@linode/ui'; import { TextField, Typography } from '@linode/ui'; -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; @@ -129,8 +129,8 @@ export const Metric = (props: MetricCriteriaProps) => { {showDeleteIcon && } - - + + { /> )} /> - - + + { /> )} /> - - + + { /> )} /> - - + + { {unit} - - + + { })} > Trigger Conditions - - + { /> )} /> - - + + { /> )} /> - - + { > consecutive occurrence(s). - - + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx index cced81ec99f..3c4ca191e09 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx @@ -6,7 +6,7 @@ import { Drawer, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; @@ -194,11 +194,11 @@ export const AddNotificationChannelDrawer = ( {selectedTemplate && selectedTemplate.channel_type === 'email' && ( - - + + To: - - + - - + + )} diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index b5bae381f73..8d4d21dd011 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -1,5 +1,5 @@ import { CircleProgress, ErrorState } from '@linode/ui'; -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; @@ -154,8 +154,8 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { */ const renderErrorState = (errorMessage: string) => { return ( - + - + ); }; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 29b7948d4bd..b7409301d30 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,5 +1,5 @@ import { Box, Paper } from '@linode/ui'; -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { Redirect } from 'react-router-dom'; @@ -87,8 +87,8 @@ export const CloudPulseDashboardLanding = () => { docsLabel="Docs" docsLink="https://techdocs.akamai.com/cloud-computing/docs/akamai-cloud-pulse" /> - - + + { )} - + - + ); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 3d86db12a1d..61382d06154 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,5 +1,5 @@ import { Box, CircleProgress, Divider, ErrorState, Paper } from '@linode/ui'; -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; @@ -129,8 +129,8 @@ export const CloudPulseDashboardWithFilters = React.memo( padding: 0, }} > - - + + - + - + ({ borderColor: theme.color.grey5, margin: 0, })} /> - + {isFilterBuilderNeeded && ( )} - )} - - + + {isMandatoryFiltersSelected ? ( { }, []); return ( - - + + { - + {selectedDashboard && ( - + ({ borderColor: theme.color.grey5, margin: 0, })} /> - + )} {selectedDashboard && ( @@ -154,6 +154,6 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { preferences={preferences} /> )} - + ); }); diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index f137b451199..ff7a182e3aa 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -1,6 +1,6 @@ import { useProfile } from '@linode/queries'; -import { Paper, Typography } from '@linode/ui'; -import { Box, Grid, Stack, useTheme } from '@mui/material'; +import { Box, Paper, Typography } from '@linode/ui'; +import { GridLegacy, Stack, useTheme } from '@mui/material'; import { DateTime } from 'luxon'; import React from 'react'; @@ -280,7 +280,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const hours = end.diff(start, 'hours').hours; const tickFormat = hours <= 24 ? 'hh:mm a' : 'LLL dd'; return ( - + { /> - + ); }; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 452c4546ee7..cf07acc0a3f 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -1,4 +1,4 @@ -import { Grid, Paper } from '@mui/material'; +import { GridLegacy, Paper } from '@mui/material'; import React from 'react'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; @@ -42,11 +42,11 @@ interface WidgetProps { const renderPlaceHolder = (subtitle: string) => { return ( - + - + ); }; @@ -138,7 +138,7 @@ export const RenderWidgets = React.memo( // maintain a copy const newDashboard: Dashboard = createObjectCopy(dashboard)!; return ( - + {{ ...newDashboard }.widgets.map((widget, index) => { // check if widget metric definition is available or not if (widget) { @@ -174,7 +174,7 @@ export const RenderWidgets = React.memo( return ; } })} - + ); }, (oldProps: WidgetProps, newProps: WidgetProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx index 45e58792447..685cc7eef2f 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx @@ -1,4 +1,4 @@ -import { Grid } from '@mui/material'; +import { GridLegacy } from '@mui/material'; import React from 'react'; import { dashboardFactory } from 'src/factories'; @@ -37,7 +37,7 @@ describe('ComponentRenderer component tests', () => { }); const { getByPlaceholderText } = renderWithTheme( - + {RenderComponent({ componentKey: 'tags', componentProps: { @@ -52,7 +52,7 @@ describe('ComponentRenderer component tests', () => { }, key: 'tags', })} - + ); expect(getByPlaceholderText('Select Tags')).toBeDefined(); @@ -76,7 +76,7 @@ describe('ComponentRenderer component tests', () => { }); const { getByPlaceholderText } = renderWithTheme( - + {RenderComponent({ componentKey: 'region', componentProps: { @@ -91,7 +91,7 @@ describe('ComponentRenderer component tests', () => { }, key: 'region', })} - + ); expect(getByPlaceholderText('Select a Region')).toBeDefined(); @@ -118,7 +118,7 @@ describe('ComponentRenderer component tests', () => { }); const { getByPlaceholderText } = renderWithTheme( - + {RenderComponent({ componentKey: 'resource_id', componentProps: { @@ -134,7 +134,7 @@ describe('ComponentRenderer component tests', () => { }, key: 'resource_id', })} - + ); expect(getByPlaceholderText('Select Resources')).toBeDefined(); }); @@ -163,7 +163,7 @@ describe('ComponentRenderer component tests', () => { }); const { getByPlaceholderText } = renderWithTheme( - + {RenderComponent({ componentKey: 'node_type', componentProps: { @@ -179,7 +179,7 @@ describe('ComponentRenderer component tests', () => { }, key: 'node_type', })} - + ); expect(getByPlaceholderText('Select a Node Type')).toBeDefined(); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index e919ac3e035..ba04c1e56dc 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -1,5 +1,5 @@ import { Button, ErrorState, Typography } from '@linode/ui'; -import { Grid, useTheme } from '@mui/material'; +import { GridLegacy, useTheme } from '@mui/material'; import * as React from 'react'; import KeyboardCaretDownIcon from 'src/assets/icons/caret_down.svg'; @@ -322,7 +322,13 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } return filters.map((filter, index) => ( - + {RenderComponent({ componentKey: filter.configuration.type !== undefined @@ -331,7 +337,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( componentProps: { ...getProps(filter) }, key: index + filter.configuration.filterKey, })} - + )); }, [dashboard, getProps, isServiceAnalyticsIntegration]); @@ -344,7 +350,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } return ( - - Filters - - + - - + + ); }, compareProps diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx index ee48340adce..4875c182954 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx @@ -1,4 +1,4 @@ -import { Grid, Paper } from '@mui/material'; +import { GridLegacy, Paper } from '@mui/material'; import React from 'react'; import CloudPulseIcon from 'src/assets/icons/entityIcons/monitor.svg'; @@ -8,7 +8,7 @@ export const CloudPulseErrorPlaceholder = React.memo( (props: { errorMessage: string }) => { const { errorMessage } = props; return ( - + - + ); } ); diff --git a/packages/manager/src/features/DataStream/DataStreamLanding.tsx b/packages/manager/src/features/DataStream/DataStreamLanding.tsx new file mode 100644 index 00000000000..1480bcf3546 --- /dev/null +++ b/packages/manager/src/features/DataStream/DataStreamLanding.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useTabs } from 'src/hooks/useTabs'; + +const Destinations = React.lazy(() => + import('./Destinations/Destinations').then((module) => ({ + default: module.Destinations, + })) +); + +const Streams = React.lazy(() => + import('./Streams/StreamsLanding').then((module) => ({ + default: module.StreamsLanding, + })) +); + +export const DataStreamLanding = React.memo(() => { + const landingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream', + }, + entity: 'DataStream', + title: 'DataStream', + }; + + const { handleTabChange, tabIndex, tabs } = useTabs([ + { + to: '/datastream/streams', + title: 'Streams', + }, + { + to: '/datastream/destinations', + title: 'Destinations', + }, + ]); + + return ( + <> + + + + + + + + }> + + + + + + + + + + + + ); +}); diff --git a/packages/manager/src/features/DataStream/Destinations/Destinations.tsx b/packages/manager/src/features/DataStream/Destinations/Destinations.tsx new file mode 100644 index 00000000000..72bb42fa7a8 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/Destinations.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export const Destinations = () => { + return

Content for Destinations tab

; +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.styles.ts b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.styles.ts new file mode 100644 index 00000000000..13e63e40589 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.styles.ts @@ -0,0 +1,23 @@ +import { makeStyles } from 'tss-react/mui'; + +import type { Theme } from '@mui/material/styles'; + +export const useStyles = makeStyles()((theme: Theme) => ({ + root: { + '& .mlMain': { + [theme.breakpoints.down('lg')]: { + flexBasis: '100%', + maxWidth: '100%', + }, + }, + '& .mlSidebar': { + [theme.breakpoints.down('lg')]: { + background: theme.color.white, + flexBasis: '100%', + maxWidth: '100%', + marginTop: theme.spacingFunction(16), + padding: theme.spacingFunction(8), + }, + }, + }, +})); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx new file mode 100644 index 00000000000..3723785002d --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx @@ -0,0 +1,48 @@ +import { Stack } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { StreamCreateCheckoutBar } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateCheckoutBar'; +import { StreamCreateDataSet } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateDataSet'; +import { StreamCreateDelivery } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery'; +import { StreamCreateGeneralInfo } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo'; + +import { useStyles } from './StreamCreate.styles'; + +export const StreamCreate = () => { + const { classes } = useStyles(); + + const landingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/create', + crumbOverrides: [ + { + label: 'DataStream', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Stream', + }; + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateCheckoutBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateCheckoutBar.tsx new file mode 100644 index 00000000000..3986aac1561 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateCheckoutBar.tsx @@ -0,0 +1,20 @@ +import { Divider } from '@linode/ui'; +import * as React from 'react'; + +import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; + +export const StreamCreateCheckoutBar = () => { + const onDeploy = () => {}; + + return ( + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDataSet.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDataSet.tsx new file mode 100644 index 00000000000..8c8d29f576c --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDataSet.tsx @@ -0,0 +1,19 @@ +import { Box, Paper, Typography } from '@linode/ui'; +import React from 'react'; + +import { DocsLink } from 'src/components/DocsLink/DocsLink'; + +export const StreamCreateDataSet = () => { + return ( + + + Data Set + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx new file mode 100644 index 00000000000..272d6832638 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx @@ -0,0 +1,19 @@ +import { Box, Paper, Typography } from '@linode/ui'; +import React from 'react'; + +import { DocsLink } from 'src/components/DocsLink/DocsLink'; + +export const StreamCreateDelivery = () => { + return ( + + + Delivery + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx new file mode 100644 index 00000000000..e8d6f71f73b --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx @@ -0,0 +1,10 @@ +import { Paper, Typography } from '@linode/ui'; +import React from 'react'; + +export const StreamCreateGeneralInfo = () => { + return ( + + General Information + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx new file mode 100644 index 00000000000..5c8672da8f7 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +import { StreamsLandingEmptyState } from 'src/features/DataStream/Streams/StreamsLandingEmptyState'; + +export const StreamsLanding = () => { + return ; +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyState.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyState.tsx new file mode 100644 index 00000000000..6b0db5b613b --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyState.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; + +import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { sendEvent } from 'src/utilities/analytics/utils'; + +import { + gettingStartedGuides, + headers, + linkAnalyticsEvent, +} from './StreamsLandingEmptyStateData'; + +export const StreamsLandingEmptyState = () => { + const navigate = useNavigate(); + + return ( + <> + + { + sendEvent({ + action: 'Click:button', + category: linkAnalyticsEvent.category, + label: 'Create Stream', + }); + navigate({ to: '/datastream/streams/create' }); + }, + }, + ]} + gettingStartedGuidesData={gettingStartedGuides} + headers={headers} + icon={ComputeIcon} + linkAnalyticsEvent={linkAnalyticsEvent} + /> + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts b/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts new file mode 100644 index 00000000000..f41b778cb95 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts @@ -0,0 +1,36 @@ +import { + docsLink, + guidesMoreLinkText, +} from 'src/utilities/emptyStateLandingUtils'; + +import type { + ResourcesHeaders, + ResourcesLinks, + ResourcesLinkSection, +} from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; + +export const headers: ResourcesHeaders = { + title: 'Streams', + subtitle: '', + description: 'Create a data stream and configure delivery of cloud logs', +}; + +export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { + action: 'Click:link', + category: 'Streams landing page empty', +}; + +export const gettingStartedGuides: ResourcesLinkSection = { + links: [ + { + // TODO: Change the link and text when proper documentation is ready + text: 'Getting started guide', + to: 'https://techdocs.akamai.com/cloud-computing/docs', + }, + ], + moreInfo: { + text: guidesMoreLinkText, + to: docsLink, + }, + title: 'Getting Started Guides', +}; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx index 19a17b667bd..ae6c3ff1b62 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx @@ -1,6 +1,6 @@ import { useIsGeckoEnabled } from '@linode/shared'; import { Divider, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; @@ -17,6 +17,7 @@ import type { ClusterSize, DatabaseEngine, Engine, + PrivateNetwork, Region, } from '@linode/api-v4'; import type { FormikErrors } from 'formik'; @@ -28,6 +29,7 @@ export interface DatabaseCreateValues { cluster_size: ClusterSize; engine: Engine; label: string; + private_network: PrivateNetwork; region: string; type: string; } diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index 5cf9f444798..e09e01513a4 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -45,6 +45,17 @@ describe('Database Create', () => { getAllByText('Create Database Cluster'); }); + it('should render VPC content when feature flag is present', async () => { + const { getAllByTestId, getAllByText } = renderWithTheme( + , + { + flags: { databaseVpc: true }, + } + ); + await waitForElementToBeRemoved(getAllByTestId(loadingTestId)); + getAllByText('Configure Networking'); + }); + it('should display the correct node price and disable 3 nodes for 1 GB plans', async () => { const standardTypes = [ databaseTypeFactory.build({ diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 4e37b6285b5..dfd6a4e1865 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -1,8 +1,8 @@ import { useRegionsQuery } from '@linode/queries'; import { CircleProgress, Divider, ErrorState, Notice, Paper } from '@linode/ui'; import { formatStorageUnits, scrollErrorIntoViewV2 } from '@linode/utilities'; -import { createDatabaseSchema } from '@linode/validation/lib/databases.schema'; -import Grid from '@mui/material/Grid2'; +import { getDynamicDatabaseSchema } from '@linode/validation/lib/databases.schema'; +import Grid from '@mui/material/Grid'; import { createLazyRoute } from '@tanstack/react-router'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -24,6 +24,7 @@ import { DatabaseSummarySection } from 'src/features/Databases/DatabaseCreate/Da import { DatabaseLogo } from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; +import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useCreateDatabaseMutation, @@ -35,11 +36,14 @@ import { validateIPs } from 'src/utilities/ipUtils'; import { ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT } from '../constants'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; +import { DatabaseCreateNetworkingConfiguration } from './DatabaseCreateNetworkingConfiguration'; +import type { AccessProps } from './DatabaseCreateAccessControls'; import type { ClusterSize, CreateDatabasePayload, Engine, + VPC, } from '@linode/api-v4/lib/databases/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; @@ -71,12 +75,17 @@ const DatabaseCreate = () => { platform: 'rdbms-default', }); + const flags = useFlags(); + const isVPCEnabled = flags.databaseVpc; + const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); const [createError, setCreateError] = React.useState(); const [ipErrorsFromAPI, setIPErrorsFromAPI] = React.useState(); const [selectedTab, setSelectedTab] = React.useState(0); + const [selectedVPC, setSelectedVPC] = React.useState(null); + const isVPCSelected = Boolean(selectedVPC); const handleIPBlur = (ips: ExtendedIP[]) => { const ipsWithMasks = enforceIPMasks(ips); @@ -118,11 +127,20 @@ const DatabaseCreate = () => { } return accum; }, []); + const hasVpc = + values.private_network.vpc_id && values.private_network.subnet_id; + const privateNetwork = hasVpc ? values.private_network : null; const createPayload: CreateDatabasePayload = { ...values, allow_list: _allow_list, + private_network: privateNetwork, }; + + // TODO (UIE-8831): Remove post VPC release, since it will always be in create payload + if (!isVPCEnabled) { + delete createPayload.private_network; + } try { const response = await createDatabase(createPayload); history.push(`/databases/${response.engine}/${response.id}`); @@ -151,12 +169,18 @@ const DatabaseCreate = () => { label: '', region: '', type: '', + private_network: { + vpc_id: null, + subnet_id: null, + public_access: false, + }, }; const { errors, handleSubmit, isSubmitting, + resetForm, setFieldError, setFieldValue, setSubmitting, @@ -169,7 +193,7 @@ const DatabaseCreate = () => { scrollErrorIntoViewV2(formRef); }, validateOnChange: false, - validationSchema: createDatabaseSchema, + validationSchema: getDynamicDatabaseSchema(isVPCSelected), }); React.useEffect(() => { @@ -215,6 +239,15 @@ const DatabaseCreate = () => { return displayTypes?.find((type) => type.id === values.type); }, [displayTypes, values.type]); + const accessControlsConfiguration: AccessProps = { + disabled: isRestricted, + errors: ipErrorsFromAPI, + ips: values.allow_list, + onBlur: handleIPBlur, + onChange: (ips: ExtendedIP[]) => setFieldValue('allow_list', ips), + variant: isVPCEnabled ? 'networking' : 'standard', + }; + const handleTabChange = (index: number) => { setSelectedTab(index); setFieldValue('type', undefined); @@ -232,6 +265,24 @@ const DatabaseCreate = () => { const handleNodeChange = (size: ClusterSize | undefined) => { setFieldValue('cluster_size', size); }; + + const handleNetworkingConfigurationChange = (vpc: null | VPC) => { + setSelectedVPC(vpc); + }; + + const handleResetForm = (partialValues?: Partial) => { + if (partialValues) { + resetForm({ + values: { + ...values, + ...partialValues, + }, + }); + } else { + resetForm(); + } + }; + return ( <> @@ -309,19 +360,31 @@ const DatabaseCreate = () => { /> - setFieldValue('allow_list', ips)} - /> + {isVPCEnabled ? ( + + setFieldValue(field, value) + } + onNetworkingConfigurationChange={ + handleNetworkingConfigurationChange + } + privateNetworkValues={values.private_network} + resetFormFields={handleResetForm} + selectedRegionId={values.region} + /> + ) : ( + + )} diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx index fb20e96e15a..da30ca59413 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx @@ -5,7 +5,7 @@ import { RadioGroup, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useState } from 'react'; import * as React from 'react'; import type { ChangeEvent } from 'react'; @@ -36,17 +36,26 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); export type AccessOption = 'none' | 'specific'; +export type AccessVariant = 'networking' | 'standard'; -interface Props { +export interface AccessProps { disabled?: boolean; errors?: APIError[]; ips: ExtendedIP[]; onBlur: (ips: ExtendedIP[]) => void; onChange: (ips: ExtendedIP[]) => void; + variant?: AccessVariant; } -export const DatabaseCreateAccessControls = (props: Props) => { - const { disabled = false, errors, ips, onBlur, onChange } = props; +export const DatabaseCreateAccessControls = (props: AccessProps) => { + const { + disabled = false, + errors, + ips, + onBlur, + onChange, + variant = 'standard', + } = props; const { classes } = useStyles(); const [accessOption, setAccessOption] = useState('specific'); @@ -59,7 +68,10 @@ export const DatabaseCreateAccessControls = (props: Props) => { return ( - + Manage Access diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx new file mode 100644 index 00000000000..a9e7bd4cc0c --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { describe, it, vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseCreateNetworkingConfiguration } from './DatabaseCreateNetworkingConfiguration'; + +import type { AccessProps } from './DatabaseCreateAccessControls'; +import type { PrivateNetwork } from '@linode/api-v4'; +describe('DatabaseCreateNetworkingConfiguration', () => { + const mockAccessControlConfig: AccessProps = { + disabled: false, + errors: [], + ips: [], + onBlur: vi.fn(), + onChange: vi.fn(), + variant: 'networking', + }; + + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: null, + subnet_id: null, + public_access: false, + }; + + const mockProps = { + accessControlsConfiguration: mockAccessControlConfig, + errors: {}, + onChange: vi.fn(), + onNetworkingConfigurationChange: vi.fn(), + privateNetworkValues: mockPrivateNetwork, + resetFormFields: vi.fn(), + selectedRegionId: 'us-east', + }; + + it('renders the networking configuration heading and description', () => { + const { getByText } = renderWithTheme( + + ); + expect( + getByText('Configure Networking', { + exact: true, + }) + ).toBeInTheDocument(); + expect( + getByText('Configure networking options for the cluster.', { + exact: true, + }) + ).toBeInTheDocument(); + }); + + it('renders DatabaseCreateAccessControls and DatabaseVPCSelector', () => { + const { getByText, getByTestId } = renderWithTheme( + + ); + expect(getByTestId('database-vpc-selector')).toBeInTheDocument(); + expect(getByText('Manage Access')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx new file mode 100644 index 00000000000..917308ee5b1 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx @@ -0,0 +1,58 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + +import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; +import { DatabaseVPCSelector } from './DatabaseVPCSelector'; + +import type { DatabaseCreateValues } from './DatabaseClusterData'; +import type { AccessProps } from './DatabaseCreateAccessControls'; +import type { PrivateNetwork, VPC } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { FormikErrors } from 'formik'; + +interface NetworkingConfigurationProps { + accessControlsConfiguration: AccessProps; + errors: FormikErrors; + onChange: (field: string, value: boolean | null | number) => void; + onNetworkingConfigurationChange: (vpcSelected: null | VPC) => void; + privateNetworkValues: PrivateNetwork; + resetFormFields: (partialValues?: Partial) => void; + selectedRegionId: string; +} + +export const DatabaseCreateNetworkingConfiguration = ( + props: NetworkingConfigurationProps +) => { + const { + accessControlsConfiguration, + errors, + onNetworkingConfigurationChange, + onChange, + selectedRegionId, + resetFormFields, + privateNetworkValues, + } = props; + + return ( + <> + Configure Networking + ({ + marginBottom: theme.spacingFunction(20), + })} + > + Configure networking options for the cluster. + + + + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx index e9a0fc6c986..bcdee109fc6 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx @@ -1,5 +1,5 @@ import { Autocomplete, Box } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { getEngineOptions } from 'src/features/Databases/DatabaseCreate/utilities'; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx index 1938f246c6c..909e632edb4 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx @@ -2,7 +2,11 @@ import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { databaseFactory, databaseTypeFactory } from 'src/factories'; +import { + databaseFactory, + databaseTypeFactory, + vpcFactory, +} from 'src/factories'; import DatabaseCreate from 'src/features/Databases/DatabaseCreate/DatabaseCreate'; import { DatabaseResize } from 'src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -19,9 +23,10 @@ describe('database summary section', () => { beta: false, enabled: true, }, + databaseVpc: true, }; - it('should render the correct number of node radio buttons, associated costs, and summary', async () => { + it('should render the correct number of node radio buttons, associated costs, vpc label and summary', async () => { const standardTypes = databaseTypeFactory.buildList(7, { class: 'standard', }); @@ -40,10 +45,15 @@ describe('database summary section', () => { return HttpResponse.json( makeResourcePage([...mockDedicatedTypes, ...standardTypes]) ); + }), + http.get('*/vpcs', () => { + return HttpResponse.json( + makeResourcePage([vpcFactory.build({ label: 'VPC 1' })]) + ); }) ); - const { getByTestId } = renderWithTheme(, { + const { getByTestId, findByText } = renderWithTheme(, { MemoryRouter: { initialEntries: ['/databases/create'] }, flags, }); @@ -53,6 +63,26 @@ describe('database summary section', () => { ); await userEvent.click(selectedPlan); + // Simulate Region Selection + const regionSelect = getByTestId('region-select').querySelector( + 'input' + ) as HTMLInputElement; + + // Open the autocomplete dropdown + await userEvent.click(regionSelect); + + const regionOption = await findByText('US, Newark, NJ (us-east)'); + await userEvent.click(regionOption); + + // Simulate VPC Selection + const vpcSelector = getByTestId('database-vpc-selector').querySelector( + 'input' + ) as HTMLInputElement; + await userEvent.click(vpcSelector); + const newVPC = await findByText('VPC 1'); + await userEvent.click(newVPC); + + // Check summary contents (ie. plan, nodes, VPC) const summary = getByTestId('currentSummary'); const selectedPlanText = 'Dedicated 4 GB $60/month'; expect(summary).toHaveTextContent(selectedPlanText); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.tsx index db9a71260ef..a00c8d9e33c 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.tsx @@ -1,6 +1,8 @@ import { Box, Typography } from '@linode/ui'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { StyledPlanSummarySpan } from '../DatabaseDetail/DatabaseResize/DatabaseResize.style'; import { useIsDatabasesEnabled } from '../utilities'; import { StyledSpan } from './DatabaseCreate.style'; @@ -11,6 +13,7 @@ import type { DatabaseClusterSizeObject, DatabasePriceObject, Engine, + VPC, } from '@linode/api-v4'; import type { Theme } from '@mui/material'; import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; @@ -19,8 +22,8 @@ interface Props { currentClusterSize: ClusterSize; currentEngine: Engine; currentPlan: PlanSelectionWithDatabaseType | undefined; - isResize?: boolean; label?: string; + mode: 'create' | 'resize'; platform?: string; resizeData?: { basePrice: string; @@ -28,6 +31,7 @@ interface Props { plan: string; price: string; }; + selectedVPC?: null | VPC; } export const DatabaseSummarySection = (props: Props) => { @@ -35,12 +39,19 @@ export const DatabaseSummarySection = (props: Props) => { currentClusterSize, currentEngine, currentPlan, - isResize, + selectedVPC, label, + mode, platform, resizeData, } = props; const { isDatabasesV2GA } = useIsDatabasesEnabled(); + const flags = useFlags(); + const isVPCEnabled = flags.databaseVpc; + const isResize = mode === 'resize'; + const isCreate = mode === 'create'; + const isVPCSelected = Boolean(selectedVPC); + const displayVPC = isCreate && isVPCEnabled; const currentPrice = currentPlan?.engines[currentEngine].find( (cluster: DatabaseClusterSizeObject) => @@ -66,11 +77,32 @@ export const DatabaseSummarySection = (props: Props) => { ) : ( {currentPlanPrice} )} - - {currentClusterSize} Node - {getSuffix(isNewDatabase, currentClusterSize)} - - {currentNodePrice} + {displayVPC ? ( + <> + + {currentClusterSize} Node + {getSuffix(isNewDatabase, currentClusterSize)} + + + {currentNodePrice} + + {isVPCSelected && ( + + {selectedVPC?.label} VPC + + )} + + ) : ( + <> + + {currentClusterSize} Node + {getSuffix(isNewDatabase, currentClusterSize)} + + {currentNodePrice} + + )} ) : ( 'Please specify your cluster configuration' diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx new file mode 100644 index 00000000000..c36d4a229da --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx @@ -0,0 +1,489 @@ +import { regionFactory } from '@linode/utilities'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { subnetFactory, vpcFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseVPCSelector } from './DatabaseVPCSelector'; + +import type { PrivateNetwork } from '@linode/api-v4'; + +// Hoist query mocks +const queryMocks = vi.hoisted(() => ({ + useRegionQuery: vi.fn().mockReturnValue({ data: {} }), + useAllVPCsQuery: vi.fn().mockReturnValue({ data: [], isLoading: false }), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useRegionQuery: queryMocks.useRegionQuery, + useAllVPCsQuery: queryMocks.useAllVPCsQuery, + }; +}); + +const mockIpv4 = '10.0.0.0/24'; + +const mockRegion = regionFactory.build({ + capabilities: ['VPCs'], + id: 'us-east', + label: 'Newark, NJ', +}); + +const mockSubnets = subnetFactory.buildList(1, { + ipv4: mockIpv4, + id: 123, + label: 'Subnet 1', +}); + +const mockVPCWithSubnet = vpcFactory.build({ + id: 1234, + label: 'VPC 1', + region: 'us-east', + subnets: mockSubnets, +}); + +const setUpBaseMocks = () => { + queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: [mockVPCWithSubnet], + isLoading: false, + }); +}; + +describe('DatabaseVPCSelector', () => { + const mockProps = { + errors: {}, + onChange: vi.fn(), + onConfigurationChange: vi.fn(), + privateNetworkValues: { + vpc_id: null, + subnet_id: null, + public_access: false, + }, + resetFormFields: vi.fn(), + selectedRegionId: '', + }; + + beforeEach(() => { + vi.resetAllMocks(); + queryMocks.useRegionQuery.mockReturnValue({ + data: null, + }); + + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: null, + isLoading: false, + }); + }); + + it('Should render the VPC selector heading', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Assign a VPC', { exact: true })).toBeInTheDocument(); + }); + + it('Should render VPC autocomplete in initial disabled state', () => { + const { getByTestId, getByText } = renderWithTheme( + + ); + const vpcSelector = getByTestId('database-vpc-selector'); + expect(vpcSelector).toBeInTheDocument(); + expect(vpcSelector.querySelector('input')).toBeDisabled(); + expect( + getByText( + 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.', + { exact: true } + ) + ).toBeInTheDocument(); + }); + + it('Should enable VPC autocomplete when VPCs are available', () => { + const subnets = subnetFactory.buildList(3, { ipv4: mockIpv4 }); + + const vpcWithSubnet = vpcFactory.build({ + subnets, + region: 'us-east', + }); + + queryMocks.useRegionQuery.mockReturnValue({ + data: mockRegion, + }); + + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: [vpcWithSubnet], + isLoading: false, + }); + + const mockEnabledProps = { ...mockProps, selectedRegionId: 'us-east' }; + const { getByTestId } = renderWithTheme( + + ); + + const vpcSelector = getByTestId('database-vpc-selector'); + expect(vpcSelector).toBeInTheDocument(); + expect(vpcSelector.querySelector('input')).toBeEnabled(); + }); + + it('Should enable Subnet autocomplete when VPC is selected', async () => { + queryMocks.useRegionQuery.mockReturnValue({ + data: mockRegion, + }); + + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: [mockVPCWithSubnet], + isLoading: false, + }); + + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: 1234, + subnet_id: null, + public_access: false, + }; + + const mockEnabledProps = { + ...mockProps, + privateNetworkValues: mockPrivateNetwork, + selectedRegionId: 'us-east', + }; + const { getByTestId } = renderWithTheme( + + ); + + const vpcSelector = getByTestId('database-vpc-selector'); + const vpcSelectorInput = vpcSelector.querySelector( + 'input' + ) as HTMLInputElement; + expect(vpcSelectorInput?.value).toBe(mockVPCWithSubnet.label); + const subnetSelector = getByTestId('database-subnet-selector'); + expect(subnetSelector).toBeInTheDocument(); + expect(subnetSelector.querySelector('input')).toBeEnabled(); + }); + + it('Should set fields for VPC, Subnet, and Public Access based on privateNetworkValues values', async () => { + queryMocks.useRegionQuery.mockReturnValue({ + data: mockRegion, + }); + + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: [mockVPCWithSubnet], + isLoading: false, + }); + + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: 1234, + subnet_id: 123, + public_access: true, + }; + + const mockEnabledProps = { + ...mockProps, + privateNetworkValues: mockPrivateNetwork, + selectedRegionId: 'us-east', + }; + const { getByTestId } = renderWithTheme( + + ); + + const vpcSelector = getByTestId('database-vpc-selector'); + const vpcSelectorInput = vpcSelector.querySelector( + 'input' + ) as HTMLInputElement; + const subnetSelector = getByTestId('database-subnet-selector'); + const expectedSubnetValue = `${mockSubnets[0].label} (${mockSubnets[0].ipv4})`; + const publicAccessCheckbox = getByTestId('database-public-access-checkbox'); + + expect(vpcSelectorInput?.value).toBe(mockVPCWithSubnet.label); + expect(subnetSelector).toBeInTheDocument(); + expect(subnetSelector.querySelector('input')?.value).toBe( + expectedSubnetValue + ); + expect(publicAccessCheckbox).toBeInTheDocument(); + expect(publicAccessCheckbox.querySelector('input')).toBeChecked(); + }); + + it('Should clear VPC and subnet when selectedRegionId changes', () => { + // Initial region, VPC, and subnet + const region1 = regionFactory.build({ + capabilities: ['VPCs'], + id: 'us-east', + label: 'Newark, NJ', + }); + const region2 = regionFactory.build({ + capabilities: ['VPCs'], + id: 'us-west', + label: 'Fremont, CA', + }); + + // Set up mocks for initial render + queryMocks.useRegionQuery.mockReturnValue({ data: region1 }); + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: [mockVPCWithSubnet], + isLoading: false, + }); + + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: 1234, + subnet_id: 123, + public_access: true, + }; + + const resetFormFields = vi.fn(); + const onConfigurationChange = vi.fn(); + + const { rerender, getByTestId } = renderWithTheme( + + ); + + // Change region to a new one + queryMocks.useRegionQuery.mockReturnValue({ data: region2 }); + queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); + + rerender( + + ); + + expect(resetFormFields).toHaveBeenCalled(); + expect(onConfigurationChange).toHaveBeenCalledWith(null); + const vpcSelector = getByTestId('database-vpc-selector'); + expect((vpcSelector.querySelector('input') as HTMLInputElement).value).toBe( + '' + ); + }); + + it('Should NOT clear VPC and subnet when selectedRegionId changes from undefined to a valid region', () => { + // Initial render with no region selected + queryMocks.useRegionQuery.mockReturnValue({ data: null }); + queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); + + const resetFormFields = vi.fn(); + const onConfigurationChange = vi.fn(); + + const { rerender } = renderWithTheme( + + ); + + // Now render with a valid region + queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); + queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); + + rerender( + + ); + + expect(resetFormFields).not.toHaveBeenCalled(); + expect(onConfigurationChange).not.toHaveBeenCalledWith(null); + }); + + it('Should show long helper text when no region is selected', () => { + const { getByText } = renderWithTheme( + + ); + expect( + getByText( + 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.', + { exact: true } + ) + ).toBeInTheDocument(); + }); + + it('Should show short helper text when a region is selected but no VPCs are available', () => { + queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); + queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); + + const { getByText } = renderWithTheme( + + ); + expect( + getByText('No VPC is available in the selected region.', { exact: true }) + ).toBeInTheDocument(); + }); + + it('Should NOT show helper text when VPCs are available', () => { + const vpcWithSubnet = vpcFactory.build({ + region: 'us-east', + subnets: subnetFactory.buildList(1, { ipv4: mockIpv4 }), + }); + queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); + queryMocks.useAllVPCsQuery.mockReturnValue({ + data: [vpcWithSubnet], + isLoading: false, + }); + + const { queryByText } = renderWithTheme( + + ); + expect( + queryByText('No VPC is available in the selected region.') + ).not.toBeInTheDocument(); + expect( + queryByText( + 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.' + ) + ).not.toBeInTheDocument(); + }); + + it('Should show subnet validation error text when there is a subnet error', () => { + setUpBaseMocks(); + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: 1234, + subnet_id: null, + public_access: false, + }; + + const mockErrors = { + private_network: { + subnet_id: 'Subnet is required.', + }, + }; + + const { getByTestId, getByText } = renderWithTheme( + + ); + + const subnetSelector = getByTestId('database-subnet-selector'); + expect(subnetSelector).toBeInTheDocument(); + expect(getByText('Subnet is required.')).toBeInTheDocument(); + }); + + it('Should clear subnet field when the VPC field is cleared', async () => { + setUpBaseMocks(); + const onChange = vi.fn(); + + // Start with both VPC and subnet selected + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: 1234, + subnet_id: 123, + public_access: false, + }; + + const { getByTestId } = renderWithTheme( + + ); + + // Simulate clearing the VPC field (user clears the Autocomplete) + const vpcSelector = getByTestId('database-vpc-selector'); + const clearButton = vpcSelector.querySelector( + 'button[title="Clear"]' + ) as HTMLElement; + await userEvent.click(clearButton); + // ...assertions as above... + expect(onChange).toHaveBeenCalledWith('private_network.vpc_id', null); + expect(onChange).toHaveBeenCalledWith('private_network.subnet_id', null); + expect(onChange).toHaveBeenCalledWith( + 'private_network.public_access', + false + ); + }); + + it('Should call onChange for the VPC field when a value is selected', async () => { + setUpBaseMocks(); + const onChange = vi.fn(); + + // Start with no VPC selected + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: null, + subnet_id: null, + public_access: false, + }; + + const { getByTestId, findByText } = renderWithTheme( + + ); + + // Simulate selecting a VPC from the Autocomplete + const vpcSelector = getByTestId('database-vpc-selector').querySelector( + 'input' + ) as HTMLInputElement; + // Open the autocomplete dropdown + await userEvent.click(vpcSelector); + + // Select the option + const newVPC = await findByText('VPC 1'); + await userEvent.click(newVPC); + + expect(onChange).toHaveBeenCalledWith( + 'private_network.vpc_id', + mockVPCWithSubnet.id + ); + }); + + it('Should call onChange for the Subnet field when subnet value is selected', async () => { + setUpBaseMocks(); + const onChange = vi.fn(); + + // Start with VPC selected and no subnet selection + const mockPrivateNetwork: PrivateNetwork = { + vpc_id: 1234, + subnet_id: null, + public_access: false, + }; + + const { getByTestId, findByText } = renderWithTheme( + + ); + + // Simulate selecting a Subnet from the Autocomplete + const subnetSelector = getByTestId( + 'database-subnet-selector' + ).querySelector('input') as HTMLInputElement; + + await userEvent.click(subnetSelector); + + // Select the option + const expectedSubnetLabel = `${mockSubnets[0].label} (${mockSubnets[0].ipv4})`; + const newSubnet = await findByText(expectedSubnetLabel); + await userEvent.click(newSubnet); + + expect(onChange).toHaveBeenCalledWith( + 'private_network.subnet_id', + mockSubnets[0].id + ); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx new file mode 100644 index 00000000000..589eb88a6a4 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx @@ -0,0 +1,190 @@ +import { useAllVPCsQuery, useRegionQuery } from '@linode/queries'; +import { + Autocomplete, + Box, + Checkbox, + Notice, + TooltipIcon, + Typography, +} from '@linode/ui'; +import * as React from 'react'; + +import type { DatabaseCreateValues } from './DatabaseClusterData'; +import type { PrivateNetwork, VPC } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { FormikErrors } from 'formik'; + +interface DatabaseVPCSelectorProps { + errors: FormikErrors; + onChange: (field: string, value: boolean | null | number) => void; + onConfigurationChange: (vpc: null | VPC) => void; + privateNetworkValues: PrivateNetwork; + resetFormFields: (partialValues?: Partial) => void; + selectedRegionId: string; +} + +export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { + const { + errors, + onConfigurationChange, + onChange, + selectedRegionId, + resetFormFields, + privateNetworkValues, + } = props; + + const { data: selectedRegion } = useRegionQuery(selectedRegionId); + const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); + + const { + data: vpcs, + error, + isLoading, + } = useAllVPCsQuery({ + enabled: regionSupportsVPCs, + filter: { region: selectedRegionId }, + }); + + const selectedVPC = React.useMemo( + () => vpcs?.find((vpc) => vpc.id === privateNetworkValues.vpc_id), + [vpcs, privateNetworkValues.vpc_id] + ); + const selectedSubnet = React.useMemo( + () => + selectedVPC?.subnets.find( + (subnet) => subnet.id === privateNetworkValues.subnet_id + ), + [selectedVPC, privateNetworkValues.subnet_id] + ); + + const prevRegionId = React.useRef(); + const regionHasVPCs = Boolean(vpcs && vpcs.length > 0); + const disableVPCSelectors = !regionSupportsVPCs || !regionHasVPCs; + + const resetVPCConfiguration = () => { + resetFormFields({ + private_network: { + vpc_id: null, + subnet_id: null, + public_access: false, + }, + }); + }; + + React.useEffect(() => { + // When the selected region has changed, reset VPC configuration. + // Then switch back to default validation behavior + if (prevRegionId.current && prevRegionId.current !== selectedRegionId) { + resetVPCConfiguration(); + onConfigurationChange(null); + } + prevRegionId.current = selectedRegionId; + }, [selectedRegionId]); + + const vpcHelperTextCopy = !selectedRegionId + ? 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.' + : 'No VPC is available in the selected region.'; + + /** Returns dynamic marginTop value used to center TooltipIcon in different scenarios */ + const getVPCTooltipIconMargin = () => { + const margins = { + longHelperText: '.75rem', + shortHelperText: '1.75rem', + noHelperText: '2.75rem', + }; + if (disableVPCSelectors && !selectedRegionId) return margins.longHelperText; + if (disableVPCSelectors && selectedRegionId) return margins.shortHelperText; + return margins.noHelperText; + }; + + return ( + <> + ({ + marginTop: theme.spacingFunction(20), + marginBottom: theme.spacingFunction(4), + })} + variant="h3" + > + Assign a VPC + + + Assign this cluster to an existing VPC. + + { + if (!value) { + onChange('private_network.subnet_id', null); + onChange('private_network.public_access', false); + } + onConfigurationChange(value ?? null); + onChange('private_network.vpc_id', value?.id ?? null); + }} + options={vpcs ?? []} + placeholder="Select a VPC" + sx={{ width: '354px' }} + value={selectedVPC ?? null} + /> + + + + {selectedVPC ? ( + <> + `${subnet.label} (${subnet.ipv4})`} + label="Subnet" + onChange={(e, value) => { + onChange('private_network.subnet_id', value?.id ?? null); + }} + options={selectedVPC?.subnets ?? []} + placeholder="Select a subnet" + value={selectedSubnet ?? null} + /> + ({ + marginTop: theme.spacingFunction(20), + })} + > + { + onChange('private_network.public_access', value ?? null); + }} + text={'Enable public access'} + toolTipText={ + 'Adds a public endpoint to the database in addition to the private VPC endpoint.' + } + /> + + + ) : ( + ({ + marginTop: theme.spacingFunction(20), + })} + text="The cluster will have public access by default if a VPC is not assigned." + variant="info" + /> + )} + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx index bc8642088da..690720026cf 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx @@ -1,5 +1,5 @@ import { Box, Button, Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx index 6dcc1afa452..4f8a0839c78 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx @@ -11,7 +11,7 @@ import { } from '@linode/ui'; import { scrollErrorIntoViewV2 } from '@linode/utilities'; import { createDynamicAdvancedConfigSchema } from '@linode/validation'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { enqueueSnackbar } from 'notistack'; import React, { useEffect, useMemo, useState } from 'react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 4fa04520297..8d73e04df74 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -13,7 +13,7 @@ import { Radio, RadioGroup, } from '@mui/material'; -import Grid from '@mui/material/Grid'; +import { GridLegacy } from '@mui/material'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateTime } from 'luxon'; @@ -187,14 +187,14 @@ export const DatabaseBackups = (props: Props) => { /> )} - - + Date { value={selectedDate} /> - - + + Time (UTC) {/* TODO: Replace Time Select to the own custom date-time picker component when it's ready */} @@ -247,9 +247,9 @@ export const DatabaseBackups = (props: Props) => { value={selectedTime} /> - - - + + + - + {database && ( { currentClusterSize={database.cluster_size} currentEngine={selectedEngine} currentPlan={currentPlan} - isResize={true} label={database.label} + mode="resize" platform={database.platform} resizeData={summaryText} /> diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx index 48ce9713611..e4d4b2ef2c5 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsMaintenance.tsx @@ -1,5 +1,5 @@ import { StyledLinkButton, TooltipIcon, Typography } from '@linode/ui'; -import { Grid, styled } from '@mui/material'; +import { GridLegacy, styled } from '@mui/material'; import * as React from 'react'; import { @@ -33,8 +33,8 @@ export const DatabaseSettingsMaintenance = (props: Props) => { const hasUpdates = hasPendingUpdates(databasePendingUpdates); return ( - - + + Maintenance Version {engineVersion} @@ -60,7 +60,7 @@ export const DatabaseSettingsMaintenance = (props: Props) => { } /> )} - + {/* TODO Uncomment and provide value when the EOL is returned by the API. Currently, it is not supported, however they are working on returning it since it has value to the end user @@ -68,7 +68,7 @@ export const DatabaseSettingsMaintenance = (props: Props) => { End of life */} - + Maintenance updates {hasUpdates ? ( @@ -84,8 +84,8 @@ export const DatabaseSettingsMaintenance = (props: Props) => { maintenance window.{' '} )} - - + + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index c97e81f0d26..96c80996d90 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -1,5 +1,5 @@ import { Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts index dbc39604847..8d775843f5e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts @@ -1,8 +1,8 @@ import { Typography } from '@linode/ui'; -import Grid2 from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; -export const StyledGridContainer = styled(Grid2, { +export const StyledGridContainer = styled(Grid, { label: 'StyledGridContainer', })(({ theme }) => ({ '&>*:nth-of-type(even)': { @@ -35,7 +35,7 @@ export const StyledLabelTypography = styled(Typography, { padding: `${theme.spacing(0.5)} 15px`, })); -export const StyledValueGrid = styled(Grid2, { +export const StyledValueGrid = styled(Grid, { label: 'StyledValueGrid', })(({ theme }) => ({ alignItems: 'center', @@ -43,5 +43,3 @@ export const StyledValueGrid = styled(Grid2, { display: 'flex', padding: `0 ${theme.spacing()}`, })); - -// theme.spacing() 8 diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx index f34111513e8..3e49fc058d2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx @@ -1,7 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import { TooltipIcon, Typography } from '@linode/ui'; import { convertMegabytesTo, formatStorageUnits } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index e9076344646..82fb4fe4854 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,7 +1,7 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; import { Button, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; import { downloadFile } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Domains/CloneDomainDrawer.tsx b/packages/manager/src/features/Domains/CloneDomainDrawer.tsx index c7ead368537..86749b4d695 100644 --- a/packages/manager/src/features/Domains/CloneDomainDrawer.tsx +++ b/packages/manager/src/features/Domains/CloneDomainDrawer.tsx @@ -1,4 +1,4 @@ -import { useGrants, useProfile } from '@linode/queries'; +import { useCloneDomainMutation, useGrants, useProfile } from '@linode/queries'; import { ActionsPanel, Drawer, @@ -12,8 +12,6 @@ import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; import React from 'react'; -import { useCloneDomainMutation } from 'src/queries/domains'; - import type { APIError, Domain } from '@linode/api-v4'; interface CloneDomainDrawerProps { diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index 5f7c9f855a6..6893f4773b7 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -1,4 +1,8 @@ -import { useGrants, useProfile } from '@linode/queries'; +import { + useCreateDomainMutation, + useGrants, + useProfile, +} from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, @@ -13,7 +17,7 @@ import { } from '@linode/ui'; import { scrollErrorIntoView } from '@linode/utilities'; import { createDomainSchema } from '@linode/validation/lib/domains.schema'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; @@ -24,7 +28,6 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { reportException } from 'src/exceptionReporting'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; -import { useCreateDomainMutation } from 'src/queries/domains'; import { sendCreateDomainEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Domains/DeleteDomain.tsx b/packages/manager/src/features/Domains/DeleteDomain.tsx index 1154aa372aa..2493a975d34 100644 --- a/packages/manager/src/features/Domains/DeleteDomain.tsx +++ b/packages/manager/src/features/Domains/DeleteDomain.tsx @@ -1,10 +1,10 @@ +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 { useDeleteDomainMutation } from 'src/queries/domains'; import type { APIError } from '@linode/api-v4'; export interface DeleteDomainProps { diff --git a/packages/manager/src/features/Domains/DisableDomainDialog.tsx b/packages/manager/src/features/Domains/DisableDomainDialog.tsx index eb0ef1487ad..ad6c75708ff 100644 --- a/packages/manager/src/features/Domains/DisableDomainDialog.tsx +++ b/packages/manager/src/features/Domains/DisableDomainDialog.tsx @@ -1,8 +1,8 @@ +import { useUpdateDomainMutation } from '@linode/queries'; import { ActionsPanel } from '@linode/ui'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useUpdateDomainMutation } from 'src/queries/domains'; import { sendDomainStatusChangeEvent } from 'src/utilities/analytics/customEventAnalytics'; import type { APIError, Domain } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 7856b1c50db..9b838db064c 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -1,3 +1,8 @@ +import { + useDomainQuery, + useDomainRecordsQuery, + useUpdateDomainMutation, +} from '@linode/queries'; import { CircleProgress, ErrorState, @@ -6,7 +11,7 @@ import { Stack, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import { useLocation, useNavigate, useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -14,11 +19,6 @@ import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; -import { - useDomainQuery, - useDomainRecordsQuery, - useUpdateDomainMutation, -} from 'src/queries/domains'; import { DeleteDomain } from '../DeleteDomain'; import { DownloadDNSZoneFileButton } from '../DownloadDNSZoneFileButton'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts index 8d19fdb2473..81cca80a3ab 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import { TableCell } from 'src/components/TableCell'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx index 0a384dddc61..f4de9f3e3e2 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -1,7 +1,7 @@ import { deleteDomainRecord as _deleteDomainRecord } from '@linode/api-v4/lib/domains'; import { ActionsPanel, Stack, Typography } from '@linode/ui'; import { scrollErrorIntoViewV2 } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; diff --git a/packages/manager/src/features/Domains/DomainDetail/index.tsx b/packages/manager/src/features/Domains/DomainDetail/index.tsx index 692c664ecac..7465a804ce5 100644 --- a/packages/manager/src/features/Domains/DomainDetail/index.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/index.tsx @@ -1,10 +1,9 @@ +import { useDomainQuery } from '@linode/queries'; import { CircleProgress, ErrorState } from '@linode/ui'; import { NotFound } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useDomainQuery } from 'src/queries/domains'; - const DomainsLanding = React.lazy(() => import('../DomainsLanding').then((module) => ({ default: module.DomainsLanding, diff --git a/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx b/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx index 1042b5ecf32..203b57846b7 100644 --- a/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx @@ -1,10 +1,9 @@ -import { useGrants, useProfile } from '@linode/queries'; +import { useGrants, useImportZoneMutation, useProfile } from '@linode/queries'; import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui'; import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; import * as React from 'react'; -import { useImportZoneMutation } from 'src/queries/domains'; import { getErrorMap } from 'src/utilities/errorUtils'; import type { ImportZonePayload } from '@linode/api-v4/lib/domains'; diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx index 9677efe0ca1..9cf137664c8 100644 --- a/packages/manager/src/features/Domains/DomainsLanding.tsx +++ b/packages/manager/src/features/Domains/DomainsLanding.tsx @@ -1,4 +1,11 @@ -import { useLinodesQuery, useProfile } from '@linode/queries'; +import { + useDeleteDomainMutation, + useDomainQuery, + useDomainsQuery, + useLinodesQuery, + useProfile, + useUpdateDomainMutation, +} from '@linode/queries'; import { Button, CircleProgress, @@ -29,12 +36,6 @@ 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 { - useDeleteDomainMutation, - useDomainQuery, - useDomainsQuery, - useUpdateDomainMutation, -} from 'src/queries/domains'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { CloneDomainDrawer } from './CloneDomainDrawer'; diff --git a/packages/manager/src/features/Domains/EditDomainDrawer.tsx b/packages/manager/src/features/Domains/EditDomainDrawer.tsx index f253478ca28..048282f183a 100644 --- a/packages/manager/src/features/Domains/EditDomainDrawer.tsx +++ b/packages/manager/src/features/Domains/EditDomainDrawer.tsx @@ -1,4 +1,8 @@ -import { useGrants, useProfile } from '@linode/queries'; +import { + useGrants, + useProfile, + useUpdateDomainMutation, +} from '@linode/queries'; import { ActionsPanel, Drawer, @@ -13,7 +17,6 @@ import * as React from 'react'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; -import { useUpdateDomainMutation } from 'src/queries/domains'; import { getErrorMap } from 'src/utilities/errorUtils'; import { handleFormikBlur } from 'src/utilities/formikTrimUtil'; import { extendedIPToString, stringToExtendedIP } from 'src/utilities/ipUtils'; diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts index 80b25d3d0b5..4d76e6e5dba 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts @@ -1,5 +1,5 @@ import { Notice } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledNotice = styled(Notice, { diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx index 4302bb60a99..2ae39b6cc49 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; import { createLazyRoute } from '@tanstack/react-router'; import { curry } from 'ramda'; diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts index 81ac813b561..09b845f766d 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts @@ -1,5 +1,5 @@ import { Button, TextField, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; // sm = 600, md = 960, lg = 1280 diff --git a/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.tsx b/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.tsx index 4f1087e68df..c82d735f8fa 100644 --- a/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.tsx +++ b/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.tsx @@ -4,10 +4,15 @@ import * as React from 'react'; import { ErrorComponent } from './ErrorComponent'; -export const ErrorBoundaryFallback: React.FC<{ +interface ErrorBoundaryFallbackProps { children?: React.ReactNode; useTanStackRouterBoundary?: boolean; -}> = ({ children, useTanStackRouterBoundary = false }) => ( +} + +export const ErrorBoundaryFallback = ({ + children, + useTanStackRouterBoundary = false, +}: ErrorBoundaryFallbackProps) => ( {useTanStackRouterBoundary ? ( 'error-boundary-fallback'}> diff --git a/packages/manager/src/features/ErrorBoundary/ErrorComponent.tsx b/packages/manager/src/features/ErrorBoundary/ErrorComponent.tsx index 3e146967ebf..1dd264b5f0e 100644 --- a/packages/manager/src/features/ErrorBoundary/ErrorComponent.tsx +++ b/packages/manager/src/features/ErrorBoundary/ErrorComponent.tsx @@ -8,10 +8,12 @@ export const ErrorComponent = ({ error, resetError, }: { - error: Error; + error: unknown; eventId: string; resetError(): void; }) => { + const normalizedError = normalizeError(error); + return ( Error details:{' '} @@ -104,3 +106,27 @@ export const ErrorComponent = ({ ); }; + +const normalizeError = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + + if (typeof error === 'string') { + return new Error(error); + } + + if (Array.isArray(error) && error.length === 1 && error[0]?.reason) { + return new Error(error[0].reason); + } + + if (typeof error === 'function' || error === undefined) { + return new Error('Unknown error'); + } + + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error('Unserializable error'); + } +}; diff --git a/packages/manager/src/features/Events/FormattedEventMessage.test.tsx b/packages/manager/src/features/Events/FormattedEventMessage.test.tsx index 62463801db6..b1facf3b1e0 100644 --- a/packages/manager/src/features/Events/FormattedEventMessage.test.tsx +++ b/packages/manager/src/features/Events/FormattedEventMessage.test.tsx @@ -39,7 +39,7 @@ describe('FormattedEventMessage', () => { expect(getByText('contact Support')).toBeInTheDocument(); expect(container.querySelector('a')).toHaveAttribute( 'href', - '/support/tickets' + '/support/tickets/open?dialogOpen=true' ); }); }); diff --git a/packages/manager/src/features/Events/factories/interface.tsx b/packages/manager/src/features/Events/factories/interface.tsx index 79a7dd69871..a842de54f20 100644 --- a/packages/manager/src/features/Events/factories/interface.tsx +++ b/packages/manager/src/features/Events/factories/interface.tsx @@ -6,81 +6,30 @@ import type { PartialEventMap } from '../types'; export const linodeInterface: PartialEventMap<'interface'> = { interface_create: { - failed: (e) => ( - <> - Linode Interface {e.entity!.id} could not be{' '} - created. - - ), - finished: (e) => ( + notification: (e) => ( <> Linode Interface has been{' '} created for Linode{' '} . ), - scheduled: (e) => ( - <> - Linode Interface {e.entity!.id} is scheduled for{' '} - creation. - - ), - started: (e) => ( - <> - Linode Interface {e.entity!.id} is being created. - - ), }, interface_delete: { - failed: (e) => ( - <> - Linode Interface {e.entity!.id} could not be{' '} - deleted. - - ), - finished: (e) => ( + notification: (e) => ( <> Linode Interface has been{' '} deleted from Linode{' '} . ), - scheduled: (e) => ( - <> - Linode Interface {e.entity!.id} is scheduled for{' '} - deletion. - - ), - started: (e) => ( - <> - Linode Interface {e.entity!.id} is being deleted. - - ), }, interface_update: { - failed: (e) => ( - <> - Linode Interface {e.entity!.id} could not be{' '} - updated. - - ), - finished: (e) => ( + notification: (e) => ( <> Linode Interface has been{' '} - updated from Linode{' '} + updated on Linode{' '} . ), - scheduled: (e) => ( - <> - Linode Interface {e.entity!.id} is scheduled for{' '} - updating. - - ), - started: (e) => ( - <> - Linode Interface {e.entity!.label} is being updated. - - ), }, }; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index a423b98a276..2d80ed84d26 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -1,14 +1,10 @@ -import { useLinodeQuery } from '@linode/queries'; -import { formatStorageUnits } from '@linode/utilities'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { useTypeQuery } from 'src/queries/types'; import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; -import type { Event } from '@linode/api-v4'; export const linode: PartialEventMap<'linode'> = { linode_addip: { @@ -488,7 +484,12 @@ export const linode: PartialEventMap<'linode'> = { resizing. ), - started: (e) => , + started: (e) => ( + <> + Linode is resizing{' '} + to the selected plan. + + ), }, linode_resize_create: { notification: (e) => ( @@ -571,26 +572,3 @@ export const linode: PartialEventMap<'linode'> = { ), }, }; - -const LinodeResizeStartedMessage = ({ event }: { event: Event }) => { - const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); - const type = useTypeQuery(linode?.type ?? ''); - - return ( - <> - Linode is{' '} - resizing - {type && ( - <> - {' '} - to the{' '} - {type.data?.label && ( - {formatStorageUnits(type.data.label)} - )}{' '} - Plan - - )} - . - - ); -}; diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx index 695e8ce345c..7208b70c2f3 100644 --- a/packages/manager/src/features/Events/utils.test.tsx +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -66,15 +66,17 @@ describe('getEventMessage', () => { }); it('returns the correct message for a manual input event', () => { - const message = getEventMessage({ - action: 'linode_create', - entity: { - id: 123, - label: 'test-linode', - type: 'linode', - }, - status: 'failed', - }); + const message = getEventMessage( + eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + type: 'linode', + }, + status: 'failed', + }) + ); const { container } = renderWithTheme(message); diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx index 7a35a17048f..4995d66c699 100644 --- a/packages/manager/src/features/Events/utils.tsx +++ b/packages/manager/src/features/Events/utils.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { formatDuration } from '@linode/utilities'; import { Duration } from 'luxon'; @@ -10,41 +11,41 @@ import { eventMessages } from './factory'; import type { Event } from '@linode/api-v4'; -type EventMessageManualInput = { - action: Event['action']; - entity?: Partial; - secondary_entity?: Partial; - status: Event['status']; -}; - /** - * The event Message Getter - * Intentionally avoiding parsing and formatting, and should remain as such. + * The event Message Getter gets an event message (JSX) from an `Event` * - * Defining two function signatures for getEventMessage: - * - A function that takes a full Event object (event page and notification center) - * - A function that takes an object with action, status, entity, and secondary_entity (getting a message for a snackbar for instance, where we manually pass the action & status) - * - * Using typescript overloads allows for both Event and EventMessageInput types. + * Intentionally avoiding parsing and formatting, and should remain as such. * * We don't include defaulting to the API message response here because: * - we want to control the message output (our types require us to define one) and rather show nothing than a broken message. * - the API message is empty 99% of the time and when present, isn't meant to be displayed as a full message, rather a part of it. (ex: `domain_record_create`) */ -export function getEventMessage(event: Event): JSX.Element | null | string; -export function getEventMessage( - event: EventMessageManualInput -): JSX.Element | null | string; -export function getEventMessage( - event: Event | EventMessageManualInput -): JSX.Element | null | string { - if (!event?.action || !event?.status) { +export function getEventMessage(event: Event): JSX.Element | null | string { + const eventActionFactory = eventMessages[event.action]; + + if (!eventActionFactory) { + if (import.meta.env.DEV) { + console.warn( + `⚠️ No event message factory found for event "${event.action}"` + ); + } + return null; } - const message = eventMessages[event?.action]?.[event.status]; + const eventMessageFunction = eventActionFactory[event.status]; + + if (!eventMessageFunction) { + if (import.meta.env.DEV) { + console.warn( + `⚠️ Event message factory for "${event.action}" was found, but a function for status "${event.status}" was not defined.` + ); + } + + return null; + } - return message ? message(event as Event) : null; + return eventMessageFunction(event); } /** diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx index df1e13eb293..aef63548003 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx @@ -1,5 +1,5 @@ import { Button, Notice, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 96df5f4d254..3a72ef782ac 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -20,7 +20,7 @@ import { useLocation } from 'react-router-dom'; import { ErrorMessage } from 'src/components/ErrorMessage'; import { createFirewallFromTemplate } from 'src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; @@ -56,198 +56,198 @@ const initialValues: CreateFirewallFormValues = { templateSlug: undefined, }; -export const CreateFirewallDrawer = React.memo( - (props: CreateFirewallDrawerProps) => { - // TODO: NBFW - We'll eventually want to check the read_write firewall grant here too, but it doesn't exist yet. - const { createFlow, onClose, onFirewallCreated, open } = props; - const { _hasGrant, _isRestrictedUser } = useAccountManagement(); - const { mutateAsync: createFirewall } = useCreateFirewall(); - const queryClient = useQueryClient(); - const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); - - const { enqueueSnackbar } = useSnackbar(); - - const location = useLocation(); - const isFromLinodeCreate = location.pathname.includes('/linodes/create'); - const queryParams = getQueryParamsFromQueryString( - location.search - ); - - const firewallFormEventOptions: LinodeCreateFormEventOptions = { - createType: queryParams.type ?? 'OS', - headerName: createFirewallText, - interaction: 'click', - label: '', - }; - - const form = useForm({ - defaultValues: initialValues, - mode: 'onBlur', - resolver: createFirewallResolver(), - values: initialValues, - }); - - const { - clearErrors, - control, - formState: { errors, isSubmitting }, - handleSubmit, - reset, - setError, - watch, - } = form; - - const createFirewallFrom = watch('createFirewallFrom'); - - const onSubmit = async (values: CreateFirewallFormValues) => { - const payload = omitProps(values, ['templateSlug', 'createFirewallFrom']); - const slug = values.templateSlug; - try { - const firewall = - createFirewallFrom === 'template' && slug - ? await createFirewallFromTemplate({ - createFirewall, - queryClient, - firewallLabel: payload.label, - templateSlug: slug, - }) - : await createFirewall(payload); - enqueueSnackbar(`Firewall ${values.label} successfully created`, { - variant: 'success', +export const CreateFirewallDrawer = (props: CreateFirewallDrawerProps) => { + const { createFlow, onClose, onFirewallCreated, open } = props; + + const queryClient = useQueryClient(); + + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + + const { mutateAsync: createFirewall } = useCreateFirewall(); + + const isFirewallCreationRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_firewalls', + }); + + const { enqueueSnackbar } = useSnackbar(); + + const location = useLocation(); + const isFromLinodeCreate = location.pathname.includes('/linodes/create'); + const queryParams = getQueryParamsFromQueryString( + location.search + ); + + const firewallFormEventOptions: LinodeCreateFormEventOptions = { + createType: queryParams.type ?? 'OS', + headerName: createFirewallText, + interaction: 'click', + label: '', + }; + + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + resolver: createFirewallResolver(), + values: initialValues, + }); + + const { + clearErrors, + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + setError, + watch, + } = form; + + const createFirewallFrom = watch('createFirewallFrom'); + + const onSubmit = async (values: CreateFirewallFormValues) => { + const payload = omitProps(values, ['templateSlug', 'createFirewallFrom']); + const slug = values.templateSlug; + try { + const firewall = + createFirewallFrom === 'template' && slug + ? await createFirewallFromTemplate({ + createFirewall, + queryClient, + firewallLabel: payload.label, + templateSlug: slug, + }) + : await createFirewall(payload); + enqueueSnackbar(`Firewall ${values.label} successfully created`, { + variant: 'success', + }); + + if (onFirewallCreated) { + onFirewallCreated(firewall); + } + onClose(); + // Fire analytics form submit upon successful firewall creation from Linode Create flow. + if (isFromLinodeCreate) { + sendLinodeCreateFormStepEvent({ + ...firewallFormEventOptions, + label: createFirewallText, }); - - if (onFirewallCreated) { - onFirewallCreated(firewall); - } - onClose(); - // Fire analytics form submit upon successful firewall creation from Linode Create flow. - if (isFromLinodeCreate) { - sendLinodeCreateFormStepEvent({ - ...firewallFormEventOptions, - label: createFirewallText, - }); - } - } catch (errors) { - for (const error of errors) { - if (error?.field === 'rules') { - setError('root', { message: error.reason }); - } else { - setError(error?.field ?? 'root', { message: error.reason }); - } + } + } catch (errors) { + for (const error of errors) { + if (error?.field === 'rules') { + setError('root', { message: error.reason }); + } else { + setError(error?.field ?? 'root', { message: error.reason }); } } - }; - - const userCannotAddFirewall = - _isRestrictedUser && !_hasGrant('add_firewalls'); - - return ( - - reset()} - open={open} - title={createFirewallText} - > -
- {userCannotAddFirewall ? ( - - ) : null} - {errors.root?.message && ( - - - - )} - {isLinodeInterfacesEnabled && ( - <> - - Create - - ( - { - field.onChange(value); - clearErrors(); - }} - row - value={field.value} - > - } - disabled={userCannotAddFirewall} - label="Custom Firewall" - value="custom" - /> - } - disabled={userCannotAddFirewall} - label="From a Template" - value="template" - /> - - )} - /> - - )} - ( - - )} + } + }; + + return ( + + reset()} + open={open} + title={createFirewallText} + > + + {isFirewallCreationRestricted && ( + - {createFirewallFrom === 'template' && isLinodeInterfacesEnabled ? ( - + + + )} + {isLinodeInterfacesEnabled && ( + <> + + Create + + ( + { + field.onChange(value); + clearErrors(); + }} + row + value={field.value} + > + } + disabled={isFirewallCreationRestricted} + label="Custom Firewall" + value="custom" + /> + } + disabled={isFirewallCreationRestricted} + label="From a Template" + value="template" + /> + + )} /> - ) : ( - + )} + ( + )} - + {createFirewallFrom === 'template' && isLinodeInterfacesEnabled ? ( + - - - - ); - } -); + ) : ( + + )} + + +
+
+ ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx index 40220fba33b..e3af37c0091 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx @@ -1,4 +1,4 @@ -import { useAllFirewallsQuery, useGrants } from '@linode/queries'; +import { useAllFirewallsQuery, useGrants, useProfile } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { Box, @@ -15,7 +15,6 @@ import { Controller, useFormContext } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; @@ -51,19 +50,24 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { open, userCannotAddFirewall, } = props; + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + const { control } = useFormContext(); - const { data } = useAllFirewallsQuery(open); - const { _isRestrictedUser } = useAccountManagement(); + const { data: grants } = useGrants(); + const { data: firewalls } = useAllFirewallsQuery(open); + const { data: profile } = useProfile(); + + const isRestrictedUser = profile?.restricted; // If a user is restricted, they can not add a read-only Linode to a firewall. - const readOnlyLinodeIds = _isRestrictedUser + const readOnlyLinodeIds = isRestrictedUser ? getEntityIdsByPermission(grants, 'linode', 'read_only') : []; // If a user is restricted, they can not add a read-only NodeBalancer to a firewall. - const readOnlyNodebalancerIds = _isRestrictedUser + const readOnlyNodebalancerIds = isRestrictedUser ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') : []; @@ -72,7 +76,9 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { ? READ_ONLY_DEVICES_HIDDEN_MESSAGE : undefined; - const assignedServices = data?.map((firewall) => firewall.entities).flat(); + const assignedServices = firewalls + ?.map((firewall) => firewall.entities) + .flat(); const assignedLinodes = assignedServices?.filter( (service) => service.type === 'linode' diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.styles.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.styles.tsx index aa59af34f4c..188c62ff8e5 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.styles.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.styles.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index 540942d3b12..26b12b3f38c 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -6,7 +6,7 @@ import { useProfile, } from '@linode/queries'; import { Button, Notice, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useSnackbar } from 'notistack'; diff --git a/packages/manager/src/features/Help/HelpLanding.test.tsx b/packages/manager/src/features/Help/HelpLanding.test.tsx index d74a1d32e35..183bfbb89e1 100644 --- a/packages/manager/src/features/Help/HelpLanding.test.tsx +++ b/packages/manager/src/features/Help/HelpLanding.test.tsx @@ -1,25 +1,25 @@ import * as React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { HelpLanding } from './HelpLanding'; describe('Help Landing', () => { - it('should render search panel', () => { - const { getByText } = renderWithTheme(); + it('should render search panel', async () => { + const { getByText } = await renderWithThemeAndRouter(); expect(getByText('What can we help you with?')).toBeVisible(); }); - it('should render popular posts panel', () => { - const { getByText } = renderWithTheme(); + it('should render popular posts panel', async () => { + const { getByText } = await renderWithThemeAndRouter(); expect(getByText('Most Popular Documentation:')).toBeVisible(); expect(getByText('Most Popular Community Posts:')).toBeVisible(); }); - it('should render other ways panel', () => { - const { getByText } = renderWithTheme(); + it('should render other ways panel', async () => { + const { getByText } = await renderWithThemeAndRouter(); expect(getByText('Other Ways to Get Help')).toBeVisible(); }); diff --git a/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx b/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx index 54fb12278a5..1a7ed2a0304 100644 --- a/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx +++ b/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx @@ -1,7 +1,6 @@ import { Autocomplete, InputAdornment, Notice } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { withRouter } from 'react-router-dom'; -import type { RouteComponentProps } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import Search from 'src/assets/icons/search.svg'; @@ -17,16 +16,15 @@ interface SelectedItem { label: string; value: string; } -interface AlgoliaSearchBarProps extends AlgoliaProps, RouteComponentProps<{}> {} /** * For Algolia search to work locally, ensure you have valid values set for * REACT_APP_ALGOLIA_APPLICATION_ID and REACT_APP_ALGOLIA_SEARCH_KEY in your .env file. */ -const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { +const AlgoliaSearchBar = (props: AlgoliaProps) => { const [inputValue, setInputValue] = React.useState(''); - const { history, searchAlgolia, searchEnabled, searchError, searchResults } = - props; + const { searchAlgolia, searchEnabled, searchError, searchResults } = props; + const navigate = useNavigate(); const options = React.useMemo(() => { const [docs, community] = searchResults; @@ -50,12 +48,6 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { [searchAlgolia] ); - const getLinkTarget = (inputValue: string) => { - return inputValue - ? `/support/search/?query=${inputValue}` - : '/support/search/'; - }; - const handleSelect = (selected: ConvertedItems | null | SelectedItem) => { if (!selected || !inputValue) { return; @@ -68,8 +60,10 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { window.open(href, '_blank', 'noopener'); } else { // If no href, we redirect to the search landing page. - const link = getLinkTarget(inputValue); - history.push(link); + navigate({ + to: '/support/search', + search: { query: inputValue || undefined }, + }); } }; return ( @@ -128,5 +122,5 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { }; export default withSearch({ highlight: false, hitsPerPage: 10 })( - withRouter(AlgoliaSearchBar) + AlgoliaSearchBar ); diff --git a/packages/manager/src/features/Help/Panels/OtherWays.tsx b/packages/manager/src/features/Help/Panels/OtherWays.tsx index d6224534c06..5c069fb5109 100644 --- a/packages/manager/src/features/Help/Panels/OtherWays.tsx +++ b/packages/manager/src/features/Help/Panels/OtherWays.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Help/Panels/PopularPosts.tsx b/packages/manager/src/features/Help/Panels/PopularPosts.tsx index d7981604513..71b6fedbd78 100644 --- a/packages/manager/src/features/Help/Panels/PopularPosts.tsx +++ b/packages/manager/src/features/Help/Panels/PopularPosts.tsx @@ -1,5 +1,5 @@ import { Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Help/SupportSearchLanding/HelpResources.tsx b/packages/manager/src/features/Help/SupportSearchLanding/HelpResources.tsx index 842c32cd33c..42e2ad4b333 100644 --- a/packages/manager/src/features/Help/SupportSearchLanding/HelpResources.tsx +++ b/packages/manager/src/features/Help/SupportSearchLanding/HelpResources.tsx @@ -1,7 +1,7 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import Community from 'src/assets/icons/community.svg'; @@ -34,7 +34,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ export const HelpResources = () => { const { classes } = useStyles(); - const history = useHistory(); + const navigate = useNavigate(); const [drawerOpen, setDrawerOpen] = React.useState(false); const openTicketDrawer = () => { setDrawerOpen(true); @@ -48,9 +48,12 @@ export const HelpResources = () => { ticketId: number, attachmentErrors: AttachmentError[] = [] ) => { - history.push({ - pathname: `/support/tickets/${ticketId}`, - state: { attachmentErrors }, + navigate({ + to: `/support/tickets/${ticketId}`, + state: (prev) => ({ + ...prev, + attachmentErrors, + }), }); setDrawerOpen(false); }; diff --git a/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.test.tsx b/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.test.tsx index ef4ff4437c3..05fc05dc0a9 100644 --- a/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.test.tsx +++ b/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.test.tsx @@ -3,7 +3,6 @@ import { screen } from '@testing-library/react'; import { assocPath } from 'ramda'; import * as React from 'react'; -import { reactRouterProps } from 'src/__data__/reactRouterProps'; import { renderWithTheme } from 'src/utilities/testHelpers'; import SupportSearchLanding from './SupportSearchLanding'; @@ -12,9 +11,25 @@ const props = { searchAlgolia: vi.fn(), searchEnabled: true, searchResults: [[], []], - ...reactRouterProps, + location: { + search: '?query=test', + }, }; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + useLocation: queryMocks.useLocation, + }; +}); + const propsWithMultiWordURLQuery = assocPath( ['location', 'search'], '?query=two%20words', @@ -22,6 +37,15 @@ const propsWithMultiWordURLQuery = assocPath( ); describe('SupportSearchLanding Component', () => { + beforeEach(() => { + queryMocks.useLocation.mockReturnValue({ + search: '?query=test', + state: { + supportTicketFormFields: {}, + }, + }); + }); + it('should render', () => { renderWithTheme(); expect(screen.getByTestId('support-search-landing')).toBeInTheDocument(); diff --git a/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.tsx b/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.tsx index 5b54eae2623..08f4638fba2 100644 --- a/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.tsx +++ b/packages/manager/src/features/Help/SupportSearchLanding/SupportSearchLanding.tsx @@ -1,10 +1,9 @@ import { Box, H1Header, InputAdornment, Notice, TextField } from '@linode/ui'; import { getQueryParamFromQueryString } from '@linode/utilities'; import Search from '@mui/icons-material/Search'; -import Grid from '@mui/material/Grid2'; -import { createLazyRoute } from '@tanstack/react-router'; +import Grid from '@mui/material/Grid'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import { COMMUNITY_SEARCH_URL, DOCS_SEARCH_URL } from 'src/constants'; @@ -38,7 +37,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); const SupportSearchLanding = (props: AlgoliaProps) => { - const history = useHistory(); + const navigate = useNavigate(); const { searchAlgolia, searchEnabled, searchError, searchResults } = props; const [docs, community] = searchResults; const { classes } = useStyles(); @@ -58,7 +57,7 @@ const SupportSearchLanding = (props: AlgoliaProps) => { const onInputChange = (e: React.ChangeEvent) => { const newQuery = e.target.value ?? ''; setQueryString(newQuery); - history.replace({ search: `?query=${newQuery}` }); + navigate({ to: '/search', search: { query: newQuery } }); searchAlgolia(newQuery); }; @@ -122,9 +121,3 @@ const SupportSearchLanding = (props: AlgoliaProps) => { export default withSearch({ highlight: false, hitsPerPage: 5 })( SupportSearchLanding ); - -export const supportSearchLandingLazyRoute = createLazyRoute('/support/search')( - { - component: SupportSearchLanding, - } -); diff --git a/packages/manager/src/features/Help/index.tsx b/packages/manager/src/features/Help/index.tsx deleted file mode 100644 index 89fd7cc9627..00000000000 --- a/packages/manager/src/features/Help/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; - -import { StatusBanners } from './StatusBanners'; - -const HelpLanding = React.lazy(() => - import('./HelpLanding').then((module) => ({ default: module.HelpLanding })) -); - -const SupportSearchLanding = React.lazy( - () => import('src/features/Help/SupportSearchLanding/SupportSearchLanding') -); - -const SupportTickets = React.lazy( - () => import('src/features/Support/SupportTickets') -); - -const SupportTicketDetail = React.lazy(() => - import('src/features/Support/SupportTicketDetail/SupportTicketDetail').then( - (module) => ({ - default: module.SupportTicketDetail, - }) - ) -); - -export const HelpAndSupport = () => { - return ( - <> - - - - - - - - - - - - - ); -}; diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 7717a801027..5b24e26cdcd 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -1,4 +1,4 @@ -import { CircleProgress, Paper } from '@linode/ui'; +import { CircleProgress, Paper, Typography } from '@linode/ui'; import React from 'react'; import { RolesTable } from 'src/features/IAM/Roles/RolesTable/RolesTable'; @@ -22,6 +22,7 @@ export const RolesLanding = () => { return ( ({ marginTop: theme.tokens.spacing.S16 })}> + Roles ); diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 0fd71ff1482..0f742afa093 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -1,7 +1,7 @@ import { useAccountUsers } from '@linode/queries'; import { ActionsPanel, Autocomplete, Drawer, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React, { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index e3d1c66e966..85b59d58645 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -1,7 +1,8 @@ import { Button, Select, Typography } from '@linode/ui'; import { capitalizeAllWords } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; import { sortRows, Table, @@ -23,58 +24,85 @@ import { getFacadeRoleDescription, mapEntityTypesForSelect, } from 'src/features/IAM/Shared/utilities'; +import { usePagination } from 'src/hooks/usePagination'; + +import { ROLES_TABLE_PREFERENCE_KEY } from '../../Shared/constants'; import type { RoleView } from '../../Shared/types'; import type { SelectOption } from '@linode/ui'; import type { Order } from 'akamai-cds-react-components/Table'; - const ALL_ROLES_OPTION: SelectOption = { label: 'All Roles', value: 'all', }; interface Props { - roles: RoleView[]; + roles?: RoleView[]; } -export const RolesTable = ({ roles }: Props) => { - const [rows, setRows] = useState(roles); - +export const RolesTable = ({ roles = [] }: Props) => { // Filter string for the search bar const [filterString, setFilterString] = React.useState(''); - - // Get just the list of entity types from this list of roles, to be used in the selection filter - const filterableOptions = React.useMemo(() => { - return [ALL_ROLES_OPTION, ...mapEntityTypesForSelect(roles, ' Roles')]; - }, [roles]); - const [filterableEntityType, setFilterableEntityType] = useState(ALL_ROLES_OPTION); - const [sort, setSort] = useState< undefined | { column: string; order: Order } >(undefined); - const [selectedRows, setSelectedRows] = useState([]); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const pagination = usePagination(1, ROLES_TABLE_PREFERENCE_KEY); + + // Filtering + const getFilteredRows = ( + text: string, + entityTypeVal = ALL_ROLES_OPTION.value + ) => { + return roles.filter( + (r) => + (entityTypeVal === ALL_ROLES_OPTION.value || + entityTypeVal === r.entity_type) && + (r.name.includes(text) || + r.description.includes(text) || + r.access.includes(text)) + ); + }; + + const filteredRows = React.useMemo( + () => getFilteredRows(filterString, filterableEntityType?.value), + [roles, filterString, filterableEntityType] + ); + + // Get just the list of entity types from this list of roles, to be used in the selection filter + const filterableOptions = React.useMemo(() => { + return [ALL_ROLES_OPTION, ...mapEntityTypesForSelect(roles, ' Roles')]; + }, [roles]); + + const sortedRows = React.useMemo(() => { + if (!sort) return filteredRows; + return sortRows(filteredRows, sort.order, sort.column); + }, [filteredRows, sort]); + + const paginatedRows = React.useMemo(() => { + const start = (pagination.page - 1) * pagination.pageSize; + return sortedRows.slice(start, start + pagination.pageSize); + }, [sortedRows, pagination.page, pagination.pageSize]); + const areAllSelected = React.useMemo(() => { return ( - !!rows?.length && + !!paginatedRows?.length && !!selectedRows?.length && - rows?.length === selectedRows?.length + paginatedRows?.length === selectedRows?.length ); - }, [rows, selectedRows]); + }, [paginatedRows, selectedRows]); const handleSort = (event: CustomEvent, column: string) => { setSort({ column, order: event.detail as Order }); - const visibleRows = sortRows(rows, event.detail as Order, column); - setRows(visibleRows); }; const handleSelect = (event: CustomEvent, row: 'all' | RoleView) => { if (row === 'all') { - setSelectedRows(areAllSelected ? [] : rows); + setSelectedRows(areAllSelected ? [] : paginatedRows); } else if (selectedRows.includes(row)) { setSelectedRows(selectedRows.filter((r) => r !== row)); } else { @@ -82,30 +110,14 @@ export const RolesTable = ({ roles }: Props) => { } }; - const getFilteredRows = ( - text: string, - entityTypeVal = ALL_ROLES_OPTION.value - ) => { - return roles.filter( - (r) => - (entityTypeVal === ALL_ROLES_OPTION.value || - entityTypeVal === r.entity_type) && - (r.name.includes(text) || - r.description.includes(text) || - r.access.includes(text)) - ); - }; - const handleTextFilter = (fs: string) => { setFilterString(fs); - const filteredRows = getFilteredRows(fs, filterableEntityType?.value); - setRows(filteredRows); + pagination.handlePageChange(1); }; const handleChangeEntityTypeFilter = (_: never, entityType: SelectOption) => { setFilterableEntityType(entityType ?? ALL_ROLES_OPTION); - const filteredRows = getFilteredRows(filterString, entityType?.value); - setRows(filteredRows); + pagination.handlePageChange(1); }; const assignRoleRow = (row: RoleView) => { @@ -118,6 +130,15 @@ export const RolesTable = ({ roles }: Props) => { setIsDrawerOpen(true); }; + const handlePageChange = (event: CustomEvent<{ page: number }>) => { + pagination.handlePageChange(Number(event.detail)); + }; + + const handlePageSizeChange = (event: CustomEvent<{ pageSize: number }>) => { + const newSize = event.detail.pageSize; + pagination.handlePageSizeChange(newSize); + pagination.handlePageChange(1); + }; return ( <> ({ marginTop: theme.tokens.spacing.S16 })}> @@ -125,7 +146,10 @@ export const RolesTable = ({ roles }: Props) => { container direction="row" spacing={2} - sx={{ justifyContent: 'space-between' }} + sx={(theme) => ({ + justifyContent: 'space-between', + marginBottom: theme.tokens.spacing.S12, + })} > { > Role Type - handleSort(event, 'description')} - sortable - sorted={sort?.column === 'description' ? sort.order : undefined} - style={{ minWidth: '38%' }} - > + Description - {!rows?.length ? ( + {!paginatedRows?.length ? ( - No items to display. + + No items to display. + ) : ( - rows.map((roleRow) => ( + paginatedRows.map((roleRow) => ( { selectable selected={selectedRows.includes(roleRow)} > - + {roleRow.name} @@ -242,7 +265,12 @@ export const RolesTable = ({ roles }: Props) => { )} - + { assignRoleRow(roleRow); @@ -260,6 +288,14 @@ export const RolesTable = ({ roles }: Props) => { )} + setIsDrawerOpen(false)} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx index 6a57242ee2e..640202e8ba6 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx @@ -8,5 +8,11 @@ interface Props { export const RolesTableActionMenu = ({ onClick }: Props) => { // This menu has evolved over time to where it isn't much of a menu at all, but rather a single action. - return ; + return ( + + ); }; diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.test.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.test.tsx index 422bf0eb397..a2acf4a1265 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.test.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.test.tsx @@ -13,6 +13,6 @@ describe('RolesTableExpandedRow', () => { it('renders when used', () => { renderWithTheme(); - expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts index f308d7b8d84..7374c6693f0 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts @@ -23,7 +23,9 @@ export const StyledDescription = styled(Typography)(({ theme }) => ({ wordBreak: 'normal', })); -export const StyledEntityBox = styled(Box)<{ +export const StyledEntityBox = styled(Box, { + shouldForwardProp: (prop) => prop !== 'hideDetails', +})<{ hideDetails: boolean | undefined; }>(({ theme, hideDetails }) => ({ marginTop: !hideDetails ? theme.tokens.spacing.S12 : undefined, diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index c85f6350ed5..62ae7741f7a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -1,6 +1,6 @@ import { Button, CircleProgress, Select, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { useHistory, useParams } from 'react-router-dom'; diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx index 2cf9efd47b7..c27a4f682cd 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -26,6 +26,7 @@ const mockEntities = [ accountEntityFactory.build({ id: 7, type: 'linode', + label: 'linode', }), accountEntityFactory.build({ id: 1, @@ -102,6 +103,34 @@ describe('Entities', () => { expect(autocomplete).toHaveLength(1); expect(autocomplete[0]).toBeVisible(); expect(autocomplete[0]).toHaveAttribute('placeholder', 'None'); + const link = screen.getByRole('link', { name: /Create an Image Entity/i }); + expect(link).toBeVisible(); + }); + + it('renders correct data when it is an entity access', () => { + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), + }); + + renderWithTheme( + + ); + + expect(screen.getByText('Entities')).toBeVisible(); + + // Verify comboboxes exist + const autocomplete = screen.getAllByRole('combobox'); + expect(autocomplete).toHaveLength(1); + expect(autocomplete[0]).toBeVisible(); + expect(autocomplete[0]).toHaveAttribute('placeholder', 'None'); + const link = screen.getByRole('link', { name: /Create a VPC Entity/i }); + expect(link).toBeVisible(); }); it('renders correct options in Autocomplete dropdown when it is an entity access', async () => { @@ -126,7 +155,11 @@ describe('Entities', () => { expect(screen.getByText('firewall-1')).toBeVisible(); }); - it('updates selected options when Autocomplete value changes when it is an entity access', () => { + it('updates selected options when Autocomplete value changes when it is an entity access', async () => { + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), + }); + renderWithTheme( { ); const autocomplete = screen.getAllByRole('combobox')[0]; - fireEvent.change(autocomplete, { target: { value: 'linode7' } }); - fireEvent.keyDown(autocomplete, { key: 'Enter' }); - expect(screen.getByText('test-1')).toBeVisible(); + await userEvent.click(autocomplete); + expect(screen.getByText('linode')).toBeVisible(); }); it('renders Autocomplete as readonly when mode is "change-role"', () => { diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx index 35238ab45fc..084643d466b 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx @@ -116,7 +116,8 @@ export const Entities = ({ - Create a {getFormattedEntityType(type)} Entity + Create {type === 'image' ? `an` : `a`}{' '} + {getFormattedEntityType(type)} Entity{' '} {' '} first or choose a different role to continue assignment. diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts index 3cb61f3d3fb..91d6ce8d4cc 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts @@ -1,23 +1,19 @@ import { Box, Typography } from '@linode/ui'; -import { Grid } from '@mui/material'; import { styled } from '@mui/material/styles'; -export const sxTooltipIcon = { - marginLeft: 1, - padding: 0, -}; - -export const StyledGrid = styled(Grid, { label: 'StyledGrid' })(() => ({ - alignItems: 'center', - marginBottom: 2, -})); +export const StyledTitle = styled(Typography, { label: 'StyledTitle' })( + ({ theme }) => ({ + font: theme.tokens.alias.Typography.Label.Bold.S, + marginBottom: theme.tokens.spacing.S4, + }) +); export const StyledPermissionItem = styled(Typography, { label: 'StyledPermissionItem', })(({ theme }) => ({ borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, display: 'inline-block', - padding: `0px ${theme.spacing(0.75)} ${theme.spacing(0.25)}`, + padding: `0px ${theme.tokens.spacing.S6} ${theme.tokens.spacing.S2}`, })); export const StyledContainer = styled('div', { diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx index a0432e9d221..16f7308b065 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx @@ -1,6 +1,6 @@ -import { StyledLinkButton, TooltipIcon, Typography } from '@linode/ui'; +import { StyledLinkButton, Typography } from '@linode/ui'; import { debounce } from '@mui/material'; -import Grid from '@mui/material/Grid'; +import { Grid } from '@mui/material'; import * as React from 'react'; import { useCalculateHiddenItems } from '../../hooks/useCalculateHiddenItems'; @@ -8,9 +8,8 @@ import { StyledBox, StyledClampedContent, StyledContainer, - StyledGrid, StyledPermissionItem, - sxTooltipIcon, + StyledTitle, } from './Permissions.style'; import type { PermissionType } from '@linode/api-v4/lib/iam/types'; @@ -46,21 +45,7 @@ export const Permissions = ({ permissions }: Props) => { // TODO: update the link for TooltipIcon when it's ready - UIE-8534 return ( - - ({ - font: theme.tokens.alias.Typography.Label.Bold.S, - })} - > - Permissions - - - - + Permissions {!permissions.length ? ( This role doesn’t include permissions. Refer to the role description diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index db7006eba78..bd63357dff6 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -19,3 +19,5 @@ export const PAID_ENTITY_TYPES = [ 'volume', 'image', ]; + +export const ROLES_TABLE_PREFERENCE_KEY = 'roles'; diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx index 234f11ce814..72080851eb5 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx @@ -1,5 +1,5 @@ import { Paper, Stack, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx index d05c73af0e3..5cbb7e7aca6 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx @@ -1,5 +1,5 @@ import { Select, Typography, useTheme } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { useLocation, useParams } from 'react-router-dom'; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx index eee51b7b614..efe557c192b 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx @@ -1,6 +1,8 @@ -import { ActionsPanel, Drawer, Typography } from '@linode/ui'; +import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; +import { useQueryClient } from '@tanstack/react-query'; +import { enqueueSnackbar } from 'notistack'; import React, { useState } from 'react'; import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; import { useParams } from 'react-router-dom'; @@ -11,9 +13,9 @@ import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFi import { AssignSingleRole } from 'src/features/IAM/Users/UserRoles/AssignSingleRole'; import { useAccountPermissions, - useAccountUserPermissions, useAccountUserPermissionsMutation, } from 'src/queries/iam/iam'; +import { iamQueries } from 'src/queries/iam/queries'; import { getAllRoles, @@ -21,6 +23,7 @@ import { } from '../../Shared/utilities'; import type { AssignNewRoleFormValues } from '../../Shared/utilities'; +import type { IamUserPermissions } from '@linode/api-v4'; interface Props { onClose: () => void; @@ -30,11 +33,10 @@ interface Props { export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { const theme = useTheme(); const { username } = useParams<{ username: string }>(); + const queryClient = useQueryClient(); const { data: accountPermissions } = useAccountPermissions(); - const { data: existingRoles } = useAccountUserPermissions(username ?? ''); - const form = useForm({ defaultValues: { roles: [ @@ -46,7 +48,7 @@ export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { }, }); - const { control, handleSubmit, reset, watch } = form; + const { control, handleSubmit, reset, watch, formState, setError } = form; const { append, fields, remove } = useFieldArray({ control, name: 'roles', @@ -64,17 +66,28 @@ export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { return getAllRoles(accountPermissions); }, [accountPermissions]); - const { mutateAsync: updateUserRolePermissions } = + const { mutateAsync: updateUserRolePermissions, isPending } = useAccountUserPermissionsMutation(username); - const onSubmit = handleSubmit(async (values: AssignNewRoleFormValues) => { - const mergedRoles = mergeAssignedRolesIntoExistingRoles( - values, - existingRoles - ); - await updateUserRolePermissions(mergedRoles); - handleClose(); - }); + const onSubmit = async (values: AssignNewRoleFormValues) => { + try { + const queryKey = iamQueries.user(username)._ctx.permissions.queryKey; + const currentRoles = + queryClient.getQueryData(queryKey); + + const mergedRoles = mergeAssignedRolesIntoExistingRoles( + values, + structuredClone(currentRoles) + ); + + await updateUserRolePermissions(mergedRoles); + + enqueueSnackbar(`Roles added.`, { variant: 'success' }); + handleClose(); + } catch (error) { + setError(error.field ?? 'root', { message: error[0].reason }); + } + }; const handleClose = () => { reset(); @@ -83,11 +96,20 @@ export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { // TODO - add a link 'Learn more" - UIE-8534 return ( - - {' '} + -
- + + {formState.errors.root?.message && ( + + + Internal Error - Issue with updating permissions. +
+ No changes were saved. +
+
+ )} + + Select a role you want to assign to a user. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment. @@ -139,6 +161,7 @@ export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { 'data-testid': 'submit', label: 'Assign', type: 'submit', + loading: isPending || formState.isSubmitting, }} secondaryButtonProps={{ 'data-testid': 'cancel', diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx index 2c78ede6a08..ce2e0fe9dad 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx @@ -30,7 +30,10 @@ export const AssignSingleRole = ({ }: Props) => { const theme = useTheme(); - const { control } = useFormContext(); + const { control, watch, setValue } = + useFormContext(); + const role = watch(`roles.${index}.role`); + const roles = watch('roles'); return ( @@ -46,39 +49,63 @@ export const AssignSingleRole = ({ ( - <> - { - onChange({ - ...value, - role: newValue, - }); - }} - options={options} - placeholder="Select a Role" - textFieldProps={{ hideLabel: true }} - value={value?.role || null} - /> - {value?.role && ( - { - onChange({ - ...value, - entities: updatedEntities, - }); - }} - role={getRoleByName(permissions, value.role?.value)!} - value={value.entities || []} - /> - )} - + name={`roles.${index}.role`} + render={({ field: { onChange, value }, fieldState }) => ( + { + onChange(newValue); + setValue(`roles.${index}.entities`, null); + }} + options={options} + placeholder="Select a Role" + textFieldProps={{ hideLabel: true }} + value={value || null} + /> )} + rules={{ + validate: (value) => { + if (!value) { + return roles.length === 1 + ? 'Select a role.' + : 'Select a role or remove this entry.'; + } + return true; + }, + }} /> + + {role && ( + ( + { + onChange(updatedEntities); + }} + role={getRoleByName(permissions, role?.value)!} + value={value || []} + /> + )} + rules={{ + validate: (value) => { + if (role.access === 'account_access') return true; + if ( + role.access === 'entity_access' && + (!value || value.length === 0) + ) { + return 'Select entities.'; + } + return true; + }, + }} + /> + )} - diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index d164342867d..c609872f099 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -1,6 +1,6 @@ import { useAccountUsers, useProfile } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; -import { Box, Button, Paper, Typography } from '@linode/ui'; +import { Button, Paper, Stack, Typography } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import React from 'react'; @@ -21,8 +21,6 @@ import { UsersLandingTableHead } from './UsersLandingTableHead'; import type { Filter } from '@linode/api-v4'; -const XS_TO_SM_BREAKPOINT = 475; - export const UsersLanding = () => { const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false); @@ -101,24 +99,18 @@ export const UsersLanding = () => { order={order} /> )} - ({ marginTop: theme.spacing(2) })}> - ({ - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - marginBottom: theme.spacing(2), - [theme.breakpoints.down(XS_TO_SM_BREAKPOINT)]: { - alignItems: 'flex-start', - flexDirection: 'column', - }, - })} + ({ marginTop: theme.tokens.spacing.S16 })}> + {isProxyUser ? ( ({ [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(1), + marginLeft: theme.tokens.spacing.S8, }, })} variant="h3" @@ -130,7 +122,7 @@ export const UsersLanding = () => { clearable containerProps={{ sx: { - width: { md: '320px', xs: '100%' }, + width: '320px', }, }} debounceTime={250} @@ -147,12 +139,9 @@ export const UsersLanding = () => { buttonType="primary" disabled={isRestrictedUser} onClick={() => setIsCreateDrawerOpen(true)} - sx={(theme) => ({ - [theme.breakpoints.down(XS_TO_SM_BREAKPOINT)]: { - marginTop: theme.spacing(1), - width: '100%', - }, - })} + sx={{ + maxWidth: '120px', + }} tooltipText={ isRestrictedUser ? 'You cannot create other users as a restricted user.' @@ -161,7 +150,7 @@ export const UsersLanding = () => { > Add a User - + diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index b8d7014f93f..f1da3b03a58 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,6 +1,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { useAllLinodeDisksQuery, + useCreateImageMutation, useGrants, useLinodeQuery, useRegionsQuery, @@ -30,7 +31,6 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useCreateImageMutation } from 'src/queries/images'; import type { CreateImagePayload } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx index e0543a7cad6..92314e21ad9 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index dc5d0cc1915..566e1ee3578 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -4,6 +4,7 @@ import { useMutateAccountAgreements, useProfile, useRegionsQuery, + useUploadImageMutation, } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { @@ -36,7 +37,6 @@ import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { useFlags } from 'src/hooks/useFlags'; import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useUploadImageMutation } from 'src/queries/images'; import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index fc3aad177c8..6299f3ed1e9 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -1,4 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; +import { useUpdateImageMutation } from '@linode/queries'; import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui'; import { Stack, Typography } from '@linode/ui'; import { updateImageSchema } from '@linode/validation'; @@ -7,7 +8,6 @@ import { Controller, useForm } from 'react-hook-form'; import Lock from 'src/assets/icons/lock.svg'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; -import { useUpdateImageMutation } from 'src/queries/images'; import { useImageAndLinodeGrantCheck } from '../utils'; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx index 53d907d0245..25e58dfb609 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -1,4 +1,7 @@ -import { useRegionsQuery } from '@linode/queries'; +import { + useRegionsQuery, + useUpdateImageRegionsMutation, +} from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { ActionsPanel, Notice, Paper, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; @@ -9,7 +12,6 @@ import type { Resolver } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; import { useFlags } from 'src/hooks/useFlags'; -import { useUpdateImageRegionsMutation } from 'src/queries/images'; import { ImageRegionRow } from './ImageRegionRow'; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx index 17870d4ce05..a3abe9d1581 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageStatus.tsx @@ -1,5 +1,5 @@ import { Stack, TooltipIcon } from '@linode/ui'; -import { capitalizeAllWords } from '@linode/utilities'; +import { capitalizeAllWords, getFormattedStatus } from '@linode/utilities'; import React from 'react'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; @@ -65,7 +65,7 @@ export const ImageStatus = (props: Props) => { return ( - {capitalizeAllWords(image.status.replace('_', ' '))} + {getFormattedStatus(image.status)} {showEventProgress && ` (${event.percent_complete}%)`} ); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index a293af0bbd0..81014ef59b4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,3 +1,9 @@ +import { + imageQueries, + useDeleteImageMutation, + useImageQuery, + useImagesQuery, +} from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { ActionsPanel, @@ -44,12 +50,6 @@ import { isEventInProgressDiskImagize, } from 'src/queries/events/event.helpers'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; -import { - imageQueries, - useDeleteImageMutation, - useImageQuery, - useImagesQuery, -} from 'src/queries/images'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { @@ -71,7 +71,7 @@ import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; import type { Filter, Image, ImageStatus } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { ImageAction, ImagesSearchParams } from 'src/routes/images'; +import type { ImageAction } from 'src/routes/images'; const useStyles = makeStyles()((theme: Theme) => ({ imageTable: { @@ -108,7 +108,7 @@ export const ImagesLanding = () => { }: { action: ImageAction; imageId: string } = useParams({ strict: false, }); - const search: ImagesSearchParams = useSearch({ from: '/images' }); + const search = useSearch({ from: '/images' }); const { query } = search; const history = useHistory(); const navigate = useNavigate(); @@ -421,17 +421,6 @@ export const ImagesLanding = () => { return ( - {isCreateImageRestricted && ( - - )} void; @@ -28,17 +28,16 @@ export const APLCopy = () => ( ); -const APL_UNSUPPORTED_CHIP_COPY = ' - COMING SOON'; - export const ApplicationPlatform = (props: APLProps) => { const { isSectionDisabled, setAPL, setHighAvailability } = props; - + const { isAPLGeneralAvailability } = useAPLAvailability(); const [isAPLChecked, setIsAPLChecked] = React.useState( isSectionDisabled ? false : undefined ); const [isAPLNotChecked, setIsAPLNotChecked] = React.useState< boolean | undefined >(isSectionDisabled ? true : undefined); + const APL_UNSUPPORTED_CHIP_COPY = `${!isAPLGeneralAvailability ? ' - ' : ''}${isSectionDisabled ? 'COMING SOON' : ''}`; /** * Reset the radio buttons to the correct default state once the user toggles cluster tiers. @@ -48,7 +47,7 @@ export const ApplicationPlatform = (props: APLProps) => { setIsAPLNotChecked(isSectionDisabled ? true : undefined); }, [isSectionDisabled]); - const CHIP_COPY = `BETA${isSectionDisabled ? APL_UNSUPPORTED_CHIP_COPY : ''}`; + const CHIP_COPY = `${!isAPLGeneralAvailability ? 'BETA' : ''}${isSectionDisabled ? APL_UNSUPPORTED_CHIP_COPY : ''}`; const handleChange = (e: React.ChangeEvent) => { setAPL(e.target.value === 'yes'); @@ -69,13 +68,15 @@ export const ApplicationPlatform = (props: APLProps) => { > Akamai App Platform - + {(!isAPLGeneralAvailability || isSectionDisabled) && ( + + )} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx index f5d6fdcc288..315dbd44efe 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx @@ -1,6 +1,6 @@ import { useAccount } from '@linode/queries'; import { Stack, Typography } from '@linode/ui'; -import { Grid2, useMediaQuery } from '@mui/material'; +import { Grid, useMediaQuery } from '@mui/material'; import React from 'react'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; @@ -50,7 +50,7 @@ export const ClusterTierPanel = (props: Props) => { - + { } tooltipPlacement={smDownBreakpoint ? 'bottom' : 'right'} /> - + ); }; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx index c39def354d4..b2ed26ad5e8 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx @@ -8,7 +8,7 @@ import { Typography, } from '@linode/ui'; import { FormLabel, styled } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { ErrorMessage } from 'src/components/ErrorMessage'; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 8561dd35696..e471df521b4 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -16,7 +16,7 @@ import { import { plansNoticesUtils, scrollErrorIntoViewV2 } from '@linode/utilities'; import { createKubeClusterWithRequiredACLSchema } from '@linode/validation'; import { Divider } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { createLazyRoute } from '@tanstack/react-router'; import { pick, remove, update } from 'ramda'; import * as React from 'react'; @@ -35,6 +35,7 @@ import { getLatestVersion, useAPLAvailability, useIsLkeEnterpriseEnabled, + useKubernetesBetaEndpoint, useLkeStandardOrEnterpriseVersions, } from 'src/features/Kubernetes/kubeUtils'; import { useFlags } from 'src/hooks/useFlags'; @@ -104,13 +105,14 @@ export const CreateCluster = () => { const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const [highAvailability, setHighAvailability] = React.useState(); const [controlPlaneACL, setControlPlaneACL] = React.useState(false); - const [apl_enabled, setApl_enabled] = React.useState(false); + const [aplEnabled, setAplEnabled] = React.useState(false); const { data, error: regionsError } = useRegionsQuery(); const regionsData = data ?? []; const history = useHistory(); const { data: account } = useAccount(); const { showAPL } = useAPLAvailability(); + const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { showHighAvailability } = getKubeHighAvailability(account); const { showControlPlaneACL } = getKubeControlPlaneACL(account); const [ipV4Addr, setIPv4Addr] = React.useState([ @@ -255,7 +257,6 @@ export const CreateCluster = () => { control_plane: { acl: { enabled: controlPlaneACL, - 'revision-id': '', ...(controlPlaneACL && // only send the IPs if we are enabling IPACL (_ipv4.length > 0 || _ipv6.length > 0) && { addresses: { @@ -273,17 +274,16 @@ export const CreateCluster = () => { }; if (isAPLSupported) { - payload = { ...payload, apl_enabled }; + payload = { ...payload, apl_enabled: aplEnabled }; } if (isLkeEnterpriseLAFeatureEnabled) { payload = { ...payload, tier: selectedTier }; } - const createClusterFn = - isAPLSupported || isLkeEnterpriseLAFeatureEnabled - ? createKubernetesClusterBeta - : createKubernetesCluster; + const createClusterFn = isUsingBetaEndpoint + ? createKubernetesClusterBeta + : createKubernetesCluster; // Since ACL is enabled by default for LKE-E clusters, run validation on the ACL IP Address fields if the acknowledgement is not explicitly checked. if (selectedTier === 'enterprise' && !isACLAcknowledgementChecked) { @@ -499,7 +499,7 @@ export const CreateCluster = () => { @@ -520,7 +520,7 @@ export const CreateCluster = () => { ? UNKNOWN_PRICE : highAvailabilityPrice } - isAPLEnabled={apl_enabled} + isAPLEnabled={aplEnabled} isErrorKubernetesTypes={isErrorKubernetesTypes} isLoadingKubernetesTypes={isLoadingKubernetesTypes} selectedRegionId={selectedRegion?.id} @@ -569,7 +569,7 @@ export const CreateCluster = () => { addNodePool={(pool: KubeNodePoolResponse) => addPool(pool)} apiError={errorMap.node_pools} hasSelectedRegion={hasSelectedRegion} - isAPLEnabled={apl_enabled} + isAPLEnabled={aplEnabled} isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} regionsData={regionsData} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index a6fd25d849c..b8988d0b925 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -1,5 +1,5 @@ import { CircleProgress, ErrorState } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useIsAcceleratedPlansEnabled } from 'src/features/components/PlansPanel/utils'; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/PremiumCPUPlanNotice.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/PremiumCPUPlanNotice.tsx index a4e6a8dbc82..a5bd8e67769 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/PremiumCPUPlanNotice.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/PremiumCPUPlanNotice.tsx @@ -6,8 +6,9 @@ import type { NoticeProps } from '@linode/ui'; export const PremiumCPUPlanNotice = (props: NoticeProps) => { return ( - Select Premium CPU instances for scenarios where low latency or high - throughput is expected. + To accommodate enterprise workloads, especially where resource contention + can impact your workloads, using Premium instances is highly recommended + and required to achieve peak performance. ); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/APLSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/APLSummaryPanel.tsx index e6e05ba553e..c97ef63f496 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/APLSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/APLSummaryPanel.tsx @@ -1,5 +1,5 @@ import { Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import axios from 'axios'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index 727af593bec..60f25d65ea7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -2,7 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { CircleProgress, TooltipIcon, Typography } from '@linode/ui'; import { pluralize } from '@linode/utilities'; import { useMediaQuery } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx index f3a4e48e170..8bf91b04124 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx @@ -103,7 +103,7 @@ export const KubeControlPlaneACLDrawer = ( : [''], }, enabled: aclPayload?.enabled ?? false, - 'revision-id': aclPayload?.['revision-id'] ?? '', + 'revision-id': aclPayload?.['revision-id'], }, }, }); @@ -149,7 +149,12 @@ export const KubeControlPlaneACLDrawer = ( const payload: KubernetesControlPlaneACLPayload = { acl: { enabled: acl.enabled, - 'revision-id': acl['revision-id'], + /** + * If revision-id is an empty string, we want to remove revision-id from the payload + * to let the API know to generate a new one + */ + 'revision-id': + acl['revision-id'] === '' ? undefined : acl['revision-id'], ...{ addresses: { ipv4, diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx index c81d723920b..623f0386f79 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx @@ -1,7 +1,7 @@ import { useProfile } from '@linode/queries'; import { Box, CircleProgress, StyledLinkButton } from '@linode/ui'; import { pluralize } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx index 72fecafdd90..7d9e77b6552 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledActionRowGrid = styled(Grid, { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index aff825d5453..0981cbe86f8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -54,8 +54,9 @@ export const KubeSummaryPanel = React.memo((props: Props) => { React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + // Access to the Kubernetes Dashboard is not supported for LKE-E clusters. const { data: dashboard, error: dashboardError } = - useKubernetesDashboardQuery(cluster.id); + useKubernetesDashboardQuery(cluster.id, cluster.tier !== 'enterprise'); const { error: resetKubeConfigError, diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index 58b61971281..965b539b856 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -6,11 +6,11 @@ import { useLocation, useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { - getKubeHighAvailability, useAPLAvailability, + useKubernetesBetaEndpoint, } from 'src/features/Kubernetes/kubeUtils'; +import { getKubeHighAvailability } from 'src/features/Kubernetes/kubeUtils'; import { useKubernetesClusterMutation, useKubernetesClusterQuery, @@ -29,7 +29,6 @@ export const KubernetesClusterDetail = () => { const id = Number(clusterID); const location = useLocation(); const { showAPL } = useAPLAvailability(); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx index 294be393765..cb611eb2b73 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx @@ -7,7 +7,7 @@ import { Toggle, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx index 78d31a203c2..c03a0df6a0b 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { usePreferences } from '@linode/queries'; import { Box, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 31e5eb46228..b961ff4e4d5 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -91,7 +91,7 @@ describe('NodeTable', () => { getByText('Pool ID 1'); }); - it('displays a provisioning message if the cluster was created within the first 10 mins and there are no nodes yet', async () => { + it('displays a provisioning message if the cluster was created within the first 20 mins and there are no nodes yet', async () => { const clusterProps = { ...props, clusterCreated: DateTime.local().toISO(), @@ -108,7 +108,7 @@ describe('NodeTable', () => { ).toBeVisible(); expect( - await findByText('Provisioning can take up to 10 minutes.') + await findByText('Provisioning can take up to ~20 minutes.') ).toBeVisible(); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index d9e6923fa2b..c162c6ed318 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -110,9 +110,9 @@ export const NodeTable = React.memo((props: Props) => { }) : null; - // It takes ~5 minutes for LKE-E cluster nodes to be provisioned and we want to explain this to the user + // It takes anywhere between 5-20+ minutes for LKE-E cluster nodes to be provisioned and we want to explain this to the user // since nodes are not returned right away unlike standard LKE - const isEnterpriseClusterWithin10MinsOfCreation = () => { + const isEnterpriseClusterWithin20MinsOfCreation = () => { if (clusterTier !== 'enterprise') { return false; } @@ -121,7 +121,7 @@ export const NodeTable = React.memo((props: Props) => { const interval = Interval.fromDateTimes( createdTime, - createdTime.plus({ minutes: 10 }) + createdTime.plus({ minutes: 20 }) ); const currentTime = DateTime.fromISO(DateTime.now().toISO(), { @@ -188,7 +188,7 @@ export const NodeTable = React.memo((props: Props) => { {rowData.length === 0 && - isEnterpriseClusterWithin10MinsOfCreation() && ( + isEnterpriseClusterWithin20MinsOfCreation() && ( { provisioning is complete. - Provisioning can take up to 10 minutes. + Provisioning can take up to ~20 minutes. } @@ -214,7 +214,7 @@ export const NodeTable = React.memo((props: Props) => { )} {(rowData.length > 0 || - !isEnterpriseClusterWithin10MinsOfCreation()) && ( + !isEnterpriseClusterWithin20MinsOfCreation()) && (
import { Typography as FontTypography } from '@linode/design-language-system'; import { Box, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import Table from '@mui/material/Table'; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx index 925e8cee43f..273bda62f78 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx @@ -1,6 +1,7 @@ import { linodeConfigInterfaceFactoryWithVPC, linodeFactory, + linodeInterfaceFactoryPublic, linodeInterfaceFactoryVPC, } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; @@ -241,9 +242,8 @@ describe('Linode Entity Detail', () => { }); }); - it('should display the interface type for a Linode with Linode interfaces and does not display firewall link', async () => { + it('should display the interface type for a Linode with Linode interfaces', async () => { const mockLinode = linodeFactory.build({ interface_generation: 'linode' }); - const mockFirewall = firewallFactory.build({ label: 'test-firewall' }); const account = accountFactory.build({ capabilities: ['Linode Interfaces'], }); @@ -254,9 +254,6 @@ describe('Linode Entity Detail', () => { }), http.get('*/linode/instances/:linodeId', () => { return HttpResponse.json(mockLinode); - }), - http.get('*/linode/instances/:linodeId/firewalls', () => { - return HttpResponse.json(makeResourcePage([mockFirewall])); }) ); @@ -279,6 +276,55 @@ describe('Linode Entity Detail', () => { }); }); + it('should display the public and VPC firewalls for a Linode using new interfaces', async () => { + const mockLinode = linodeFactory.build({ interface_generation: 'linode' }); + const mockPublicInterface = linodeInterfaceFactoryPublic.build(); + const mockVPCInterface = linodeInterfaceFactoryVPC.build(); + const mockFirewall = firewallFactory.build(); + const account = accountFactory.build({ + capabilities: ['Linode Interfaces'], + }); + + server.use( + http.get('*/v4/account', () => { + return HttpResponse.json(account); + }), + http.get('*/linode/instances/:linodeId', () => { + return HttpResponse.json(mockLinode); + }), + http.get('*/linode/instances/:linodeId/interfaces', () => { + return HttpResponse.json({ + interfaces: [mockPublicInterface, mockVPCInterface], + }); + }), + http.get( + '*/linode/instances/:linodeId/interfaces/:interfaceId/firewalls', + () => { + return HttpResponse.json(makeResourcePage([mockFirewall])); + } + ) + ); + + const { getByText } = renderWithTheme( + , + { + flags: { + linodeInterfaces: { enabled: true }, + }, + } + ); + + await waitFor(() => { + expect(getByText('Linode')).toBeVisible(); + expect(getByText('Public Interface Firewall:')).toBeVisible(); + expect(getByText('VPC Interface Firewall:')).toBeVisible(); + }); + }); + it('should not display the encryption status of the linode if the account lacks the capability or the feature flag is off', () => { // situation where isDiskEncryptionFeatureEnabled === false const { queryByTestId } = renderWithTheme( diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index cc6badeb0b0..559a2ef3c61 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -1,5 +1,5 @@ import { - useLinodeFirewallsQuery, + useAllImagesQuery, useLinodeVolumesQuery, useRegionsQuery, } from '@linode/queries'; @@ -14,7 +14,6 @@ import { notificationCenterContext as _notificationContext } from 'src/features/ import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useVPCInterface } from 'src/hooks/useVPCInterface'; import { useInProgressEvents } from 'src/queries/events/events'; -import { useAllImagesQuery } from 'src/queries/images'; import { useTypeQuery } from 'src/queries/types'; import { LinodeEntityDetailBody } from './LinodeEntityDetailBody'; @@ -70,13 +69,6 @@ export const LinodeEntityDetail = (props: Props) => { linodeId: linode.id, }); - const { data: attachedFirewallData } = useLinodeFirewallsQuery( - linode.id, - !isLinodeInterface - ); - - const attachedFirewalls = attachedFirewallData?.data ?? []; - const isLinodesGrantReadOnly = useIsResourceRestricted({ grantLevel: 'read_only', grantType: 'linode', @@ -129,7 +121,6 @@ export const LinodeEntityDetail = (props: Props) => { body={ { const { encryptionStatus, - firewalls, gbRAM, gbStorage, interfaceGeneration, @@ -111,13 +105,6 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { vpcLinodeIsAssignedTo, } = props; - const location = useLocation(); - const history = useHistory(); - - const openUpgradeInterfacesDialog = () => { - history.replace(`${location.pathname}/upgrade-interfaces`); - }; - const { data: profile } = useProfile(); const { data: maskSensitiveDataPreference } = usePreferences( @@ -130,22 +117,9 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { const { isDiskEncryptionFeatureEnabled } = useIsDiskEncryptionFeatureEnabled(); - const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); const isLinodeInterface = interfaceGeneration === 'linode'; const vpcIPv4 = getVPCIPv4(interfaceWithVPC); - const { canUpgradeInterfaces, unableToUpgradeReasons } = - useCanUpgradeInterfaces(linodeLkeClusterId, region, interfaceGeneration); - - const unableToUpgradeTooltipText = getUnableToUpgradeTooltipText( - unableToUpgradeReasons - ); - - // Take the first firewall to display. Linodes with legacy config interfaces can only be assigned to one firewall (currently). We'll only display - // the attached firewall for Linodes with legacy config interfaces - Linodes with new Linode interfaces can be associated with multiple firewalls - // since each interface can have a firewall. - const attachedFirewall = firewalls.length > 0 ? firewalls[0] : undefined; - // @TODO LDE: Remove usages of this variable once LDE is fully rolled out (being used to determine formatting adjustments currently) const isDisplayingEncryptedStatus = isDiskEncryptionFeatureEnabled && Boolean(encryptionStatus); @@ -409,118 +383,20 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { )} - {(linodeLkeClusterId || - attachedFirewall || - isLinodeInterfacesEnabled) && ( - - {linodeLkeClusterId && ( - - LKE Cluster:{' '} - - {cluster?.label ?? `${linodeLkeClusterId}`} - -   - {cluster ? `(ID: ${linodeLkeClusterId})` : undefined} - - )} - {!isLinodeInterface && attachedFirewall && ( - - Firewall:{' '} - - {attachedFirewall.label ?? `${attachedFirewall.id}`} - -   - {attachedFirewall && `(ID: ${attachedFirewall.id})`} - - )} - {isLinodeInterfacesEnabled && ( - - Interfaces:{' '} - {isLinodeInterface ? ( - 'Linode' - ) : ( - - Configuration Profile - - - ({ - backgroundColor: theme.color.tagButtonBg, - color: theme.tokens.color.Neutrals[80], - marginLeft: theme.spacingFunction(12), - })} - /> - - {!canUpgradeInterfaces && unableToUpgradeTooltipText && ( - - )} - - - )} - - )} - + {isLinodeInterface ? ( + + ) : ( + )} ); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 5068705f3fc..5a217514996 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -1,5 +1,5 @@ import { useLinodeUpdateMutation, useProfile } from '@linode/queries'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx new file mode 100644 index 00000000000..5f0b81b1373 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx @@ -0,0 +1,163 @@ +import { useLinodeFirewallsQuery } from '@linode/queries'; +import { Box, Chip, Tooltip, TooltipIcon, useTheme } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { useCanUpgradeInterfaces } from 'src/hooks/useCanUpgradeInterfaces'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; + +import { + StyledBox, + StyledLabelBox, + StyledListItem, +} from './LinodeEntityDetail.styles'; +import { + FirewallCell, + LKEClusterCell, +} from './LinodeEntityDetailRowInterfaceFirewall'; +import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/LinodeConfigs'; +import { getUnableToUpgradeTooltipText } from './LinodesDetail/LinodeConfigs/UpgradeInterfaces/utils'; + +import type { + InterfaceGenerationType, + KubernetesCluster, +} from '@linode/api-v4'; + +interface Props { + cluster: KubernetesCluster | undefined; + interfaceGeneration: InterfaceGenerationType | undefined; + linodeId: number; + linodeLkeClusterId: null | number; + region: string; +} + +export const LinodeEntityDetailRowConfigFirewall = (props: Props) => { + const { cluster, linodeId, linodeLkeClusterId, interfaceGeneration, region } = + props; + + const location = useLocation(); + const history = useHistory(); + const theme = useTheme(); + + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + + const { data: attachedFirewallData } = useLinodeFirewallsQuery( + linodeId, + interfaceGeneration !== 'linode' + ); + const attachedFirewalls = attachedFirewallData?.data ?? []; + const attachedFirewall = + attachedFirewalls.find((firewall) => firewall.status === 'enabled') ?? + (attachedFirewalls.length > 0 ? attachedFirewalls[0] : undefined); + + const { canUpgradeInterfaces, unableToUpgradeReasons } = + useCanUpgradeInterfaces(linodeLkeClusterId, region, interfaceGeneration); + + const unableToUpgradeTooltipText = getUnableToUpgradeTooltipText( + unableToUpgradeReasons + ); + + const openUpgradeInterfacesDialog = () => { + history.replace(`${location.pathname}/upgrade-interfaces`); + }; + + if (!isLinodeInterfacesEnabled && !linodeLkeClusterId && !attachedFirewall) { + return null; + } + + return ( + + {(linodeLkeClusterId || attachedFirewall) && ( + + {linodeLkeClusterId && ( + + )} + {attachedFirewall && ( + + )} + + )} + {isLinodeInterfacesEnabled && ( + + Interfaces:{' '} + + Configuration Profile + + + ({ + backgroundColor: theme.color.tagButtonBg, + color: theme.tokens.color.Neutrals[80], + marginLeft: theme.spacingFunction(12), + })} + /> + + {!canUpgradeInterfaces && unableToUpgradeTooltipText && ( + + )} + + + + )} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailRowInterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailRowInterfaceFirewall.tsx new file mode 100644 index 00000000000..ad3842fb8e5 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailRowInterfaceFirewall.tsx @@ -0,0 +1,179 @@ +import { + linodeQueries, + useLinodeInterfacesQuery, + useQueries, +} from '@linode/queries'; +import { useTheme } from '@mui/material'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import { + StyledBox, + StyledLabelBox, + StyledListItem, +} from './LinodeEntityDetail.styles'; +import { getLinodeInterfaceType } from './LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities'; + +import type { LinodeInterfaceType } from './LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities'; +import type { Firewall, KubernetesCluster } from '@linode/api-v4'; +import type { SxProps } from '@mui/material'; + +interface Props { + cluster: KubernetesCluster | undefined; + linodeId: number; + linodeLkeClusterId: null | number; +} + +export const LinodeEntityDetailRowInterfaceFirewall = (props: Props) => { + const { cluster, linodeId, linodeLkeClusterId } = props; + + const theme = useTheme(); + + const { data: linodeInterfaces } = useLinodeInterfacesQuery(linodeId); + + const nonVlanInterfaces = + linodeInterfaces?.interfaces.filter((iface) => !iface.vlan) ?? []; + + const interfaceFirewalls = useQueries({ + queries: nonVlanInterfaces.map( + (iface) => + linodeQueries.linode(linodeId)._ctx.interfaces._ctx.interface(iface.id) + ._ctx.firewalls + ), + combine(result) { + return result.reduce>( + (acc, res, index) => { + if (res.data) { + const firewalls = res.data.data; + const shownFirewall = + (firewalls.find((firewall) => firewall.status === 'enabled') ?? + firewalls.length > 0) + ? firewalls[0] + : undefined; + const iface = nonVlanInterfaces[index]; + acc[getLinodeInterfaceType(iface)] = shownFirewall; + } + return acc; + }, + { VPC: undefined, Public: undefined, VLAN: undefined } + ); + }, + }); + + const publicInterfaceFirewall = interfaceFirewalls.Public; + const vpcInterfaceFirewall = interfaceFirewalls.VPC; + + return ( + + + {linodeLkeClusterId && ( + + )} + {publicInterfaceFirewall && ( + + )} + {vpcInterfaceFirewall && ( + + )} + + Interfaces: Linode + + + + ); +}; + +export const LKEClusterCell = ({ + hideLKECellRightBorder, + cluster, + linodeLkeClusterId, +}: { + cluster: KubernetesCluster | undefined; + hideLKECellRightBorder: boolean; + linodeLkeClusterId: number; +}) => { + return ( + + LKE Cluster:{' '} + + {cluster?.label ?? `${linodeLkeClusterId}`} + +   + {cluster ? `(ID: ${linodeLkeClusterId})` : undefined} + + ); +}; + +export const FirewallCell = ({ + additionalSx, + cellLabel, + firewall, + hidePaddingLeft, +}: { + additionalSx?: SxProps; + cellLabel: string; + firewall: Firewall; + hidePaddingLeft: boolean; +}) => { + return ( + + {cellLabel}{' '} + + {firewall.label} + +   + {`(ID: ${firewall.id})`} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx index 46efd319466..dfc3b77c358 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx @@ -1,8 +1,9 @@ -import { useGrants, usePreferences } from '@linode/queries'; -import { Box, Notice } from '@linode/ui'; +import { useGrants, useLinodeQuery, usePreferences } from '@linode/queries'; +import { Box } from '@linode/ui'; import * as React from 'react'; import { useParams } from 'react-router-dom'; +import { AlertReusableComponent } from 'src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent'; import { useFlags } from 'src/hooks/useFlags'; import { AclpPreferenceToggle } from '../AclpPreferenceToggle'; @@ -11,8 +12,10 @@ import { LinodeSettingsAlertsPanel } from '../LinodeSettings/LinodeSettingsAlert const LinodeAlerts = () => { const { linodeId } = useParams<{ linodeId: string }>(); const id = Number(linodeId); + const flags = useFlags(); const { data: grants } = useGrants(); + const { data: linode } = useLinodeQuery(id); const { data: isAclpAlertsPreferenceBeta } = usePreferences( (preferences) => preferences?.isAclpAlertsBeta ); @@ -27,7 +30,11 @@ const LinodeAlerts = () => { {flags.aclpIntegration ? : null} {flags.aclpIntegration && isAclpAlertsPreferenceBeta ? ( // Beta ACLP Alerts View - ACLP Alerts Coming soon... + ) : ( // Legacy Alerts View diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 5b098c45b35..4f778741677 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -34,7 +34,7 @@ import { createStringsFromDevices, scrollErrorIntoViewV2, } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import { useQueryClient } from '@tanstack/react-query'; import { useFormik } from 'formik'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ConfigSelectDialogContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ConfigSelectDialogContent.tsx index c02c30781ee..180234b6e3a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ConfigSelectDialogContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ConfigSelectDialogContent.tsx @@ -63,6 +63,7 @@ export const ConfigSelectDialogContent = ( onChange={(_, item) => setSelectedConfigId(item.value)} options={configOptions} placeholder="Select Configuration Profile" + searchable value={ configOptions.find((options) => options.value === selectedConfigId) ?? null diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx index 27843aae1f3..67e3eb5d7b5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx @@ -7,7 +7,7 @@ import { } from '@linode/queries'; import { Autocomplete, ErrorState, Paper, Stack, Typography } from '@linode/ui'; import { formatNumber, formatPercentage, getMetrics } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import { DateTime } from 'luxon'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/NetworkGraphs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/NetworkGraphs.tsx index 15e5dc5e7bf..9ffb7e85a00 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/NetworkGraphs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/NetworkGraphs.tsx @@ -1,6 +1,6 @@ import { Paper } from '@linode/ui'; import { getMetrics } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index f2cb11ad22e..8b1b9d0b787 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -17,7 +17,7 @@ import { Typography, } from '@linode/ui'; import { API_MAX_PAGE_SIZE, areArraysEqual } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx index b47d2c70476..f2c5cd3a4f0 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx @@ -15,7 +15,7 @@ import { Typography, } from '@linode/ui'; import { usePrevious } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx index f600e3f4212..86aea9672f6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx @@ -1,6 +1,6 @@ import { useRegionsQuery } from '@linode/queries'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx index be45a2993e5..2b7059d21b6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx @@ -1,7 +1,7 @@ import { useLinodeQuery } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx index 3a05a077160..4e169011fd3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx @@ -1,5 +1,5 @@ import { CircleProgress, Notice } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx index b6a0e89ce3d..7d3f346af5c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx @@ -1,10 +1,9 @@ -import { useLinodeQuery, useRegionQuery } from '@linode/queries'; +import { useImageQuery, useLinodeQuery, useRegionQuery } from '@linode/queries'; import { Accordion, Checkbox, Notice, TextField, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Link } from 'src/components/Link'; -import { useImageQuery } from 'src/queries/images'; import type { RebuildLinodeFormValues } from './utils'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx index 57ebbc5678d..3a66fbe25ce 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx @@ -1,6 +1,5 @@ import { Box, - Divider, fadeIn, FormControlLabel, InputAdornment, @@ -8,7 +7,7 @@ import { Toggle, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -43,106 +42,103 @@ export const AlertSection = (props: Props) => { } = props; return ( - <> + - - - - } - data-qa-alert={title} - label={title} - sx={{ - '& > span:last-child': { - ...theme.typography.h3, - }, - '.MuiFormControlLabel-label': { - paddingLeft: '12px', - }, - }} - /> - - + + } + data-qa-alert={title} + label={title} sx={{ - paddingLeft: '70px', - [theme.breakpoints.down('md')]: { - marginTop: '-12px', + '& > span:last-child': { + ...theme.typography.h3, + }, + '.MuiFormControlLabel-label': { + paddingLeft: '12px', }, }} - > - {copy} - - - + + - {endAdornment} - ), - }} - label={textTitle} - max={Infinity} - min={0} - onChange={onValueChange} - sx={{ - '.MuiInput-root': { - animation: `${fadeIn} .3s ease-in-out forwards`, - marginTop: 0, - maxWidth: 150, - }, - }} - type="number" - value={value} - /> - + {copy} + + + + {endAdornment} + ), + }} + label={textTitle} + max={Infinity} + min={0} + onChange={onValueChange} + sx={{ + '.MuiInput-root': { + animation: `${fadeIn} .3s ease-in-out forwards`, + marginTop: 0, + maxWidth: 150, + }, + }} + type="number" + value={value} + /> - - + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index b3189e342d7..52c9582997c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -7,7 +7,7 @@ import { TextField, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx index 593fc9a81ce..7b2e26d80d7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx @@ -1,10 +1,11 @@ import { useLinodeQuery, useLinodeUpdateMutation } from '@linode/queries'; -import { Accordion, ActionsPanel, Notice } from '@linode/ui'; +import { ActionsPanel, Divider, Notice, Paper, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useTypeQuery } from 'src/queries/types'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -20,6 +21,7 @@ interface Props { export const LinodeSettingsAlertsPanel = (props: Props) => { const { isReadOnly, linodeId } = props; const { enqueueSnackbar } = useSnackbar(); + const flags = useFlags(); const { data: linode } = useLinodeQuery(linodeId); @@ -93,7 +95,10 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { : 0 ), onValueChange: (e: React.ChangeEvent) => - formik.setFieldValue('cpu', e.target.valueAsNumber), + formik.setFieldValue( + 'cpu', + !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : 0 + ), radioInputLabel: 'cpu_usage_state', state: formik.values.cpu > 0, textInputLabel: 'cpu_usage_threshold', @@ -115,7 +120,10 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { checked ? (linode?.alerts.io ? linode?.alerts.io : 10000) : 0 ), onValueChange: (e: React.ChangeEvent) => - formik.setFieldValue('io', e.target.valueAsNumber), + formik.setFieldValue( + 'io', + !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : 0 + ), radioInputLabel: 'disk_io_state', state: formik.values.io > 0, textInputLabel: 'disk_io_threshold', @@ -141,7 +149,10 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { : 0 ), onValueChange: (e: React.ChangeEvent) => - formik.setFieldValue('network_in', e.target.valueAsNumber), + formik.setFieldValue( + 'network_in', + !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : 0 + ), radioInputLabel: 'incoming_traffic_state', state: formik.values.network_in > 0, textInputLabel: 'incoming_traffic_threshold', @@ -167,7 +178,10 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { : 0 ), onValueChange: (e: React.ChangeEvent) => - formik.setFieldValue('network_out', e.target.valueAsNumber), + formik.setFieldValue( + 'network_out', + !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : 0 + ), radioInputLabel: 'outbound_traffic_state', state: formik.values.network_out > 0, textInputLabel: 'outbound_traffic_threshold', @@ -193,7 +207,10 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { : 0 ), onValueChange: (e: React.ChangeEvent) => - formik.setFieldValue('transfer_quota', e.target.valueAsNumber), + formik.setFieldValue( + 'transfer_quota', + !Number.isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : 0 + ), radioInputLabel: 'transfer_quota_state', state: formik.values.transfer_quota > 0, textInputLabel: 'transfer_quota_threshold', @@ -203,8 +220,24 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { }, ].filter((thisAlert) => !thisAlert.hidden); - const renderExpansionActions = () => { - return ( + const generalError = hasErrorFor('none'); + const alertsHeading = flags.aclpIntegration ? 'Default Alerts' : 'Alerts'; + + return ( + ({ pb: theme.spacingFunction(16) })}> + ({ mb: theme.spacingFunction(12) })} + variant="h2" + > + {alertsHeading} + + {generalError && {generalError}} + {alertSections.map((p, idx) => ( + + + {idx !== alertSections.length - 1 ? : null} + + ))} { onClick: () => formik.handleSubmit(), }} /> - ); - }; - - const generalError = hasErrorFor('none'); - - return ( - - {generalError && {generalError}} - {alertSections.map((p, idx) => ( - - ))} - + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index 8178c71c56f..1f250299659 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -9,7 +9,7 @@ import { Toggle, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; interface Props { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx index 5f9052309d0..5bf2f420c30 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx @@ -5,7 +5,7 @@ import { } from '@linode/queries'; import { Box, Button, Paper, Stack, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useParams } from 'react-router-dom'; @@ -23,6 +23,7 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { TableSortCell } from 'src/components/TableSortCell'; import { sendEvent } from 'src/utilities/analytics/utils'; +import { addUsedDiskSpace } from '../utilities'; import { CreateDiskDrawer } from './CreateDiskDrawer'; import { DeleteDiskDialog } from './DeleteDiskDialog'; import { LinodeDiskRow } from './LinodeDiskRow'; @@ -238,7 +239,3 @@ export const LinodeDisks = () => { ); }; - -export const addUsedDiskSpace = (disks: Disk[]) => { - return disks.reduce((accum, eachDisk) => eachDisk.size + accum, 0); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx index 5542367bc4c..5c2bdd45322 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx @@ -12,8 +12,8 @@ import { MBpsIntraDC } from 'src/constants'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useTypeQuery } from 'src/queries/types'; -import { addUsedDiskSpace } from '../LinodeStorage/LinodeDisks'; import { MutateDrawer } from '../MutateDrawer/MutateDrawer'; +import { addUsedDiskSpace } from '../utilities'; interface Props { linodeId: number; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/Notifications.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/Notifications.tsx index a9b681fad93..c6b31ac9ef2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/Notifications.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/Notifications.tsx @@ -7,8 +7,10 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import { MaintenanceBanner } from 'src/components/MaintenanceBanner/MaintenanceBanner'; +import { LinodePlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner'; import { ProductNotification } from 'src/components/ProductNotification/ProductNotification'; import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; +import { isPlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; import { MigrationNotification } from './MigrationNotification'; @@ -31,11 +33,13 @@ const Notifications = () => { PENDING_MAINTENANCE_FILTER ); - const maintenanceForThisLinode = accountMaintenanceData?.find( - (thisMaintenance) => - thisMaintenance.entity.type === 'linode' && - thisMaintenance.entity.id === linode?.id - ); + const maintenanceForThisLinode = accountMaintenanceData + ?.filter((maintenance) => !isPlatformMaintenance(maintenance)) // Platform maintenance is handled separately + ?.find( + (thisMaintenance) => + thisMaintenance.entity.type === 'linode' && + thisMaintenance.entity.id === linode?.id + ); const generateNotificationBody = (notification: Notification) => { switch (notification.type) { @@ -78,6 +82,9 @@ const Notifications = () => { ); })} + {linode ? ( + + ) : null} {maintenanceForThisLinode ? ( { return `ssh root@${ipv4}`; }; @@ -19,3 +21,7 @@ export const getSelectedDeviceOption = ( } return optionList.find((option) => option.value === selectedValue) || null; }; + +export const addUsedDiskSpace = (disks: Disk[]) => { + return disks.reduce((accum, eachDisk) => eachDisk.size + accum, 0); +}; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx index 09d7a0858fd..fd04cbe9864 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx @@ -1,6 +1,6 @@ import { useProfile } from '@linode/queries'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { keyframes, styled } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx index 0251355ba46..1b4244a0f98 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx @@ -8,7 +8,7 @@ import { Typography, } from '@linode/ui'; import { groupByTags, sortGroups } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import GridView from 'src/assets/icons/grid-view.svg'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx index 61f168f6271..d00fdf9a14c 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx @@ -1,7 +1,7 @@ import { useIsGeckoEnabled } from '@linode/shared'; import { Box, CircleProgress, IconButton, Paper, Tooltip } from '@linode/ui'; import { getQueryParamsFromQueryString } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index c12fd08ee68..8a391d9c92c 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -1,6 +1,6 @@ import { Tooltip, TooltipIcon, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import { capitalizeAllWords, formatStorageUnits } from '@linode/utilities'; +import { formatStorageUnits, getFormattedStatus } from '@linode/utilities'; import * as React from 'react'; import Flag from 'src/assets/icons/flag.svg'; @@ -72,7 +72,7 @@ export const LinodeRow = (props: Props) => { <> This Linode’s maintenance window opens at{' '} {parsedMaintenanceStartTime}. For more information, see your{' '} - open support tickets. + open support tickets. ); }; @@ -122,7 +122,7 @@ export const LinodeRow = (props: Props) => { ) : ( <> - {capitalizeAllWords(status.replace('_', ' '))} + {getFormattedStatus(status)} ) ) : ( diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.styles.ts b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.styles.ts index 8ca5e1000cb..8c34adbea3a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.styles.ts +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.styles.ts @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledWrapperGrid = styled(Grid, { label: 'StyledWrapperGrid' })({ diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index f6d39d5dbfc..91500509f6a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -8,6 +8,7 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { Link } from 'src/components/Link'; import { MaintenanceBanner } from 'src/components/MaintenanceBanner/MaintenanceBanner'; import OrderBy from 'src/components/OrderBy'; +import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; import { PreferenceToggle } from 'src/components/PreferenceToggle/PreferenceToggle'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; @@ -302,6 +303,7 @@ class ListLinodes extends React.Component { /> )} + {this.props.someLinodesHaveScheduledMaintenance && ( )} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx index 0e2d82a177e..93d1686f664 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx @@ -1,5 +1,5 @@ import { usePreferences } from '@linode/queries'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { Table } from 'src/components/Table'; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index 89119f0642a..9b9cc62a741 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -1,6 +1,7 @@ import { useAccountAgreements, useAllLinodeDisksQuery, + useImageQuery, useLinodeMigrateMutation, useLinodeQuery, useMutateAccountAgreements, @@ -35,7 +36,6 @@ import { useEventsPollingActions, useInProgressEvents, } from 'src/queries/events/events'; -import { useImageQuery } from 'src/queries/images'; import { useTypeQuery } from 'src/queries/types'; import { sendMigrationInitiatedEvent } from 'src/utilities/analytics/customEventAnalytics'; import { formatDate } from 'src/utilities/formatDate'; @@ -43,7 +43,7 @@ import { getGDPRDetails } from 'src/utilities/formatRegion'; import { getLinodeDescription } from 'src/utilities/getLinodeDescription'; import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; -import { addUsedDiskSpace } from '../LinodesDetail/LinodeStorage/LinodeDisks'; +import { addUsedDiskSpace } from '../LinodesDetail/utilities'; import { CautionNotice } from './CautionNotice'; import { ConfigureForm } from './ConfigureForm'; diff --git a/packages/manager/src/features/Linodes/RenderIPs.tsx b/packages/manager/src/features/Linodes/RenderIPs.tsx index 11a77f019d6..62add23b0b2 100644 --- a/packages/manager/src/features/Linodes/RenderIPs.tsx +++ b/packages/manager/src/features/Linodes/RenderIPs.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx b/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx index a80e3967738..640d0aba7cb 100644 --- a/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx +++ b/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx @@ -106,7 +106,7 @@ describe('SMTPRestrictionText component', () => { expect(getByText('open a support ticket')).toHaveAttribute( 'href', - '/support/tickets' + '/support/tickets/open?dialogOpen=true' ); }); }); diff --git a/packages/manager/src/features/Linodes/transitions.ts b/packages/manager/src/features/Linodes/transitions.ts index 266611aa992..6d3352b7ead 100644 --- a/packages/manager/src/features/Linodes/transitions.ts +++ b/packages/manager/src/features/Linodes/transitions.ts @@ -1,4 +1,4 @@ -import { capitalizeAllWords } from '@linode/utilities'; +import { getFormattedStatus } from '@linode/utilities'; import { isEventRelevantToLinode, @@ -73,7 +73,7 @@ export const transitionText = ( return transitionActionMap[recentEvent.action]; } - return capitalizeAllWords(status.replace('_', ' ')); + return getFormattedStatus(status); }; // Given a list of Events, returns a set of all Linode IDs that are involved in an in-progress event. diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx index fccefb80f3e..7dc5619d81d 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx index 1503a0a11e5..ed8bfd7bed6 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx @@ -1,6 +1,6 @@ import { Box, Notice, Typography } from '@linode/ui'; import { isToday as _isToday } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/ApacheGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/ApacheGraphs.tsx index ac82f80e235..9154aa1fea9 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/ApacheGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/ApacheGraphs.tsx @@ -1,5 +1,5 @@ import { roundTo } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/CommonStyles.styles.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/CommonStyles.styles.tsx index 8b34bd3a22b..a3120815b10 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/CommonStyles.styles.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/CommonStyles.styles.tsx @@ -1,5 +1,5 @@ import { Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledTypography = styled(Typography, { diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx index 12ff3431095..624b11101a5 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { CPUGauge } from '../../LongviewLanding/Gauges/CPU'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx index c9243588dd1..fadb9cd3bde 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx @@ -1,6 +1,6 @@ import { Box, Stack, Typography } from '@linode/ui'; import { formatUptime, readableBytes } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import CPUIcon from 'src/assets/icons/longview/cpu-icon.svg'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx index 2c80fda2371..8f7e0526763 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import Paginate from 'src/components/Paginate'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx index 77b2b08e204..4ea7c75ad7d 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx @@ -1,5 +1,5 @@ import { Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLGraphs.tsx index 118d072bb01..c7554b8cf3d 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLGraphs.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx index cf45330be89..cfc4b4c266f 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx @@ -1,6 +1,6 @@ import { Box, Notice, Typography } from '@linode/ui'; import { isToday as _isToday } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx index 8b637126438..ff168c81e2b 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx @@ -1,6 +1,6 @@ import { Box, Notice, Typography } from '@linode/ui'; import { isToday as _isToday } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx index 0448c525d91..a7b16d50ceb 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx index 58a926f7d38..dc81887acfb 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx @@ -1,5 +1,5 @@ import { isToday as _isToday } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx index 0ec131b8fde..87b90d90172 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx @@ -1,6 +1,6 @@ import { Paper } from '@linode/ui'; import { isToday as _isToday } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx index 86a763f3f75..0c586928faa 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx @@ -1,5 +1,5 @@ import { convertBytesToTarget, readableBytes } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx index b318ede448b..0b224ee99a4 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx @@ -1,7 +1,7 @@ import { TextField } from '@linode/ui'; import { isToday as _isToday } from '@linode/utilities'; import { escapeRegExp } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx index 372daf05d03..81a4f93ff23 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx @@ -1,6 +1,6 @@ import { Box, Typography } from '@linode/ui'; import { readableBytes } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { Table } from 'src/components/Table'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx index 5689e12a496..a3451c35116 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx @@ -2,7 +2,6 @@ import { useProfile } from '@linode/queries'; import { CircleProgress, ErrorState, Notice, Paper } from '@linode/ui'; import { NotFound } from '@linode/ui'; import * as React from 'react'; -import { compose } from 'recompose'; import { LandingHeader } from 'src/components/LandingHeader'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; @@ -294,39 +293,38 @@ export const LongviewDetail = (props: CombinedProps) => { ); }; -type LongviewDetailParams = { - id: string; -}; - -const EnhancedLongviewDetail = compose( - React.memo, +interface LongviewDetailParams { + id: number; +} +const EnhancedLongviewDetail = React.memo( withClientStats<{ match: { params: LongviewDetailParams } }>((ownProps) => { - return +(ownProps?.match?.params?.id ?? ''); - }), - withLongviewClients( - ( - own, - { - longviewClientsData, - longviewClientsError, - longviewClientsLastUpdated, - longviewClientsLoading, - } - ) => { - // This is explicitly typed, otherwise `client` would be typed as - // `LongviewClient`, even though there's a chance it could be undefined. - const client: LongviewClient | undefined = - longviewClientsData[own?.match.params.id ?? '']; + return ownProps.match.params.id; + })( + withLongviewClients( + ( + own, + { + longviewClientsData, + longviewClientsError, + longviewClientsLastUpdated, + longviewClientsLoading, + } + ) => { + // This is explicitly typed, otherwise `client` would be typed as + // `LongviewClient`, even though there's a chance it could be undefined. + const client: LongviewClient | undefined = + longviewClientsData[own?.match.params.id ?? '']; - return { - client, - longviewClientsError, - longviewClientsLastUpdated, - longviewClientsLoading, - }; - } + return { + client, + longviewClientsError, + longviewClientsLastUpdated, + longviewClientsLoading, + }; + } + )(LongviewDetail) ) -)(LongviewDetail); +); export default EnhancedLongviewDetail; diff --git a/packages/manager/src/features/Longview/LongviewLanding/Gauges/Network.tsx b/packages/manager/src/features/Longview/LongviewLanding/Gauges/Network.tsx index d05fc9ab0d5..55b190bca7a 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/Gauges/Network.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/Gauges/Network.tsx @@ -1,7 +1,6 @@ import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { compose } from 'recompose'; import { GaugePercent } from 'src/components/GaugePercent/GaugePercent'; import withClientStats from 'src/containers/longview.stats.container'; @@ -102,10 +101,9 @@ const Network = (props: NetworkProps) => { ); }; -export const NetworkGauge = compose( - React.memo, - withClientStats((ownProps) => ownProps.clientID) -)(Network); +export const NetworkGauge = React.memo( + withClientStats((ownProps) => ownProps.clientID)(Network) +); /* What's returned from Network is a bit of an unknown, but assuming that diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts index 31a4f939257..67f98c0dc9b 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts @@ -1,5 +1,5 @@ import { Button } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledButton = styled(Button, { label: 'StyledButton' })({ diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx index 2a60e5b9c9b..94aeb00fbc8 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx @@ -1,9 +1,8 @@ import { useProfile } from '@linode/queries'; import { Typography } from '@linode/ui'; import { formatUptime } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; -import { compose } from 'recompose'; import { EditableEntityLabel } from 'src/components/EditableEntityLabel/EditableEntityLabel'; import { Link } from 'src/components/Link'; @@ -34,115 +33,111 @@ interface Props { userCanModifyClient: boolean; } -interface LongviewClientHeaderProps extends Props, DispatchProps, LVDataProps {} +interface LongviewClientHeaderProps extends Props, LVDataProps {} -const enhanced = compose( - withClientStats((ownProps: Props) => ownProps.clientID) -); +export const LongviewClientHeader = withClientStats( + (ownProps: Props) => ownProps.clientID +)((props: LongviewClientHeaderProps) => { + const { + clientID, + clientLabel, + lastUpdatedError, + longviewClientData, + longviewClientDataLoading, + longviewClientLastUpdated, + openPackageDrawer, + updateLongviewClient, + userCanModifyClient, + } = props; -export const LongviewClientHeader = enhanced( - (props: LongviewClientHeaderProps) => { - const { - clientID, - clientLabel, - lastUpdatedError, - longviewClientData, - longviewClientDataLoading, - longviewClientLastUpdated, - openPackageDrawer, - updateLongviewClient, - userCanModifyClient, - } = props; + const [updating, setUpdating] = React.useState(false); - const [updating, setUpdating] = React.useState(false); + const { data: profile } = useProfile(); - const { data: profile } = useProfile(); + const handleUpdateLabel = (newLabel: string) => { + setUpdating(true); + return updateLongviewClient(clientID, newLabel) + .then((_) => { + setUpdating(false); + }) + .catch((error) => { + setUpdating(false); + return Promise.reject( + getAPIErrorOrDefault(error, 'Error updating label')[0].reason + ); + }); + }; - const handleUpdateLabel = (newLabel: string) => { - setUpdating(true); - return updateLongviewClient(clientID, newLabel) - .then((_) => { - setUpdating(false); - }) - .catch((error) => { - setUpdating(false); - return Promise.reject( - getAPIErrorOrDefault(error, 'Error updating label')[0].reason - ); - }); - }; + const hostname = + longviewClientData.SysInfo?.hostname ?? 'Hostname not available'; + const uptime = longviewClientData?.Uptime ?? null; + const formattedUptime = + uptime !== null ? `Up ${formatUptime(uptime)}` : 'Uptime not available'; + const packages = longviewClientData?.Packages ?? null; + const numPackagesToUpdate = packages ? packages.length : 0; + const packagesToUpdate = getPackageNoticeText(packages); - const hostname = - longviewClientData.SysInfo?.hostname ?? 'Hostname not available'; - const uptime = longviewClientData?.Uptime ?? null; - const formattedUptime = - uptime !== null ? `Up ${formatUptime(uptime)}` : 'Uptime not available'; - const packages = longviewClientData?.Packages ?? null; - const numPackagesToUpdate = packages ? packages.length : 0; - const packagesToUpdate = getPackageNoticeText(packages); + const formattedlastUpdatedTime = + longviewClientLastUpdated !== undefined + ? `Last updated ${formatDate(longviewClientLastUpdated, { + timezone: profile?.timezone, + })}` + : 'Latest update time not available'; - const formattedlastUpdatedTime = - longviewClientLastUpdated !== undefined - ? `Last updated ${formatDate(longviewClientLastUpdated, { - timezone: profile?.timezone, - })}` - : 'Latest update time not available'; + /** + * The pathOrs ahead will default to 'not available' values if + * there's an error, so the only case we need to handle is + * the loading state, which should be displayed only if + * data is loading for the first time and there are no errors. + */ + const loading = + longviewClientDataLoading && + !lastUpdatedError && + longviewClientLastUpdated !== 0; - /** - * The pathOrs ahead will default to 'not available' values if - * there's an error, so the only case we need to handle is - * the loading state, which should be displayed only if - * data is loading for the first time and there are no errors. - */ - const loading = - longviewClientDataLoading && - !lastUpdatedError && - longviewClientLastUpdated !== 0; - - return ( - - - {userCanModifyClient ? ( - - ) : ( - - )} - - - {loading ? ( - Loading... - ) : ( - <> - {formattedUptime} - {numPackagesToUpdate > 0 ? ( - openPackageDrawer()} - title={packagesToUpdate} - > - {packagesToUpdate} - - ) : ( - {packagesToUpdate} - )} - - )} - - - View Details - {!loading && ( - - - {formattedlastUpdatedTime} - - - )} - - - ); - } -); + return ( + + + {userCanModifyClient ? ( + + ) : ( + + )} + + + {loading ? ( + Loading... + ) : ( + <> + {formattedUptime} + {numPackagesToUpdate > 0 ? ( + openPackageDrawer()} + title={packagesToUpdate} + > + {packagesToUpdate} + + ) : ( + {packagesToUpdate} + )} + + )} + + + View Details + {!loading && ( + + + {formattedlastUpdatedTime} + + + )} + + + ); +}); diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx index f1e51e9b333..9c8d6e0e09a 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx @@ -1,5 +1,5 @@ import { Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx index 55916cdc777..a41a1019968 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx @@ -1,8 +1,7 @@ import { useGrants } from '@linode/queries'; import { Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; -import { compose } from 'recompose'; import withLongviewClients from 'src/containers/longview.container'; import withClientStats from 'src/containers/longview.stats.container'; @@ -19,7 +18,6 @@ import { LongviewClientHeader } from './LongviewClientHeader'; import { LongviewClientInstructions } from './LongviewClientInstructions'; import type { ActionHandlers } from './LongviewActionMenu'; -import type { Grant } from '@linode/api-v4'; import type { DispatchProps } from 'src/containers/longview.container'; import type { Props as LVDataProps } from 'src/containers/longview.stats.container'; @@ -31,11 +29,7 @@ interface Props extends ActionHandlers { openPackageDrawer: () => void; } -interface LongviewClientRowProps - extends Props, - LVDataProps, - DispatchProps, - GrantProps {} +interface LongviewClientRowProps extends Props, LVDataProps, DispatchProps {} const LongviewClientRow = (props: LongviewClientRowProps) => { const { @@ -58,9 +52,7 @@ const LongviewClientRow = (props: LongviewClientRowProps) => { const longviewPermissions = grants?.longview || []; - const thisPermission = (longviewPermissions as Grant[]).find( - (r) => r.id === clientID - ); + const thisPermission = longviewPermissions.find((r) => r.id === clientID); const userCanModifyClient = thisPermission ? thisPermission.permissions === 'read_write' @@ -213,13 +205,8 @@ const LongviewClientRow = (props: LongviewClientRowProps) => { ); }; -interface GrantProps { - userCanModifyClient: boolean; -} - -export default compose( - React.memo, - withClientStats((ownProps) => ownProps.clientID), - /** We only need the update action here, easier than prop drilling through 4 components */ - withLongviewClients(() => ({})) -)(LongviewClientRow); +export default React.memo( + withClientStats((ownProps) => ownProps.clientID)( + withLongviewClients(() => ({}))(LongviewClientRow) + ) +); diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.styles.ts b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.styles.ts index 49b6dde5137..4c73957710e 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.styles.ts +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.styles.ts @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledCTAGrid = styled(Grid, { label: 'StyledCTAGrid' })( diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index 76d98ba07e9..2623dc77496 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -1,9 +1,8 @@ import { useAccountSettings, useGrants, useProfile } from '@linode/queries'; import { Autocomplete, Typography } from '@linode/ui'; -import { useLocation, useNavigate } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { connect } from 'react-redux'; -import { compose } from 'recompose'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -31,7 +30,6 @@ import type { LongviewSubscription, } from '@linode/api-v4/lib/longview/types'; import type { Props as LongviewProps } from 'src/containers/longview.container'; -import type { LongviewState } from 'src/routes/longview'; import type { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; import type { MapState } from 'src/store/types'; @@ -53,8 +51,6 @@ type SortKey = 'cpu' | 'load' | 'name' | 'network' | 'ram' | 'storage' | 'swap'; export const LongviewClients = (props: LongviewClientsCombinedProps) => { const { getLongviewClients } = props; const navigate = useNavigate(); - const location = useLocation(); - const locationState = location.state as LongviewState; const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { data: accountSettings } = useAccountSettings(); @@ -128,8 +124,16 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { const handleSubmit = () => { if (isManaged) { navigate({ - state: (prev) => ({ ...prev, ...locationState }), - to: '/support/tickets', + state: (prev) => ({ + ...prev, + supportTicketFormFields: { + title: 'Request for additional Longview clients', + }, + }), + search: { + dialogOpen: drawerOpen, + }, + to: '/support/tickets/open', }); return; } @@ -289,11 +293,7 @@ const mapStateToProps: MapState = (state, _ownProps) => { const connected = connect(mapStateToProps); -export default compose( - React.memo, - connected, - withLongviewClients() -)(LongviewClients); +export default React.memo(connected(withLongviewClients()(LongviewClients))); /** * Helper function for sortClientsBy, diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx index 6102bc94612..f7a106471b0 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx @@ -3,9 +3,8 @@ import { getLongviewSubscriptions, } from '@linode/api-v4/lib/longview'; import { useAccountSettings } from '@linode/queries'; -import { Notice } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { useLocation, useNavigate } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -29,15 +28,12 @@ import type { LongviewSubscription, } from '@linode/api-v4/lib/longview/types'; import type { Props as LongviewProps } from 'src/containers/longview.container'; -import type { LongviewState } from 'src/routes/longview'; const LongviewClients = React.lazy(() => import('./LongviewClients')); const LongviewPlans = React.lazy(() => import('./LongviewPlans')); export const LongviewLanding = (props: LongviewProps) => { const navigate = useNavigate(); - const location = useLocation(); - const locationState = location.state as LongviewState; const { enqueueSnackbar } = useSnackbar(); const activeSubscriptionRequestHook = useAPIRequest( () => getActiveLongviewPlan().then((response) => response), @@ -106,8 +102,13 @@ export const LongviewLanding = (props: LongviewProps) => { const handleSubmit = () => { if (isManaged) { navigate({ - state: (prev) => ({ ...prev, ...locationState }), - to: '/support/tickets', + state: (prev) => ({ + ...prev, + supportTicketFormFields: { + title: 'Request for additional Longview clients', + }, + }), + to: '/support/tickets/open', }); return; } @@ -118,17 +119,6 @@ export const LongviewLanding = (props: LongviewProps) => { return ( <> - {isLongviewCreationRestricted && ( - - )} { const { issues } = props; - const history = useHistory(); + const navigate = useNavigate(); const openIssues = issues.filter((thisIssue) => !thisIssue.dateClosed); @@ -57,12 +56,17 @@ export const MonitorTickets = (props: MonitorTicketsProps) => { - history.push({ - pathname: '/support/tickets', - state: { - open: true, - title: 'Managed monitor issue', + navigate({ + search: { + dialogOpen: true, }, + state: (prev) => ({ + ...prev, + supportTicketFormFields: { + title: 'Managed monitor issue', + }, + }), + to: '/support/tickets/open', }) } > diff --git a/packages/manager/src/features/Managed/MonitorDrawer.tsx b/packages/manager/src/features/Managed/MonitorDrawer.tsx index b8c9b26cde8..92635aea7a1 100644 --- a/packages/manager/src/features/Managed/MonitorDrawer.tsx +++ b/packages/manager/src/features/Managed/MonitorDrawer.tsx @@ -8,7 +8,7 @@ import { TextField, } from '@linode/ui'; import { createServiceMonitorSchema } from '@linode/validation/lib/managed.schema'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; import { Formik } from 'formik'; import * as React from 'react'; diff --git a/packages/manager/src/features/Managed/Monitors/IssueDay.styles.tsx b/packages/manager/src/features/Managed/Monitors/IssueDay.styles.tsx index 9e4abbe7f1b..cb1b2b54c28 100644 --- a/packages/manager/src/features/Managed/Monitors/IssueDay.styles.tsx +++ b/packages/manager/src/features/Managed/Monitors/IssueDay.styles.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; diff --git a/packages/manager/src/features/Managed/Monitors/IssueDay.tsx b/packages/manager/src/features/Managed/Monitors/IssueDay.tsx index 2a2d7af15e5..ce34e0b23d2 100644 --- a/packages/manager/src/features/Managed/Monitors/IssueDay.tsx +++ b/packages/manager/src/features/Managed/Monitors/IssueDay.tsx @@ -1,5 +1,5 @@ import { Tooltip } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import Bad from 'src/assets/icons/monitor-failed.svg'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx b/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx index fd372426b08..4d2f08a9424 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx b/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx index ecb7ef9334b..0522d1f27ce 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx @@ -1,5 +1,5 @@ import { Tooltip, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import TicketIcon from 'src/assets/icons/ticket.svg'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx index 20d3366ba45..97d9faeaeff 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledGrid = styled(Grid, { diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx index 9a250b38341..ab573f5296a 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx @@ -1,5 +1,5 @@ import { Button, Notice, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx index 2b99a4de8c4..eae1012dc76 100644 --- a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx @@ -7,7 +7,7 @@ import { Toggle, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useNavigate } from '@tanstack/react-router'; import { Formik } from 'formik'; import * as React from 'react'; diff --git a/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.styles.tsx b/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.styles.tsx index 4a18debd516..5b9b6480b34 100644 --- a/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.styles.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.styles.tsx @@ -1,5 +1,5 @@ import { CircleProgress, Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import SSHKeyIcon from 'src/assets/icons/ssh-key.svg'; diff --git a/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.tsx b/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.tsx index 5428aa29c0b..2215e2eb4e9 100644 --- a/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/LinodePubKey.tsx @@ -1,7 +1,7 @@ import { usePreferences } from '@linode/queries'; import { Button, ErrorState, Stack, Typography } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import copy from 'copy-to-clipboard'; import * as React from 'react'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx index 6dcd084b64f..bc129d6d475 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx @@ -11,7 +11,7 @@ import { CHECK_INTERVAL, CHECK_TIMEOUT, } from '@linode/validation'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useFlags } from 'src/hooks/useFlags'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index a714eccf44a..9deb4ea7a12 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -8,7 +8,7 @@ import { TextField, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index 88fb929ae4e..82f69f80dc3 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -11,7 +11,7 @@ import { TextField, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx index 8c2ee800cd0..06f2f742473 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx @@ -6,6 +6,7 @@ import NodeBalancerCreate from './NodeBalancerCreate'; const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => vi.fn()), + useFlags: vi.fn().mockReturnValue({}), })); vi.mock('@tanstack/react-router', async () => { @@ -16,8 +17,19 @@ vi.mock('@tanstack/react-router', async () => { }; }); +vi.mock('src/hooks/useFlags', () => { + const actual = vi.importActual('src/hooks/useFlags'); + return { + ...actual, + useFlags: queryMocks.useFlags, + }; +}); + // Note: see nodeblaancers-create-in-complex-form.spec.ts for an e2e test of this flow describe('NodeBalancerCreate', () => { + queryMocks.useFlags.mockReturnValue({ + nodebalancerVpc: true, + }); it('renders all parts of the NodeBalancerCreate page', () => { const { getAllByText, getByLabelText, getByText } = renderWithTheme( @@ -37,6 +49,9 @@ describe('NodeBalancerCreate', () => { ) ).toBeVisible(); + // confirm VPC Panel renders + expect(getByLabelText('Assign VPC')).toBeVisible(); + // confirm default configuration renders - only confirming headers, as we have additional // unit tests to check the functionality of the NodeBalancerConfigPanel expect(getByText('Configuration - Port 80')).toBeVisible(); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 4065ade3e74..f95ded0b087 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -1,6 +1,7 @@ import { useAccountAgreements, useMutateAccountAgreements, + useNodebalancerCreateBetaMutation, useNodebalancerCreateMutation, useNodeBalancerTypesQuery, useProfile, @@ -18,7 +19,7 @@ import { TextField, Typography, } from '@linode/ui'; -import { scrollErrorIntoView } from '@linode/utilities'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; import { useTheme } from '@mui/material'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useNavigate } from '@tanstack/react-router'; @@ -60,10 +61,12 @@ import { createNewNodeBalancerConfig, createNewNodeBalancerConfigNode, transformConfigsForRequest, + useIsNodebalancerVPCEnabled, } from './utils'; +import { VPCPanel } from './VPCPanel'; import type { NodeBalancerConfigFieldsWithStatus } from './types'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { APIError, NodeBalancerVpcPayload } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { Tag } from 'src/components/TagsInput/TagsInput'; @@ -78,6 +81,7 @@ interface NodeBalancerFieldsState { label?: string; region?: string; tags?: string[]; + vpcs?: NodeBalancerVpcPayload[]; } const errorResources = { @@ -104,6 +108,7 @@ const NodeBalancerCreate = () => { flags.gecko2?.enabled, flags.gecko2?.la ); + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); const navigate = useNavigate(); const { data: agreements } = useAccountAgreements(); const { data: profile } = useProfile(); @@ -111,11 +116,29 @@ const NodeBalancerCreate = () => { const { data: types } = useNodeBalancerTypesQuery(); const { - error, - isPending, + error: createNodeBalancerBetaError, + isPending: createNodeBalancerBetaIsPending, + mutateAsync: createNodeBalancerBeta, + } = useNodebalancerCreateBetaMutation(); + + const { + error: createNodebalancerError, + isPending: createNodeBalancerIsPending, mutateAsync: createNodeBalancer, } = useNodebalancerCreateMutation(); + const error = React.useMemo( + () => + isNodebalancerVPCEnabled + ? createNodeBalancerBetaError + : createNodebalancerError, + [ + isNodebalancerVPCEnabled, + createNodebalancerError, + createNodeBalancerBetaError, + ] + ); + const [nodeBalancerFields, setNodeBalancerFields] = React.useState(defaultFieldsStates); @@ -139,6 +162,14 @@ const NodeBalancerCreate = () => { globalGrantType: 'add_nodebalancers', }); + const [isVpcSelected, setIsVpcSelected] = React.useState(false); + const [vpcErrors, setVPCErrors] = React.useState([]); + const formContainerRef = React.useRef(null); + + React.useEffect(() => { + setVPCErrors([]); + }, [isVpcSelected]); + const addNodeBalancer = () => { if (isRestricted) { return; @@ -246,18 +277,6 @@ const NodeBalancerCreate = () => { }); }; - const clearNodeErrors = () => { - setNodeBalancerFields((prev) => { - const newConfigs = [...prev.configs].map((config) => ({ - ...config, - errors: [], - nodes: config.nodes.map((node) => ({ ...node, errors: [] })), - })); - - return { ...prev, configs: newConfigs }; - }); - }; - const setNodeErrors = (errors: APIError[]) => { /* Map the objects with this shape { @@ -281,26 +300,69 @@ const NodeBalancerCreate = () => { // Apply the error updater functions with a compose setNodeBalancerFields((compose as any)(...setFns)); - scrollErrorIntoView(); + }; + + const clearErrors = () => { + setNodeBalancerFields((prev) => { + const newConfigs = [...prev.configs].map(({ errors: _, ...config }) => ({ + ...config, + nodes: config.nodes.map(({ errors: _, ...node }) => ({ + ...node, + })), + })); + // sometimes 'errors' key is added from setNodeErrors() + if ('errors' in prev) { + delete prev['errors']; + } + + return { ...prev, configs: newConfigs }; + }); + setVPCErrors([]); }; const onCreate = () => { + if (isVpcSelected && nodeBalancerFields?.vpcs === undefined) { + const subnetError = { + field: 'vpc[0].subnet_id', + reason: 'Subnet is required', + }; + setVPCErrors((prev) => (prev ? [...prev, subnetError] : [subnetError])); + scrollErrorIntoViewV2(formContainerRef); + return; + } + clearErrors(); /* transform node data for the requests */ const nodeBalancerRequestData = clone(nodeBalancerFields); + if ( + nodeBalancerRequestData?.vpcs && + nodeBalancerRequestData.vpcs.length > 0 + ) { + nodeBalancerRequestData.vpcs = nodeBalancerRequestData.vpcs.map((vpc) => + vpc.ipv4_range + ? { + ...vpc, + ipv4_range: vpc.ipv4_range.endsWith('/30') + ? vpc.ipv4_range + : `${vpc.ipv4_range}/30`, + } + : vpc + ); + } nodeBalancerRequestData.configs = transformConfigsForRequest( nodeBalancerRequestData.configs ); - /* Clear node errors */ - clearNodeErrors(); - if (hasSignedAgreement) { updateAgreements({ eu_model: true, }).catch(reportAgreementSigningError); } - createNodeBalancer(nodeBalancerRequestData) + const createNodeBalancerFn = isNodebalancerVPCEnabled + ? createNodeBalancerBeta + : createNodeBalancer; + + createNodeBalancerFn(nodeBalancerRequestData) .then((nodeBalancer) => { navigate({ params: { id: String(nodeBalancer.id) }, @@ -317,8 +379,30 @@ const NodeBalancerCreate = () => { ...(e.field && { field: e.field.replace(/(\[|\]\.)/g, '_') }), })) ); + const vpcErrors = errors + .map((err) => { + if (!err?.field) return null; + if (err?.field.includes('subnet_id')) { + return { + field: 'vpcs.subnet_id', + reason: err.reason, + }; + } + if (err?.field.includes('ipv4_range')) { + const indexMatch = err.field.match(/\[(\d+)\]/); + const index = indexMatch ? Number(indexMatch[1]) : -1; + return { + field: `vpcs[${index}].ipv4_range`, + reason: err.reason, + }; + } + return null; + }) + .filter((err) => err !== null); + + setVPCErrors(vpcErrors); - scrollErrorIntoView(); + scrollErrorIntoViewV2(formContainerRef); }); }; @@ -406,16 +490,68 @@ const NodeBalancerCreate = () => { if (nodeBalancerFields.region === region) { return; } - - setNodeBalancerFields((prev) => ({ + // We just changed the region so any selected IP addresses, Subnets and VPCs are likely invalid + setNodeBalancerFields(({ vpcs: _, ...prev }) => ({ ...prev, region, })); - - // We just changed the region so any selected IP addresses are likely invalid + setIsVpcSelected(false); resetNodeAddresses(); }; + const subnetChange = (subnetIds: null | number[]) => { + if ( + nodeBalancerFields?.vpcs?.every((vpc) => + subnetIds?.some((id) => id === vpc.subnet_id) + ) + ) { + return; + } + if (subnetIds === null) { + setNodeBalancerFields((prev) => { + // eslint-disable-next-line no-unused-vars, sonarjs/no-unused-vars + const { vpcs: _, ...rest } = prev; + return { ...rest }; + }); + } else { + const vpcs = subnetIds.map((id) => ({ subnet_id: id })); + setNodeBalancerFields((prev) => ({ + ...prev, + vpcs, + })); + } + }; + + const ipv4Change = (ipv4Range: null | string, index: number) => { + if (nodeBalancerFields?.vpcs?.[index].ipv4_range === ipv4Range) { + return; + } + const vpcs = nodeBalancerFields?.vpcs; + if (ipv4Range === null) { + // handling auto-assign ipv4 ranges for all subnets + setNodeBalancerFields((prev) => { + const { vpcs: vpcs, ...rest } = prev; + const updatedVpcs = vpcs?.map(({ subnet_id }) => ({ + subnet_id, + })); + return { ...rest, vpcs: updatedVpcs }; + }); + } else if (ipv4Range === '') { + // removing vpcs from the payload if ipv4 ranges are removed + setNodeBalancerFields((prev) => { + // eslint-disable-next-line no-unused-vars, sonarjs/no-unused-vars + const { vpcs: _, ...rest } = prev; + return { ...rest }; + }); + } else if (vpcs) { + vpcs[index].ipv4_range = ipv4Range; + setNodeBalancerFields((prev) => ({ + ...prev, + vpcs, + })); + } + }; + const onCloseConfirmation = () => setDeleteConfigConfirmDialog(clone(defaultDeleteConfigConfirmDialogState)); @@ -448,6 +584,10 @@ const NodeBalancerCreate = () => { summaryItems.push({ title: regionLabel }); } + if (nodeBalancerFields.vpcs?.length) { + summaryItems.push({ title: 'VPC Assigned' }); + } + if (nodeBalancerFields.firewall_id) { summaryItems.push({ title: 'Firewall Assigned' }); } @@ -474,7 +614,7 @@ const NodeBalancerCreate = () => { } return ( - +
{ } selectedFirewallId={nodeBalancerFields.firewall_id ?? -1} /> + {isNodebalancerVPCEnabled && ( + + )} {nodeBalancerFields.configs.map((nodeBalancerConfig, idx) => { @@ -700,7 +851,9 @@ const NodeBalancerCreate = () => { isRestricted || isInvalidPrice } - loading={isPending} + loading={ + createNodeBalancerIsPending || createNodeBalancerBetaIsPending + } onClick={onCreate} sx={{ flexShrink: 0, @@ -735,7 +888,7 @@ const NodeBalancerCreate = () => { Are you sure you want to delete this NodeBalancer Configuration? - +
); }; @@ -753,13 +906,13 @@ const getPathAndFieldFromFieldString = (value: string) => { if (configMatch && configMatch[1]) { path = [...path, 'configs', +configMatch[1]]; field = field.replace(configRegExp, ''); - } - const nodeRegExp = new RegExp(/nodes_(\d+)_/); - const nodeMatch = nodeRegExp.exec(value); - if (nodeMatch && nodeMatch[1]) { - path = [...path, 'nodes', +nodeMatch[1]]; - field = field.replace(nodeRegExp, ''); + const nodeRegExp = new RegExp(/nodes_(\d+)_/); + const nodeMatch = nodeRegExp.exec(value); + if (nodeMatch && nodeMatch[1]) { + path = [...path, 'nodes', +nodeMatch[1]]; + field = field.replace(nodeRegExp, ''); + } } return { field, path }; }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx index e5efa06488c..57af68da21b 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx @@ -1,5 +1,5 @@ import { useNodeBalancerQuery } from '@linode/queries'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx index e82c22e12ef..27134063f39 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx @@ -1,5 +1,6 @@ import { nodeBalancerConfigFactory, + nodeBalancerConfigVPCFactory, nodeBalancerFactory, } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; @@ -15,6 +16,9 @@ const queryMocks = vi.hoisted(() => ({ useAllNodeBalancerConfigsQuery: vi.fn().mockReturnValue({ data: undefined }), useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }), useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), + useNodeBalancerVPCConfigsBetaQuery: vi + .fn() + .mockReturnValue({ data: undefined }), useParams: vi.fn().mockReturnValue({}), })); @@ -33,10 +37,13 @@ vi.mock('@linode/queries', async () => { useAllNodeBalancerConfigsQuery: queryMocks.useAllNodeBalancerConfigsQuery, useNodeBalancerQuery: queryMocks.useNodeBalancerQuery, useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery, + useNodeBalancerVPCConfigsBetaQuery: + queryMocks.useNodeBalancerVPCConfigsBetaQuery, }; }); const nodeBalancerDetails = 'NodeBalancer Details'; +const nbVpcConfig = nodeBalancerConfigVPCFactory.build(); describe('SummaryPanel', () => { beforeEach(() => { @@ -49,6 +56,11 @@ describe('SummaryPanel', () => { queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ data: { data: [firewallFactory.build({ label: 'mock-firewall-1' })] }, }); + queryMocks.useNodeBalancerVPCConfigsBetaQuery.mockReturnValue({ + data: { + data: [nbVpcConfig], + }, + }); queryMocks.useParams.mockReturnValue({ id: 1 }); }); @@ -75,7 +87,9 @@ describe('SummaryPanel', () => { }); it('renders the panel if there is data to render', () => { - const { getByText, queryByText } = renderWithTheme(); + const { getByText, queryByText } = renderWithTheme(, { + flags: { nodebalancerVpc: true }, + }); // Main summary panel expect(getByText(nodeBalancerDetails)).toBeVisible(); @@ -100,6 +114,11 @@ describe('SummaryPanel', () => { expect(getByText('IP Addresses')).toBeVisible(); expect(getByText('0.0.0.0')).toBeVisible(); + // VPC Details Panel + expect(getByText('VPC')).toBeVisible(); + expect(getByText('Subnets:')).toBeVisible(); + expect(getByText(`${nbVpcConfig.ipv4_range}`)).toBeVisible(); + // Tags panel expect(getByText('Tags')).toBeVisible(); expect(getByText('Add a tag')).toBeVisible(); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index 175a3373d90..37be0910ee0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -3,7 +3,9 @@ import { useNodeBalancerQuery, useNodeBalancersFirewallsQuery, useNodebalancerUpdateMutation, + useNodeBalancerVPCConfigsBetaQuery, useRegionsQuery, + useVPCQuery, } from '@linode/queries'; import { Paper, Typography } from '@linode/ui'; import { convertMegabytesTo } from '@linode/utilities'; @@ -13,26 +15,20 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; -import { - useIsLkeEnterpriseEnabled, - useKubernetesBetaEndpoint, -} from 'src/features/Kubernetes/kubeUtils'; +import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; +import { useIsNodebalancerVPCEnabled } from '../../utils'; + export const SummaryPanel = () => { const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { id } = useParams({ from: '/nodebalancers/$id/summary', }); - const { data: nodebalancer } = useNodeBalancerQuery( - Number(id), - Boolean(id), - isLkeEnterpriseLAFeatureEnabled - ); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); const { data: configs } = useAllNodeBalancerConfigsQuery(Number(id)); const { data: regions } = useRegionsQuery(); const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery( @@ -52,6 +48,31 @@ export const SummaryPanel = () => { id: nodebalancer?.id, }); + const flags = useIsNodebalancerVPCEnabled(); + + const { data: vpcConfig } = useNodeBalancerVPCConfigsBetaQuery( + Number(id), + flags.isNodebalancerVPCEnabled + ); + + const { data: vpcDetails } = useVPCQuery( + Number(vpcConfig?.data[0]?.vpc_id) || -1, + Boolean(vpcConfig?.data[0]?.vpc_id) + ); + + const nbVPCConfigs = vpcConfig?.data ?? []; + const subnets = vpcDetails?.subnets ?? []; + + const mergedSubnets = nbVPCConfigs.map((config) => { + const subnet = subnets.find((s) => s.id === config.subnet_id); + + return { + id: config.subnet_id, + label: subnet?.label ?? `Subnet ${config.subnet_id}`, + ipv4Range: config.ipv4_range, + }; + }); + // If we can't get the cluster (status === 'error'), we can assume it's been deleted const { status: clusterStatus } = useKubernetesClusterQuery({ enabled: Boolean(nodebalancer?.lke_cluster), @@ -192,6 +213,49 @@ export const SummaryPanel = () => { + {flags.isNodebalancerVPCEnabled && Boolean(vpcConfig?.data.length) && ( + + + VPC + + + + VPC:{' '} + {vpcConfig?.data.map((vpc, i) => ( + + + {vpcDetails?.label} + + {i < vpcConfig.data.length - 1 ? ', ' : ''} + + ))} + + + + + Subnets: + + + {mergedSubnets.map((subnet) => ( + + + {`${subnet.label}:`} + + + {subnet.ipv4Range} + + + ))} + + + )} Tags diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx index 6f18819f921..6656d36eaf9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx @@ -4,7 +4,7 @@ import { Toggle, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import type { NodeBalancerConfigPanelProps } from './types'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index ef4515b3cd5..b473e8497ed 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -1,4 +1,3 @@ -import { Hidden } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useNavigate } from '@tanstack/react-router'; @@ -9,6 +8,8 @@ import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuActi import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useIsNodebalancerVPCEnabled } from '../utils'; + import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -29,6 +30,8 @@ export const NodeBalancerActionMenu = (props: Props) => { id: nodeBalancerId, }); + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); + const actions: Action[] = [ { onClick: () => { @@ -72,26 +75,25 @@ export const NodeBalancerActionMenu = (props: Props) => { }, ]; - return ( - <> - {!matchesMdDown && - actions.map((action) => { - return ( - - ); - })} - - { + return ( + - - + ); + }); + } + return ( + ); }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index 9deb553141c..51ee4b7f613 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -10,12 +10,15 @@ import { TableRow } from 'src/components/TableRow'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { RegionIndicator } from 'src/features/Linodes/LinodesLanding/RegionIndicator'; +import { useIsNodebalancerVPCEnabled } from '../utils'; import { NodeBalancerActionMenu } from './NodeBalancerActionMenu'; +import { NodeBalancerVPC } from './NodeBalancerVPC'; import type { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; export const NodeBalancerTableRow = (props: NodeBalancer) => { const { id, ipv4, label, region, transfer } = props; + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); const { data: configs } = useAllNodeBalancerConfigsQuery(id); @@ -68,6 +71,13 @@ export const NodeBalancerTableRow = (props: NodeBalancer) => { + {isNodebalancerVPCEnabled && ( + + + + + + )} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerVPC.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerVPC.tsx new file mode 100644 index 00000000000..c28242278cc --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerVPC.tsx @@ -0,0 +1,42 @@ +import { + useNodeBalancerVPCConfigsBetaQuery, + useVPCQuery, +} from '@linode/queries'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { Skeleton } from 'src/components/Skeleton'; + +interface Props { + nodeBalancerId: number; +} + +export const NodeBalancerVPC = ({ nodeBalancerId }: Props) => { + const { data: vpcConfig, isLoading: isVPCConfigLoading } = + useNodeBalancerVPCConfigsBetaQuery(nodeBalancerId, Boolean(nodeBalancerId)); + + const { data: vpcDetails, isLoading: isVPCDetailsLoading } = useVPCQuery( + Number(vpcConfig?.data[0]?.vpc_id), + Boolean(vpcConfig?.data[0]?.vpc_id) + ); + + if (isVPCConfigLoading || isVPCDetailsLoading) { + return ; + } + + if (vpcConfig?.data?.length === 0) { + return 'None'; + } + + return vpcConfig?.data.map(({ vpc_id: vpcId }, i) => ( + + + {vpcDetails?.label} + + {i < vpcConfig.data.length - 1 ? ', ' : ''} + + )); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index c14b28e5aa7..78e3cf26537 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -20,6 +20,7 @@ import { usePagination } from 'src/hooks/usePagination'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; +import { useIsNodebalancerVPCEnabled } from '../utils'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; import { NodeBalancerTableRow } from './NodeBalancerTableRow'; @@ -61,6 +62,8 @@ export const NodeBalancersLanding = () => { error: selectedNodeBalancerError, } = useNodeBalancerQuery(Number(params.id), !!params.id); + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); + if (error) { return ( { Region + {isNodebalancerVPCEnabled && ( + + VPC + + )} diff --git a/packages/manager/src/features/NodeBalancers/VPCPanel.test.tsx b/packages/manager/src/features/NodeBalancers/VPCPanel.test.tsx new file mode 100644 index 00000000000..ce4c5826371 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/VPCPanel.test.tsx @@ -0,0 +1,224 @@ +import { regionFactory } from '@linode/utilities'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { subnetFactory, vpcFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { VPCPanel } from './VPCPanel'; + +beforeAll(() => mockMatchMedia()); + +const props = { + errors: [], + ipv4Change: vi.fn(), + regionSelected: '', + subnetChange: vi.fn(), + setIsVpcSelected: vi.fn(), +}; + +describe('VPCPanel', () => { + it('render no options for the VPC select if no region is selected', async () => { + renderWithTheme(); + + const vpcSelect = screen.getByLabelText('Assign VPC'); + + expect(vpcSelect).toBeVisible(); + await userEvent.click(vpcSelect); + + await screen.findByText('Select a region to see available VPCs.', { + exact: false, + }); + }); + + it('renders a warning if the selected region does not support VPC', async () => { + const region = regionFactory.build({ + capabilities: ['NodeBalancers'], + id: 'us-east', + label: 'Newark, NJ', + }); + + server.use( + http.get('*/vpcs', () => { + return HttpResponse.json(makeResourcePage([])); + }), + http.get(`*/regions/${region.id}`, () => { + return HttpResponse.json(region); + }) + ); + + const _props = { ...props, regionSelected: region.id }; + renderWithTheme(); + + await screen.findByText('VPC is not available in the selected region.', { + exact: false, + }); + }); + + it('renders a subnet select if a VPC is selected', async () => { + const region = regionFactory.build({ + capabilities: ['NodeBalancers', 'VPCs'], + id: 'us-east', + label: 'Newark, NJ', + }); + + const subnets = subnetFactory.buildList(3, { ipv4: '10.0.0.0/24' }); + + const vpcWithSubnet = vpcFactory.build({ + subnets, + region: 'us-east', + }); + + server.use( + http.get('*/vpcs', () => { + return HttpResponse.json(makeResourcePage([vpcWithSubnet])); + }), + http.get(`*/regions/${region.id}`, () => { + return HttpResponse.json(region); + }) + ); + + const _props = { + ...props, + regionSelected: region.id, + vpcSelected: vpcWithSubnet, + }; + + renderWithTheme(); + + const vpcSelect = screen.getByLabelText('Assign VPC'); + + await userEvent.click(vpcSelect); + + await userEvent.click( + await screen.findByText(vpcWithSubnet.label, { exact: false }) + ); + expect(screen.getByLabelText('Subnet')).toBeVisible(); + }); + + it('renders VPC IPv4 Select when a subnet is selected', async () => { + const region = regionFactory.build({ + capabilities: ['NodeBalancers', 'VPCs'], + id: 'us-east', + label: 'Newark, NJ', + }); + + const subnets = subnetFactory.buildList(3, { ipv4: '10.0.0.0/24' }); + + const vpcWithSubnet = vpcFactory.build({ + subnets, + region: 'us-east', + }); + + server.use( + http.get('*/vpcs', () => { + return HttpResponse.json(makeResourcePage([vpcWithSubnet])); + }), + http.get(`*/regions/${region.id}`, () => { + return HttpResponse.json(region); + }) + ); + + const subnetsProp = [ + { + subnet_id: subnets[0].id, + ipv4_range: '', + }, + ]; + + const _props = { + ...props, + regionSelected: region.id, + subnets: subnetsProp, + vpcSelected: vpcWithSubnet, + }; + + renderWithTheme(); + + const vpcSelect = screen.getByLabelText('Assign VPC'); + + await userEvent.click(vpcSelect); + + await userEvent.click( + await screen.findByText(vpcWithSubnet.label, { exact: false }) + ); + + const checkbox = screen.getByTestId('vpc-ipv4-checkbox'); + + await userEvent.click(checkbox); + + expect( + screen.getByLabelText( + 'Auto-assign a /30 CIDR in each subnet for this NodeBalancer' + ) + ).not.toBeChecked(); + + expect( + screen.getByLabelText(`${subnets[0].label}`, { + exact: false, + }) + ).toBeVisible(); + + expect( + screen.getByLabelText(`NodeBalancer IPv4 CIDR for ${subnets[0].label}`, { + exact: false, + }) + ).toBeVisible(); + }); + + it('does not renders VPC IPv4 Select when a subnet is not selected', async () => { + const region = regionFactory.build({ + capabilities: ['NodeBalancers', 'VPCs'], + id: 'us-east', + label: 'Newark, NJ', + }); + + const subnets = subnetFactory.buildList(3, { ipv4: '10.0.0.0/24' }); + + const vpcWithSubnet = vpcFactory.build({ + subnets, + region: 'us-east', + }); + + server.use( + http.get('*/vpcs', () => { + return HttpResponse.json(makeResourcePage([vpcWithSubnet])); + }), + http.get(`*/regions/${region.id}`, () => { + return HttpResponse.json(region); + }) + ); + + const _props = { + ...props, + regionSelected: region.id, + vpcSelected: vpcWithSubnet, + }; + + renderWithTheme(); + + const vpcSelect = screen.getByLabelText('Assign VPC'); + + await userEvent.click(vpcSelect); + + await userEvent.click( + await screen.findByText(vpcWithSubnet.label, { exact: false }) + ); + + const subnetSelect = screen.getByLabelText('Subnet'); + + expect(subnetSelect).toHaveValue(''); + + expect( + screen.queryByLabelText( + `NodeBalancer IPv4 CIDR for ${subnets[0].label}`, + { + exact: false, + } + ) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/VPCPanel.tsx b/packages/manager/src/features/NodeBalancers/VPCPanel.tsx new file mode 100644 index 00000000000..dda32540825 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/VPCPanel.tsx @@ -0,0 +1,220 @@ +import { useAllVPCsQuery, useRegionQuery } from '@linode/queries'; +import { + Autocomplete, + Box, + Checkbox, + FormControlLabel, + InputAdornment, + Paper, + Stack, + TextField, + TooltipIcon, + Typography, +} from '@linode/ui'; +import { useMediaQuery, useTheme } from '@mui/material'; +import React from 'react'; + +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { + NB_AUTO_ASSIGN_CIDR_TOOLTIP, + NODEBALANCER_REGION_CAVEAT_HELPER_TEXT, +} from '../VPCs/constants'; + +import type { APIError, NodeBalancerVpcPayload, VPC } from '@linode/api-v4'; + +export interface Props { + disabled?: boolean; + errors?: APIError[]; + ipv4Change: (ipv4Range: null | string, index: number) => void; + regionSelected: string; + setIsVpcSelected: (vpc: boolean) => void; + subnetChange: (subnetIds: null | number[]) => void; + subnets?: NodeBalancerVpcPayload[]; +} + +export const VPCPanel = (props: Props) => { + const { + disabled, + errors, + ipv4Change, + regionSelected, + setIsVpcSelected, + subnets, + subnetChange, + } = props; + + const theme = useTheme(); + const isSmallBp = useMediaQuery(theme.breakpoints.down('sm')); + + const { data: region } = useRegionQuery(regionSelected); + + const regionSupportsVPC = region?.capabilities.includes('VPCs') || false; + + const { + data: vpcs, + error: error, + isLoading: isVPCLoading, + } = useAllVPCsQuery({ + enabled: regionSupportsVPC, + filter: { region: regionSelected }, + }); + + const [autoAssignIPv4WithinVPC, toggleAutoAssignIPv4Range] = + React.useState(true); + const [VPCSelected, setVPCSelected] = React.useState(null); + + React.useEffect(() => { + setVPCSelected(null); + }, [regionSelected]); + + const getVPCSubnetLabelFromId = (subnetId: number): string => { + const subnet = VPCSelected?.subnets.find(({ id }) => id === subnetId); + return subnet?.label || ''; + }; + + const vpcError = error + ? getAPIErrorOrDefault(error, 'Unable to load VPCs')[0].reason + : undefined; + + return ( + + + VPC + + { + setVPCSelected(vpc ?? null); + setIsVpcSelected(Boolean(vpc)); + + if (vpc && vpc.subnets.length === 1) { + // If the user selects a VPC and the VPC only has one subnet, + // preselect that subnet for the user. + subnetChange([vpc.subnets[0].id]); + } else { + // Otherwise, just clear the selected subnet + subnetChange(null); + } + }} + options={vpcs ?? []} + placeholder="None" + textFieldProps={{ + tooltipText: NODEBALANCER_REGION_CAVEAT_HELPER_TEXT, + }} + value={VPCSelected ?? null} + /> + {VPCSelected && ( + <> + err.field?.includes('subnet_id')) + ?.reason + } + getOptionLabel={(subnet) => `${subnet.label} (${subnet.ipv4})`} + label="Subnet" + noMarginTop + onChange={(_, subnet) => + subnetChange(subnet ? [subnet.id] : null) + } + options={VPCSelected?.subnets ?? []} + placeholder="Select Subnet" + value={ + VPCSelected?.subnets.find( + (subnet) => subnet.id === subnets?.[0].subnet_id + ) ?? null + } + /> + {subnets && ( + <> + ({ + marginLeft: theme.spacingFunction(2), + paddingTop: theme.spacingFunction(8), + })} + > + { + if (checked) { + ipv4Change(null, 0); + } + toggleAutoAssignIPv4Range(checked); + }} + /> + } + data-testid="vpc-ipv4-checkbox" + label={ + + + Auto-assign a /30 CIDR in each subnet for this + NodeBalancer + + + + } + /> + + {!autoAssignIPv4WithinVPC && + subnets.map((vpc, index) => ( + + err.field?.includes(`vpcs[${index}].ipv4_range`) + )?.reason + } + key={`${vpc.subnet_id}`} + label={`NodeBalancer IPv4 CIDR for ${getVPCSubnetLabelFromId(vpc.subnet_id)}`} + noMarginTop + onChange={(e) => + ipv4Change(e.target.value ?? '', index) + } + // eslint-disable-next-line sonarjs/no-hardcoded-ip + placeholder="10.0.0.24" + slotProps={{ + input: { + endAdornment: ( + + /30 + + ), + }, + }} + value={vpc.ipv4_range} + /> + ))} + + )} + + )} + + + + ); +}; diff --git a/packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx index 5bd2011cdfb..ac9299a44ca 100644 --- a/packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx @@ -193,7 +193,7 @@ const interceptNotification = ( {' '} resides on a host that is pending critical maintenance. You should have received a{' '} - + support ticket {' '} that details how you will be affected. Please see the aforementioned diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index c1b0ba55b89..0fd7fb04951 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -4,14 +4,12 @@ import { updateObjectStorageKey, } from '@linode/api-v4/lib/object-storage'; import { useAccountSettings } from '@linode/queries'; -import { isFeatureEnabledV2, useErrors, useOpenClose } from '@linode/utilities'; +import { useErrors, useOpenClose } from '@linode/utilities'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { usePagination } from 'src/hooks/usePagination'; import { useObjectStorageAccessKeys } from 'src/queries/object-storage/queries'; import { @@ -21,6 +19,7 @@ import { } from 'src/utilities/analytics/customEventAnalytics'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; +import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { AccessKeyDrawer } from './AccessKeyDrawer'; import { AccessKeyTable } from './AccessKeyTable/AccessKeyTable'; import { OMC_AccessKeyDrawer } from './OMC_AccessKeyDrawer'; @@ -82,14 +81,8 @@ export const AccessKeyLanding = (props: Props) => { const displayKeysDialog = useOpenClose(); const revokeKeysDialog = useOpenClose(); - const flags = useFlags(); - const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); const handleCreateKey = ( values: CreateObjectStorageKeyPayload, diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx index d4ed62ba2a5..84f54698674 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx @@ -1,12 +1,11 @@ import { Stack } from '@linode/ui'; -import { isFeatureEnabledV2 } from '@linode/utilities'; import { useMediaQuery } from '@mui/material'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; + +import { useIsObjMultiClusterEnabled } from '../../hooks/useIsObjectStorageGen2Enabled'; import type { OpenAccessDrawer } from '../types'; import type { ObjectStorageKey } from '@linode/api-v4'; @@ -29,14 +28,7 @@ export const AccessKeyActionMenu = (props: Props) => { openRevokeDialog, } = props; - const flags = useFlags(); - const { account } = useAccountManagement(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); const isSmallViewport = useMediaQuery((theme) => theme.breakpoints.down('md') diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx index fc90373300b..535da66a385 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -1,3 +1,4 @@ +import { useAccount } from '@linode/queries'; import { Hidden } from '@linode/ui'; import { isFeatureEnabledV2 } from '@linode/utilities'; import React, { useState } from 'react'; @@ -7,7 +8,6 @@ import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { HostNamesDrawer } from '../HostNamesDrawer'; @@ -44,7 +44,7 @@ export const AccessKeyTable = (props: AccessKeyTableProps) => { const [hostNames, setHostNames] = useState([]); const flags = useFlags(); - const { account } = useAccountManagement(); + const { data: account } = useAccount(); const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx index 7979ad5867e..735e987349f 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx @@ -1,3 +1,4 @@ +import { useAccount } from '@linode/queries'; import { Stack, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { isFeatureEnabledV2 } from '@linode/utilities'; @@ -8,7 +9,6 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { AccessKeyActionMenu } from './AccessKeyActionMenu'; @@ -34,7 +34,7 @@ export const AccessKeyTableRow = (props: Props) => { storageKeyData, } = props; - const { account } = useAccountManagement(); + const { data: account } = useAccount(); const flags = useFlags(); const isObjMultiClusterEnabled = isFeatureEnabledV2( diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx index a006a9e9dfd..d41a5921107 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx @@ -1,10 +1,7 @@ import { FormControlLabel, Toggle, TooltipIcon, Typography } from '@linode/ui'; -import { isFeatureEnabledV2 } from '@linode/utilities'; import * as React from 'react'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; - +import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { AccessTable } from './AccessTable'; import { BucketPermissionsTable } from './BucketPermissionsTable'; @@ -38,14 +35,7 @@ interface Props { export const LimitedAccessControls = React.memo((props: Props) => { const { checked, handleToggle, ...rest } = props; - const flags = useFlags(); - const { account } = useAccountManagement(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( <> diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx index f798235fb85..9bdfc6f8ebc 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx @@ -1,10 +1,7 @@ import { Drawer, Typography } from '@linode/ui'; -import { isFeatureEnabledV2 } from '@linode/utilities'; import * as React from 'react'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; - +import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { AccessTable } from './AccessTable'; import { BucketPermissionsTable } from './BucketPermissionsTable'; @@ -19,14 +16,7 @@ export interface Props { export const ViewPermissionsDrawer = (props: Props) => { const { objectStorageKey, onClose, open } = props; - const flags = useFlags(); - const { account } = useAccountManagement(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx index 8f211d215f2..0a5614a4fc2 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx @@ -1,5 +1,5 @@ import { Box, Divider, Notice, Paper, Stack, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import EnabledIcon from 'src/assets/icons/checkmark-enabled.svg'; diff --git a/packages/manager/src/features/Profile/Referrals/Referrals.styles.ts b/packages/manager/src/features/Profile/Referrals/Referrals.styles.ts index aba4d27b317..dc62bd95228 100644 --- a/packages/manager/src/features/Profile/Referrals/Referrals.styles.ts +++ b/packages/manager/src/features/Profile/Referrals/Referrals.styles.ts @@ -1,5 +1,5 @@ import { Notice, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledResultsWrapper = styled('div', { diff --git a/packages/manager/src/features/Profile/Referrals/Referrals.tsx b/packages/manager/src/features/Profile/Referrals/Referrals.tsx index c8d63e65852..df42cc6e1e5 100644 --- a/packages/manager/src/features/Profile/Referrals/Referrals.tsx +++ b/packages/manager/src/features/Profile/Referrals/Referrals.tsx @@ -1,6 +1,6 @@ import { useProfile } from '@linode/queries'; import { CircleProgress, Notice, Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; diff --git a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx index 889ec6f3760..59d80ad8e62 100644 --- a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx +++ b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx @@ -1,6 +1,6 @@ import { useRegionsQuery } from '@linode/queries'; import { ActionsPanel, Box, Notice } from '@linode/ui'; -import { getRegionsByRegionId, isFeatureEnabledV2 } from '@linode/utilities'; +import { getRegionsByRegionId } from '@linode/utilities'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -8,8 +8,7 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { CopyAllHostnames } from 'src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames'; import { HostNamesList } from 'src/features/ObjectStorage/AccessKeyLanding/HostNamesList'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; +import { useIsObjMultiClusterEnabled } from 'src/features/ObjectStorage/hooks/useIsObjectStorageGen2Enabled'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; @@ -40,14 +39,7 @@ export const SecretTokenDialog = (props: Props) => { const { data: regionsData } = useRegionsQuery(); const regionsLookup = regionsData && getRegionsByRegionId(regionsData); - const flags = useFlags(); - const { account } = useAccountManagement(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); const modalConfirmationButtonText = objectStorageKey ? 'I Have Saved My Secret Key' diff --git a/packages/manager/src/features/Search/ResultGroup.tsx b/packages/manager/src/features/Search/ResultGroup.tsx index b0e649e1fcd..79864d159c2 100644 --- a/packages/manager/src/features/Search/ResultGroup.tsx +++ b/packages/manager/src/features/Search/ResultGroup.tsx @@ -1,6 +1,6 @@ import { Hidden } from '@linode/ui'; import { capitalize, splitAt } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { isEmpty } from 'ramda'; import * as React from 'react'; diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 7551ef0a3c9..4bd2e035831 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -1,21 +1,20 @@ import { CircleProgress, Notice, Stack, Typography } from '@linode/ui'; -import { getQueryParamFromQueryString } from '@linode/utilities'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useSearch } from '@tanstack/react-router'; import React from 'react'; -import { useLocation } from 'react-router-dom'; import { ResultGroup } from './ResultGroup'; -import { useSearch } from './useSearch'; +import { useSearch as useCMSearch } from './useSearch'; import { getErrorsFromErrorMap, searchableEntityDisplayNameMap } from './utils'; import type { SearchResultsByEntity } from './search.interfaces'; -const SearchLanding = () => { - const location = useLocation(); - const query = getQueryParamFromQueryString(location.search, 'query'); +export const SearchLanding = () => { + const { query } = useSearch({ + from: '/search', + }); const { combinedResults, entityErrors, isLoading, searchResultsByEntity } = - useSearch({ + useCMSearch({ query, }); @@ -62,9 +61,3 @@ const SearchLanding = () => { ); }; - -export const searchLandingLazyRoute = createLazyRoute('/search')({ - component: SearchLanding, -}); - -export default SearchLanding; diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index 4b7637ff80b..2bf33523c6e 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -1,14 +1,16 @@ -import { useStackScriptsInfiniteQuery } from '@linode/queries'; -import { useInfiniteVolumesQuery } from '@linode/queries'; -import { useFirewallsInfiniteQuery } from '@linode/queries'; -import { useInfiniteNodebalancersQuery } from '@linode/queries'; -import { useInfiniteLinodesQuery } from '@linode/queries'; +import { + useDomainsInfiniteQuery, + useFirewallsInfiniteQuery, + useImagesInfiniteQuery, + useInfiniteLinodesQuery, + useInfiniteNodebalancersQuery, + useInfiniteVolumesQuery, + useStackScriptsInfiniteQuery, +} from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { useDebouncedValue } from '@linode/utilities'; import { useDatabasesInfiniteQuery } from 'src/queries/databases/databases'; -import { useDomainsInfiniteQuery } from 'src/queries/domains'; -import { useImagesInfiniteQuery } from 'src/queries/images'; import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; import { databaseToSearchableItem, diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 74659bae398..6f22e047bb5 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -1,13 +1,15 @@ -import { useAllLinodesQuery } from '@linode/queries'; -import { useAllFirewallsQuery } from '@linode/queries'; -import { useAllVolumesQuery } from '@linode/queries'; -import { useAllNodeBalancersQuery } from '@linode/queries'; -import { useAllAccountStackScriptsQuery } from '@linode/queries'; +import { + useAllAccountStackScriptsQuery, + useAllDomainsQuery, + useAllFirewallsQuery, + useAllImagesQuery, + useAllLinodesQuery, + useAllNodeBalancersQuery, + useAllVolumesQuery, +} from '@linode/queries'; import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllDatabasesQuery } from 'src/queries/databases/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllImagesQuery } from 'src/queries/images'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { diff --git a/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts b/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts index 018d8d69f7e..b9e57065455 100644 --- a/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts +++ b/packages/manager/src/features/StackScripts/CommonStackScript.styles.ts @@ -1,5 +1,5 @@ import { Button, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import { TableCell } from 'src/components/TableCell'; diff --git a/packages/manager/src/features/Support/AttachFileListItem.tsx b/packages/manager/src/features/Support/AttachFileListItem.tsx index 7aa4181750b..07251e33bec 100644 --- a/packages/manager/src/features/Support/AttachFileListItem.tsx +++ b/packages/manager/src/features/Support/AttachFileListItem.tsx @@ -1,6 +1,6 @@ import { CloseIcon, InputAdornment, TextField } from '@linode/ui'; import CloudUpload from '@mui/icons-material/CloudUpload'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx index 40e4ec90926..9f076e5ee94 100644 --- a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx +++ b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx @@ -1,6 +1,6 @@ import { useProfile } from '@linode/queries'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx index a3682fa1a2a..940bd16928a 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx @@ -16,9 +16,28 @@ import { import { SupportTicketDetail } from './SupportTicketDetail'; +const queryMocks = vi.hoisted(() => ({ + useLocation: vi.fn(), + useParams: vi.fn(), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + useParams: queryMocks.useParams, + }; +}); + describe('Support Ticket Detail', () => { beforeAll(() => { resizeScreenSize(breakpoints.values.lg); + queryMocks.useParams.mockReturnValue({ ticketId: '1' }); + queryMocks.useLocation.mockReturnValue({ + state: {}, + pathname: '/support/tickets/1', + }); }); it('should display a loading spinner', () => { diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx index 3add1c09e2a..c9ed4dcf58b 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx @@ -4,12 +4,11 @@ import { useSupportTicketQuery, } from '@linode/queries'; import { CircleProgress, ErrorState, Stack } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useLocation, useParams } from '@tanstack/react-router'; import { isEmpty } from 'ramda'; import * as React from 'react'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; import { Waypoint } from 'react-waypoint'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -23,6 +22,7 @@ import { ReplyContainer } from './TabbedReply/ReplyContainer'; import { TicketStatus } from './TicketStatus'; import type { SupportReply } from '@linode/api-v4/lib/support'; +import type { SupportState } from 'src/routes/support'; export interface AttachmentError { error: string; @@ -30,23 +30,26 @@ export interface AttachmentError { } export const SupportTicketDetail = () => { - const history = useHistory<{ attachmentErrors?: AttachmentError[] }>(); const location = useLocation(); - const { ticketId } = useParams<{ ticketId: string }>(); - const id = Number(ticketId); + const { ticketId } = useParams({ from: '/support/tickets/$ticketId' }); - const attachmentErrors = history.location.state?.attachmentErrors; + const locationState = location.state as SupportState; const { data: profile } = useProfile(); - const { data: ticket, error, isLoading, refetch } = useSupportTicketQuery(id); + const { + data: ticket, + error, + isLoading, + refetch, + } = useSupportTicketQuery(ticketId); const { data: repliesData, error: repliesError, fetchNextPage, hasNextPage, isLoading: repliesLoading, - } = useInfiniteSupportTicketRepliesQuery(id); + } = useInfiniteSupportTicketRepliesQuery(ticketId); const replies = repliesData?.pages.flatMap((page) => page.data); @@ -79,11 +82,7 @@ export const SupportTicketDetail = () => { crumbOverrides: [ { linkTo: { - pathname: `/support/tickets`, - // If we're viewing a `Closed` ticket, the Breadcrumb link should take us to `Closed` tickets. - search: `type=${ - ticket.status === 'closed' ? 'closed' : 'open' - }`, + pathname: `/support/tickets/${ticket.status}`, }, position: 2, }, @@ -95,15 +94,17 @@ export const SupportTicketDetail = () => { {/* If a user attached files when creating the ticket and was redirected here, display those errors. */} - {attachmentErrors !== undefined && - !isEmpty(attachmentErrors) && - attachmentErrors?.map((error, idx: number) => ( - - ))} + {locationState?.attachmentErrors !== undefined && + !isEmpty(locationState?.attachmentErrors) && + locationState?.attachmentErrors?.map( + (error: AttachmentError, idx: number) => ( + + ) + )} {/* If the ticket isn't blank, display it, followed by replies (if any). */} @@ -152,9 +153,3 @@ const StyledStack = styled(Stack, { marginLeft: theme.spacing(), marginRight: theme.spacing(), })); - -export const supportTicketDetailLazyRoute = createLazyRoute( - '/support/tickets/$ticketId' -)({ - component: SupportTicketDetail, -}); diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx index 249dfcabf31..9643d03b27b 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx @@ -1,7 +1,7 @@ import { uploadAttachment } from '@linode/api-v4'; import { useSupportTicketReplyMutation } from '@linode/queries'; import { Accordion, Notice } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { lensPath, set } from 'ramda'; import * as React from 'react'; import { debounce } from 'throttle-debounce'; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx index 20d126a9373..2fa9a918cca 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx @@ -2,7 +2,7 @@ import { useProfile } from '@linode/queries'; import { Paper, Stack, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import React from 'react'; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.test.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.test.tsx index bb6e886b69e..79cc7bcd273 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.test.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { SupportTicketDialog } from './SupportTicketDialog'; @@ -13,8 +13,10 @@ const props: SupportTicketDialogProps = { }; describe('Support Ticket Drawer', () => { - it('should render', () => { - const { getByText } = renderWithTheme(); + it('should render', async () => { + const { getByText } = await renderWithThemeAndRouter( + + ); expect(getByText('Open a Support Ticket')); }); }); diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index 3df834f209c..39f3fce124f 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -12,10 +12,12 @@ import { Typography, } from '@linode/ui'; import { reduceAsync, scrollErrorIntoViewV2 } from '@linode/utilities'; +import { useLocation as useLocationTanstack } from '@tanstack/react-router'; import { update } from 'ramda'; import * as React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; -import { useLocation } from 'react-router-dom'; +// eslint-disable-next-line no-restricted-imports +import { useLocation as useLocationRouterDom } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { sendSupportTicketExitEvent } from 'src/utilities/analytics/customEventAnalytics'; @@ -48,6 +50,7 @@ import type { TicketSeverity, } from '@linode/api-v4'; import type { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; +import type { SupportState } from 'src/routes/support'; interface Accumulator { errors: AttachmentError[]; @@ -136,8 +139,11 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { prefilledTitle, } = props; - const location = useLocation(); - const stateParams = location.state; + const locationRouterDom = useLocationRouterDom(); + const locationTanstack = useLocationTanstack(); + const locationTanstackState = locationTanstack.state as SupportState; + const stateParams = + locationRouterDom.state ?? locationTanstackState.supportTicketFormFields; // Collect prefilled data from props or Link parameters. const _prefilledDescription: string = @@ -197,7 +203,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { React.useEffect(() => { if (!open) { - resetDrawer(); + resetDialog(); } }, [open]); @@ -220,7 +226,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { }, [summary, description, entityId, entityType, selectedSeverity]); /** - * Clear the drawer completely if clearValues is passed (when canceling out of the drawer or successfully submitting) + * Clear the dialog completely if clearValues is passed (when canceling out of the dialog or successfully submitting) * or reset to the default values (from localStorage) otherwise. */ const resetTicket = (clearValues: boolean = false) => { @@ -238,7 +244,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { }); }; - const resetDrawer = (clearValues: boolean = false) => { + const resetDialog = (clearValues: boolean = false) => { resetTicket(clearValues); setFiles([]); @@ -249,7 +255,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { const handleClose = () => { if (ticketType !== 'general') { - window.setTimeout(() => resetDrawer(true), 500); + window.setTimeout(() => resetDialog(true), 500); } props.onClose(); sendSupportTicketExitEvent('Close'); @@ -257,7 +263,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { const handleCancel = () => { props.onClose(); - window.setTimeout(() => resetDrawer(true), 500); + window.setTimeout(() => resetDialog(true), 500); sendSupportTicketExitEvent('Cancel'); }; @@ -378,7 +384,7 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { attachFiles(response!.id).then(({ errors: _errors }: Accumulator) => { setSubmitting(false); if (!props.keepOpenOnSuccess) { - window.setTimeout(() => resetDrawer(true), 500); + window.setTimeout(() => resetDialog(true), 500); props.onClose(); } /* Errors will be an array of errors, or empty if all attachments succeeded. */ diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 51076b307f4..df2b4785367 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -1,4 +1,5 @@ import { + useAllDomainsQuery, useAllFirewallsQuery, useAllLinodesQuery, useAllNodeBalancersQuery, @@ -11,7 +12,6 @@ import { Controller, useFormContext } from 'react-hook-form'; import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllDatabasesQuery } from 'src/queries/databases/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.test.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.test.tsx index b5d42c8d843..81a498b40f3 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.test.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.test.tsx @@ -1,11 +1,27 @@ import * as React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; -import SupportTicketsLanding from './SupportTicketsLanding'; +import { SupportTicketsLanding } from './SupportTicketsLanding'; -describe('Support Tickets Landing', () => { - const { getByText } = renderWithTheme(); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(), + useSearch: vi.fn().mockReturnValue({ dialogOpen: false }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + useSearch: queryMocks.useSearch, + }; +}); + +describe('Support Tickets Landing', async () => { + const { getByText } = await renderWithThemeAndRouter( + + ); it('should render a header', () => { getByText('Tickets'); diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx index 3bb2c249e2b..f4e766fb8b4 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx @@ -1,107 +1,97 @@ -import { getQueryParamsFromQueryString } from '@linode/utilities'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { Tab } from 'src/components/Tabs/Tab'; -import { TabList } from 'src/components/Tabs/TabList'; -import { TabPanel } from 'src/components/Tabs/TabPanel'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useTabs } from 'src/hooks/useTabs'; import { SupportTicketDialog } from './SupportTicketDialog'; import { TicketList } from './TicketList'; import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; -import type { BaseQueryParams } from '@linode/utilities'; -import type { BooleanString } from 'src/features/Linodes/types'; - -interface QueryParams extends BaseQueryParams { - drawerOpen: BooleanString; -} - -const tabs = ['open', 'closed']; - -const SupportTicketsLanding = () => { - const location = useLocation(); - const history = useHistory(); +export const SupportTicketsLanding = () => { + const navigate = useNavigate(); /** ?drawerOpen=true to allow external links to go directly to the ticket drawer */ - const parsedParams = getQueryParamsFromQueryString( - location.search - ); + const { dialogOpen } = useSearch({ + strict: false, + }); - const stateParams = location.state; - - const [drawerOpen, setDrawerOpen] = React.useState( - stateParams ? stateParams.open : parsedParams.drawerOpen === 'true' - ); + const { tabs, tabIndex, handleTabChange } = useTabs([ + { + title: 'Open Tickets', + to: '/support/tickets/open', + }, + { + title: 'Closed Tickets', + to: '/support/tickets/closed', + }, + ]); const handleAddTicketSuccess = ( ticketId: number, attachmentErrors: AttachmentError[] = [] ) => { - history.push({ - pathname: `/support/tickets/${ticketId}`, - state: { attachmentErrors }, + navigate({ + to: '/support/tickets/$ticketId', + state: (prev) => ({ + ...prev, + attachmentErrors, + }), + params: { + ticketId, + }, }); - setDrawerOpen(false); }; - const handleButtonKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - setDrawerOpen(true); - } - }; - - const tabIndex = tabs.indexOf(parsedParams.type); - return ( setDrawerOpen(true)} - onButtonKeyPress={handleButtonKeyPress} + onButtonClick={() => + navigate({ + to: '/support/tickets', + search: { dialogOpen: true }, + }) + } spacingBottom={4} title="Tickets" /> - { - history.push(`/support/tickets?type=${tabs[index]}`); - }} - > - - Open Tickets - Closed Tickets - - - - - - - - - + + + }> + + + + + + + + + setDrawerOpen(false)} + onClose={() => + navigate({ + to: '/support/tickets', + search: { dialogOpen: false }, + }) + } onSuccess={handleAddTicketSuccess} - open={drawerOpen} + open={Boolean(dialogOpen)} /> ); }; - -export default SupportTicketsLanding; - -export const supportTicketsLandingLazyRoute = createLazyRoute( - '/support/tickets' -)({ - component: SupportTicketsLanding, -}); diff --git a/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx b/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx index 4265dbd73d4..1d3efba168d 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx @@ -18,6 +18,20 @@ const props: Props = { const loadingTestId = 'table-row-loading'; +const queryMocks = vi.hoisted(() => ({ + useSearch: vi.fn().mockReturnValue({ dialogOpen: false }), + useNavigate: vi.fn(), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useSearch: queryMocks.useSearch, + useNavigate: queryMocks.useNavigate, + }; +}); + describe('TicketList', () => { it('renders loading state', () => { renderWithTheme(); diff --git a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx index 392dd05a253..b465e955c4a 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx @@ -12,8 +12,8 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; -import { useOrder } from 'src/hooks/useOrder'; -import { usePagination } from 'src/hooks/usePagination'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { TicketRow } from './TicketRow'; import { getStatusFilter, useTicketSeverityCapability } from './ticketUtils'; @@ -32,15 +32,27 @@ export const TicketList = (props: Props) => { const hasSeverityCapability = useTicketSeverityCapability(); - const pagination = usePagination(1, preferenceKey); - - const { handleOrderChange, order, orderBy } = useOrder( - { - order: 'desc', - orderBy: 'opened', + const pagination = usePaginationV2({ + currentRoute: + filterStatus === 'open' + ? '/support/tickets/open' + : '/support/tickets/closed', + preferenceKey, + }); + + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'desc', + orderBy: 'opened', + }, + from: + filterStatus === 'open' + ? '/support/tickets/open' + : '/support/tickets/closed', }, - `${preferenceKey}-order` - ); + preferenceKey: `${preferenceKey}-order`, + }); const filter = { ['+order']: order, diff --git a/packages/manager/src/features/Support/SupportTickets/index.tsx b/packages/manager/src/features/Support/SupportTickets/index.tsx deleted file mode 100644 index d8bb05ee6db..00000000000 --- a/packages/manager/src/features/Support/SupportTickets/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import SupportTicketsLanding from './SupportTicketsLanding'; -export default SupportTicketsLanding; diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts index 8b334158c07..9838dac4b34 100644 --- a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts @@ -1,7 +1,7 @@ import { getTickets } from '@linode/api-v4/lib/support'; +import { useAccount } from '@linode/queries'; import { isFeatureEnabled } from '@linode/utilities'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { @@ -65,7 +65,7 @@ export const getTicketsPage = ( export const useTicketSeverityCapability = () => { const flags = useFlags(); - const { account } = useAccountManagement(); + const { data: account } = useAccount(); return isFeatureEnabled( 'Support Ticket Severity', diff --git a/packages/manager/src/features/Support/TicketAttachmentList.tsx b/packages/manager/src/features/Support/TicketAttachmentList.tsx index 6fbcdc8a1c1..0ad1c0cfff9 100644 --- a/packages/manager/src/features/Support/TicketAttachmentList.tsx +++ b/packages/manager/src/features/Support/TicketAttachmentList.tsx @@ -1,7 +1,7 @@ import { Typography } from '@linode/ui'; import InsertDriveFile from '@mui/icons-material/InsertDriveFile'; import InsertPhoto from '@mui/icons-material/InsertPhoto'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { isEmpty, slice } from 'ramda'; import * as React from 'react'; diff --git a/packages/manager/src/features/Support/TicketDetailText.tsx b/packages/manager/src/features/Support/TicketDetailText.tsx index e92058fd13e..913c468c065 100644 --- a/packages/manager/src/features/Support/TicketDetailText.tsx +++ b/packages/manager/src/features/Support/TicketDetailText.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@linode/ui'; import { truncate } from '@linode/utilities'; import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 9054d716b44..d65bb51fb97 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -9,6 +9,7 @@ import { import { getQueryParamsFromQueryString } from '@linode/utilities'; import { useMediaQuery, useTheme } from '@mui/material'; import React from 'react'; +// eslint-disable-next-line no-restricted-imports import { useHistory } from 'react-router-dom'; import Search from 'src/assets/icons/search.svg'; diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx index b9b1ac07a20..ed03be68b4a 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx @@ -1,5 +1,6 @@ import { Box, Chip, SvgIcon } from '@linode/ui'; import * as React from 'react'; +// eslint-disable-next-line no-restricted-imports import { useHistory } from 'react-router-dom'; import { searchableEntityIconMap } from 'src/features/Search/utils'; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 74ae06e3558..a53919f78e4 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -1,7 +1,7 @@ -import { useProfile } from '@linode/queries'; +import { useAccount, useGrants, useProfile } from '@linode/queries'; import { Box, Divider, Stack, Typography } from '@linode/ui'; import { styled } from '@mui/material'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import Popover from '@mui/material/Popover'; import { useTheme } from '@mui/material/styles'; import React from 'react'; @@ -10,8 +10,8 @@ import { Link } from 'src/components/Link'; import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext'; import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getStorage } from 'src/utilities/storage'; @@ -54,18 +54,30 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { const flags = useFlags(); const theme = useTheme(); - const { - _hasAccountAccess, - _isRestrictedUser, - account, - canSwitchBetweenParentOrProxyAccount, - hasReadWriteAccess, - profile, - } = useAccountManagement(); + const { data: account } = useAccount(); + const { data: profile } = useProfile(); + const { data: grants } = useGrants(); + + const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'child_account_access', + }); + + const isProxyUser = profile?.user_type === 'proxy'; + + const isRestrictedUser = profile?.restricted ?? false; + + const hasAccountAccess = + !isRestrictedUser || Boolean(grants?.global.account_access); + + const hasFullAccountAccess = + !isRestrictedUser || grants?.global.account_access === 'read_write'; + + const canSwitchBetweenParentOrProxyAccount = + (profile?.user_type === 'parent' && !isChildAccountAccessRestricted) || + profile?.user_type === 'proxy'; const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; - const isProxyUser = profile?.user_type === 'proxy'; // Used for fetching parent profile and account data by making a request with the parent's token. const proxyHeaders = isProxyUser @@ -92,7 +104,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { // Restricted users can't view the Users tab regardless of their grants { display: 'Users & Grants', - hide: _isRestrictedUser, + hide: isRestrictedUser, href: '/account/users', }, { @@ -103,7 +115,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { // Restricted users can't view the Transfers tab regardless of their grants { display: 'Service Transfers', - hide: _isRestrictedUser, + hide: isRestrictedUser, href: '/account/service-transfers', }, { @@ -113,11 +125,11 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { // Restricted users with read_write account access can view Settings. { display: 'Account Settings', - hide: !hasReadWriteAccess, + hide: !hasFullAccountAccess, href: '/account/settings', }, ], - [hasReadWriteAccess, _isRestrictedUser] + [hasFullAccountAccess, isRestrictedUser] ); const renderLink = (link: MenuLink) => { @@ -225,7 +237,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { - {_hasAccountAccess && ( + {hasAccountAccess && ( Account diff --git a/packages/manager/src/features/Users/UserPermissions.styles.ts b/packages/manager/src/features/Users/UserPermissions.styles.ts index 2df58b8f27f..e92c31d68d3 100644 --- a/packages/manager/src/features/Users/UserPermissions.styles.ts +++ b/packages/manager/src/features/Users/UserPermissions.styles.ts @@ -1,5 +1,5 @@ import { CircleProgress, Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; export const StyledDivWrapper = styled('div', { diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index f96db5d4ec4..6ec88b599cc 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -17,7 +17,7 @@ import { Typography, } from '@linode/ui'; import { scrollErrorIntoViewV2 } from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { enqueueSnackbar } from 'notistack'; import { compose, flatten, lensPath, omit, set } from 'ramda'; import * as React from 'react'; diff --git a/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx b/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx index d38a47da35d..15fbadb29db 100644 --- a/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx +++ b/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx @@ -1,5 +1,5 @@ import { Paper, Stack, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; diff --git a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx index f3bc5a375c7..959a6ed681b 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx @@ -1,5 +1,5 @@ import { Button, Divider } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx index 8ea29742cfc..0827379d6fc 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx @@ -1,5 +1,5 @@ import { Button, CloseIcon, TextField } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; diff --git a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx index 8a3472cee82..b342d4cf032 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.tsx @@ -1,5 +1,5 @@ import { ActionsPanel, Notice, Paper } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { FormProvider } from 'react-hook-form'; @@ -47,7 +47,7 @@ const VPCCreate = () => { /> {userCannotAddVPC && CannotCreateVPCNotice} - + {errors.root?.message && ( )} diff --git a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx index 82d07244040..65e7f29854b 100644 --- a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.tsx @@ -1,5 +1,5 @@ import { ActionsPanel, Box, Drawer, Notice } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { FormProvider } from 'react-hook-form'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index c5d21f92d68..995d2de1df2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -1,7 +1,7 @@ import { useLinodeQuery } from '@linode/queries'; import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import { capitalizeAllWords } from '@linode/utilities'; +import { getFormattedStatus } from '@linode/utilities'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; import * as React from 'react'; @@ -111,20 +111,20 @@ export const SubnetLinodeRow = (props: Props) => { ) : _hasUnrecommendedConfiguration(config, subnetId); - if (linodeLoading || !linode) { + if (linodeLoading) { return ( - + ); } - if (linodeError) { + if (linodeError || !linode) { return ( - + { /> ) : ( - capitalizeAllWords(linode.status.replace('_', ' ')) + getFormattedStatus(linode.status) )} @@ -341,7 +341,7 @@ const getIPRangesCellContents = ( export const SubnetLinodeTableRowHead = ( - Linode Label + Linode Status VPC IPv4 diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.test.tsx new file mode 100644 index 00000000000..c296c4806a4 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.test.tsx @@ -0,0 +1,97 @@ +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; +import { afterAll, afterEach, beforeAll, describe, it } from 'vitest'; + +import { + firewallFactory, + subnetAssignedNodebalancerDataFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +import { SubnetNodeBalancerRow } from './SubnetNodebalancerRow'; + +const LOADING_TEST_ID = 'circle-progress'; + +beforeAll(() => mockMatchMedia()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('SubnetNodeBalancerRow', () => { + const nodebalancer = { + id: 123, + label: 'test-nodebalancer', + }; + + const configs = [ + { nodes_status: { up: 3, down: 1 } }, + { nodes_status: { up: 2, down: 2 } }, + ]; + + const firewalls = makeResourcePage( + firewallFactory.buildList(1, { label: 'mock-firewall' }) + ); + + const subnetNodebalancer = subnetAssignedNodebalancerDataFactory.build({ + id: nodebalancer.id, + ipv4_range: '192.168.99.0/30', + }); + + it('renders loading state', async () => { + const { getByTestId } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(getByTestId(LOADING_TEST_ID)).toBeInTheDocument(); + await waitForElementToBeRemoved(() => getByTestId(LOADING_TEST_ID)); + }); + + it('renders nodebalancer row with data', async () => { + server.use( + http.get('*/nodebalancers/:id', () => { + return HttpResponse.json(nodebalancer); + }), + http.get('*/nodebalancers/:id/configs', () => { + return HttpResponse.json(configs); + }), + http.get('*/nodebalancers/:id/firewalls', () => { + return HttpResponse.json(firewalls); + }) + ); + + const { getByText, getByRole } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + await waitFor(() => { + expect(getByText(nodebalancer.label)).toBeInTheDocument(); + }); + + expect(getByText(subnetNodebalancer.ipv4_range)).toBeInTheDocument(); + expect(getByText('mock-firewall')).toBeInTheDocument(); + + const nodebalancerLink = getByRole('link', { + name: nodebalancer.label, + }); + + expect(nodebalancerLink).toHaveAttribute( + 'href', + `/nodebalancers/${nodebalancer.id}/summary` + ); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx new file mode 100644 index 00000000000..a46a950fb91 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx @@ -0,0 +1,121 @@ +import { + useAllNodeBalancerConfigsQuery, + useNodeBalancerQuery, + useNodeBalancersFirewallsQuery, +} from '@linode/queries'; +import { Box, CircleProgress, Hidden } from '@linode/ui'; +import ErrorOutline from '@mui/icons-material/ErrorOutline'; +import { Typography } from '@mui/material'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; + +interface Props { + hover?: boolean; + ipv4: string; + nodeBalancerId: number; +} + +export const SubnetNodeBalancerRow = ({ + nodeBalancerId, + hover = false, + ipv4, +}: Props) => { + const { + data: nodebalancer, + error: nodebalancerError, + isLoading: nodebalancerLoading, + } = useNodeBalancerQuery(nodeBalancerId); + const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery( + Number(nodeBalancerId) + ); + const { data: configs } = useAllNodeBalancerConfigsQuery( + Number(nodeBalancerId) + ); + + const firewallLabel = attachedFirewallData?.data[0]?.label; + const firewallId = attachedFirewallData?.data[0]?.id; + + const down = configs?.reduce((acc: number, config) => { + return acc + config.nodes_status.down; + }, 0); // add the downtime for each config together + + const up = configs?.reduce((acc: number, config) => { + return acc + config.nodes_status.up; + }, 0); // add the uptime for each config together + + if (nodebalancerLoading) { + return ( + + + + + + ); + } + + if (nodebalancerError || !nodebalancer) { + return ( + + + + ({ color: theme.color.red, marginRight: 1 })} + /> + + There was an error loading{' '} + + Nodebalancer {nodeBalancerId} + + + + + + ); + } + + return ( + + + + {nodebalancer?.label} + + + + + {`${up} up, ${down} down`} + + {ipv4} + + + {firewallLabel} + + + + ); +}; + +export const SubnetNodebalancerTableRowHead = ( + + NodeBalancer + Backend Status + + VPC IPv4 Range + + + Firewalls + + + +); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx index 3992a0b6aad..aefd1aa0e45 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx @@ -76,6 +76,43 @@ describe('VPC Detail Summary section', () => { getAllByText(vpcFactory1.updated); }); + it('should display number of subnets and resources, region, id, creation and update dates', async () => { + const vpcFactory1 = vpcFactory.build({ id: 1, subnets: [] }); + server.use( + http.get('*/vpcs/:vpcId', () => { + return HttpResponse.json(vpcFactory1); + }) + ); + + const { getAllByText, queryByTestId } = await renderWithThemeAndRouter( + , + { + flags: { nodebalancerVpc: true }, + } + ); + + const loadingState = queryByTestId(loadingTestId); + if (loadingState) { + await waitForElementToBeRemoved(loadingState); + } + + getAllByText('Subnets'); + getAllByText('Resources'); + getAllByText('0'); + + getAllByText('Region'); + getAllByText('US, Newark, NJ'); + + getAllByText('VPC ID'); + getAllByText(vpcFactory1.id); + + getAllByText('Created'); + getAllByText(vpcFactory1.created); + + getAllByText('Updated'); + getAllByText(vpcFactory1.updated); + }); + it('should display description if one is provided', async () => { const vpcFactory1 = vpcFactory.build({ description: `VPC for webserver and database.`, diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index 4eb370c1734..557cea1daea 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -16,11 +16,13 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { LandingHeader } from 'src/components/LandingHeader'; import { LKE_ENTERPRISE_VPC_WARNING } from 'src/features/Kubernetes/constants'; +import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; import { getIsVPCLKEEnterpriseCluster, getUniqueLinodesFromSubnets, + getUniqueResourcesFromSubnets, } from '../utils'; import { VPCDeleteDialog } from '../VPCLanding/VPCDeleteDialog'; import { VPCEditDrawer } from '../VPCLanding/VPCEditDrawer'; @@ -47,6 +49,9 @@ const VPCDetail = () => { isFetching: isFetchingVPC, isLoading, } = useVPCQuery(Number(vpcId) || -1, Boolean(vpcId)); + + const flags = useIsNodebalancerVPCEnabled(); + const { data: regions } = useRegionsQuery(); const handleEditVPC = (vpc: VPC) => { @@ -92,7 +97,9 @@ const VPCDetail = () => { const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? vpc.region; - const numLinodes = getUniqueLinodesFromSubnets(vpc.subnets); + const numResources = flags.isNodebalancerVPCEnabled + ? getUniqueResourcesFromSubnets(vpc.subnets) + : getUniqueLinodesFromSubnets(vpc.subnets); const summaryData = [ [ @@ -101,8 +108,8 @@ const VPCDetail = () => { value: vpc.subnets.length, }, { - label: 'Linodes', - value: numLinodes, + label: flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes', + value: numResources, }, ], [ diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index 01f937e46c9..6d32fc39632 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -70,7 +70,7 @@ describe('VPC Subnets table', () => { } getByPlaceholderText('Filter Subnets by label or id'); - getByText('Subnet Label'); + getByText('Subnet'); getByText(subnet.label); getByText('Subnet ID'); getAllByText(subnet.id); @@ -90,6 +90,66 @@ describe('VPC Subnets table', () => { getByText('Delete'); }); + it('should display filter input, subnet label, id, ip range, number of resources, and action menu', async () => { + const subnet = subnetFactory.build({ + linodes: [ + subnetAssignedLinodeDataFactory.build({ id: 1 }), + subnetAssignedLinodeDataFactory.build({ id: 2 }), + subnetAssignedLinodeDataFactory.build({ id: 3 }), + ], + }); + server.use( + http.get('*/vpcs/:vpcId/subnets', () => { + return HttpResponse.json(makeResourcePage([subnet])); + }), + http.get('*/networking/firewalls/settings', () => { + return HttpResponse.json(firewallSettingsFactory.build()); + }) + ); + + const { + getAllByRole, + getAllByText, + getByPlaceholderText, + getByText, + queryByTestId, + } = await renderWithThemeAndRouter( + , + { + flags: { nodebalancerVpc: true }, + } + ); + + const loadingState = queryByTestId(loadingTestId); + if (loadingState) { + await waitForElementToBeRemoved(loadingState); + } + + getByPlaceholderText('Filter Subnets by label or id'); + getByText('Subnet'); + getByText(subnet.label); + getByText('Subnet ID'); + getAllByText(subnet.id); + + getByText('Subnet IP Range'); + getByText(subnet.ipv4!); + + getByText('Resources'); + getByText(subnet.linodes.length + subnet.nodebalancers.length); + + const actionMenuButton = getAllByRole('button')[4]; + await userEvent.click(actionMenuButton); + + getByText('Assign Linodes'); + getByText('Unassign Linodes'); + getByText('Edit'); + getByText('Delete'); + }); + it('should display no linodes text if there are no linodes associated with the subnet', async () => { const subnet = subnetFactory.build({ linodes: [] }); server.use( @@ -149,12 +209,77 @@ describe('VPC Subnets table', () => { const expandTableButton = getAllByRole('button')[3]; await userEvent.click(expandTableButton); - getByText('Linode Label'); + getByText('Linode'); getByText('Status'); getByText('VPC IPv4'); getByText('Firewalls'); }); + it('should display no nodeBalancers text if there are no nodeBalancers associated with the subnet', async () => { + const subnet = subnetFactory.build({ nodebalancers: [] }); + + server.use( + http.get('*/vpcs/:vpcId/subnets', () => { + return HttpResponse.json(makeResourcePage([subnet])); + }), + http.get('*/networking/firewalls/settings', () => { + return HttpResponse.json(firewallSettingsFactory.build()); + }) + ); + + const { getAllByRole, getByText, queryByTestId } = + await renderWithThemeAndRouter( + , + { flags: { nodebalancerVpc: true } } + ); + + const loadingState = queryByTestId(loadingTestId); + if (loadingState) { + await waitForElementToBeRemoved(loadingState); + } + + const expandTableButton = getAllByRole('button')[3]; + await userEvent.click(expandTableButton); + getByText('No NodeBalancers'); + }); + + it('should show Nodebalancer table head data when table is expanded', async () => { + const subnet = subnetFactory.build(); + server.use( + http.get('*/vpcs/:vpcId/subnets', () => { + return HttpResponse.json(makeResourcePage([subnet])); + }), + http.get('*/networking/firewalls/settings', () => { + return HttpResponse.json(firewallSettingsFactory.build()); + }) + ); + const { getAllByRole, getByText, queryByTestId } = + await renderWithThemeAndRouter( + , + { flags: { nodebalancerVpc: true } } + ); + + const loadingState = queryByTestId(loadingTestId); + if (loadingState) { + await waitForElementToBeRemoved(loadingState); + } + + const expandTableButton = getAllByRole('button')[3]; + await userEvent.click(expandTableButton); + + getByText('NodeBalancer'); + getByText('Backend Status'); + getByText('VPC IPv4 Range'); + }); + it('should disable Create Subnet button if the VPC is associated with a LKE-E cluster', async () => { server.use( http.get('*/networking/firewalls/settings', () => { diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 84d4ce1578c..b509b94c2f0 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -26,6 +26,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; +import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { SubnetActionMenu } from 'src/features/VPCs/VPCDetail/SubnetActionMenu'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -37,6 +38,10 @@ import { SubnetCreateDrawer } from './SubnetCreateDrawer'; import { SubnetDeleteDialog } from './SubnetDeleteDialog'; import { SubnetEditDrawer } from './SubnetEditDrawer'; import { SubnetLinodeRow, SubnetLinodeTableRowHead } from './SubnetLinodeRow'; +import { + SubnetNodeBalancerRow, + SubnetNodebalancerTableRowHead, +} from './SubnetNodebalancerRow'; import { SubnetUnassignLinodesDrawer } from './SubnetUnassignLinodesDrawer'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; @@ -81,6 +86,8 @@ export const VPCSubnetsTable = (props: Props) => { }); const { query } = search; + const flags = useIsNodebalancerVPCEnabled(); + const pagination = usePaginationV2({ currentRoute: VPC_DETAILS_ROUTE, preferenceKey, @@ -271,7 +278,7 @@ export const VPCSubnetsTable = (props: Props) => { width: '24%', })} > - Subnet Label + Subnet { Subnet IP Range - Linodes + {`${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`} @@ -301,7 +310,9 @@ export const VPCSubnetsTable = (props: Props) => { {subnet.ipv4} - {subnet.linodes.length} + + {`${flags.isNodebalancerVPCEnabled ? subnet.linodes.length + subnet.nodebalancers.length : subnet.linodes.length}`} + { ); const InnerTable = ( -
- - {SubnetLinodeTableRowHead} - - - {subnet.linodes.length > 0 ? ( - subnet.linodes.map((linodeInfo) => ( - - )) - ) : ( - - )} - -
+ <> + + + {SubnetLinodeTableRowHead} + + + {subnet.linodes.length > 0 ? ( + subnet.linodes.map((linodeInfo) => ( + + )) + ) : ( + + )} + +
+ {flags.isNodebalancerVPCEnabled && ( + + + {SubnetNodebalancerTableRowHead} + + + {subnet.nodebalancers?.length > 0 ? ( + subnet.nodebalancers.map((nb) => ( + + )) + ) : ( + + )} + +
+ )} + ); return { @@ -398,7 +434,7 @@ export const VPCSubnetsTable = (props: Props) => { + } TableRowHead={SubnetTableRowHead} /> diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.test.tsx index 91d88a6b56e..504db8f4532 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.test.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.test.tsx @@ -44,6 +44,36 @@ describe('VPC Landing Table', () => { getAllByText('Linodes'); }); + it('should render vpc landing table with items with nodebalancerVpc flag enabled', async () => { + server.use( + http.get('*/vpcs', () => { + const vpcsWithSubnet = vpcFactory.buildList(3, { + subnets: subnetFactory.buildList(Math.floor(Math.random() * 10) + 1), + }); + return HttpResponse.json(makeResourcePage(vpcsWithSubnet)); + }) + ); + + const { getAllByText, queryByTestId } = await renderWithThemeAndRouter( + , + { + flags: { nodebalancerVpc: true }, + } + ); + + const loadingState = queryByTestId(loadingTestId); + if (loadingState) { + await waitForElementToBeRemoved(loadingState); + } + + // Static text and table column headers + getAllByText('Label'); + getAllByText('Region'); + getAllByText('VPC ID'); + getAllByText('Subnets'); + getAllByText('Resources'); + }); + it('should render vpc landing with empty state', async () => { server.use( http.get('*/vpcs', () => { diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx index 7b872674e24..cb1947226a3 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -13,6 +13,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { VPC_CREATE_ROUTE, VPC_LANDING_ROUTE, @@ -100,6 +101,8 @@ const VPCLanding = () => { error: selectedVPCError, } = useVPCQuery(params.vpcId ?? -1, !!params.vpcId); + const flags = useIsNodebalancerVPCEnabled(); + if (error) { return ( { Subnets - Linodes + {`${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`} @@ -178,6 +181,7 @@ const VPCLanding = () => { handleDeleteVPC(vpc)} handleEditVPC={() => handleEditVPC(vpc)} + isNodebalancerVPCEnabled={flags.isNodebalancerVPCEnabled} key={vpc.id} vpc={vpc} /> diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx index f7864e78675..ab70607450b 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx @@ -17,7 +17,12 @@ describe('VPC Table Row', () => { const { getAllByText, getByText } = renderWithTheme( wrapWithTableBody( - + ) ); @@ -38,6 +43,7 @@ describe('VPC Table Row', () => { ) @@ -55,6 +61,7 @@ describe('VPC Table Row', () => { ) @@ -71,7 +78,12 @@ describe('VPC Table Row', () => { }); const { getAllByRole } = renderWithTheme( wrapWithTableBody( - + ) ); const actionButtons = getAllByRole('button'); diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index 6e7ccba7130..dacac7209b4 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -10,7 +10,11 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { LKE_ENTERPRISE_VPC_WARNING } from 'src/features/Kubernetes/constants'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; -import { getIsVPCLKEEnterpriseCluster } from '../utils'; +import { + getIsVPCLKEEnterpriseCluster, + getUniqueLinodesFromSubnets, + getUniqueResourcesFromSubnets, +} from '../utils'; import type { VPC } from '@linode/api-v4/lib/vpcs/types'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -18,18 +22,23 @@ import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { handleDeleteVPC: () => void; handleEditVPC: () => void; + isNodebalancerVPCEnabled: boolean; vpc: VPC; } -export const VPCRow = ({ handleDeleteVPC, handleEditVPC, vpc }: Props) => { +export const VPCRow = ({ + handleDeleteVPC, + handleEditVPC, + isNodebalancerVPCEnabled, + vpc, +}: Props) => { const { id, label, subnets } = vpc; const { data: regions } = useRegionsQuery(); const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? ''; - const numLinodes = subnets.reduce( - (acc, subnet) => acc + subnet.linodes.length, - 0 - ); + const numResources = isNodebalancerVPCEnabled + ? getUniqueResourcesFromSubnets(vpc.subnets) + : getUniqueLinodesFromSubnets(vpc.subnets); const isVPCReadOnly = useIsResourceRestricted({ grantLevel: 'read_only', @@ -83,7 +92,7 @@ export const VPCRow = ({ handleDeleteVPC, handleEditVPC, vpc }: Props) => { {subnets.length} - {numLinodes} + {numResources} {actions.map((action) => ( diff --git a/packages/manager/src/features/VPCs/constants.ts b/packages/manager/src/features/VPCs/constants.ts index 08e794ffd6e..a6387b047b0 100644 --- a/packages/manager/src/features/VPCs/constants.ts +++ b/packages/manager/src/features/VPCs/constants.ts @@ -14,12 +14,18 @@ export const WARNING_ICON_UNRECOMMENDED_CONFIG = export const REGION_CAVEAT_HELPER_TEXT = 'A Linode may be assigned only to a VPC in the same region.'; +export const NODEBALANCER_REGION_CAVEAT_HELPER_TEXT = + 'A NodeBalancer may be assigned only to a VPC in the same region.'; + export const REGIONAL_LINODE_MESSAGE = "Select the Linodes you would like to assign to this subnet. Only Linodes in this VPC's region are displayed."; export const MULTIPLE_CONFIGURATIONS_MESSAGE = 'This Linode has multiple configurations. Select which configuration you would like added to the subnet.'; +export const NB_AUTO_ASSIGN_CIDR_TOOLTIP = + 'A /30 CIDR is needed for the NodeBalancer to communicate with the subnet. Deselect this option to define a custom IP range.'; + export const VPC_AUTO_ASSIGN_IPV4_TOOLTIP = 'Automatically assign an IPv4 address as the private IP address for this Linode in the VPC.'; diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index 21c48cb1982..11cf3a75b63 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -7,6 +7,7 @@ import { import { linodeConfigFactory } from 'src/factories/linodeConfigs'; import { subnetAssignedLinodeDataFactory, + subnetAssignedNodebalancerDataFactory, subnetFactory, } from 'src/factories/subnets'; @@ -14,6 +15,7 @@ import { getLinodeInterfacePrimaryIPv4, getLinodeInterfaceRanges, getUniqueLinodesFromSubnets, + getUniqueResourcesFromSubnets, getVPCInterfacePayload, hasUnrecommendedConfiguration, hasUnrecommendedConfigurationLinodeInterface, @@ -26,6 +28,76 @@ const subnetLinodeInfoList1 = subnetAssignedLinodeDataFactory.buildList(4); const subnetLinodeInfoId1 = subnetAssignedLinodeDataFactory.build({ id: 1 }); const subnetLinodeInfoId3 = subnetAssignedLinodeDataFactory.build({ id: 3 }); +const subnetNodeBalancerInfoList1 = + subnetAssignedNodebalancerDataFactory.buildList(4); +const subnetNodeBalancerInfoId1 = subnetAssignedNodebalancerDataFactory.build({ + id: 1, +}); +const subnetNodeBalancerInfoId3 = subnetAssignedNodebalancerDataFactory.build({ + id: 3, +}); + +describe('getUniqueResourcesFromSubnets', () => { + it(`returns the number of unique linodes and nodeBalancers within a VPC's subnets`, () => { + const subnets0 = [subnetFactory.build({ linodes: [], nodebalancers: [] })]; + const subnets1 = [ + subnetFactory.build({ + linodes: subnetLinodeInfoList1, + nodebalancers: subnetNodeBalancerInfoList1, + }), + ]; + const subnets2 = [ + subnetFactory.build({ + linodes: [ + subnetLinodeInfoId1, + subnetLinodeInfoId1, + subnetLinodeInfoId3, + subnetLinodeInfoId3, + ], + nodebalancers: [ + subnetNodeBalancerInfoId1, + subnetNodeBalancerInfoId1, + subnetNodeBalancerInfoId3, + subnetNodeBalancerInfoId3, + ], + }), + ]; + const subnets3 = [ + subnetFactory.build({ + linodes: subnetLinodeInfoList1, + nodebalancers: subnetNodeBalancerInfoList1, + }), + subnetFactory.build({ linodes: [], nodebalancers: [] }), + subnetFactory.build({ + linodes: [subnetLinodeInfoId3], + nodebalancers: [subnetNodeBalancerInfoId3], + }), + subnetFactory.build({ + linodes: [ + subnetAssignedLinodeDataFactory.build({ id: 6 }), + subnetAssignedLinodeDataFactory.build({ id: 7 }), + subnetAssignedLinodeDataFactory.build({ id: 8 }), + subnetAssignedLinodeDataFactory.build({ id: 9 }), + subnetLinodeInfoId1, + ], + nodebalancers: [ + subnetAssignedNodebalancerDataFactory.build({ id: 6 }), + subnetAssignedNodebalancerDataFactory.build({ id: 7 }), + subnetAssignedNodebalancerDataFactory.build({ id: 8 }), + subnetAssignedNodebalancerDataFactory.build({ id: 9 }), + subnetNodeBalancerInfoId1, + ], + }), + ]; + + expect(getUniqueResourcesFromSubnets(subnets0)).toBe(0); + expect(getUniqueResourcesFromSubnets(subnets1)).toBe(8); + expect(getUniqueResourcesFromSubnets(subnets2)).toBe(4); + // updated factory for generating linode ids, so unique linodes will be different + expect(getUniqueResourcesFromSubnets(subnets3)).toBe(16); + }); +}); + describe('getUniqueLinodesFromSubnets', () => { it(`returns the number of unique linodes within a VPC's subnets`, () => { const subnets0 = [subnetFactory.build({ linodes: [] })]; diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index 65781321421..31a171caef3 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -23,6 +23,24 @@ export const getUniqueLinodesFromSubnets = (subnets: Subnet[]) => { return linodes.length; }; +export const getUniqueResourcesFromSubnets = (subnets: Subnet[]) => { + const linodes: number[] = []; + const nodeBalancer: number[] = []; + for (const subnet of subnets) { + subnet.linodes.forEach((linodeInfo) => { + if (!linodes.includes(linodeInfo.id)) { + linodes.push(linodeInfo.id); + } + }); + subnet.nodebalancers.forEach((nodeBalancerInfo) => { + if (!nodeBalancer.includes(nodeBalancerInfo.id)) { + nodeBalancer.push(nodeBalancerInfo.id); + } + }); + } + return linodes.length + nodeBalancer.length; +}; + // Linode Interfaces: show unrecommended notice if (active) VPC interface has an IPv4 nat_1_1 address but isn't the default IPv4 route export const hasUnrecommendedConfigurationLinodeInterface = ( linodeInterface: LinodeInterface | undefined, diff --git a/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAttachForm.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAttachForm.tsx index d390d02a7ca..91a279929ed 100644 --- a/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAttachForm.tsx +++ b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAttachForm.tsx @@ -97,7 +97,10 @@ export const LinodeVolumeAttachForm = (props: Props) => { validationSchema: AttachVolumeValidationSchema, }); - const { data: volume } = useVolumeQuery(values.volume_id); + const { data: volume } = useVolumeQuery( + values.volume_id, + values.volume_id !== -1 + ); const linodeRequiresClientLibraryUpdate = volume?.encryption === 'enabled' && diff --git a/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/VolumeSelect.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/VolumeSelect.tsx index e2c3e2f18d8..9e5717dd77f 100644 --- a/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/VolumeSelect.tsx +++ b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/VolumeSelect.tsx @@ -1,5 +1,6 @@ -import { useInfiniteVolumesQuery } from '@linode/queries'; +import { useInfiniteVolumesQuery, useVolumeQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; +import { useDebouncedValue } from '@linode/utilities'; import * as React from 'react'; interface Props { @@ -17,11 +18,13 @@ export const VolumeSelect = (props: Props) => { const [inputValue, setInputValue] = React.useState(''); - const searchFilter = inputValue + const debouncedInputValue = useDebouncedValue(inputValue); + + const searchFilter = debouncedInputValue ? { '+or': [ - { label: { '+contains': inputValue } }, - { tags: { '+contains': inputValue } }, + { label: { '+contains': debouncedInputValue } }, + { tags: { '+contains': debouncedInputValue } }, ], } : {}; @@ -35,9 +38,16 @@ export const VolumeSelect = (props: Props) => { '+order_by': 'label', }); - const options = data?.pages - .flatMap((page) => page.data) - .map(({ id, label }) => ({ id, label })); + const options = data?.pages.flatMap((page) => page.data); + + const { data: volume, isLoading: isLoadingSelected } = useVolumeQuery( + value, + value > 0 + ); + + if (value && volume && !options?.some((item) => item.id === volume.id)) { + options?.push(volume); + } const selectedVolume = options?.find((option) => option.id === value) ?? null; @@ -45,6 +55,7 @@ export const VolumeSelect = (props: Props) => { options} helperText={ region && "Only volumes in this Linode's region are attachable." } @@ -64,13 +75,13 @@ export const VolumeSelect = (props: Props) => { } }, }} - loading={isLoading} + loading={isLoading || isLoadingSelected} onBlur={onBlur} - onChange={(event, value) => { - onChange(value?.id ?? -1); + onChange={(_, value) => { setInputValue(''); + onChange(value?.id ?? -1); }} - onInputChange={(event, value, reason) => { + onInputChange={(_, value, reason) => { if (reason === 'input') { setInputValue(value); } else { diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 4bc248bfeda..06bae2921d2 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -1,6 +1,7 @@ import { useNotificationsQuery, useRegionsQuery } from '@linode/queries'; import { Box, Chip, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; +import { getFormattedStatus } from '@linode/utilities'; import * as React from 'react'; // eslint-disable-next-line no-restricted-imports import { useHistory } from 'react-router-dom'; @@ -148,7 +149,8 @@ export const VolumeTableRow = React.memo((props: Props) => { - {volumeStatus} {getEventProgress(mostRecentVolumeEvent)} + {getFormattedStatus(volumeStatus)}{' '} + {getEventProgress(mostRecentVolumeEvent)} {isVolumesLanding && ( diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index 87e1ab47bfc..8f8d019d530 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -5,7 +5,6 @@ import { ErrorState, IconButton, InputAdornment, - Notice, TextField, } from '@linode/ui'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; @@ -24,7 +23,6 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -67,12 +65,11 @@ export const VolumesLanding = () => { query: search.query, }), }); - const isRestricted = useRestrictedGlobalGrantCheck({ + const isVolumeCreationRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_volumes', }); - const { query } = search; - const { _isRestrictedUser } = useAccountManagement(); + const { query } = search; const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -172,16 +169,6 @@ export const VolumesLanding = () => { return ( <> - {_isRestrictedUser && ( - - )} { resourceType: 'Volumes', }), }} - disabledCreateButton={isRestricted} + disabledCreateButton={isVolumeCreationRestricted} docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" onButtonClick={() => navigate({ to: '/volumes/create' })} diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index 56c8b4555ee..a6be8949f4c 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -1,6 +1,6 @@ import { Notice, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; diff --git a/packages/manager/src/hooks/useAccountManagement.ts b/packages/manager/src/hooks/useAccountManagement.ts deleted file mode 100644 index 4cb910c6397..00000000000 --- a/packages/manager/src/hooks/useAccountManagement.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - useAccount, - useAccountSettings, - useGrants, - useProfile, -} from '@linode/queries'; - -import { useRestrictedGlobalGrantCheck } from './useRestrictedGlobalGrantCheck'; - -import type { GlobalGrantTypes } from '@linode/api-v4/lib/account'; - -export const useAccountManagement = () => { - const { data: account, error: accountError } = useAccount(); - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - - const isRestrictedGlobalGrant = useRestrictedGlobalGrantCheck({ - globalGrantType: 'child_account_access', - }); - - const _isRestrictedUser = profile?.restricted ?? false; - const { data: accountSettings } = useAccountSettings(); - - const _hasGrant = (grant: GlobalGrantTypes) => - grants?.global?.[grant] ?? false; - - const _hasAccountAccess = !_isRestrictedUser || _hasGrant('account_access'); - - const _isManagedAccount = accountSettings?.managed ?? false; - - const hasReadWriteAccess = _hasGrant('account_access') === 'read_write'; - - const canSwitchBetweenParentOrProxyAccount = - (profile?.user_type === 'parent' && !isRestrictedGlobalGrant) || - profile?.user_type === 'proxy'; - - return { - _hasAccountAccess, - _hasGrant, - _isManagedAccount, - _isRestrictedUser, - account, - accountError, - accountSettings, - canSwitchBetweenParentOrProxyAccount, - hasReadWriteAccess, - profile, - }; -}; diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index d85391276da..607ba914feb 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -2,7 +2,7 @@ import { loadScript } from '@linode/utilities'; // `loadScript` from `useScript` import React from 'react'; import { useHistory } from 'react-router-dom'; -import { ADOBE_ANALYTICS_URL, NUM_ADOBE_SCRIPTS } from 'src/constants'; +import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; /** @@ -16,19 +16,17 @@ export const useAdobeAnalytics = () => { if (ADOBE_ANALYTICS_URL) { loadScript(ADOBE_ANALYTICS_URL, { location: 'head' }) .then((data) => { - const adobeScriptTags = document.querySelectorAll( - 'script[src^="https://assets.adobedtm.com/"]' - ); - // Log an error; if the promise resolved, the _satellite object and 3 Adobe scripts should be present in the DOM. - if ( - data.status !== 'ready' || - !window._satellite || - adobeScriptTags.length !== NUM_ADOBE_SCRIPTS - ) { + // Log a Sentry error if the Launch script isn't ready or the _satellite object isn't present in the DOM. + if (data.status !== 'ready' || !window._satellite) { reportException( 'Adobe Analytics error: Not all Adobe Launch scripts and extensions were loaded correctly; analytics cannot be sent.' ); } + + // Fire the first page view for the landing page + window._satellite.track('page view', { + url: window.location.pathname, + }); }) .catch(() => { // Do nothing; a user may have analytics script requests blocked. diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index df799c14c09..366e6e63082 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -1,5 +1,6 @@ import { firewallEventsHandler, + imageEventsHandler, nodebalancerEventHandler, oauthClientsEventHandler, placementGroupEventHandler, @@ -11,13 +12,13 @@ import { useQueryClient } from '@tanstack/react-query'; import { databaseEventsHandler } from 'src/queries/databases/events'; import { domainEventsHandler } from 'src/queries/domains'; -import { imageEventsHandler } from 'src/queries/images'; import { stackScriptEventHandler } from 'src/queries/stackscripts'; import { supportTicketEventHandler } from 'src/queries/support'; import { volumeEventsHandler } from 'src/queries/volumes/events'; import { diskEventHandler, + interfaceEventHandler, linodeEventsHandler, } from '../queries/linodes/events'; @@ -92,6 +93,10 @@ export const eventHandlers: { filter: (event) => event.action.startsWith('tax_id'), handler: taxIdEventHandler, }, + { + filter: (event) => event.action.startsWith('interface'), + handler: interfaceEventHandler, + }, ]; export const useEventHandlers = () => { diff --git a/packages/manager/src/hooks/useOrderV2.ts b/packages/manager/src/hooks/useOrderV2.ts index fd778160ea4..22b3729170a 100644 --- a/packages/manager/src/hooks/useOrderV2.ts +++ b/packages/manager/src/hooks/useOrderV2.ts @@ -6,6 +6,7 @@ import { sortData } from 'src/components/OrderBy'; import type { OrderSetWithPrefix } from '@linode/utilities'; import type { LinkProps, RegisteredRouter } from '@tanstack/react-router'; +import type { TableSearchParams } from 'src/routes/types'; export type Order = 'asc' | 'desc'; @@ -109,7 +110,7 @@ export const useOrderV2 = ({ }; navigate({ - search: (prev) => ({ + search: (prev: TableSearchParams) => ({ ...prev, ...searchParams, ...urlData, diff --git a/packages/manager/src/hooks/usePendo.ts b/packages/manager/src/hooks/usePendo.ts index 6c8619d431a..08dde8ca39c 100644 --- a/packages/manager/src/hooks/usePendo.ts +++ b/packages/manager/src/hooks/usePendo.ts @@ -2,12 +2,8 @@ import { useAccount, useProfile } from '@linode/queries'; import { loadScript } from '@linode/utilities'; // `loadScript` from `useScript` hook import React from 'react'; -import { APP_ROOT, PENDO_API_KEY } from 'src/constants'; -import { - checkOptanonConsent, - getCookie, - ONE_TRUST_COOKIE_CATEGORIES, -} from 'src/utilities/analytics/utils'; +import { ADOBE_ANALYTICS_URL, APP_ROOT, PENDO_API_KEY } from 'src/constants'; +import { reportException } from 'src/exceptionReporting'; declare global { interface Window { @@ -70,21 +66,8 @@ export const usePendo = () => { const accountId = getUniquePendoId(account?.euuid); const visitorId = getUniquePendoId(profile?.uid.toString()); - const optanonCookie = getCookie('OptanonConsent'); - // Since OptanonConsent cookie always has a .linode.com domain, only check for consent in dev/staging/prod envs. - // When running the app locally, do not try to check for OneTrust cookie consent, just enable Pendo. - const hasConsentEnabled = - APP_ROOT.includes('localhost') || - checkOptanonConsent( - optanonCookie, - ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies'] - ); - - // This URL uses a Pendo-configured CNAME (M3-8742). - const PENDO_URL = `https://content.psp.cloud.linode.com/agent/static/${PENDO_API_KEY}/pendo.js`; - React.useEffect(() => { - if (PENDO_API_KEY && hasConsentEnabled) { + if (PENDO_API_KEY && ADOBE_ANALYTICS_URL) { // Adapted Pendo install script for readability // Refer to: https://support.pendo.io/hc/en-us/articles/21362607464987-Components-of-the-install-script#01H6S2EXET8C9FGSHP08XZAE4F @@ -115,54 +98,61 @@ export const usePendo = () => { })(methodNames[index]); }); - // Load Pendo script into the head HTML tag, then initialize Pendo with metadata - loadScript(PENDO_URL, { + // Ensure the Adobe Launch script is loaded, then initialize Pendo with metadata + loadScript(ADOBE_ANALYTICS_URL, { location: 'head', }).then(() => { - window.pendo.initialize({ - account: { - id: accountId, // Highly recommended, required if using Pendo Feedback - // name: // Optional - // is_paying: // Recommended if using Pendo Feedback - // monthly_value:// Recommended if using Pendo Feedback - // planLevel: // Optional - // planPrice: // Optional - // creationDate: // Optional - - // You can add any additional account level key-values here, - // as long as it's not one of the above reserved names. - }, - // Controls what URLs we send to Pendo. Refer to: https://agent.pendo.io/advanced/location/. - location: { - transforms: [ - { - action: 'Clear', - attr: 'hash', - }, - { - action: 'Clear', - attr: 'search', - }, - { - action: 'Replace', - attr: 'pathname', - data(url: string) { - return transformUrl(url); + try { + window.pendo.initialize({ + account: { + id: accountId, // Highly recommended, required if using Pendo Feedback + // name: // Optional + // is_paying: // Recommended if using Pendo Feedback + // monthly_value:// Recommended if using Pendo Feedback + // planLevel: // Optional + // planPrice: // Optional + // creationDate: // Optional + + // You can add any additional account level key-values here, + // as long as it's not one of the above reserved names. + }, + // Controls what URLs we send to Pendo. Refer to: https://agent.pendo.io/advanced/location/. + location: { + transforms: [ + { + action: 'Clear', + attr: 'hash', + }, + { + action: 'Clear', + attr: 'search', + }, + { + action: 'Replace', + attr: 'pathname', + data(url: string) { + return transformUrl(url); + }, }, - }, - ], - }, - visitor: { - id: visitorId, // Required if user is logged in - // email: // Recommended if using Pendo Feedback, or NPS Email - // full_name: // Recommended if using Pendo Feedback - // role: // Optional - - // You can add any additional visitor level key-values here, - // as long as it's not one of the above reserved names. - }, - }); + ], + }, + visitor: { + id: visitorId, // Required if user is logged in + // email: // Recommended if using Pendo Feedback, or NPS Email + // full_name: // Recommended if using Pendo Feedback + // role: // Optional + + // You can add any additional visitor level key-values here, + // as long as it's not one of the above reserved names. + }, + }); + } catch (error) { + reportException( + 'An error occurred when trying to initialize Pendo.', + { error } + ); + } }); } - }, [PENDO_URL, accountId, hasConsentEnabled, visitorId]); + }, [accountId, visitorId]); }; diff --git a/packages/manager/src/hooks/usePlatformMaintenance.test.ts b/packages/manager/src/hooks/usePlatformMaintenance.test.ts new file mode 100644 index 00000000000..acf7d15330b --- /dev/null +++ b/packages/manager/src/hooks/usePlatformMaintenance.test.ts @@ -0,0 +1,99 @@ +import { renderHook } from '@testing-library/react'; + +import { accountMaintenanceFactory, notificationFactory } from 'src/factories'; + +import { usePlatformMaintenance } from './usePlatformMaintenance'; + +const queryMocks = vi.hoisted(() => ({ + useNotificationsQuery: vi.fn().mockReturnValue({}), + useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + ...queryMocks, + }; +}); + +describe('usePlatformMaintenace', () => { + it('returns false when there is no platform maintenance notification', () => { + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: accountMaintenanceFactory.buildList(3, { + type: 'reboot', + entity: { + type: 'linode', + }, + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: [], + }); + + const { result } = renderHook(() => usePlatformMaintenance()); + + expect(result.current.accountHasPlatformMaintenance).toBe(false); + }); + + it('returns true when there is a platform maintenance notifications', () => { + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: [], + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { result } = renderHook(() => usePlatformMaintenance()); + + expect(result.current).toEqual({ + accountHasPlatformMaintenance: true, + linodesWithPlatformMaintenance: new Set(), + platformMaintenanceByLinode: {}, + }); + }); + + it('includes linodes with platform maintenance', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(2, { + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Your Linode needs a critical security update', + }); + const mockMaintenance = [ + ...mockPlatformMaintenance, + accountMaintenanceFactory.build({ + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Unrelated maintenance item', + }), + ]; + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockMaintenance, + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { result } = renderHook(() => usePlatformMaintenance()); + + expect(result.current).toEqual({ + accountHasPlatformMaintenance: true, + linodesWithPlatformMaintenance: new Set( + mockPlatformMaintenance.map((m) => m.entity.id) + ), + platformMaintenanceByLinode: Object.fromEntries( + mockPlatformMaintenance.map((m) => [m.entity.id, [m]]) + ), + }); + }); +}); diff --git a/packages/manager/src/hooks/usePlatformMaintenance.ts b/packages/manager/src/hooks/usePlatformMaintenance.ts new file mode 100644 index 00000000000..adb69f8faef --- /dev/null +++ b/packages/manager/src/hooks/usePlatformMaintenance.ts @@ -0,0 +1,81 @@ +import { + useAllAccountMaintenanceQuery, + useNotificationsQuery, +} from '@linode/queries'; + +import { + PENDING_MAINTENANCE_FILTER, + PLATFORM_MAINTENANCE_REASON_MATCH, + PLATFORM_MAINTENANCE_TYPE, +} from 'src/features/Account/Maintenance/utilities'; + +import type { AccountMaintenance, Linode } from '@linode/api-v4'; + +interface UsePlatformMaintenanceResult { + accountHasPlatformMaintenance: boolean; + linodesWithPlatformMaintenance: Set; + platformMaintenanceByLinode: Record; +} + +/** + * Determines whether an account has platform maintenance, which + * is system-wide maintenance requiring a reboot, and returns + * associated maintenance items. + */ +export const usePlatformMaintenance = (): UsePlatformMaintenanceResult => { + const { data: accountNotifications } = useNotificationsQuery(); + + const accountHasPlatformMaintenance = + accountNotifications?.find( + (notification) => notification.type === PLATFORM_MAINTENANCE_TYPE + ) !== undefined; + + const { data: accountMaintenanceData } = useAllAccountMaintenanceQuery( + {}, + PENDING_MAINTENANCE_FILTER, + accountHasPlatformMaintenance + ); + + const platformMaintenanceItems = accountMaintenanceData?.filter( + isPlatformMaintenance + ); + + return getPlatformMaintenanceResult( + accountHasPlatformMaintenance, + platformMaintenanceItems + ); +}; + +export const isPlatformMaintenance = ( + maintenance: AccountMaintenance +): boolean => + maintenance.type === 'reboot' && + maintenance.entity.type === 'linode' && + PLATFORM_MAINTENANCE_REASON_MATCH.some((match) => + maintenance.reason.includes(match) + ); + +const getPlatformMaintenanceResult = ( + accountHasPlatformMaintenance: boolean, + maintenanceItems: AccountMaintenance[] = [] +): UsePlatformMaintenanceResult => { + const platformMaintenanceByLinode: Record< + Linode['id'], + AccountMaintenance[] + > = {}; + for (const maintenance of maintenanceItems) { + if (!(maintenance.entity.id in platformMaintenanceByLinode)) + platformMaintenanceByLinode[maintenance.entity.id] = []; + platformMaintenanceByLinode[maintenance.entity.id].push(maintenance); + } + + const linodesWithPlatformMaintenance = new Set( + Object.keys(platformMaintenanceByLinode).map(Number) + ); + + return { + accountHasPlatformMaintenance, + platformMaintenanceByLinode, + linodesWithPlatformMaintenance, + }; +}; diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index f32238e29da..a94eb33ec81 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -1,30 +1,25 @@ import { queryClientFactory } from '@linode/queries'; -import { getRoot } from '@linode/utilities'; import CssBaseline from '@mui/material/CssBaseline'; import { QueryClientProvider } from '@tanstack/react-query'; -import * as React from 'react'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; import { Provider as ReduxStoreProvider } from 'react-redux'; import { Route, BrowserRouter as Router, Switch } from 'react-router-dom'; import { CookieWarning } from 'src/components/CookieWarning'; import { Snackbar } from 'src/components/Snackbar/Snackbar'; -import { SplashScreen } from 'src/components/SplashScreen'; import 'src/exceptionReporting'; +import { SplashScreen } from 'src/components/SplashScreen'; import Logout from 'src/layouts/Logout'; import { setupInterceptors } from 'src/request'; import { storeFactory } from 'src/store'; import { App } from './App'; import NullComponent from './components/NullComponent'; -import { loadDevTools, shouldLoadDevTools } from './dev-tools/load'; import './index.css'; +import { ENABLE_DEV_TOOLS } from './constants'; import { LinodeThemeWrapper } from './LinodeThemeWrapper'; -const queryClient = queryClientFactory('longLived'); -const store = storeFactory(); - -setupInterceptors(store); - const Lish = React.lazy(() => import('src/features/Lish')); const CancelLanding = React.lazy(() => @@ -38,6 +33,11 @@ const LoginAsCustomerCallback = React.lazy( ); const OAuthCallbackPage = React.lazy(() => import('src/layouts/OAuth')); +const queryClient = queryClientFactory('longLived'); +const store = storeFactory(); + +setupInterceptors(store); + const Main = () => { if (!navigator.cookieEnabled) { return ; @@ -89,15 +89,23 @@ const Main = () => { }; async function loadApp() { - if (shouldLoadDevTools) { - await loadDevTools(store, queryClient); + if (ENABLE_DEV_TOOLS) { + const devTools = await import('./dev-tools/load'); + await devTools.loadDevTools(); + + const { DevTools } = await import('./dev-tools/DevTools'); + + const devToolsRootContainer = document.createElement('div'); + devToolsRootContainer.id = 'dev-tools-root'; + document.body.appendChild(devToolsRootContainer); + + const root = createRoot(devToolsRootContainer); + + root.render(); } const container = document.getElementById('root'); - if (container) { - const root = getRoot(container); - root.render(
); - } + createRoot(container!).render(
); } loadApp(); diff --git a/packages/manager/src/initSentry.ts b/packages/manager/src/initSentry.ts index a322e596fdc..bc76feccdef 100644 --- a/packages/manager/src/initSentry.ts +++ b/packages/manager/src/initSentry.ts @@ -5,7 +5,8 @@ import { APP_ROOT, SENTRY_URL } from 'src/constants'; import packageJson from '../package.json'; -import type { BrowserOptions, Event as SentryEvent } from '@sentry/react'; +import type { APIError } from '@linode/api-v4'; +import type { ErrorEvent as SentryErrorEvent } from '@sentry/react'; export const initSentry = () => { const environment = getSentryEnvironment(); @@ -19,7 +20,6 @@ export const initSentry = () => { 'linode.com', 'localhost:3000', ], - autoSessionTracking: false, beforeSend, denyUrls: [ // New Relic script @@ -90,7 +90,7 @@ export const initSentry = () => { } }; -const beforeSend: BrowserOptions['beforeSend'] = (sentryEvent, hint) => { +const beforeSend = (sentryEvent: SentryErrorEvent): null | SentryErrorEvent => { const normalizedErrorMessage = normalizeErrorMessage(sentryEvent.message); if ( @@ -138,7 +138,10 @@ export const errorsToIgnore: RegExp[] = [ // actually be something like a (Linode) API Error instead of a string. We need // to ensure we're dealing with strings so we can determine if we should ignore // the error, or appropriately report the message to Sentry (i.e. not ""). -export const normalizeErrorMessage = (sentryErrorMessage: any): string => { +type ErrorMessage = (() => void) | [APIError] | object | string | undefined; +export const normalizeErrorMessage = ( + sentryErrorMessage: ErrorMessage +): string => { if (typeof sentryErrorMessage === 'string') { return sentryErrorMessage; } @@ -158,7 +161,9 @@ export const normalizeErrorMessage = (sentryErrorMessage: any): string => { return JSON.stringify(sentryErrorMessage); }; -const maybeAddCustomFingerprint = (event: SentryEvent): SentryEvent => { +const maybeAddCustomFingerprint = ( + event: SentryErrorEvent +): SentryErrorEvent => { const fingerprint = Object.keys(customFingerPrintMap).reduce((acc, value) => { /** if our sentry error matches one of the keys in the map */ const exception = event.exception; diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index 739e98f38dd..b61db63d9b9 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -45,6 +45,7 @@ export const emptyStore: MockState = { supportTickets: [], volumes: [], vpcs: [], + vpcsIps: [], }; /** diff --git a/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts b/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts index c1a7d847b49..1d9f0d29fa4 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts @@ -388,6 +388,10 @@ export const getKubernetesVersions = () => [ APIErrorResponse | APIPaginatedResponse > => { const versions = kubernetesVersionFactory.buildList(3); + + // Send the data in this explicit order to match the API. + request.headers.set('X-Filter', JSON.stringify({ '+order': 'desc' })); + return makePaginatedResponse({ data: versions, request, @@ -402,6 +406,9 @@ export const getKubernetesVersions = () => [ }): StrictResponse< APIErrorResponse | APIPaginatedResponse > => { + // Send the data in this explicit order to match the API. + request.headers.set('X-Filter', JSON.stringify({ '+order': 'desc' })); + const versions = kubernetesStandardTierVersionFactory.buildList(3); return makePaginatedResponse({ data: versions, @@ -417,7 +424,23 @@ export const getKubernetesVersions = () => [ }): StrictResponse< APIErrorResponse | APIPaginatedResponse > => { - const versions = kubernetesEnterpriseTierVersionFactory.buildList(3); + const kubeVersion1 = kubernetesEnterpriseTierVersionFactory.build({ + id: 'v1.31.8+lke1', + }); + const kubeVersion2 = kubernetesEnterpriseTierVersionFactory.build({ + id: 'v1.31.6+lke3', + }); + const kubeVersion3 = kubernetesEnterpriseTierVersionFactory.build({ + id: 'v1.31.6+lke2', + }); + const kubeVersion4 = kubernetesEnterpriseTierVersionFactory.build({ + id: 'v1.31.1+lke4', + }); + const versions = [kubeVersion1, kubeVersion2, kubeVersion3, kubeVersion4]; + + // Send the data in this explicit order to match the API. + request.headers.set('X-Filter', JSON.stringify({ '+order': 'desc' })); + return makePaginatedResponse({ data: versions, request, diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts index 32e030401a6..7019b31785a 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts @@ -231,10 +231,15 @@ const addFirewallDevice = async (inputs: { export const createLinode = (mockState: MockState) => [ http.post('*/v4/linode/instances', async ({ request }) => { const payload = await request.clone().json(); + const payloadCopy = { ...payload }; + + // Ensure linode object does not have `interfaces` property + delete payloadCopy['interfaces']; + const linode = linodeFactory.build({ created: DateTime.now().toISO(), status: 'provisioning', - ...payload, + ...payloadCopy, }); if (!linode.label) { @@ -275,6 +280,9 @@ export const createLinode = (mockState: MockState) => [ if (subnetFromDB && vpc) { const vpcInterface = linodeInterfaceFactoryVPC.build({ ...vpcIfacePayload, + default_route: { + ipv4: true, + }, created: DateTime.now().toISO(), updated: DateTime.now().toISO(), }); diff --git a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts index 95366a9c727..7bbcd0ce3b8 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts @@ -169,7 +169,7 @@ export const getQuotas = () => [ '*/v4*/:service/quotas/:id', async ({ params }): Promise> => { const quota = mockQuotas[params.service as QuotaType].find( - ({ quota_id }) => quota_id === +params.id + ({ quota_id }) => quota_id === params.id ); if (!quota) { @@ -187,7 +187,7 @@ export const getQuotas = () => [ }): Promise> => { const service = params.service as QuotaType; const quota = mockQuotas[service].find( - ({ quota_id }) => quota_id === +params.id + ({ quota_id }) => quota_id === params.id ); if (!quota) { diff --git a/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts b/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts index 0e07faa63c0..e5ba8e5baa5 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts @@ -134,6 +134,13 @@ export const createVPC = (mockState: MockState) => [ mockState ); + const vpcIp = vpcIPFactory.build({ + vpc_id: createdVPC.id, + }); + + // add entry for VPC IP + mswDB.add('vpcsIps', vpcIp, mockState); + // so that we can assign subnets to the correct VPC for (const subnet of vpcSubnets) { createSubnetPromises.push( @@ -229,6 +236,20 @@ export const deleteVPC = (mockState: MockState) => [ return makeNotFoundResponse(); } + const vpcsIPs = await mswDB.getAll('vpcsIps'); + const deleteVPCsIPsPromises = []; + + if (vpcsIPs) { + const vpcsIPsWithMatchingVPCId = vpcsIPs?.filter( + (vpcIP) => vpcIP.vpc_id === id + ); + for (const vpcIP of vpcsIPsWithMatchingVPCId) { + deleteVPCsIPsPromises.push( + mswDB.delete('vpcsIps', vpcIP.vpc_id, mockState) + ); + } + } + const deleteSubnetPromises = []; for (const subnet of vpc.subnets) { @@ -238,6 +259,7 @@ export const deleteVPC = (mockState: MockState) => [ } await Promise.all(deleteSubnetPromises); + await Promise.all(deleteVPCsIPsPromises); await mswDB.delete('vpcs', id, mockState); queueEvents({ @@ -415,9 +437,6 @@ export const deleteSubnet = (mockState: MockState) => [ ), ]; -// TODO: integrate with DB if needed -const vpcIPs = vpcIPFactory.buildList(10); - export const getVPCIPs = () => [ http.get( '*/v4beta/vpcs/ips', @@ -426,22 +445,35 @@ export const getVPCIPs = () => [ }): Promise< StrictResponse> > => { + const vpcsIPs = await mswDB.getAll('vpcsIps'); + + if (!vpcsIPs) { + return makeNotFoundResponse(); + } + return makePaginatedResponse({ - data: vpcIPs, + data: vpcsIPs, request, }); } ), http.get( - '*/v4beta/:vpcId/ips', + '*/v4beta/vpcs/:vpcId/ips', async ({ params, request, }): Promise< StrictResponse> > => { - const specificVPCIPs = vpcIPs.filter((ip) => ip.vpc_id === +params.vpcId); + const vpcsIPs = await mswDB.getAll('vpcsIps'); + const specificVPCIPs = vpcsIPs?.filter( + (ip) => ip.vpc_id === +params.vpcId + ); + + if (!specificVPCIPs) { + return makeNotFoundResponse(); + } return makePaginatedResponse({ data: specificVPCIPs, diff --git a/packages/manager/src/mocks/presets/extra/account/customEvents.ts b/packages/manager/src/mocks/presets/extra/account/customEvents.ts new file mode 100644 index 00000000000..f3b17c31c51 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/customEvents.ts @@ -0,0 +1,28 @@ +import { http, HttpResponse } from 'msw'; + +import { makeResourcePage } from 'src/mocks/serverHandlers'; + +import type { Event } from '@linode/api-v4'; +import type { MockPresetExtra } from 'src/mocks/types'; + +let customEventsData: Event[] | null = null; + +export const setCustomEventsData = (data: Event[] | null) => { + customEventsData = data; +}; + +const mockCustomEvents = () => { + return [ + http.get('*/account/events', () => { + return HttpResponse.json(makeResourcePage(customEventsData ?? [])); + }), + ]; +}; + +export const customEventsPreset: MockPresetExtra = { + desc: 'Custom Events', + group: { id: 'Events', type: 'events' }, + handlers: [mockCustomEvents], + id: 'events:custom', + label: 'Custom Events', +}; diff --git a/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts b/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts new file mode 100644 index 00000000000..96f57cc4339 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts @@ -0,0 +1,67 @@ +import { http, HttpResponse } from 'msw'; + +import { makeResourcePage } from 'src/mocks/serverHandlers'; + +import type { AccountMaintenance } from '@linode/api-v4'; +import type { MockPresetExtra } from 'src/mocks/types'; + +let customMaintenanceData: AccountMaintenance[] | null = null; + +export const setCustomMaintenanceData = (data: AccountMaintenance[] | null) => { + customMaintenanceData = data; +}; + +const mockCustomMaintenance = () => { + return [ + http.get('*/account/maintenance', ({ request }) => { + const url = new URL(request.url); + + const page = Number(url.searchParams.get('page') || 1); + const pageSize = Number(url.searchParams.get('page_size') || 25); + const headers = JSON.parse(request.headers.get('x-filter') || '{}'); + + const accountMaintenance = + customMaintenanceData?.filter((maintenance) => + JSON.stringify(headers.status).includes(maintenance.status) + ) ?? []; + + if (request.headers.get('x-filter')) { + accountMaintenance.sort((a, b) => { + const statusA = a[headers['+order_by'] as keyof AccountMaintenance]; + const statusB = b[headers['+order_by'] as keyof AccountMaintenance]; + + if (statusA < statusB) { + return -1; + } + if (statusA > statusB) { + return 1; + } + return 0; + }); + + if (headers['+order'] === 'desc') { + accountMaintenance.reverse(); + } + return HttpResponse.json({ + data: accountMaintenance.slice( + (page - 1) * pageSize, + (page - 1) * pageSize + pageSize + ), + page, + pages: Math.ceil(accountMaintenance.length / pageSize), + results: accountMaintenance.length, + }); + } + + return HttpResponse.json(makeResourcePage(accountMaintenance)); + }), + ]; +}; + +export const customMaintenancePreset: MockPresetExtra = { + desc: 'Custom Maintenance', + group: { id: 'Maintenance', type: 'maintenance' }, + handlers: [mockCustomMaintenance], + id: 'maintenance:custom', + label: 'Custom Maintenance', +}; diff --git a/packages/manager/src/mocks/presets/extra/account/customNotifications.ts b/packages/manager/src/mocks/presets/extra/account/customNotifications.ts new file mode 100644 index 00000000000..2d614da381c --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/customNotifications.ts @@ -0,0 +1,29 @@ +import { http } from 'msw'; + +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { Notification } from '@linode/api-v4'; +import type { MockPresetExtra } from 'src/mocks/types'; + +let customNotificationData: Notification[] | null = null; + +export const setCustomNotificationsData = (data: Notification[] | null) => { + customNotificationData = data; +}; + +const mockCustomNotifications = () => { + return [ + http.get('*/v4*/account/notifications', async () => { + return makeResponse(makeResourcePage(customNotificationData ?? [])); + }), + ]; +}; + +export const customNotificationsPreset: MockPresetExtra = { + desc: 'Custom Notifications', + group: { id: 'Notifications', type: 'notifications' }, + handlers: [mockCustomNotifications], + id: 'notifications:custom', + label: 'Custom Notifications', +}; diff --git a/packages/manager/src/mocks/presets/index.ts b/packages/manager/src/mocks/presets/index.ts index 538b739a05c..174ae7e87f8 100644 --- a/packages/manager/src/mocks/presets/index.ts +++ b/packages/manager/src/mocks/presets/index.ts @@ -6,6 +6,9 @@ import { baselineCrudPreset } from './baseline/crud'; import { baselineLegacyPreset } from './baseline/legacy'; import { baselineNoMocksPreset } from './baseline/noMocks'; import { customAccountPreset } from './extra/account/customAccount'; +import { customEventsPreset } from './extra/account/customEvents'; +import { customMaintenancePreset } from './extra/account/customMaintenance'; +import { customNotificationsPreset } from './extra/account/customNotifications'; import { customProfilePreset } from './extra/account/customProfile'; import { managedDisabledPreset } from './extra/account/managedDisabled'; import { managedEnabledPreset } from './extra/account/managedEnabled'; @@ -41,6 +44,9 @@ export const extraMockPresets: MockPresetExtra[] = [ apiResponseTimePreset, customAccountPreset, customProfilePreset, + customEventsPreset, + customMaintenancePreset, + customNotificationsPreset, linodeLimitsPreset, lkeLimitsPreset, managedEnabledPreset, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 63a893ccf14..41a715dedfc 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1401,6 +1401,7 @@ export const handlers = [ 'active', 'creating', 'migrating', + 'key_rotating', 'offline', 'resizing', ]; diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 4f355e52162..ad74a5324c1 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -22,6 +22,7 @@ import type { SupportTicket, Volume, VPC, + VPCIP, } from '@linode/api-v4'; import type { HttpHandler } from 'msw'; @@ -59,23 +60,41 @@ export interface MockPresetBaseline extends MockPresetBase { * Mock Preset Extra */ export type MockPresetExtraGroup = { - id: - | 'Account' - | 'API' - | 'Capabilities' - | 'Limits' - | 'Managed' - | 'Profile' - | 'Regions'; - type: 'account' | 'checkbox' | 'profile' | 'select'; + id: MockPresetExtraGroupId; + type: MockPresetExtraGroupType; }; + +export type MockPresetExtraGroupId = + | 'Account' + | 'API' + | 'Capabilities' + | 'Events' + | 'Limits' + | 'Maintenance' + | 'Managed' + | 'Notifications' + | 'Profile' + | 'Regions'; + +export type MockPresetExtraGroupType = + | 'account' + | 'checkbox' + | 'events' + | 'maintenance' + | 'notifications' + | 'profile' + | 'select'; + export type MockPresetExtraId = | 'account:custom' | 'account:managed-disabled' | 'account:managed-enabled' | 'api:response-time' + | 'events:custom' | 'limits:linode-limits' | 'limits:lke-limits' + | 'maintenance:custom' + | 'notifications:custom' | 'profile:custom' | 'regions:core-and-distributed' | 'regions:core-only' @@ -151,6 +170,7 @@ export interface MockState { supportTickets: SupportTicket[]; volumes: Volume[]; vpcs: VPC[]; + vpcsIps: VPCIP[]; } export interface MockSeeder extends Omit { diff --git a/packages/manager/src/queries/cloudpulse/services.ts b/packages/manager/src/queries/cloudpulse/services.ts index 59d5678f00f..3f5dd0b9514 100644 --- a/packages/manager/src/queries/cloudpulse/services.ts +++ b/packages/manager/src/queries/cloudpulse/services.ts @@ -11,7 +11,7 @@ import type { ResourcePage, ServiceTypesList, } from '@linode/api-v4'; -import type { Params } from '@sentry/react/types/types'; +import type { Params } from '@linode/api-v4'; export const useGetCloudPulseMetricDefinitionsByServiceType = ( serviceType: string | undefined, diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index 70ada487811..2def543c6cb 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -1,216 +1,7 @@ -import { - cloneDomain, - createDomain, - deleteDomain, - getDomain, - getDomainRecords, - getDomains, - importZone, - updateDomain, -} from '@linode/api-v4'; -import { profileQueries } from '@linode/queries'; -import { getAll } from '@linode/utilities'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { - keepPreviousData, - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; +import { domainQueries } from '@linode/queries'; -import type { - APIError, - CloneDomainPayload, - CreateDomainPayload, - Domain, - DomainRecord, - Filter, - ImportZonePayload, - Params, - ResourcePage, - UpdateDomainPayload, -} from '@linode/api-v4'; import type { EventHandlerData } from '@linode/queries'; -export const getAllDomains = () => - getAll((params) => getDomains(params))().then((data) => data.data); - -const getAllDomainRecords = (domainId: number) => - getAll((params) => getDomainRecords(domainId, params))().then( - ({ data }) => data - ); - -const domainQueries = createQueryKeys('domains', { - domain: (id: number) => ({ - contextQueries: { - records: { - queryFn: () => getAllDomainRecords(id), - queryKey: null, - }, - }, - queryFn: () => getDomain(id), - queryKey: [id], - }), - domains: { - contextQueries: { - all: { - queryFn: getAllDomains, - queryKey: null, - }, - infinite: (filter: Filter) => ({ - queryFn: ({ pageParam }) => - getDomains({ page: pageParam as number }, filter), - queryKey: [filter], - }), - paginated: (params: Params = {}, filter: Filter = {}) => ({ - queryFn: () => getDomains(params, filter), - queryKey: [params, filter], - }), - }, - queryKey: null, - }, -}); - -export const useDomainsQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>({ - ...domainQueries.domains._ctx.paginated(params, filter), - placeholderData: keepPreviousData, - }); - -export const useAllDomainsQuery = (enabled: boolean = false) => - useQuery({ - ...domainQueries.domains._ctx.all, - enabled, - }); - -export const useDomainsInfiniteQuery = (filter: Filter, enabled: boolean) => { - return useInfiniteQuery, APIError[]>({ - ...domainQueries.domains._ctx.infinite(filter), - enabled, - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - initialPageParam: 1, - retry: false, - }); -}; - -export const useDomainQuery = (id: number, enabled: boolean = true) => - useQuery({ - ...domainQueries.domain(id), - enabled, - }); - -export const useDomainRecordsQuery = (id: number) => - useQuery(domainQueries.domain(id)._ctx.records); - -export const useCreateDomainMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createDomain, - onSuccess(domain) { - // Invalidate paginated lists - queryClient.invalidateQueries({ - queryKey: domainQueries.domains.queryKey, - }); - - // Set Domain in cache - queryClient.setQueryData( - domainQueries.domain(domain.id).queryKey, - domain - ); - - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries({ - queryKey: profileQueries.grants.queryKey, - }); - }, - }); -}; - -export const useCloneDomainMutation = (id: number) => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data) => cloneDomain(id, data), - onSuccess(domain) { - // Invalidate paginated lists - queryClient.invalidateQueries({ - queryKey: domainQueries.domains.queryKey, - }); - - // Set Domain in cache - queryClient.setQueryData( - domainQueries.domain(domain.id).queryKey, - domain - ); - }, - }); -}; - -export const useImportZoneMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: importZone, - onSuccess(domain) { - // Invalidate paginated lists - queryClient.invalidateQueries({ - queryKey: domainQueries.domains.queryKey, - }); - - // Set Domain in cache - queryClient.setQueryData( - domainQueries.domain(domain.id).queryKey, - domain - ); - }, - }); -}; - -export const useDeleteDomainMutation = (id: number) => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>({ - mutationFn: () => deleteDomain(id), - onSuccess() { - // Invalidate paginated lists - queryClient.invalidateQueries({ - queryKey: domainQueries.domains.queryKey, - }); - - // Remove domain (and its sub-queries) from the cache - queryClient.removeQueries({ - queryKey: domainQueries.domain(id).queryKey, - }); - }, - }); -}; - -interface UpdateDomainPayloadWithId extends UpdateDomainPayload { - id: number; -} - -export const useUpdateDomainMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, ...data }) => updateDomain(id, data), - onSuccess(domain) { - // Invalidate paginated lists - queryClient.invalidateQueries({ - queryKey: domainQueries.domains.queryKey, - }); - - // Update domain in cache - queryClient.setQueryData( - domainQueries.domain(domain.id).queryKey, - domain - ); - }, - }); -}; - export const domainEventsHandler = ({ event, invalidateQueries, diff --git a/packages/manager/src/queries/iam/iam.ts b/packages/manager/src/queries/iam/iam.ts index ed6d4536949..ccb7dd863a7 100644 --- a/packages/manager/src/queries/iam/iam.ts +++ b/packages/manager/src/queries/iam/iam.ts @@ -30,7 +30,7 @@ export const useAccountUserPermissionsMutation = (username: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data) => updateUserPermissions(username, data), - onSuccess(role) { + onSuccess: (role) => { queryClient.setQueryData( iamQueries.user(username)._ctx.permissions.queryKey, role diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index fd0c71d44ca..312e596c91a 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -506,10 +506,14 @@ export const useAllKubernetesNodePoolQuery = ( }); }; -export const useKubernetesDashboardQuery = (clusterId: number) => { - return useQuery( - kubernetesQueries.cluster(clusterId)._ctx.dashboard - ); +export const useKubernetesDashboardQuery = ( + clusterId: number, + enabled: boolean = true +) => { + return useQuery({ + ...kubernetesQueries.cluster(clusterId)._ctx.dashboard, + enabled, + }); }; export const useKubernetesVersionQuery = () => diff --git a/packages/manager/src/queries/linodes/events.ts b/packages/manager/src/queries/linodes/events.ts index 8682bf56e14..cca2c1432d4 100644 --- a/packages/manager/src/queries/linodes/events.ts +++ b/packages/manager/src/queries/linodes/events.ts @@ -152,6 +152,20 @@ export const diskEventHandler = ({ }); }; +export const interfaceEventHandler = ({ + event, + invalidateQueries, +}: EventHandlerData) => { + // For Interface events, the `entity` is the Interface and the `secondary_entity` is the Linode. + + if (event.secondary_entity) { + invalidateQueries({ + queryKey: linodeQueries.linode(event.secondary_entity.id)._ctx.interfaces + .queryKey, + }); + } +}; + /** * shouldRequestNotifications * diff --git a/packages/manager/src/routes/account/index.ts b/packages/manager/src/routes/account/index.ts index 591dce6f2af..2eb5b812bed 100644 --- a/packages/manager/src/routes/account/index.ts +++ b/packages/manager/src/routes/account/index.ts @@ -141,15 +141,6 @@ const accountEntityTransfersCreateRoute = createRoute({ ).then((m) => m.entityTransfersCreateLazyRoute) ); -const accountActivationLandingRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'account-activation', -}).lazy(() => - import('src/components/AccountActivation/AccountActivationLanding').then( - (m) => m.accountActivationLandingLazyRoute - ) -); - export const accountRouteTree = accountRoute.addChildren([ accountIndexRoute, accountUsersRoute, @@ -161,7 +152,6 @@ export const accountRouteTree = accountRoute.addChildren([ accountUsersUsernameProfileRoute, accountUsersUsernamePermissionsRoute, ]), - accountActivationLandingRoute, accountBillingRoute, accountBillingMakePaymentRoute, accountBillingPaymentMethodsRoute, diff --git a/packages/manager/src/routes/datastream/DataStreamRoute.tsx b/packages/manager/src/routes/datastream/DataStreamRoute.tsx new file mode 100644 index 00000000000..8e2c14ef033 --- /dev/null +++ b/packages/manager/src/routes/datastream/DataStreamRoute.tsx @@ -0,0 +1,16 @@ +import { NotFound } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useFlags } from 'src/hooks/useFlags'; + +export const DataStreamRoute = () => { + const flags = useFlags(); + const { aclpLogs } = flags; + return ( + }> + {aclpLogs?.enabled ? : } + + ); +}; diff --git a/packages/manager/src/routes/datastream/dataStreamLazyRoutes.ts b/packages/manager/src/routes/datastream/dataStreamLazyRoutes.ts new file mode 100644 index 00000000000..d6041e32c0c --- /dev/null +++ b/packages/manager/src/routes/datastream/dataStreamLazyRoutes.ts @@ -0,0 +1,14 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { DataStreamLanding } from 'src/features/DataStream/DataStreamLanding'; +import { StreamCreate } from 'src/features/DataStream/Streams/StreamCreate/StreamCreate'; + +export const dataStreamLandingLazyRoute = createLazyRoute('/datastream')({ + component: DataStreamLanding, +}); + +export const streamCreateLazyRoute = createLazyRoute( + '/datastream/streams/create' +)({ + component: StreamCreate, +}); diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts new file mode 100644 index 00000000000..e9b29011a67 --- /dev/null +++ b/packages/manager/src/routes/datastream/index.ts @@ -0,0 +1,47 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { DataStreamRoute } from './DataStreamRoute'; + +export const dataStreamRoute = createRoute({ + component: DataStreamRoute, + getParentRoute: () => rootRoute, + path: 'datastream', +}); + +const dataStreamLandingRoute = createRoute({ + beforeLoad: () => { + throw redirect({ to: '/datastream/streams' }); + }, + getParentRoute: () => dataStreamRoute, + path: '/', +}).lazy(() => + import('./dataStreamLazyRoutes').then((m) => m.dataStreamLandingLazyRoute) +); + +const streamsRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + path: 'streams', +}).lazy(() => + import('./dataStreamLazyRoutes').then((m) => m.dataStreamLandingLazyRoute) +); + +const streamsCreateRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + path: 'streams/create', +}).lazy(() => + import('./dataStreamLazyRoutes').then((m) => m.streamCreateLazyRoute) +); + +const destinationsRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + path: 'destinations', +}).lazy(() => + import('./dataStreamLazyRoutes').then((m) => m.dataStreamLandingLazyRoute) +); + +export const dataStreamRouteTree = dataStreamRoute.addChildren([ + dataStreamLandingRoute, + streamsRoute.addChildren([streamsCreateRoute]), + destinationsRoute, +]); diff --git a/packages/manager/src/routes/images/index.ts b/packages/manager/src/routes/images/index.ts index 0f0dc49154a..735622127ea 100644 --- a/packages/manager/src/routes/images/index.ts +++ b/packages/manager/src/routes/images/index.ts @@ -38,6 +38,7 @@ const imagesRoute = createRoute({ component: ImagesRoute, getParentRoute: () => rootRoute, path: 'images', + validateSearch: (search: ImagesSearchParams) => search, }); const imagesIndexRoute = createRoute({ diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index f8ec870898e..9c848b64230 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -9,6 +9,7 @@ import { accountRouteTree } from './account'; import { cloudPulseAlertsRouteTree } from './alerts'; import { betaRouteTree } from './betas'; import { databasesRouteTree } from './databases'; +import { dataStreamRouteTree } from './datastream'; import { domainsRouteTree } from './domains'; import { eventsRouteTree } from './events'; import { firewallsRouteTree } from './firewalls'; @@ -46,6 +47,7 @@ export const routeTree = rootRoute.addChildren([ cloudPulseAlertsRouteTree, cloudPulseMetricsRouteTree, databasesRouteTree, + dataStreamRouteTree, domainsRouteTree, eventsRouteTree, firewallsRouteTree, @@ -90,6 +92,7 @@ declare module '@tanstack/react-router' { export const migrationRouteTree = migrationRootRoute.addChildren([ betaRouteTree, domainsRouteTree, + dataStreamRouteTree, firewallsRouteTree, imagesRouteTree, longviewRouteTree, @@ -97,7 +100,9 @@ export const migrationRouteTree = migrationRootRoute.addChildren([ nodeBalancersRouteTree, objectStorageRouteTree, placementGroupsRouteTree, + searchRouteTree, stackScriptsRouteTree, + supportRouteTree, volumesRouteTree, vpcsRouteTree, ]); diff --git a/packages/manager/src/routes/longview/index.ts b/packages/manager/src/routes/longview/index.ts index 48f3a61668e..58de680dd02 100644 --- a/packages/manager/src/routes/longview/index.ts +++ b/packages/manager/src/routes/longview/index.ts @@ -3,11 +3,6 @@ import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { LongviewRoute } from './LongviewRoute'; -export type LongviewState = { - open?: boolean; - title?: string; -}; - const longviewRoute = createRoute({ component: LongviewRoute, getParentRoute: () => rootRoute, diff --git a/packages/manager/src/routes/search/index.ts b/packages/manager/src/routes/search/index.ts index 5e2119ab625..949ee8b007e 100644 --- a/packages/manager/src/routes/search/index.ts +++ b/packages/manager/src/routes/search/index.ts @@ -3,19 +3,22 @@ import { createRoute } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { SearchRoute } from './SearchRoute'; +type SearchSearchParams = { + query: string; +}; + const searchRoute = createRoute({ component: SearchRoute, getParentRoute: () => rootRoute, path: 'search', + validateSearch: (search: SearchSearchParams) => search, }); const searchLandingRoute = createRoute({ getParentRoute: () => searchRoute, path: '/', }).lazy(() => - import('src/features/Search/SearchLanding').then( - (m) => m.searchLandingLazyRoute - ) + import('./searchLazyRoutes').then((m) => m.searchLandingLazyRoute) ); export const searchRouteTree = searchRoute.addChildren([searchLandingRoute]); diff --git a/packages/manager/src/routes/search/searchLazyRoutes.ts b/packages/manager/src/routes/search/searchLazyRoutes.ts new file mode 100644 index 00000000000..98e89c9024d --- /dev/null +++ b/packages/manager/src/routes/search/searchLazyRoutes.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { SearchLanding } from 'src/features/Search/SearchLanding'; + +export const searchLandingLazyRoute = createLazyRoute('/search')({ + component: SearchLanding, +}); diff --git a/packages/manager/src/routes/support/index.ts b/packages/manager/src/routes/support/index.ts index b82ce14bbd7..d34d48cf96d 100644 --- a/packages/manager/src/routes/support/index.ts +++ b/packages/manager/src/routes/support/index.ts @@ -1,10 +1,22 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; +import { SupportSearchLandingWrapper } from './supportLazyRoutes'; import { SupportTicketsRoute } from './SupportRoute'; +import type { AttachmentError } from 'src/features/Support/SupportTicketDetail/SupportTicketDetail'; +import type { SupportTicketFormFields } from 'src/features/Support/SupportTickets/SupportTicketDialog'; + +interface SupportSearchParams { + dialogOpen?: boolean; +} + +export interface SupportState { + attachmentErrors?: AttachmentError[]; + supportTicketFormFields?: SupportTicketFormFields; +} + const supportRoute = createRoute({ - // TODO: TanStackRouter - got to handle the MainContent.tsx `globalErrors.account_unactivated` logic. component: SupportTicketsRoute, getParentRoute: () => rootRoute, path: 'support', @@ -17,13 +29,38 @@ const supportLandingRoute = createRoute({ import('src/features/Help/HelpLanding').then((m) => m.helpLandingLazyRoute) ); -const supportTicketsRoute = createRoute({ +const supportTicketsLandingRoute = createRoute({ getParentRoute: () => supportRoute, path: 'tickets', + validateSearch: (search: SupportSearchParams) => search, +}).lazy(() => + import('./supportLazyRoutes').then((m) => m.supportTicketsLandingLazyRoute) +); + +const supportTicketsNewRoute = createRoute({ + beforeLoad: async () => { + throw redirect({ to: '/support/tickets', search: { dialogOpen: true } }); + }, + getParentRoute: () => supportTicketsLandingRoute, + path: 'new', }).lazy(() => - import('src/features/Support/SupportTickets/SupportTicketsLanding').then( - (m) => m.supportTicketsLandingLazyRoute - ) + import('./supportLazyRoutes').then((m) => m.supportTicketsLandingLazyRoute) +); + +const supportTicketsLandingRouteOpen = createRoute({ + getParentRoute: () => supportTicketsLandingRoute, + path: 'open', + validateSearch: (search: SupportSearchParams) => search, +}).lazy(() => + import('./supportLazyRoutes').then((m) => m.supportTicketsLandingLazyRoute) +); + +const supportTicketsLandingRouteClosed = createRoute({ + getParentRoute: () => supportTicketsLandingRoute, + path: 'closed', + validateSearch: (search: SupportSearchParams) => search, +}).lazy(() => + import('./supportLazyRoutes').then((m) => m.supportTicketsLandingLazyRoute) ); const supportTicketDetailRoute = createRoute({ @@ -33,22 +70,36 @@ const supportTicketDetailRoute = createRoute({ }), path: 'tickets/$ticketId', }).lazy(() => - import('src/features/Support/SupportTicketDetail/SupportTicketDetail').then( - (m) => m.supportTicketDetailLazyRoute - ) + import('./supportLazyRoutes').then((m) => m.supportTicketDetailLazyRoute) ); const supportSearchLandingRoute = createRoute({ + component: SupportSearchLandingWrapper, getParentRoute: () => supportRoute, path: 'search', +}); + +export const accountActivationLandingRoute = createRoute({ + beforeLoad: async ({ context }) => { + if (!context.globalErrors?.account_unactivated) { + throw redirect({ to: '/' }); + } + return true; + }, + getParentRoute: () => rootRoute, + path: 'account-activation', }).lazy(() => - import('src/features/Help/SupportSearchLanding/SupportSearchLanding').then( - (m) => m.supportSearchLandingLazyRoute - ) + import('./supportLazyRoutes').then((m) => m.accountActivationLandingLazyRoute) ); export const supportRouteTree = supportRoute.addChildren([ supportLandingRoute, - supportTicketsRoute.addChildren([supportTicketDetailRoute]), + supportTicketsLandingRoute.addChildren([ + supportTicketsNewRoute, + supportTicketsLandingRouteOpen, + supportTicketsLandingRouteClosed, + supportTicketDetailRoute, + ]), supportSearchLandingRoute, + accountActivationLandingRoute, ]); diff --git a/packages/manager/src/routes/support/supportLazyRoutes.tsx b/packages/manager/src/routes/support/supportLazyRoutes.tsx new file mode 100644 index 00000000000..b91edb33c90 --- /dev/null +++ b/packages/manager/src/routes/support/supportLazyRoutes.tsx @@ -0,0 +1,31 @@ +import { createLazyRoute } from '@tanstack/react-router'; +import * as React from 'react'; + +import { AccountActivationLanding } from 'src/components/AccountActivation/AccountActivationLanding'; +import SupportSearchLanding from 'src/features/Help/SupportSearchLanding/SupportSearchLanding'; +import { SupportTicketDetail } from 'src/features/Support/SupportTicketDetail/SupportTicketDetail'; +import { SupportTicketsLanding } from 'src/features/Support/SupportTickets/SupportTicketsLanding'; + +import type { AlgoliaState as AlgoliaProps } from 'src/features/Help/SearchHOC'; + +export const SupportSearchLandingWrapper = (props: AlgoliaProps) => { + return ; +}; + +export const supportTicketsLandingLazyRoute = createLazyRoute( + '/support/tickets' +)({ + component: SupportTicketsLanding, +}); + +export const supportTicketDetailLazyRoute = createLazyRoute( + '/support/tickets/$ticketId' +)({ + component: SupportTicketDetail, +}); + +export const accountActivationLandingLazyRoute = createLazyRoute( + '/account-activation' +)({ + component: AccountActivationLanding, +}); diff --git a/packages/manager/src/utilities/analytics/utils.test.ts b/packages/manager/src/utilities/analytics/utils.test.ts index d2ec1e9b138..b606c03d238 100644 --- a/packages/manager/src/utilities/analytics/utils.test.ts +++ b/packages/manager/src/utilities/analytics/utils.test.ts @@ -1,85 +1,11 @@ import { generateTimeOfDay } from './customEventAnalytics'; import { - checkOptanonConsent, - getCookie, getFormattedStringFromFormEventOptions, - ONE_TRUST_COOKIE_CATEGORIES, waitForAdobeAnalyticsToBeLoaded, } from './utils'; import type { FormEventOptions } from './types'; -describe('getCookie', () => { - beforeAll(() => { - const mockCookies = - 'mycookie=my-cookie-value; OptanonConsent=cookie-consent-here; mythirdcookie=my-third-cookie;'; - vi.spyOn(document, 'cookie', 'get').mockReturnValue(mockCookies); - }); - - it('should return the value of a cookie from document.cookie given its name, given cookie in middle position', () => { - expect(getCookie('OptanonConsent')).toEqual('cookie-consent-here'); - }); - - it('should return the value of a cookie from document.cookie given its name, given cookie in first position', () => { - expect(getCookie('mycookie')).toEqual('my-cookie-value'); - }); - - it('should return the value of a cookie from document.cookie given its name, given cookie in last position', () => { - expect(getCookie('mythirdcookie')).toEqual('my-third-cookie'); - }); - - it('should return undefined if the cookie does not exist in document.cookie', () => { - expect(getCookie('mysecondcookie')).toEqual(undefined); - }); -}); - -describe('checkOptanonConsent', () => { - it('should return true if consent is enabled for the given Optanon cookie category', () => { - const mockPerformanceCookieConsentEnabled = - 'somestuffhere&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A1%2CC0005%3A1&intType=6'; - - expect( - checkOptanonConsent( - mockPerformanceCookieConsentEnabled, - ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies'] - ) - ).toEqual(true); - }); - - it('should return false if consent is disabled for the given Optanon cookie category', () => { - const mockPerformanceCookieConsentDisabled = - 'somestuffhere&groups=C0001%3A1%2CC0002%3A0%2CC0003%3A1%2CC0004%3A1%2CC0005%3A1&intType=6'; - - expect( - checkOptanonConsent( - mockPerformanceCookieConsentDisabled, - ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies'] - ) - ).toEqual(false); - }); - - it('should return false if the consent category does not exist in the cookie', () => { - const mockNoPerformanceCookieCategory = - 'somestuffhere&groups=C0001%3A1%2CC0003%3A1%2CC0004%3A1%2CC0005%3A1&intType=6'; - - expect( - checkOptanonConsent( - mockNoPerformanceCookieCategory, - ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies'] - ) - ).toEqual(false); - }); - - it('should return false if the cookie is undefined', () => { - expect( - checkOptanonConsent( - undefined, - ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies'] - ) - ).toEqual(false); - }); -}); - describe('generateTimeOfDay', () => { it('should generate human-readable time of day', () => { expect(generateTimeOfDay(0)).toBe('Early Morning'); diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index 14a9ee8dd41..2f6b66f92a3 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -11,60 +11,6 @@ import type { FormStepEvent, } from './types'; -/** - * Based on Login's OneTrust cookie list - */ -export const ONE_TRUST_COOKIE_CATEGORIES = { - 'Functional Cookies': 'C0003', - 'Performance Cookies': 'C0002', // Analytics cookies fall into this category - 'Social Media Cookies': 'C0004', - 'Strictly Necessary Cookies': 'C0001', - 'Targeting Cookies': 'C0005', -} as const; - -/** - * Given the name of a cookie, parses the document.cookie string and returns the cookie's value. - * @param name cookie's name - * @returns value of cookie if it exists in the document; else, undefined - */ -export const getCookie = (name: string) => { - const cookies = document.cookie.split(';'); - - const selectedCookie = cookies.find( - (cookie) => cookie.trim().startsWith(name + '=') // Trim whitespace so position in cookie string doesn't matter - ); - - return selectedCookie?.trim().substring(name.length + 1); -}; - -/** - * This function parses the categories in the OptanonConsent cookie to check if consent is provided. - * @param optanonCookie the OptanonConsent cookie from OneTrust - * @param selectedCategory the category code based on cookie type - * @returns true if the user has consented to cookie enablement for the category; else, false - */ -export const checkOptanonConsent = ( - optanonCookie: string | undefined, - selectedCategory: (typeof ONE_TRUST_COOKIE_CATEGORIES)[keyof typeof ONE_TRUST_COOKIE_CATEGORIES] -): boolean => { - const optanonGroups = optanonCookie?.match(/groups=([^&]*)/); - - if (!optanonCookie || !optanonGroups) { - return false; - } - - // Optanon consent groups will be of the form: "C000[N]:[0/1]". - const unencodedOptanonGroups = decodeURIComponent(optanonGroups[1]).split( - ',' - ); - return unencodedOptanonGroups.some((consentGroup) => { - if (consentGroup.includes(selectedCategory)) { - return Number(consentGroup.split(':')[1]) === 1; // Cookie enabled - } - return false; - }); -}; - /** * Sends a direct call rule events to Adobe for a Component Click (and optionally, with `data`, Component Details). * This should be used for all custom events other than form events, which should use sendFormEvent. diff --git a/packages/manager/src/utilities/logic-query-parser.d.ts b/packages/manager/src/utilities/logic-query-parser.d.ts deleted file mode 100644 index f9db6bb57f1..00000000000 --- a/packages/manager/src/utilities/logic-query-parser.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @todo: add types - -declare module 'logic-query-parser'; diff --git a/packages/manager/src/utilities/search-string.d.ts b/packages/manager/src/utilities/search-string.d.ts deleted file mode 100644 index 9312500ad2d..00000000000 --- a/packages/manager/src/utilities/search-string.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @todo: add types - -declare module 'search-string'; diff --git a/packages/manager/src/utilities/storage.ts b/packages/manager/src/utilities/storage.ts index c93a41782fd..798fc97b2aa 100644 --- a/packages/manager/src/utilities/storage.ts +++ b/packages/manager/src/utilities/storage.ts @@ -1,4 +1,4 @@ -import { shouldLoadDevTools } from 'src/dev-tools/load'; +import { ENABLE_DEV_TOOLS } from 'src/constants'; import type { RegionSite } from '@linode/api-v4'; import type { StackScriptPayload } from '@linode/api-v4/lib/stackscripts/types'; @@ -244,7 +244,7 @@ export const { export const getEnvLocalStorageOverrides = () => { // This is broken into two logical branches so that local storage is accessed // ONLY if the dev tools are enabled and it's a development build. - if (shouldLoadDevTools && import.meta.env.DEV) { + if (ENABLE_DEV_TOOLS && import.meta.env.DEV) { const localStorageOverrides = storage.devToolsEnv.get(); if (localStorageOverrides) { return localStorageOverrides; diff --git a/packages/manager/tsconfig.json b/packages/manager/tsconfig.json index 57e2d52cdbd..2796b3ef2ed 100644 --- a/packages/manager/tsconfig.json +++ b/packages/manager/tsconfig.json @@ -23,7 +23,6 @@ /* Interop Constraints */ "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, /* Type Checking */ "allowUnreachableCode": false, diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index a59105aebfc..076a3deb0fd 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2025-06-03] - v0.6.0 + +### Added: + +- Create `domains/` directory and migrate relevant query keys and hooks ([#12204](https://github.com/linode/manager/pull/12204)) +- Create `images/` directory and migrate relevant query keys and hooks ([#12205](https://github.com/linode/manager/pull/12205)) +- `quotas/` directory and migrated relevant query keys and hook ([#12221](https://github.com/linode/manager/pull/12221)) + +### Removed: + +- `isUsingBetaEndpoint` parameter from `useNodeBalancerQuery` ([#12217](https://github.com/linode/manager/pull/12217)) + ## [2025-05-20] - v0.5.0 ### Added: diff --git a/packages/queries/package.json b/packages/queries/package.json index a396c566990..1e31c46ab8f 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.5.0", + "version": "0.6.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", @@ -36,6 +36,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@linode/tsconfig": "workspace:*", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", diff --git a/packages/queries/src/domains/domains.ts b/packages/queries/src/domains/domains.ts new file mode 100644 index 00000000000..7fe91bb6939 --- /dev/null +++ b/packages/queries/src/domains/domains.ts @@ -0,0 +1,169 @@ +import { + cloneDomain, + createDomain, + deleteDomain, + importZone, + updateDomain, +} from '@linode/api-v4'; +import { profileQueries } from '@linode/queries'; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { domainQueries } from './keys'; + +import type { + APIError, + CloneDomainPayload, + CreateDomainPayload, + Domain, + DomainRecord, + Filter, + ImportZonePayload, + Params, + ResourcePage, + UpdateDomainPayload, +} from '@linode/api-v4'; + +export const useDomainsQuery = (params: Params, filter: Filter) => + useQuery, APIError[]>({ + ...domainQueries.domains._ctx.paginated(params, filter), + placeholderData: keepPreviousData, + }); + +export const useAllDomainsQuery = (enabled: boolean = false) => + useQuery({ + ...domainQueries.domains._ctx.all, + enabled, + }); + +export const useDomainsInfiniteQuery = (filter: Filter, enabled: boolean) => { + return useInfiniteQuery, APIError[]>({ + ...domainQueries.domains._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + +export const useDomainQuery = (id: number, enabled: boolean = true) => + useQuery({ + ...domainQueries.domain(id), + enabled, + }); + +export const useDomainRecordsQuery = (id: number) => + useQuery(domainQueries.domain(id)._ctx.records); + +export const useCreateDomainMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDomain, + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain, + ); + + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); + }, + }); +}; + +export const useCloneDomainMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => cloneDomain(id, data), + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain, + ); + }, + }); +}; + +export const useImportZoneMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: importZone, + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain, + ); + }, + }); +}; + +export const useDeleteDomainMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteDomain(id), + onSuccess() { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Remove domain (and its sub-queries) from the cache + queryClient.removeQueries({ + queryKey: domainQueries.domain(id).queryKey, + }); + }, + }); +}; + +interface UpdateDomainPayloadWithId extends UpdateDomainPayload { + id: number; +} + +export const useUpdateDomainMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateDomain(id, data), + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Update domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain, + ); + }, + }); +}; diff --git a/packages/queries/src/domains/index.ts b/packages/queries/src/domains/index.ts new file mode 100644 index 00000000000..a192c177929 --- /dev/null +++ b/packages/queries/src/domains/index.ts @@ -0,0 +1,3 @@ +export * from './domains'; +export * from './keys'; +export * from './requests'; diff --git a/packages/queries/src/domains/keys.ts b/packages/queries/src/domains/keys.ts new file mode 100644 index 00000000000..690c1c77b93 --- /dev/null +++ b/packages/queries/src/domains/keys.ts @@ -0,0 +1,37 @@ +import { getDomain, getDomains } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { getAllDomainRecords, getAllDomains } from './requests'; + +import type { Filter, Params } from '@linode/api-v4'; + +export const domainQueries = createQueryKeys('domains', { + domain: (id: number) => ({ + contextQueries: { + records: { + queryFn: () => getAllDomainRecords(id), + queryKey: null, + }, + }, + queryFn: () => getDomain(id), + queryKey: [id], + }), + domains: { + contextQueries: { + all: { + queryFn: getAllDomains, + queryKey: null, + }, + infinite: (filter: Filter) => ({ + queryFn: ({ pageParam }) => + getDomains({ page: pageParam as number }, filter), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getDomains(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); diff --git a/packages/queries/src/domains/requests.ts b/packages/queries/src/domains/requests.ts new file mode 100644 index 00000000000..12dcc32a40e --- /dev/null +++ b/packages/queries/src/domains/requests.ts @@ -0,0 +1,12 @@ +import { getDomainRecords, getDomains } from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { Domain, DomainRecord } from '@linode/api-v4'; + +export const getAllDomains = () => + getAll((params) => getDomains(params))().then((data) => data.data); + +export const getAllDomainRecords = (domainId: number) => + getAll((params) => getDomainRecords(domainId, params))().then( + ({ data }) => data, + ); diff --git a/packages/manager/src/queries/images.ts b/packages/queries/src/images/images.ts similarity index 97% rename from packages/manager/src/queries/images.ts rename to packages/queries/src/images/images.ts index 19611911847..0664ebc5d3c 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/queries/src/images/images.ts @@ -34,10 +34,10 @@ import type { UseQueryOptions } from '@tanstack/react-query'; export const getAllImages = ( passedParams: Params = {}, - passedFilter: Filter = {} + passedFilter: Filter = {}, ) => getAll((params, filter) => - getImages({ ...params, ...passedParams }, { ...filter, ...passedFilter }) + getImages({ ...params, ...passedParams }, { ...filter, ...passedFilter }), )().then((data) => data.data); export const imageQueries = createQueryKeys('images', { @@ -63,7 +63,7 @@ export const imageQueries = createQueryKeys('images', { export const useImagesQuery = ( params: Params, filters: Filter, - options?: Partial, APIError[]>> + options?: Partial, APIError[]>>, ) => useQuery, APIError[]>({ ...imageQueries.paginated(params, filters), @@ -102,7 +102,7 @@ export const useCreateImageMutation = () => { }); queryClient.setQueryData( imageQueries.image(image.id).queryKey, - image + image, ); // If a restricted user creates an entity, we must make sure grants are up to date. queryClient.invalidateQueries({ @@ -127,7 +127,7 @@ export const useUpdateImageMutation = () => { }); queryClient.setQueryData( imageQueries.image(image.id).queryKey, - image + image, ); }, }); @@ -151,7 +151,7 @@ export const useDeleteImageMutation = () => { export const useAllImagesQuery = ( params: Params = {}, filters: Filter = {}, - enabled = true + enabled = true, ) => useQuery({ ...imageQueries.all(params, filters), @@ -171,7 +171,7 @@ export const useUploadImageMutation = () => { }); queryClient.setQueryData( imageQueries.image(data.image.id).queryKey, - data.image + data.image, ); }, }); @@ -190,7 +190,7 @@ export const useUpdateImageRegionsMutation = (imageId: string) => { }); queryClient.setQueryData( imageQueries.image(image.id).queryKey, - image + image, ); }, }); diff --git a/packages/queries/src/images/index.ts b/packages/queries/src/images/index.ts new file mode 100644 index 00000000000..67e4bd303d9 --- /dev/null +++ b/packages/queries/src/images/index.ts @@ -0,0 +1 @@ +export * from './images'; diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index 7bf48ab2284..40b3a4f4cef 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -1,12 +1,15 @@ export * from './account'; export * from './base'; +export * from './domains'; export * from './eventHandlers'; export * from './firewalls'; +export * from './images'; export * from './linodes'; export * from './networking'; export * from './nodebalancers'; export * from './placementGroups'; export * from './profile'; +export * from './quotas'; export * from './regions'; export * from './stackscripts'; export * from './support'; diff --git a/packages/queries/src/nodebalancers/index.ts b/packages/queries/src/nodebalancers/index.ts index 34c40d2833b..23f912e384e 100644 --- a/packages/queries/src/nodebalancers/index.ts +++ b/packages/queries/src/nodebalancers/index.ts @@ -1 +1,2 @@ +export * from './keys'; export * from './nodebalancers'; diff --git a/packages/queries/src/nodebalancers/keys.ts b/packages/queries/src/nodebalancers/keys.ts new file mode 100644 index 00000000000..dec7cc85ec0 --- /dev/null +++ b/packages/queries/src/nodebalancers/keys.ts @@ -0,0 +1,66 @@ +import { + getNodeBalancer, + getNodeBalancerFirewalls, + getNodeBalancers, + getNodeBalancerStats, + getNodeBalancerVPCConfigsBeta, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { + getAllNodeBalancerConfigs, + getAllNodeBalancers, + getAllNodeBalancerTypes, +} from './requests'; + +import type { Filter, Params } from '@linode/api-v4'; + +export const nodebalancerQueries = createQueryKeys('nodebalancers', { + nodebalancer: (id: number) => ({ + contextQueries: { + configurations: { + queryFn: () => getAllNodeBalancerConfigs(id), + queryKey: null, + }, + firewalls: { + queryFn: () => getNodeBalancerFirewalls(id), + queryKey: null, + }, + stats: { + queryFn: () => getNodeBalancerStats(id), + queryKey: null, + }, + vpcsBeta: { + queryFn: () => getNodeBalancerVPCConfigsBeta(id), + queryKey: null, + }, + }, + queryFn: () => getNodeBalancer(id), + queryKey: [id], + }), + nodebalancers: { + contextQueries: { + all: { + queryFn: getAllNodeBalancers, + queryKey: null, + }, + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getNodeBalancers( + { page: pageParam as number, page_size: 25 }, + filter, + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getNodeBalancers(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + types: { + queryFn: getAllNodeBalancerTypes, + queryKey: null, + }, +}); diff --git a/packages/queries/src/nodebalancers/nodebalancers.ts b/packages/queries/src/nodebalancers/nodebalancers.ts index dc361ca2477..3baf9bb8b3f 100644 --- a/packages/queries/src/nodebalancers/nodebalancers.ts +++ b/packages/queries/src/nodebalancers/nodebalancers.ts @@ -1,20 +1,12 @@ import { createNodeBalancer, + createNodeBalancerBeta, createNodeBalancerConfig, deleteNodeBalancer, deleteNodeBalancerConfig, - getNodeBalancer, - getNodeBalancerBeta, - getNodeBalancerConfigs, - getNodeBalancerFirewalls, - getNodeBalancers, - getNodeBalancerStats, - getNodeBalancerTypes, updateNodeBalancer, updateNodeBalancerConfig, } from '@linode/api-v4'; -import { getAll } from '@linode/utilities'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, useInfiniteQuery, @@ -26,6 +18,8 @@ import { import { queryPresets } from '../base'; import { firewallQueries } from '../firewalls'; import { profileQueries } from '../profile'; +import { vpcQueries } from '../vpcs'; +import { nodebalancerQueries } from './keys'; import type { EventHandlerData } from '../eventHandlers'; import type { @@ -37,77 +31,12 @@ import type { NodeBalancer, NodeBalancerConfig, NodeBalancerStats, + NodeBalancerVpcConfig, Params, PriceType, ResourcePage, } from '@linode/api-v4'; -const getAllNodeBalancerTypes = () => - getAll((params) => getNodeBalancerTypes(params))().then( - (results) => results.data, - ); - -export const getAllNodeBalancerConfigs = (id: number) => - getAll((params) => - getNodeBalancerConfigs(id, params), - )().then((data) => data.data); - -export const getAllNodeBalancers = () => - getAll((params) => getNodeBalancers(params))().then( - (data) => data.data, - ); - -export const nodebalancerQueries = createQueryKeys('nodebalancers', { - nodebalancer: (id: number) => ({ - contextQueries: { - configurations: { - queryFn: () => getAllNodeBalancerConfigs(id), - queryKey: null, - }, - firewalls: { - queryFn: () => getNodeBalancerFirewalls(id), - queryKey: null, - }, - nodebalancer: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: () => - isUsingBetaEndpoint ? getNodeBalancerBeta(id) : getNodeBalancer(id), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), - stats: { - queryFn: () => getNodeBalancerStats(id), - queryKey: null, - }, - }, - queryFn: () => getNodeBalancer(id), - queryKey: [id], - }), - nodebalancers: { - contextQueries: { - all: { - queryFn: getAllNodeBalancers, - queryKey: null, - }, - infinite: (filter: Filter = {}) => ({ - queryFn: ({ pageParam }) => - getNodeBalancers( - { page: pageParam as number, page_size: 25 }, - filter, - ), - queryKey: [filter], - }), - paginated: (params: Params = {}, filter: Filter = {}) => ({ - queryFn: () => getNodeBalancers(params, filter), - queryKey: [params, filter], - }), - }, - queryKey: null, - }, - types: { - queryFn: getAllNodeBalancerTypes, - queryKey: null, - }, -}); - export const useNodeBalancerStatsQuery = (id: number) => { return useQuery({ ...nodebalancerQueries.nodebalancer(id)._ctx.stats, @@ -122,15 +51,9 @@ export const useNodeBalancersQuery = (params: Params, filter: Filter) => placeholderData: keepPreviousData, }); -export const useNodeBalancerQuery = ( - id: number, - enabled = true, - isUsingBetaEndpoint = false, -) => { +export const useNodeBalancerQuery = (id: number, enabled = true) => { return useQuery({ - ...nodebalancerQueries - .nodebalancer(id) - ._ctx.nodebalancer(isUsingBetaEndpoint), + ...nodebalancerQueries.nodebalancer(id), enabled, }); }; @@ -206,6 +129,52 @@ export const useNodebalancerCreateMutation = () => { }); }; +/** + * duplicated function of useNodebalancerCreateMutation + */ + +export const useNodebalancerCreateBetaMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createNodeBalancerBeta, + onSuccess(nodebalancer, variables) { + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + // Prime the cache for this specific NodeBalancer + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancer.id).queryKey, + nodebalancer, + ); + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); + + // If a NodeBalancer is assigned to a firewall upon creation, make sure we invalidate that firewall + // so it reflects the new entity. + if (variables.firewall_id) { + // Invalidate the paginated list of firewalls because GET /v4/networking/firewalls returns all firewall entities + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Invalidate the affected firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(variables.firewall_id).queryKey, + }); + } + // If a Nodebalancer is created with a VPC, invalidate the related VPC queries + // so it reflects the new entity. + if (variables.vpcs?.length) { + // Invalidating all vpc related queries since we don't have the specific vpc_id + queryClient.invalidateQueries({ queryKey: vpcQueries._def }); + } + }, + }); +}; + export const useNodebalancerConfigCreateMutation = (id: number) => { const queryClient = useQueryClient(); return useMutation({ @@ -361,3 +330,12 @@ export const nodebalancerEventHandler = ({ }); } }; + +export const useNodeBalancerVPCConfigsBetaQuery = ( + nodebalancerId: number, + enabled = false, +) => + useQuery, APIError[]>({ + ...nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.vpcsBeta, + enabled, + }); diff --git a/packages/queries/src/nodebalancers/requests.ts b/packages/queries/src/nodebalancers/requests.ts new file mode 100644 index 00000000000..177bf740aca --- /dev/null +++ b/packages/queries/src/nodebalancers/requests.ts @@ -0,0 +1,27 @@ +import { + getNodeBalancerConfigs, + getNodeBalancers, + getNodeBalancerTypes, +} from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { + NodeBalancer, + NodeBalancerConfig, + PriceType, +} from '@linode/api-v4'; + +export const getAllNodeBalancerTypes = () => + getAll((params) => getNodeBalancerTypes(params))().then( + (results) => results.data, + ); + +export const getAllNodeBalancerConfigs = (id: number) => + getAll((params) => + getNodeBalancerConfigs(id, params), + )().then((data) => data.data); + +export const getAllNodeBalancers = () => + getAll((params) => getNodeBalancers(params))().then( + (data) => data.data, + ); diff --git a/packages/queries/src/quotas/index.ts b/packages/queries/src/quotas/index.ts new file mode 100644 index 00000000000..ba0826b96ed --- /dev/null +++ b/packages/queries/src/quotas/index.ts @@ -0,0 +1,3 @@ +export * from './keys'; +export * from './quotas'; +export * from './requests'; diff --git a/packages/queries/src/quotas/keys.ts b/packages/queries/src/quotas/keys.ts new file mode 100644 index 00000000000..bc483878472 --- /dev/null +++ b/packages/queries/src/quotas/keys.ts @@ -0,0 +1,30 @@ +import { getQuota, getQuotas, getQuotaUsage } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { getAllQuotas } from './requests'; + +import type { Filter, Params, QuotaType } from '@linode/api-v4'; + +export const quotaQueries = createQueryKeys('quotas', { + service: (type: QuotaType) => ({ + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllQuotas(type, params, filter), + queryKey: [params, filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getQuotas(type, params, filter), + queryKey: [params, filter], + }), + quota: (id: number) => ({ + queryFn: () => getQuota(type, id), + queryKey: [id], + }), + usage: (id: string) => ({ + queryFn: () => getQuotaUsage(type, id), + queryKey: [id], + }), + }, + queryKey: [type], + }), +}); diff --git a/packages/manager/src/queries/quotas/quotas.ts b/packages/queries/src/quotas/quotas.ts similarity index 52% rename from packages/manager/src/queries/quotas/quotas.ts rename to packages/queries/src/quotas/quotas.ts index 1351dc36f19..abb92b6e947 100644 --- a/packages/manager/src/queries/quotas/quotas.ts +++ b/packages/queries/src/quotas/quotas.ts @@ -1,8 +1,6 @@ -import { getQuota, getQuotas, getQuotaUsage } from '@linode/api-v4'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { getAllQuotas } from './requests'; +import { quotaQueries } from './keys'; import type { APIError, @@ -14,30 +12,6 @@ import type { ResourcePage, } from '@linode/api-v4'; -export const quotaQueries = createQueryKeys('quotas', { - service: (type: QuotaType) => ({ - contextQueries: { - all: (params: Params = {}, filter: Filter = {}) => ({ - queryFn: () => getAllQuotas(type, params, filter), - queryKey: [params, filter], - }), - paginated: (params: Params = {}, filter: Filter = {}) => ({ - queryFn: () => getQuotas(type, params, filter), - queryKey: [params, filter], - }), - quota: (id: number) => ({ - queryFn: () => getQuota(type, id), - queryKey: [id], - }), - usage: (id: number) => ({ - queryFn: () => getQuotaUsage(type, id), - queryKey: [id], - }), - }, - queryKey: [type], - }), -}); - export const useQuotaQuery = (service: QuotaType, id: number, enabled = true) => useQuery({ ...quotaQueries.service(service)._ctx.quota(id), @@ -48,7 +22,7 @@ export const useQuotasQuery = ( service: QuotaType, params: Params = {}, filter: Filter, - enabled = true + enabled = true, ) => useQuery, APIError[]>({ ...quotaQueries.service(service)._ctx.paginated(params, filter), @@ -60,7 +34,7 @@ export const useAllQuotasQuery = ( service: QuotaType, params: Params = {}, filter: Filter, - enabled = true + enabled = true, ) => useQuery({ ...quotaQueries.service(service)._ctx.all(params, filter), @@ -69,8 +43,8 @@ export const useAllQuotasQuery = ( export const useQuotaUsageQuery = ( service: QuotaType, - id: number, - enabled = true + id: string, + enabled = true, ) => useQuery({ ...quotaQueries.service(service)._ctx.usage(id), diff --git a/packages/manager/src/queries/quotas/requests.ts b/packages/queries/src/quotas/requests.ts similarity index 83% rename from packages/manager/src/queries/quotas/requests.ts rename to packages/queries/src/quotas/requests.ts index 39db01c395f..ae9b029867e 100644 --- a/packages/manager/src/queries/quotas/requests.ts +++ b/packages/queries/src/quotas/requests.ts @@ -6,12 +6,12 @@ import type { Filter, Params, Quota, QuotaType } from '@linode/api-v4'; export const getAllQuotas = ( service: QuotaType, passedParams: Params = {}, - passedFilter: Filter = {} + passedFilter: Filter = {}, ) => getAll((params, filter) => getQuotas( service, { ...params, ...passedParams }, - { ...filter, ...passedFilter } - ) + { ...filter, ...passedFilter }, + ), )().then((data) => data.data); diff --git a/packages/queries/tsconfig.json b/packages/queries/tsconfig.json index f335a47531f..02145053904 100644 --- a/packages/queries/tsconfig.json +++ b/packages/queries/tsconfig.json @@ -1,19 +1,8 @@ { - "compilerOptions": { - "jsx": "react", - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "skipLibCheck": true, - "noEmit": true, - "allowUnreachableCode": false, - "noImplicitAny": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true, - "incremental": true - }, + "extends": [ + "@linode/tsconfig/package", + "@linode/tsconfig/react", + "@linode/tsconfig/non-strict" + ], "include": ["src"] } diff --git a/packages/search/package.json b/packages/search/package.json index 46b074abb63..60a62446bc7 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -9,12 +9,16 @@ "license": "Apache-2.0", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "typecheck": "tsc" }, "dependencies": { "peggy": "^4.0.3", "@linode/api-v4": "workspace:*" }, + "devDependencies": { + "@linode/tsconfig": "workspace:*" + }, "peerDependencies": { "vite": "^6.3.4" } diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json index a175d2d5de6..ae1c86a6567 100644 --- a/packages/search/tsconfig.json +++ b/packages/search/tsconfig.json @@ -1,14 +1,4 @@ { - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "skipLibCheck": true, - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true, - "incremental": true - }, + "extends": ["@linode/tsconfig/package"], "include": ["src"] } diff --git a/packages/shared/package.json b/packages/shared/package.json index 6dd969ce11c..3a94fb74f8b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -21,7 +21,6 @@ "typecheck": "tsc", "test": "vitest run", "test:watch": "vitest", - "test:debug": "node --inspect-brk scripts/test.js --runInBand", "precommit": "lint-staged" }, "lint-staged": { @@ -39,6 +38,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@linode/tsconfig": "workspace:*", "@storybook/addon-actions": "^8.6.7", "@storybook/react": "^8.6.7", "@testing-library/dom": "^10.1.0", diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 160560a852f..92b9f1a2b39 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,19 +1,10 @@ { + "extends": [ + "@linode/tsconfig/package", + "@linode/tsconfig/react", + "@linode/tsconfig/non-strict" + ], "compilerOptions": { - "jsx": "react", - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "skipLibCheck": true, - "noEmit": true, - "allowUnreachableCode": false, - "noImplicitAny": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true, - "incremental": true, "types": ["@testing-library/jest-dom"] }, "include": ["src"] diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json new file mode 100644 index 00000000000..c0157564d08 --- /dev/null +++ b/packages/tsconfig/package.json @@ -0,0 +1,12 @@ +{ + "name": "@linode/tsconfig", + "version": "0.0.0", + "description": "Shared TypeScript configuration for Linode projects", + "keywords": ["TypeScript", "tsconfig", "tsc", "Linode", "Akamai"], + "exports": { + "./package": "./tsconfig.package.json", + "./react": "./tsconfig.react.json", + "./non-strict": "./tsconfig.non-strict.json", + "./emit-types": "./tsconfig.emit-types.json" + } +} diff --git a/packages/tsconfig/tsconfig.emit-types.json b/packages/tsconfig/tsconfig.emit-types.json new file mode 100644 index 00000000000..4d438eaa544 --- /dev/null +++ b/packages/tsconfig/tsconfig.emit-types.json @@ -0,0 +1,15 @@ +{ + /** + * This tsconfig makes tsc emit type declaration files. + * + * Currently, only api-v4 and validation emit types. All other packages don't have a build step. + * + * Note: When using this config, specify `outDir` in the comsuming tsconfig. + */ + "compilerOptions": { + "noEmit": false, + "declarationMap": true, + "declaration": true, + "emitDeclarationOnly": true + } +} diff --git a/packages/tsconfig/tsconfig.non-strict.json b/packages/tsconfig/tsconfig.non-strict.json new file mode 100644 index 00000000000..34ed04ae3f4 --- /dev/null +++ b/packages/tsconfig/tsconfig.non-strict.json @@ -0,0 +1,17 @@ +{ + /** + * This tsconfig disables strict mode but enables some nice strict-like options. + * + * Note: We want to work towards making all of our packages use `strict: true`. + * The current blocker is related to type inference with our React Query query key factory. + */ + "compilerOptions": { + "strict": false, + "allowUnreachableCode": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "strictNullChecks": true, + } +} \ No newline at end of file diff --git a/packages/tsconfig/tsconfig.package.json b/packages/tsconfig/tsconfig.package.json new file mode 100644 index 00000000000..cd768475207 --- /dev/null +++ b/packages/tsconfig/tsconfig.package.json @@ -0,0 +1,19 @@ +{ + /** + * This tsconfig is our base config for packages. + */ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "incremental": true, + "noEmit": true + }, + "include": [ + "src" + ] +} diff --git a/packages/tsconfig/tsconfig.react.json b/packages/tsconfig/tsconfig.react.json new file mode 100644 index 00000000000..7b97ca65fbc --- /dev/null +++ b/packages/tsconfig/tsconfig.react.json @@ -0,0 +1,8 @@ +{ + /** + * This tsconfig should be used when a package needs React / JSX. + */ + "compilerOptions": { + "jsx": "react" + } +} \ No newline at end of file diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 14edca3263e..9ecd73c9959 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2025-06-03] - v0.13.0 + +### Changed: + +- Akamai Design System - Label component ([#12224](https://github.com/linode/manager/pull/12224)) + +### Fixed: + +- Input placeholder opacity ([#12208](https://github.com/linode/manager/pull/12208)) + ## [2025-05-20] - v0.12.0 ### Added: diff --git a/packages/ui/package.json b/packages/ui/package.json index 11abab3d0a3..c1534476d93 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.12.0", + "version": "0.13.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", @@ -19,9 +19,9 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@linode/design-language-system": "^4.0.0", - "@mui/icons-material": "^6.4.5", - "@mui/material": "^6.4.5", - "@mui/utils": "^6.4.3", + "@mui/icons-material": "^7.1.0", + "@mui/material": "^7.1.0", + "@mui/utils": "^7.1.0", "@mui/x-date-pickers": "^7.27.0", "luxon": "3.4.4", "react": "^18.2.0", @@ -34,7 +34,6 @@ "typecheck": "tsc", "test": "vitest run", "test:watch": "vitest", - "test:debug": "node --inspect-brk scripts/test.js --runInBand", "coverage": "vitest run --coverage && open coverage/index.html", "coverage:summary": "vitest run --coverage.enabled --reporter=junit --coverage.reporter=json-summary" }, @@ -45,6 +44,7 @@ ] }, "devDependencies": { + "@linode/tsconfig": "workspace:*", "@storybook/addon-actions": "^8.6.7", "@storybook/preview-api": "^8.6.7", "@storybook/react": "^8.6.7", diff --git a/packages/ui/src/components/Accordion/Accordion.tsx b/packages/ui/src/components/Accordion/Accordion.tsx index 8f91557de02..c6ae73b3549 100644 --- a/packages/ui/src/components/Accordion/Accordion.tsx +++ b/packages/ui/src/components/Accordion/Accordion.tsx @@ -1,7 +1,7 @@ import { default as _Accordion } from '@mui/material/Accordion'; import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index d58070a59a6..70f1ac85a06 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -1,6 +1,6 @@ import { CloseIcon } from '@linode/ui'; import _Drawer from '@mui/material/Drawer'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/ui/src/components/ErrorState/ErrorState.tsx b/packages/ui/src/components/ErrorState/ErrorState.tsx index 84a960663a8..a18f1730f39 100644 --- a/packages/ui/src/components/ErrorState/ErrorState.tsx +++ b/packages/ui/src/components/ErrorState/ErrorState.tsx @@ -1,5 +1,5 @@ import ErrorOutline from '@mui/icons-material/ErrorOutline'; -import Grid from '@mui/material/Grid2'; +import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; diff --git a/packages/ui/src/components/Paper/Paper.tsx b/packages/ui/src/components/Paper/Paper.tsx index caa909adadf..22efa21d8be 100644 --- a/packages/ui/src/components/Paper/Paper.tsx +++ b/packages/ui/src/components/Paper/Paper.tsx @@ -41,8 +41,8 @@ const StyledPaper = styled(_Paper, { shouldForwardProp: (prop) => prop !== 'error', })(({ theme, ...props }) => ({ borderColor: props.error ? theme.palette.error.dark : undefined, - padding: theme.spacing(3), - paddingTop: 17, + padding: theme.spacingFunction(24), + paddingTop: theme.spacingFunction(16), })); const StyledErrorText = styled(FormHelperText)(({ theme }) => ({ diff --git a/packages/ui/src/components/TextField/TextField.stories.tsx b/packages/ui/src/components/TextField/TextField.stories.tsx index eae1adca573..ae3a9f00ffa 100644 --- a/packages/ui/src/components/TextField/TextField.stories.tsx +++ b/packages/ui/src/components/TextField/TextField.stories.tsx @@ -98,6 +98,36 @@ export const WithTooltip: Story = { }, }; +export const WithTooltipIconLeft: Story = { + args: { + label: 'Label', + labelTooltipText: 'Tooltip Text', + noMarginTop: true, + placeholder: 'Placeholder', + labelTooltipIconPosition: 'left', + }, +}; + +export const WithTooltipSmall: Story = { + args: { + label: 'Label', + labelTooltipText: 'Tooltip Text', + noMarginTop: true, + placeholder: 'Placeholder', + labelTooltipIconSize: 'small', + }, +}; + +export const WithTooltipLarge: Story = { + args: { + label: 'Label', + labelTooltipText: 'Tooltip Text', + noMarginTop: true, + placeholder: 'Placeholder', + labelTooltipIconSize: 'large', + }, +}; + export const WithAdornment: Story = { args: { InputProps: { diff --git a/packages/ui/src/components/TextField/TextField.tsx b/packages/ui/src/components/TextField/TextField.tsx index c9ee4281a3a..46b8999bea5 100644 --- a/packages/ui/src/components/TextField/TextField.tsx +++ b/packages/ui/src/components/TextField/TextField.tsx @@ -103,6 +103,16 @@ interface BaseProps { type Value = null | number | string | undefined; interface LabelToolTipProps { + /** + * Position of the tooltip icon + * @default right + */ + labelTooltipIconPosition?: 'left' | 'right'; + /** + * Size of the tooltip icon + * @default small + */ + labelTooltipIconSize?: 'large' | 'small'; labelTooltipText?: JSX.Element | string; } @@ -147,6 +157,8 @@ export const TextField = (props: TextFieldProps) => { inputProps, label, labelTooltipText, + labelTooltipIconPosition = 'right', + labelTooltipIconSize = 'small', loading, max, min, @@ -170,6 +182,26 @@ export const TextField = (props: TextFieldProps) => { const [_value, setValue] = React.useState(value ?? ''); const theme = useTheme(); + const sxTooltipIconLeft = { + marginRight: `${theme.spacingFunction(4)}`, + padding: `${theme.spacingFunction(4)} ${theme.spacingFunction(4)} ${theme.spacingFunction(4)} ${theme.spacingFunction(2)}`, + '&& svg': { + fill: theme.tokens.component.Label.Icon, + stroke: theme.tokens.component.Label.Icon, + strokeWidth: 0, + ':hover': { + color: theme.tokens.alias.Content.Icon.Primary.Hover, + fill: theme.tokens.alias.Content.Icon.Primary.Hover, + stroke: theme.tokens.alias.Content.Icon.Primary.Hover, + }, + }, + }; + + const sxTooltipIconRight = { + marginLeft: `${theme.spacingFunction(4)}`, + padding: `${theme.spacingFunction(4)}`, + }; + const { errorScrollClassName, errorTextId, helperTextId, validInputId } = useFieldIds({ errorGroup, hasError: Boolean(errorText), inputId, label }); @@ -254,9 +286,7 @@ export const TextField = (props: TextFieldProps) => { return ( { ...(!noMarginTop && { marginTop: theme.spacing(2) }), }} > + {labelTooltipText && labelTooltipIconPosition === 'left' && ( + + )} @@ -293,13 +336,11 @@ export const TextField = (props: TextFieldProps) => { )} - {labelTooltipText && ( + {labelTooltipText && labelTooltipIconPosition === 'right' && ( diff --git a/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx b/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx index 211c9308c76..4c5ce121d2f 100644 --- a/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx +++ b/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx @@ -28,4 +28,22 @@ export const VariableWidth: Story = { render: (args) => , }; +export const SmallTooltipIcon: Story = { + args: { + status: 'help', + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + labelTooltipIconSize: 'small', + }, + render: (args) => , +}; + +export const LargeTooltipIcon: Story = { + args: { + status: 'help', + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + labelTooltipIconSize: 'large', + }, + render: (args) => , +}; + export default meta; diff --git a/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx b/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx index c757d379c65..23317c2d2c7 100644 --- a/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx +++ b/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx @@ -40,6 +40,11 @@ export interface TooltipIconProps * @todo this seems like a flaw... passing an icon should not require `status` to be `other` */ icon?: JSX.Element; + /** + * Size of the tooltip icon + * @default small + */ + labelTooltipIconSize?: 'large' | 'small'; /** * Enables a leaveDelay of 3000ms * @default false @@ -100,6 +105,7 @@ export const TooltipIcon = (props: TooltipIconProps) => { tooltipAnalyticsEvent, tooltipPosition, width, + labelTooltipIconSize, } = props; const handleOpenTooltip = () => { @@ -112,18 +118,17 @@ export const TooltipIcon = (props: TooltipIconProps) => { const sxRootStyle = { '&&': { - fill: theme.tokens.color.Neutrals[50], - stroke: theme.tokens.color.Neutrals[50], + fill: theme.tokens.component.Label.InfoIcon, + stroke: theme.tokens.component.Label.InfoIcon, strokeWidth: 0, }, '&:hover': { - color: theme.palette.primary.main, - fill: theme.palette.primary.main, - stroke: theme.palette.primary.main, + color: theme.tokens.alias.Content.Icon.Primary.Hover, + fill: theme.tokens.alias.Content.Icon.Primary.Hover, + stroke: theme.tokens.alias.Content.Icon.Primary.Hover, }, - color: theme.tokens.color.Neutrals[50], - height: 20, - width: 20, + height: labelTooltipIconSize === 'small' ? 16 : 20, + width: labelTooltipIconSize === 'small' ? 16 : 20, }; switch (status) { diff --git a/packages/ui/src/foundations/breakpoints.ts b/packages/ui/src/foundations/breakpoints.ts index 2c93d86802e..a8f267e1080 100644 --- a/packages/ui/src/foundations/breakpoints.ts +++ b/packages/ui/src/foundations/breakpoints.ts @@ -1,16 +1,11 @@ -import { createTheme } from '@mui/material'; +import { unstable_createBreakpoints } from '@mui/material'; -// This is a hack to create breakpoints outside of the theme itself. -// This will likely have to change at some point 😖 -export const breakpoints = createTheme({ - breakpoints: { - values: { - lg: 1280, - md: 960, - sm: 600, - xl: 1920, - xs: 0, - }, +export const breakpoints = unstable_createBreakpoints({ + values: { + lg: 1280, + md: 960, + sm: 600, + xl: 1920, + xs: 0, }, - name: 'light', -}).breakpoints; +}); diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index f9f95453dcf..38bce07501a 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -553,15 +553,15 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '&$disabled': { - color: Color.Neutrals[40], + color: Component.Label.Text, }, '&$error': { - color: Color.Neutrals[40], + color: Component.Label.Text, }, '&.Mui-focused': { - color: Color.Neutrals[40], + color: Component.Label.Text, }, - color: Color.Neutrals[40], + color: Component.Label.Text, }, }, }, diff --git a/packages/ui/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts index 82f52537661..77b86709092 100644 --- a/packages/ui/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -78,7 +78,7 @@ type ComponentTypes = MergeTypes; * This allows us to add custom fields to the theme. * Avoid doing this unless you have a good reason. */ -declare module '@mui/material/styles/createTheme' { +declare module '@mui/material/styles' { export interface Theme { addCircleHoverEffect?: any; animateCircleIcon?: any; diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index eed56ddad3f..e7b2e717c56 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -877,16 +877,16 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&$disabled': { - color: Color.Neutrals[70], + color: Component.Label.Text, opacity: 0.5, }, '&$error': { - color: Color.Neutrals[70], + color: Component.Label.Text, }, '&.Mui-focused': { - color: Color.Neutrals[70], + color: Component.Label.Text, }, - color: Color.Neutrals[70], + color: Component.Label.Text, font: Typography.Body.Bold, marginBottom: 8, }, @@ -958,6 +958,7 @@ export const lightTheme: ThemeOptions = { color: TextField.Placeholder.Text, font: Typography.Label.Regular.Placeholder, fontStyle: 'italic', + opacity: 1, }, '&:disabled, &.Mui-disabled': { cursor: 'not-allowed', diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index a9b006a91d5..a0428fa04e5 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,15 +1,7 @@ { - "compilerOptions": { - "jsx": "react", - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "skipLibCheck": true, - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true, - "incremental": true - }, + "extends": [ + "@linode/tsconfig/package", + "@linode/tsconfig/react", + ], "include": ["src"], } diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 58fa22f159d..df630a17e60 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -21,7 +21,6 @@ "typecheck": "tsc", "test": "vitest run", "test:watch": "vitest", - "test:debug": "node --inspect-brk scripts/test.js --runInBand", "coverage": "vitest run --coverage && open coverage/index.html", "coverage:summary": "vitest run --coverage.enabled --reporter=junit --coverage.reporter=json-summary" }, @@ -39,6 +38,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@linode/tsconfig": "workspace:*", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", diff --git a/packages/utilities/src/factories/grants.ts b/packages/utilities/src/factories/grants.ts index d364456861c..5021aa167ec 100644 --- a/packages/utilities/src/factories/grants.ts +++ b/packages/utilities/src/factories/grants.ts @@ -32,7 +32,6 @@ export const grantsFactory = Factory.Sync.makeFactory({ ], global: { account_access: 'read_write', - add_buckets: true, add_databases: true, add_domains: true, add_firewalls: true, diff --git a/packages/utilities/src/factories/linodeInterface.ts b/packages/utilities/src/factories/linodeInterface.ts index e1aef5c9cc0..220aedd19de 100644 --- a/packages/utilities/src/factories/linodeInterface.ts +++ b/packages/utilities/src/factories/linodeInterface.ts @@ -2,7 +2,11 @@ import { Factory } from './factoryProxy'; -import type { LinodeInterface, LinodeInterfaceSettings } from '@linode/api-v4'; +import type { + LinodeInterface, + LinodeInterfaceSettings, + UpgradeInterfaceData, +} from '@linode/api-v4'; export const linodeInterfaceSettingsFactory = Factory.Sync.makeFactory({ @@ -15,6 +19,13 @@ export const linodeInterfaceSettingsFactory = }, }); +export const upgradeLinodeInterfaceFactory = + Factory.Sync.makeFactory({ + config_id: Factory.each((i) => i), + dry_run: true, + interfaces: [], + }); + export const linodeInterfaceFactoryVlan = Factory.Sync.makeFactory({ created: '2025-03-19T03:58:04', diff --git a/packages/utilities/src/factories/nodebalancer.ts b/packages/utilities/src/factories/nodebalancer.ts index e794f723086..77c2fa122ae 100644 --- a/packages/utilities/src/factories/nodebalancer.ts +++ b/packages/utilities/src/factories/nodebalancer.ts @@ -6,7 +6,8 @@ import type { NodeBalancerConfig, NodeBalancerConfigNode, NodeBalancerStats, -} from '@linode/api-v4/lib/nodebalancers/types'; + NodeBalancerVpcConfig, +} from '@linode/api-v4'; export const nodeBalancerFactory = Factory.Sync.makeFactory({ client_conn_throttle: 0, @@ -24,6 +25,8 @@ export const nodeBalancerFactory = Factory.Sync.makeFactory({ total: 0, }, updated: '2019-12-13T00:00:00', + lke_cluster: null, + type: 'common', }); export const nodeBalancerConfigFactory = @@ -61,6 +64,17 @@ export const nodeBalancerConfigNodeFactory = nodebalancer_id: Factory.each((id) => id), status: 'DOWN', weight: 100, + vpc_config_id: null, + }); + +export const nodeBalancerConfigVPCFactory = + Factory.Sync.makeFactory({ + id: Factory.each((i) => i), + ipv4_range: Factory.each((i) => `192.168.${i}.0/30`), + ipv6_range: null, + nodebalancer_id: Factory.each((i) => 1000 + i), + subnet_id: Factory.each((i) => 2000 + i), + vpc_id: Factory.each((i) => 3000 + i), }); export const nodeBalancerStatsFactory = diff --git a/packages/utilities/src/helpers/formatStatus.test.ts b/packages/utilities/src/helpers/formatStatus.test.ts new file mode 100644 index 00000000000..3705c957351 --- /dev/null +++ b/packages/utilities/src/helpers/formatStatus.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getFormattedStatus } from './formatStatus'; + +describe('getFormattedStatus', () => { + it('should capitalize a single lowercase word', () => { + expect(getFormattedStatus('active')).toBe('Active'); + }); + + it('should replace underscore with space and capitalize each word', () => { + expect(getFormattedStatus('in_progress')).toBe('In Progress'); + }); + + it('should handle multiple underscores correctly', () => { + expect(getFormattedStatus('waiting_for_user_action')).toBe( + 'Waiting For User Action', + ); + }); + + it('should handle mixed case inputs', () => { + expect(getFormattedStatus('In_Progress')).toBe('In Progress'); + }); + + it('should handle empty string', () => { + expect(getFormattedStatus('')).toBe(''); + }); +}); diff --git a/packages/utilities/src/helpers/formatStatus.ts b/packages/utilities/src/helpers/formatStatus.ts new file mode 100644 index 00000000000..03f9dc17240 --- /dev/null +++ b/packages/utilities/src/helpers/formatStatus.ts @@ -0,0 +1,5 @@ +import { capitalizeAllWords } from './capitalize'; + +export const getFormattedStatus = (status: string): string => { + return capitalizeAllWords(status.replace(/_/g, ' ')); +}; diff --git a/packages/utilities/src/helpers/index.ts b/packages/utilities/src/helpers/index.ts index 104575d7396..b8c74575725 100644 --- a/packages/utilities/src/helpers/index.ts +++ b/packages/utilities/src/helpers/index.ts @@ -15,6 +15,7 @@ export * from './env'; export * from './escapeRegExp'; export * from './evenizeNumber'; export * from './formatDuration'; +export * from './formatStatus'; export * from './formatStorageUnits'; export * from './formatUptime'; export * from './getAll'; @@ -45,7 +46,6 @@ export * from './redactAccessToken'; export * from './reduceAsync'; export * from './regions'; export * from './replaceNewlinesWithLineBreaks'; -export * from './rootManager'; export * from './roundTo'; export * from './scrollErrorIntoView'; export * from './scrollErrorIntoViewV2'; diff --git a/packages/utilities/src/helpers/random.ts b/packages/utilities/src/helpers/random.ts index 67e8aafa653..c780a7d4bca 100644 --- a/packages/utilities/src/helpers/random.ts +++ b/packages/utilities/src/helpers/random.ts @@ -1,3 +1,5 @@ +import { DateTime } from 'luxon'; + /** * Picks a random element from an array * @param items { T[] } an array of any kind @@ -18,5 +20,7 @@ export const randomDate = ( start: Date = new Date(), end: Date = new Date(2021, 10, 25), ) => - // eslint-disable-next-line sonarjs/pseudo-random - new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); + DateTime.fromMillis( + // eslint-disable-next-line sonarjs/pseudo-random + start.getTime() + Math.random() * (end.getTime() - start.getTime()), + ); diff --git a/packages/utilities/src/helpers/rootManager.test.ts b/packages/utilities/src/helpers/rootManager.test.ts deleted file mode 100644 index 4cb1fc6bf06..00000000000 --- a/packages/utilities/src/helpers/rootManager.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { getRoot, rootInstances } from './rootManager'; - -vi.mock('react-dom/client', () => ({ - createRoot: vi.fn().mockImplementation((container) => ({ - _internalRoot: container, // Mock implementation detail - render: vi.fn(), - })), -})); - -describe('getRoot', () => { - beforeEach(() => { - vi.clearAllMocks(); - rootInstances.clear(); - }); - - it('should create a new root for a new container', () => { - const container = document.createElement('div'); - const root = getRoot(container); - - expect(createRoot).toHaveBeenCalledWith(container); - expect(rootInstances.get(container)).toBe(root); - expect(createRoot).toHaveBeenCalledTimes(1); - }); - - it('should return the existing root for an existing container', () => { - const container = document.createElement('div'); - // Call getRoot twice with the same container - const firstCallRoot = getRoot(container); - const secondCallRoot = getRoot(container); - - // createRoot should only have been called once - expect(createRoot).toHaveBeenCalledTimes(1); - expect(firstCallRoot).toBe(secondCallRoot); - expect(rootInstances.size).toBe(1); - }); -}); diff --git a/packages/utilities/src/helpers/rootManager.ts b/packages/utilities/src/helpers/rootManager.ts deleted file mode 100644 index 267ee9f5a33..00000000000 --- a/packages/utilities/src/helpers/rootManager.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import type { Root } from 'react-dom/client'; - -export const rootInstances = new Map(); - -/** - * This utility helps manage React roots efficiently, - * ensuring that only one root is created per container and allowing reuse of existing roots when possible. - * It's particularly useful in scenarios where you need to dynamically create and manage multiple React roots in an application. (In our case, the APP and DevTools) - */ -export const getRoot = (container: HTMLElement): Root => { - let root = rootInstances.get(container); - - if (!root) { - root = createRoot(container); - rootInstances.set(container, root); - } - - return root; -}; diff --git a/packages/utilities/src/helpers/sort-by.ts b/packages/utilities/src/helpers/sort-by.ts index 410a1b872d5..a37c33f683d 100644 --- a/packages/utilities/src/helpers/sort-by.ts +++ b/packages/utilities/src/helpers/sort-by.ts @@ -47,7 +47,7 @@ export const sortByArrayLength = (a: any[], b: any[], order: SortOrder) => { * * @param {string} a - The first version string to compare. * @param {string} b - The second version string to compare. - * @param {SortOrder} order - The order to sort by, can be 'asc' for ascending or 'desc' for descending. + * @param {SortOrder} order - The intended sort direction of the output; 'asc' means lower versions come first, 'desc' means higher versions come first. * @returns {number} Returns a positive number if version `a` is greater than `b` according to the sort order, * zero if they are equal, and a negative number if `b` is greater than `a`. * diff --git a/packages/utilities/src/hooks/useScript.ts b/packages/utilities/src/hooks/useScript.ts index 839c577854e..35069417dd0 100644 --- a/packages/utilities/src/hooks/useScript.ts +++ b/packages/utilities/src/hooks/useScript.ts @@ -60,7 +60,9 @@ export const loadScript = ( } } else { // Grab existing script status from attribute and set to state. - options?.setStatus?.(script.getAttribute('data-status') as ScriptStatus); + const existingStatus = script.getAttribute('data-status') as ScriptStatus; + options?.setStatus?.(existingStatus); + resolve({ status: existingStatus }); } // Script event handler to update status in state // Note: Even if the script already exists we still need to add diff --git a/packages/utilities/tsconfig.json b/packages/utilities/tsconfig.json index b136bfd20b1..84f506343dc 100644 --- a/packages/utilities/tsconfig.json +++ b/packages/utilities/tsconfig.json @@ -1,15 +1,7 @@ { - "compilerOptions": { - "jsx": "react", - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "skipLibCheck": true, - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true, - "incremental": true - }, + "extends": [ + "@linode/tsconfig/package", + "@linode/tsconfig/react" + ], "include": ["src"] } diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 27f7bf0ddce..4e4a952154c 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2025-06-03] - v0.67.0 + +### Added: + +- Method to retrieve dynamic validation for Create database schema ([#12281](https://github.com/linode/manager/pull/12281)) + +### Fixed: + +- Handling duplicate subnet-ids during Nodebalancer creation with VPC enabled ([#12181](https://github.com/linode/manager/pull/12181)) + ## [2025-05-20] - v0.66.0 ### Upcoming Features: diff --git a/packages/validation/package.json b/packages/validation/package.json index 9beaee549f7..0151262ef18 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.66.0", + "version": "0.67.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", @@ -41,6 +41,7 @@ "yup": "^1.4.0" }, "devDependencies": { + "@linode/tsconfig": "workspace:*", "concurrently": "^9.0.1", "tsup": "^8.4.0" }, diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 68a0bfbc5e8..e4a6d08d210 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -18,6 +18,18 @@ export const createDatabaseSchema = object({ replication_commit_type: string().notRequired().nullable(), // TODO (UIE-8214) remove POST GA }); +export const getDynamicDatabaseSchema = (isVPCSelected: boolean) => { + if (!isVPCSelected) { + return createDatabaseSchema; + } + + return createDatabaseSchema.shape({ + private_network: object().shape({ + subnet_id: number().nullable().required('Subnet is required.'), + }), + }); +}; + export const updateDatabaseSchema = object({ label: string().notRequired().min(3, LABEL_MESSAGE).max(32, LABEL_MESSAGE), allow_list: array().of(string()).notRequired(), diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 6a3687f52b8..f983ac45abc 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -1,6 +1,6 @@ -import { array, boolean, lazy, mixed, number, object, string } from 'yup'; +import { array, boolean, mixed, number, object, string } from 'yup'; -import { IP_EITHER_BOTH_NOT_NEITHER, vpcsValidateIP } from './vpcs.schema'; +import { vpcsValidateIP } from './vpcs.schema'; const PORT_WARNING = 'Port must be between 1 and 65535.'; const LABEL_WARNING = 'Label must be between 3 and 32 characters.'; @@ -265,79 +265,40 @@ const clientUdpSessThrottle = number() ) .typeError('UDP Session Throttle must be a number.'); -const createNodeBalancerVPCsSchema = object().shape( - { - subnet_id: number() - .typeError('Subnet ID must be a number.') - .required('Subnet ID is required.'), - ipv4_range: string().when('ipv6_range', { - is: (value: unknown) => - value === '' || value === null || value === undefined, - then: (schema) => - schema - .required(IP_EITHER_BOTH_NOT_NEITHER) - .matches(PRIVATE_IPV4_REGEX, PRIVATE_IPV4_WARNING) - .test({ - name: 'IPv4 CIDR format', - message: 'The IPv4 range must be in CIDR format.', - test: (value) => - vpcsValidateIP({ - value, - shouldHaveIPMask: true, - mustBeIPMask: false, - }), - }), - otherwise: (schema) => - lazy((value: string | undefined) => { - switch (typeof value) { - case 'string': - return schema - .notRequired() - .matches(PRIVATE_IPV4_REGEX, PRIVATE_IPV4_WARNING) - .test({ - name: 'IPv4 CIDR format', - message: 'The IPv4 range must be in CIDR format.', - test: (value) => - vpcsValidateIP({ - value, - shouldHaveIPMask: true, - mustBeIPMask: false, - }), - }); - - case 'undefined': - return schema.notRequired().nullable(); - - default: - return schema.notRequired().nullable(); - } +const createNodeBalancerVPCsSchema = object().shape({ + subnet_id: number() + .typeError('Subnet ID must be a number.') + .required('Subnet ID is required.'), + ipv4_range: string() + .notRequired() + .matches(PRIVATE_IPV4_REGEX, PRIVATE_IPV4_WARNING) + .test({ + name: 'IPv4 CIDR format', + message: 'The IPv4 range must be in CIDR format.', + test: (value) => + !value || + vpcsValidateIP({ + value, + shouldHaveIPMask: true, + mustBeIPMask: false, }), }), - ipv6_range: string().when('ipv4_range', { - is: (value: unknown) => - value === '' || value === null || value === undefined, - then: (schema) => - schema - .required(IP_EITHER_BOTH_NOT_NEITHER) - .matches(PRIVATE_IPV6_REGEX, 'Must be a valid private IPv6 address.') - .test({ - name: 'valid-ipv6-range', - message: - 'Must be a valid private IPv6 range, e.g. fd12:3456:789a:1::1/64.', - test: (value) => - vpcsValidateIP({ - value, - shouldHaveIPMask: true, - mustBeIPMask: false, - }), - }), + ipv6_range: string() + .notRequired() + .matches(PRIVATE_IPV6_REGEX, 'Must be a valid private IPv6 address.') + .test({ + name: 'valid-ipv6-range', + message: + 'Must be a valid private IPv6 range, e.g. fd12:3456:789a:1::1/64.', + test: (value) => + !value || + vpcsValidateIP({ + value, + shouldHaveIPMask: true, + mustBeIPMask: false, + }), }), - }, - [ - ['ipv4_range', 'ipv6_range'], - ['ipv6_range', 'ipv4_range'], - ], -); +}); export const NodeBalancerSchema = object({ label: string() @@ -398,10 +359,15 @@ export const NodeBalancerSchema = object({ ids.forEach( (id, index) => ids.indexOf(id) !== index && duplicates.push(index), ); - const idStrings = ids.map((id: number) => `vpcs[${id}].subnet_id`); + if (duplicates.length === 0) { + return true; + } + const idStrings = duplicates.map( + (idx: number) => `vpcs[${idx}].subnet_id`, + ); throw this.createError({ path: idStrings.join('|'), - message: 'Subnet ID must be unique', + message: 'Subnet IDs must be unique', }); }), }); diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json index 58df0f1dff7..22d69fd4250 100644 --- a/packages/validation/tsconfig.json +++ b/packages/validation/tsconfig.json @@ -1,18 +1,8 @@ { + "extends": ["@linode/tsconfig/package", "@linode/tsconfig/emit-types"], "compilerOptions": { - "target": "esnext", - "module": "esnext", - "emitDeclarationOnly": true, - "declaration": true, "outDir": "./lib", - "esModuleInterop": true, - "moduleResolution": "bundler", - "skipLibCheck": true, - "strict": true, "baseUrl": ".", - "noUnusedLocals": true, - "declarationMap": true, - "incremental": true }, "include": [ "src" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4825b5d186..c785b5ad8d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.23.0(jiti@2.4.2)) + eslint-plugin-react-refresh: + specifier: 0.4.20 + version: 0.4.20(eslint@9.23.0(jiti@2.4.2)) eslint-plugin-sonarjs: specifier: ^3.0.2 version: 3.0.2(eslint@9.23.0(jiti@2.4.2)) @@ -98,6 +101,9 @@ importers: specifier: ^1.4.0 version: 1.5.0 devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig axios-mock-adapter: specifier: ^1.22.0 version: 1.22.0(axios@1.8.3) @@ -165,17 +171,17 @@ importers: specifier: ^1.3.4 version: 1.3.4(@tanstack/query-core@5.51.24)(@tanstack/react-query@5.51.24(react@18.3.1)) '@mui/icons-material': - specifier: ^6.4.5 - version: 6.4.5(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + specifier: ^7.1.0 + version: 7.1.0(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/material': - specifier: ^6.4.5 - version: 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.1.0 + version: 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/utils': - specifier: ^6.4.3 - version: 6.4.3(@types/react@18.3.12)(react@18.3.1) + specifier: ^7.1.0 + version: 7.1.0(@types/react@18.3.12)(react@18.3.1) '@mui/x-date-pickers': specifier: ^7.27.0 - version: 7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@paypal/react-paypal-js': specifier: ^8.8.3 version: 8.8.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -183,8 +189,8 @@ importers: specifier: ^0.18.0 version: 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/react': - specifier: ^7.119.1 - version: 7.120.0(react@18.3.1) + specifier: ^9.19.0 + version: 9.19.0(react@18.3.1) '@shikijs/langs': specifier: ^3.1.0 version: 3.1.0 @@ -334,7 +340,7 @@ importers: version: 2.3.0 tss-react: specifier: ^4.8.2 - version: 4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) typescript-fsa: specifier: ^3.0.0 version: 3.0.0 @@ -592,6 +598,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -619,6 +628,10 @@ importers: vite: specifier: ^6.3.4 version: 6.3.4(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig packages/shared: dependencies: @@ -641,6 +654,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig '@storybook/addon-actions': specifier: ^8.6.7 version: 8.6.9(storybook@8.6.9(prettier@3.5.3)) @@ -669,6 +685,8 @@ importers: specifier: ^3.2.0 version: 3.3.0(rollup@4.40.1)(typescript@5.7.3)(vite@6.3.4(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + packages/tsconfig: {} + packages/ui: dependencies: '@emotion/react': @@ -681,17 +699,17 @@ importers: specifier: ^4.0.0 version: 4.0.0 '@mui/icons-material': - specifier: ^6.4.5 - version: 6.4.5(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + specifier: ^7.1.0 + version: 7.1.0(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/material': - specifier: ^6.4.5 - version: 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.1.0 + version: 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/utils': - specifier: ^6.4.3 - version: 6.4.3(@types/react@18.3.12)(react@18.3.1) + specifier: ^7.1.0 + version: 7.1.0(@types/react@18.3.12)(react@18.3.1) '@mui/x-date-pickers': specifier: ^7.27.0 - version: 7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) luxon: specifier: 3.4.4 version: 3.4.4 @@ -703,8 +721,11 @@ importers: version: 18.3.1(react@18.3.1) tss-react: specifier: ^4.8.2 - version: 4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig '@storybook/addon-actions': specifier: ^8.6.7 version: 8.6.9(storybook@8.6.9(prettier@3.5.3)) @@ -757,6 +778,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -794,6 +818,9 @@ importers: specifier: ^1.4.0 version: 1.5.0 devDependencies: + '@linode/tsconfig': + specifier: workspace:* + version: link:../tsconfig concurrently: specifier: ^9.0.1 version: 9.1.0 @@ -950,6 +977,10 @@ packages: resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.27.1': + resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -1645,27 +1676,27 @@ packages: resolution: {integrity: sha512-SvE+tSpcX884RJrPCskXxoS965Ky/pYABDEhWW6oeSRhpUDLrS5nTvT5n1LLSDVDYvty4imVmXsy+3/ROVuknA==} engines: {node: '>=18'} - '@mui/core-downloads-tracker@6.4.5': - resolution: {integrity: sha512-zoXvHU1YuoodgMlPS+epP084Pqv9V+Vg+5IGv9n/7IIFVQ2nkTngYHYxElCq8pdTTbDcgji+nNh0lxri2abWgA==} + '@mui/core-downloads-tracker@7.1.0': + resolution: {integrity: sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==} - '@mui/icons-material@6.4.5': - resolution: {integrity: sha512-4A//t8Nrc+4u4pbVhGarIFU98zpuB5AV9hTNzgXx1ySZJ1tWtx+i/1SbQ8PtGJxWeXlljhwimZJNPQ3x0CiIFw==} + '@mui/icons-material@7.1.0': + resolution: {integrity: sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==} engines: {node: '>=14.0.0'} peerDependencies: - '@mui/material': ^6.4.5 + '@mui/material': ^7.1.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/material@6.4.5': - resolution: {integrity: sha512-5eyEgSXocIeV1JkXs8mYyJXU0aFyXZIWI5kq2g/mCnIgJe594lkOBNAKnCIaGVfQTu2T6TTEHF8/hHIqpiIRGA==} + '@mui/material@7.1.0': + resolution: {integrity: sha512-ahUJdrhEv+mCp4XHW+tHIEYzZMSRLg8z4AjUOsj44QpD1ZaMxQoVOG2xiHvLFdcsIPbgSRx1bg1eQSheHBgvtg==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@mui/material-pigment-css': ^6.4.3 + '@mui/material-pigment-css': ^7.1.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1679,8 +1710,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@6.4.3': - resolution: {integrity: sha512-7x9HaNwDCeoERc4BoEWLieuzKzXu5ZrhRnEM6AUcRXUScQLvF1NFkTlP59+IJfTbEMgcGg1wWHApyoqcksrBpQ==} + '@mui/private-theming@7.1.0': + resolution: {integrity: sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1689,8 +1720,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@6.4.3': - resolution: {integrity: sha512-OC402VfK+ra2+f12Gef8maY7Y9n7B6CZcoQ9u7mIkh/7PKwW/xH81xwX+yW+Ak1zBT3HYcVjh2X82k5cKMFGoQ==} + '@mui/styled-engine@7.1.0': + resolution: {integrity: sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -1702,8 +1733,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@6.4.3': - resolution: {integrity: sha512-Q0iDwnH3+xoxQ0pqVbt8hFdzhq1g2XzzR4Y5pVcICTNtoCLJmpJS3vI4y/OIM1FHFmpfmiEC2IRIq7YcZ8nsmg==} + '@mui/system@7.1.0': + resolution: {integrity: sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -1726,6 +1757,14 @@ packages: '@types/react': optional: true + '@mui/types@7.4.2': + resolution: {integrity: sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@mui/utils@6.4.3': resolution: {integrity: sha512-jxHRHh3BqVXE9ABxDm+Tc3wlBooYz/4XPa0+4AI+iF38rV1/+btJmSUgG4shDtSWVs/I97aDn5jBCt6SF2Uq2A==} engines: {node: '>=14.0.0'} @@ -1736,6 +1775,16 @@ packages: '@types/react': optional: true + '@mui/utils@7.1.0': + resolution: {integrity: sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@mui/x-date-pickers@7.27.0': resolution: {integrity: sha512-wSx8JGk4WQ2hTObfQITc+zlmUKNleQYoH1hGocaQlpWpo1HhauDtcQfX6sDN0J0dPT2eeyxDWGj4uJmiSfQKcw==} engines: {node: '>=14.0.0'} @@ -2069,47 +2118,35 @@ packages: cpu: [x64] os: [win32] - '@sentry-internal/feedback@7.120.0': - resolution: {integrity: sha512-+nU2PXMAyrYyK64PlfxXyRZ+LIl6IWAcdnBeX916WqOJy2WWmtdOrAX8muVwLVIXHzp1EMG1nEZgtpL/Vr2XKQ==} - engines: {node: '>=12'} + '@sentry-internal/browser-utils@9.19.0': + resolution: {integrity: sha512-DlEHX4eIHe5yIuh/cFu9OiaFuk1CTnFK95zj61I7Q2fxmN43dIwC3xAAGJ/Hy+GDQi7kU+BiS2sudSHSTq81BA==} + engines: {node: '>=18'} - '@sentry-internal/replay-canvas@7.120.0': - resolution: {integrity: sha512-ZEFZBP+Jxmy/8IY7IZDZVPqAJ6pPxAFo1lNTd8xfpbno3WAtHw0FLewLfjrFt0zfIgCk8EXj4PW355zRP3C2NQ==} - engines: {node: '>=12'} + '@sentry-internal/feedback@9.19.0': + resolution: {integrity: sha512-yixRrv4NfpjhFW56AuUTjVwZlignB9FWAXXyrmRP3SsFeJCFrAsSD8HOxV9RXNr9ePYl7MEU0Agi43YWhJsiAw==} + engines: {node: '>=18'} - '@sentry-internal/tracing@7.120.0': - resolution: {integrity: sha512-VymJoIGMV0PcTJyshka9uJ1sKpR7bHooqW5jTEr6g0dYAwB723fPXHjVW+7SETF7i5+yr2KMprYKreqRidKyKA==} - engines: {node: '>=8'} + '@sentry-internal/replay-canvas@9.19.0': + resolution: {integrity: sha512-YC8yrOjuKSfQgGniJnzkdbFsWEPTlNpzeeYPTxS4ouH1FwfGrSkPmcddjor2YHaLfiuHHqQ/Vvq70n+zruJH7A==} + engines: {node: '>=18'} - '@sentry/browser@7.120.0': - resolution: {integrity: sha512-2hRE3QPLBBX+qqZEHY2IbJv4YvfXY7m/bWmNjN15phyNK3oBcm2Pa8ZiKUYrk8u/4DCEGzNUlhOmFgaxwSfpNw==} - engines: {node: '>=8'} + '@sentry-internal/replay@9.19.0': + resolution: {integrity: sha512-i/X9brRchbAF25yjxLTI7E8eoESRPBgIyQOWoWRXXt2n51iBRTjLXSaEfGvjdN+qrMq/yd6nC1/UqJVxXHeIhA==} + engines: {node: '>=18'} - '@sentry/core@7.120.0': - resolution: {integrity: sha512-uTc2sUQ0heZrMI31oFOHGxjKgw16MbV3C2mcT7qcrb6UmSGR9WqPOXZhnVVuzPWCnQ8B5IPPVdynK//J+9/m6g==} - engines: {node: '>=8'} + '@sentry/browser@9.19.0': + resolution: {integrity: sha512-efKfPQ0yQkdIkC7qJ5TIHxnecLNENGUYl1YD/TC8yyzW2JRf/3OYo5yg1hY2rhsP5RwQShXlT7uA03ABVIkA4A==} + engines: {node: '>=18'} - '@sentry/integrations@7.120.0': - resolution: {integrity: sha512-/Hs9MgSmG4JFNyeQkJ+MWh/fxO/U38Pz0VSH3hDrfyCjI8vH9Vz9inGEQXgB9Ke4eH8XnhsQ7xPnM27lWJts6g==} - engines: {node: '>=8'} + '@sentry/core@9.19.0': + resolution: {integrity: sha512-I41rKpMJHHZb0z0Nja+Lxto6IkEEmX3uWjnECypF8Z1HIjeJB0+PXl8p/7TeaKYqw2J2GYcRTg7jQZDmvKle1w==} + engines: {node: '>=18'} - '@sentry/react@7.120.0': - resolution: {integrity: sha512-YTzmTRO9a2ZIdZiiT3Ob4h8/wLDEDC24qrUqomrYHG8Rcj+9EHjTqQQmoB8ARw9Kh0SrIzR5jbDK7C8JO6jzCQ==} - engines: {node: '>=8'} + '@sentry/react@9.19.0': + resolution: {integrity: sha512-tHuzPVbqKsONlFQsy7FqqGjBaujQoLRIDBLlPPMNoiGvP3rodBl6t1v5zoNAq4m47i3MhvpLEYf6C00j1w5UMQ==} + engines: {node: '>=18'} peerDependencies: - react: 15.x || 16.x || 17.x || 18.x - - '@sentry/replay@7.120.0': - resolution: {integrity: sha512-wV9fIYwNtMvFOHQB5eSm+kCorRXsX5+v1DxyTC8Lee1hfzcUQ2Wvqh75VktpXuM9TeZE8h7aQ4Wo4qCgTUdtvA==} - engines: {node: '>=12'} - - '@sentry/types@7.120.0': - resolution: {integrity: sha512-3mvELhBQBo6EljcRrJzfpGJYHKIZuBXmqh0y8prh03SWE62pwRL614GIYtd4YOC6OP1gfPn8S8h9w3dD5bF5HA==} - engines: {node: '>=8'} - - '@sentry/utils@7.120.0': - resolution: {integrity: sha512-XZsPcBHoYu4+HYn14IOnhabUZgCF99Xn4IdWn8Hjs/c+VPtuAVDhRTsfPyPrpY3OcN8DgO5fZX4qcv/6kNbX1A==} - engines: {node: '>=8'} + react: ^16.14.0 || 17.x || 18.x || 19.x '@shikijs/core@3.1.0': resolution: {integrity: sha512-1ppAOyg3F18N8Ge9DmJjGqRVswihN33rOgPovR6gUHW17Hw1L4RlRhnmVQcsacSHh0A8IO1FIgNbtTxUFwodmg==} @@ -3867,6 +3904,11 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react-refresh@0.4.20: + resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} + peerDependencies: + eslint: '>=8.40' + eslint-plugin-react@7.37.4: resolution: {integrity: sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==} engines: {node: '>=4'} @@ -4378,9 +4420,6 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - immediate@3.0.6: - resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} @@ -4837,9 +4876,6 @@ packages: libphonenumber-js@1.11.14: resolution: {integrity: sha512-sexvAfwcW1Lqws4zFp8heAtAEXbEDnvkYCEGzvOoMgZR7JhXo/IkE9MkkGACgBed5fWqh3ShBGnJBdDnU9N8EQ==} - lie@3.1.1: - resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -4881,9 +4917,6 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - localforage@1.10.0: - resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5650,6 +5683,9 @@ packages: react-is@19.0.0: resolution: {integrity: sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==} + react-is@19.1.0: + resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==} + react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} @@ -7036,6 +7072,8 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.27.1': {} + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -7676,23 +7714,23 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@mui/core-downloads-tracker@6.4.5': {} + '@mui/core-downloads-tracker@7.1.0': {} - '@mui/icons-material@6.4.5(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': + '@mui/icons-material@7.1.0(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.0 - '@mui/material': 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.27.1 + '@mui/material': 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 optionalDependencies: '@types/react': 18.3.12 - '@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.0 - '@mui/core-downloads-tracker': 6.4.5 - '@mui/system': 6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/types': 7.2.21(@types/react@18.3.12) - '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@mui/core-downloads-tracker': 7.1.0 + '@mui/system': 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + '@mui/types': 7.4.2(@types/react@18.3.12) + '@mui/utils': 7.1.0(@types/react@18.3.12)(react@18.3.1) '@popperjs/core': 2.11.8 '@types/react-transition-group': 4.4.12(@types/react@18.3.12) clsx: 2.1.1 @@ -7700,25 +7738,25 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-is: 19.0.0 + react-is: 19.1.0 react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: '@emotion/react': 11.13.5(@types/react@18.3.12)(react@18.3.1) '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@types/react': 18.3.12 - '@mui/private-theming@6.4.3(@types/react@18.3.12)(react@18.3.1)': + '@mui/private-theming@7.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.0 - '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@mui/utils': 7.1.0(@types/react@18.3.12)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 optionalDependencies: '@types/react': 18.3.12 - '@mui/styled-engine@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': + '@mui/styled-engine@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.27.1 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -7729,13 +7767,13 @@ snapshots: '@emotion/react': 11.13.5(@types/react@18.3.12)(react@18.3.1) '@emotion/styled': 11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/system@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': + '@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.0 - '@mui/private-theming': 6.4.3(@types/react@18.3.12)(react@18.3.1) - '@mui/styled-engine': 6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) - '@mui/types': 7.2.21(@types/react@18.3.12) - '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.27.1 + '@mui/private-theming': 7.1.0(@types/react@18.3.12)(react@18.3.1) + '@mui/styled-engine': 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + '@mui/types': 7.4.2(@types/react@18.3.12) + '@mui/utils': 7.1.0(@types/react@18.3.12)(react@18.3.1) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 @@ -7749,6 +7787,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@mui/types@7.4.2(@types/react@18.3.12)': + dependencies: + '@babel/runtime': 7.27.1 + optionalDependencies: + '@types/react': 18.3.12 + '@mui/utils@6.4.3(@types/react@18.3.12)(react@18.3.1)': dependencies: '@babel/runtime': 7.27.0 @@ -7761,11 +7805,23 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@mui/x-date-pickers@7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/utils@7.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/types': 7.4.2(@types/react@18.3.12) + '@types/prop-types': 15.7.14 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.1.0 + optionalDependencies: + '@types/react': 18.3.12 + + '@mui/x-date-pickers@7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.27.0 - '@mui/material': 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/system': 6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + '@mui/material': 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/system': 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) '@mui/x-internals': 7.26.0(@types/react@18.3.12)(react@18.3.1) '@types/react-transition-group': 4.4.11 @@ -8003,70 +8059,41 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.1': optional: true - '@sentry-internal/feedback@7.120.0': + '@sentry-internal/browser-utils@9.19.0': dependencies: - '@sentry/core': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 + '@sentry/core': 9.19.0 - '@sentry-internal/replay-canvas@7.120.0': + '@sentry-internal/feedback@9.19.0': dependencies: - '@sentry/core': 7.120.0 - '@sentry/replay': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 + '@sentry/core': 9.19.0 - '@sentry-internal/tracing@7.120.0': + '@sentry-internal/replay-canvas@9.19.0': dependencies: - '@sentry/core': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 + '@sentry-internal/replay': 9.19.0 + '@sentry/core': 9.19.0 - '@sentry/browser@7.120.0': + '@sentry-internal/replay@9.19.0': dependencies: - '@sentry-internal/feedback': 7.120.0 - '@sentry-internal/replay-canvas': 7.120.0 - '@sentry-internal/tracing': 7.120.0 - '@sentry/core': 7.120.0 - '@sentry/integrations': 7.120.0 - '@sentry/replay': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 + '@sentry-internal/browser-utils': 9.19.0 + '@sentry/core': 9.19.0 - '@sentry/core@7.120.0': + '@sentry/browser@9.19.0': dependencies: - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 + '@sentry-internal/browser-utils': 9.19.0 + '@sentry-internal/feedback': 9.19.0 + '@sentry-internal/replay': 9.19.0 + '@sentry-internal/replay-canvas': 9.19.0 + '@sentry/core': 9.19.0 - '@sentry/integrations@7.120.0': - dependencies: - '@sentry/core': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 - localforage: 1.10.0 + '@sentry/core@9.19.0': {} - '@sentry/react@7.120.0(react@18.3.1)': + '@sentry/react@9.19.0(react@18.3.1)': dependencies: - '@sentry/browser': 7.120.0 - '@sentry/core': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 + '@sentry/browser': 9.19.0 + '@sentry/core': 9.19.0 hoist-non-react-statics: 3.3.2 react: 18.3.1 - '@sentry/replay@7.120.0': - dependencies: - '@sentry-internal/tracing': 7.120.0 - '@sentry/core': 7.120.0 - '@sentry/types': 7.120.0 - '@sentry/utils': 7.120.0 - - '@sentry/types@7.120.0': {} - - '@sentry/utils@7.120.0': - dependencies: - '@sentry/types': 7.120.0 - '@shikijs/core@3.1.0': dependencies: '@shikijs/types': 3.1.0 @@ -9825,7 +9852,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.27.1 csstype: 3.1.3 dompurify@3.2.4: @@ -10170,6 +10197,10 @@ snapshots: dependencies: eslint: 9.23.0(jiti@2.4.2) + eslint-plugin-react-refresh@0.4.20(eslint@9.23.0(jiti@2.4.2)): + dependencies: + eslint: 9.23.0(jiti@2.4.2) + eslint-plugin-react@7.37.4(eslint@9.23.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 @@ -10773,8 +10804,6 @@ snapshots: ignore@5.3.2: {} - immediate@3.0.6: {} - immer@9.0.21: {} import-fresh@3.3.0: @@ -11241,10 +11270,6 @@ snapshots: libphonenumber-js@1.11.14: {} - lie@3.1.1: - dependencies: - immediate: 3.0.6 - lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -11308,10 +11333,6 @@ snapshots: load-tsconfig@0.2.5: {} - localforage@1.10.0: - dependencies: - lie: 3.1.1 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -12265,6 +12286,8 @@ snapshots: react-is@19.0.0: {} + react-is@19.1.0: {} + react-lifecycles-compat@3.0.4: {} react-number-format@3.6.2(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -12327,7 +12350,7 @@ snapshots: react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.27.1 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -13153,7 +13176,7 @@ snapshots: tslib@2.8.1: {} - tss-react@4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + tss-react@4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@emotion/cache': 11.13.5 '@emotion/react': 11.13.5(@types/react@18.3.12)(react@18.3.1) @@ -13161,7 +13184,7 @@ snapshots: '@emotion/utils': 1.4.2 react: 18.3.1 optionalDependencies: - '@mui/material': 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/material': 7.1.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsup@8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1): dependencies: