From 150d2e5835ed742f590b18351b7485d26e450eb0 Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Thu, 10 Oct 2024 15:49:12 -0400 Subject: [PATCH 01/64] test: [M3-8445] Add new test to confirm changes to the Object details drawer for OBJ Gen 2 (#11045) * Add new tests for object details drawer * fix bucketCluster value * Added changeset: Add new test to confirm changes to the Object details drawer for OBJ Gen 2 --- .../pr-11045-tests-1727985809023.md | 5 + .../bucket-object-gen2.spec.ts | 375 ++++++++++++++++++ .../support/intercepts/object-storage.ts | 42 +- 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11045-tests-1727985809023.md create mode 100644 packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts diff --git a/packages/manager/.changeset/pr-11045-tests-1727985809023.md b/packages/manager/.changeset/pr-11045-tests-1727985809023.md new file mode 100644 index 00000000000..02b9784d7ca --- /dev/null +++ b/packages/manager/.changeset/pr-11045-tests-1727985809023.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add new test to confirm changes to the Object details drawer for OBJ Gen 2 ([#11045](https://github.com/linode/manager/pull/11045)) diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts new file mode 100644 index 00000000000..4dbc46e3907 --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts @@ -0,0 +1,375 @@ +import 'cypress-file-upload'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + accountFactory, + objectStorageBucketFactoryGen2, + objectStorageEndpointsFactory, + regionFactory, +} from 'src/factories'; +import { chooseRegion } from 'support/util/regions'; +import { ObjectStorageEndpoint } from '@linode/api-v4'; +import { randomItem, randomLabel } from 'support/util/random'; +import { + mockCreateBucket, + mockGetBucket, + mockGetBucketObjectFilename, + mockGetBucketObjects, + mockGetBucketsForRegion, + mockGetObjectStorageEndpoints, + mockUploadBucketObject, + mockUploadBucketObjectS3, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +describe('Object Storage Gen2 bucket object tests', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: true }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Object Storage', + 'Object Storage Endpoint Types', + 'Object Storage Access Key Regions', + ], + }) + ).as('getAccount'); + }); + + // Moved these constants to top of scope - they will likely be used for other obj storage gen2 bucket create tests + const mockRegions = regionFactory.buildList(5, { + capabilities: ['Object Storage'], + }); + const mockRegion = chooseRegion({ regions: [...mockRegions] }); + + const mockEndpoints: ObjectStorageEndpoint[] = [ + objectStorageEndpointsFactory.build({ + endpoint_type: 'E0', + region: mockRegion.id, + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: mockRegion.id, + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: mockRegion.id, + s3_endpoint: 'us-sea-1.linodeobjects.com', + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E2', + region: mockRegion.id, + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E3', + region: mockRegion.id, + s3_endpoint: null, + }), + ]; + + const bucketFile = randomItem([ + 'object-storage-files/1.txt', + 'object-storage-files/2.jpg', + 'object-storage-files/3.jpg', + 'object-storage-files/4.zip', + ]); + + const bucketFilename = bucketFile.split('/')[1]; + + const ACLNotification = 'Private: Only you can download this Object'; + + // For E0/E1, confirm CORS toggle and ACL selection are both present + // For E2/E3, confirm ACL and Cors are removed + const checkBucketObjectDetailsDrawer = ( + bucketFilename: string, + endpointType: string + ) => { + ui.drawer.findByTitle(bucketFilename).within(() => { + if ( + endpointType === 'Standard (E3)' || + endpointType === 'Standard (E2)' + ) { + ui.toggle.find().should('not.exist'); + cy.contains('CORS Enabled').should('not.exist'); + cy.findByLabelText('Access Control List (ACL)').should('not.exist'); + } else { + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); + cy.contains('CORS Enabled').should('be.visible'); + + cy.contains(ACLNotification).should('not.exist'); + // Verify that ACL selection show up as options + cy.findByLabelText('Access Control List (ACL)') + .should('be.visible') + .should('have.value', 'Private') + .click(); + ui.autocompletePopper + .findByTitle('Public Read') + .should('be.visible') + .should('be.enabled'); + ui.autocompletePopper + .findByTitle('Authenticated Read') + .should('be.visible') + .should('be.enabled'); + ui.autocompletePopper + .findByTitle('Private') + .should('be.visible') + .should('be.enabled') + .click(); + } + // Close the Details drawer + cy.get('[data-qa-close-drawer="true"]').should('be.visible').click(); + }); + }; + + /** + + */ + it('can check Object details drawer with E0 endpoint type', () => { + const endpointTypeE0 = 'Legacy (E0)'; + const bucketLabel = randomLabel(); + const bucketCluster = mockRegion.id; + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E0', + s3_endpoint: undefined, + }); + + //mockGetBuckets([]).as('getBuckets'); + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E0', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); + mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + mockUploadBucketObject(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObject' + ); + mockUploadBucketObjectS3(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObjectS3' + ); + mockGetBucketObjectFilename(bucketLabel, bucketCluster, bucketFilename).as( + 'getBucketFilename' + ); + + cy.visitWithLogin( + `/object-storage/buckets/${bucketCluster}/${bucketLabel}` + ); + + cy.fixture(bucketFile, null).then((bucketFileContents) => { + cy.get('[data-qa-drop-zone="true"]').attachFile( + { + fileContent: bucketFileContents, + fileName: bucketFilename, + }, + { + subjectType: 'drag-n-drop', + } + ); + }); + + cy.wait(['@uploadBucketObject', '@uploadBucketObjectS3']); + + cy.findByLabelText('List of Bucket Objects').within(() => { + cy.findByText(bucketFilename).should('be.visible').click(); + }); + + ui.drawer.findByTitle(bucketFilename).should('be.visible'); + + checkBucketObjectDetailsDrawer(bucketFilename, endpointTypeE0); + }); + + it('can check Object details drawer with E1 endpoint type', () => { + const endpointTypeE1 = 'Standard (E1)'; + const bucketLabel = randomLabel(); + const bucketCluster = mockRegion.id; + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E1', + s3_endpoint: 'us-sea-1.linodeobjects.com', + }); + + //mockGetBuckets([]).as('getBuckets'); + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E1', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); + mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + mockUploadBucketObject(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObject' + ); + mockUploadBucketObjectS3(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObjectS3' + ); + mockGetBucketObjectFilename(bucketLabel, bucketCluster, bucketFilename).as( + 'getBucketFilename' + ); + + cy.visitWithLogin( + `/object-storage/buckets/${bucketCluster}/${bucketLabel}` + ); + + cy.fixture(bucketFile, null).then((bucketFileContents) => { + cy.get('[data-qa-drop-zone="true"]').attachFile( + { + fileContent: bucketFileContents, + fileName: bucketFilename, + }, + { + subjectType: 'drag-n-drop', + } + ); + }); + + cy.wait(['@uploadBucketObject', '@uploadBucketObjectS3']); + + cy.findByLabelText('List of Bucket Objects').within(() => { + cy.findByText(bucketFilename).should('be.visible').click(); + }); + + ui.drawer.findByTitle(bucketFilename).should('be.visible'); + + checkBucketObjectDetailsDrawer(bucketFilename, endpointTypeE1); + }); + + it('can check Object details drawer with E2 endpoint type', () => { + const endpointTypeE2 = 'Standard (E2)'; + const bucketLabel = randomLabel(); + const bucketCluster = mockRegion.id; + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E2', + s3_endpoint: undefined, + }); + + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E2', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); + mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + mockUploadBucketObject(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObject' + ); + mockUploadBucketObjectS3(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObjectS3' + ); + mockGetBucketObjectFilename(bucketLabel, bucketCluster, bucketFilename).as( + 'getBucketFilename' + ); + mockGetBucket(bucketLabel, bucketCluster).as('getBucket'); + + cy.visitWithLogin( + `/object-storage/buckets/${bucketCluster}/${bucketLabel}` + ); + + cy.fixture(bucketFile, null).then((bucketFileContents) => { + cy.get('[data-qa-drop-zone="true"]').attachFile( + { + fileContent: bucketFileContents, + fileName: bucketFilename, + }, + { + subjectType: 'drag-n-drop', + } + ); + }); + + cy.wait(['@uploadBucketObject', '@uploadBucketObjectS3']); + + cy.findByLabelText('List of Bucket Objects').within(() => { + cy.findByText(bucketFilename).should('be.visible').click(); + }); + + ui.drawer.findByTitle(bucketFilename).should('be.visible'); + + checkBucketObjectDetailsDrawer(bucketFilename, endpointTypeE2); + }); + + it('can check Object details drawer with E3 endpoint type', () => { + const endpointTypeE3 = 'Standard (E3)'; + const bucketLabel = randomLabel(); + const bucketCluster = mockRegion.id; + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E3', + s3_endpoint: undefined, + }); + + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E3', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); + mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + mockUploadBucketObject(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObject' + ); + mockUploadBucketObjectS3(bucketLabel, bucketCluster, bucketFilename).as( + 'uploadBucketObjectS3' + ); + mockGetBucketObjectFilename(bucketLabel, bucketCluster, bucketFilename).as( + 'getBucketFilename' + ); + mockGetBucket(bucketLabel, bucketCluster).as('getBucket'); + + cy.visitWithLogin( + `/object-storage/buckets/${bucketCluster}/${bucketLabel}` + ); + + cy.fixture(bucketFile, null).then((bucketFileContents) => { + cy.get('[data-qa-drop-zone="true"]').attachFile( + { + fileContent: bucketFileContents, + fileName: bucketFilename, + }, + { + subjectType: 'drag-n-drop', + } + ); + }); + + cy.wait(['@uploadBucketObject', '@uploadBucketObjectS3']); + + cy.findByLabelText('List of Bucket Objects').within(() => { + cy.findByText(bucketFilename).should('be.visible').click(); + }); + + ui.drawer.findByTitle(bucketFilename).should('be.visible'); + + checkBucketObjectDetailsDrawer(bucketFilename, endpointTypeE3); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 34cc91f800d..8dfcbfa8ed7 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -504,7 +504,47 @@ export const mockGetObjectStorageEndpoints = ( }; /** - * Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket, and mocks response. + * Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket and mock the response. + * + * + * @param label - Object storage bucket label. + * @param cluster - Object storage bucket cluster. + * @param bucketFilename - uploaded bucketFilename + * + * @returns Cypress chainable. + */ +export const mockGetBucketObjectFilename = ( + label: string, + cluster: string, + bucketFilename: string +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher( + `object-storage/buckets/${cluster}/${label}/object-acl?name=${bucketFilename}` + ), + { + body: {}, + statusCode: 200, + } + ); +}; + +export const mockGetBucket = ( + label: string, + cluster: string +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`object-storage/buckets/${cluster}/${label}`), + { + body: {}, + statusCode: 200, + } + ); +}; + + /* Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket, and mocks response. * * @param label - Object storage bucket label. * @param cluster - Object storage bucket cluster. From 60a19258fe51813cab340f32f473e2d881fc950d Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 11 Oct 2024 09:32:58 -0400 Subject: [PATCH 02/64] feat: [M3-8560] - NodeBalancer Configurations - Support Linodes with Multiple Private IPs (#11069) * initial work to support multiple private ipv4s on a Linode * fix style bug * clean up validation and utils * add changeset * add more detailed changesets * feedback @hkhalil-akamai * clean `LinodeSelect` extra props --------- Co-authored-by: Banks Nussman --- .../pr-11069-fixed-1728443895478.md | 5 + .../LinodeCreate/shared/LinodeSelectTable.tsx | 4 +- .../Linodes/LinodeCreate/utilities.ts | 5 +- .../LinodeSelect/LinodeSelect.test.tsx | 42 +---- .../Linodes/LinodeSelect/LinodeSelect.tsx | 20 --- .../Linodes/LinodesLanding/IPAddress.tsx | 4 +- .../Managed/SSHAccess/EditSSHAccessDrawer.tsx | 6 +- .../NodeBalancers/ConfigNodeIPSelect.tsx | 170 +++++++++--------- .../ConfigNodeIPSelect.utils.test.ts | 32 ++++ .../NodeBalancers/ConfigNodeIPSelect.utils.ts | 36 ++++ .../NodeBalancers/NodeBalancerConfigNode.tsx | 7 +- .../manager/src/utilities/ipUtils.test.ts | 13 ++ packages/manager/src/utilities/ipUtils.ts | 8 +- .../pr-11069-added-1728444089255.md | 5 + .../pr-11069-changed-1728444173048.md | 5 + .../validation/src/nodebalancers.schema.ts | 7 +- 16 files changed, 204 insertions(+), 165 deletions(-) create mode 100644 packages/manager/.changeset/pr-11069-fixed-1728443895478.md create mode 100644 packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts create mode 100644 packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts create mode 100644 packages/manager/src/utilities/ipUtils.test.ts create mode 100644 packages/validation/.changeset/pr-11069-added-1728444089255.md create mode 100644 packages/validation/.changeset/pr-11069-changed-1728444173048.md diff --git a/packages/manager/.changeset/pr-11069-fixed-1728443895478.md b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md new file mode 100644 index 00000000000..057ac261ea2 --- /dev/null +++ b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Support Linodes with multiple private IPs in NodeBalancer configurations ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx index 8321d07ee4f..fc7673d7e3e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx @@ -24,7 +24,7 @@ import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useLinodesQuery } from 'src/queries/linodes/linodes'; import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { isPrivateIP } from 'src/utilities/ipUtils'; import { isNumeric } from 'src/utilities/stringUtils'; import { @@ -105,7 +105,7 @@ export const LinodeSelectTable = (props: Props) => { const queryClient = useQueryClient(); const handleSelect = async (linode: Linode) => { - const hasPrivateIP = linode.ipv4.some((ipv4) => privateIPRegex.test(ipv4)); + const hasPrivateIP = linode.ipv4.some(isPrivateIP); reset((prev) => ({ ...prev, backup_id: null, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 55641bee1ae..92fd7443e30 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -6,7 +6,7 @@ import { linodeQueries } from 'src/queries/linodes/linodes'; import { stackscriptQueries } from 'src/queries/stackscripts'; import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics'; import { sendLinodeCreateFormErrorEvent } from 'src/utilities/analytics/formEventAnalytics'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { isPrivateIP } from 'src/utilities/ipUtils'; import { utoa } from 'src/utilities/metadata'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; import { omitProps } from 'src/utilities/omittedProps'; @@ -299,8 +299,7 @@ export const defaultValues = async ( ? await queryClient.ensureQueryData(linodeQueries.linode(params.linodeID)) : null; - const privateIp = - linode?.ipv4.some((ipv4) => privateIPRegex.test(ipv4)) ?? false; + const privateIp = linode?.ipv4.some(isPrivateIP) ?? false; const values: LinodeCreateFormValues = { backup_id: params.backupID, diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx index a6c44f5eef3..8a0b44c0abf 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -8,49 +7,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { LinodeSelect } from './LinodeSelect'; -const fakeLinodeData = linodeFactory.build({ - id: 1, - image: 'metadata-test-image', - label: 'metadata-test-region', - region: 'eu-west', -}); +import type { Linode } from '@linode/api-v4'; + const TEXTFIELD_ID = 'textfield-input'; describe('LinodeSelect', () => { - test('renders custom options using renderOption', async () => { - // Create a mock renderOption function - const mockRenderOption = (linode: Linode, selected: boolean) => ( - - {`${linode.label} - ${selected ? 'Selected' : 'Not Selected'}`} - - ); - - // Render the component with the custom renderOption function - renderWithTheme( - - ); - - const input = screen.getByTestId(TEXTFIELD_ID); - - // Open the dropdown - await userEvent.click(input); - - // Wait for the options to load (use some unique identifier for the options) - await waitFor(() => { - const customOption = screen.getByTestId('custom-option-1'); - expect(customOption).toBeInTheDocument(); - expect(customOption).toHaveTextContent( - 'metadata-test-region - Not Selected' - ); - }); - }); test('should display custom no options message', async () => { const customNoOptionsMessage = 'Custom No Options Message'; const options: Linode[] = []; // Assuming no options are available diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index 84722abb76d..0b0de416bac 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -45,10 +45,6 @@ interface LinodeSelectProps { optionsFilter?: (linode: Linode) => boolean; /* Displayed when the input is blank. */ placeholder?: string; - /* Render a custom option. */ - renderOption?: (linode: Linode, selected: boolean) => JSX.Element; - /* Render a custom option label. */ - renderOptionLabel?: (linode: Linode) => string; /* Displays an indication that the input is required. */ required?: boolean; /* Adds custom styles to the component. */ @@ -98,8 +94,6 @@ export const LinodeSelect = ( options, optionsFilter, placeholder, - renderOption, - renderOptionLabel, sx, value, } = props; @@ -122,9 +116,6 @@ export const LinodeSelect = ( return ( - renderOptionLabel ? renderOptionLabel(linode) : linode.label - } isOptionEqualToValue={ checkIsOptionEqualToValue ? (option, value) => option.id === value.id @@ -145,17 +136,6 @@ export const LinodeSelect = ( ? 'Select Linodes' : 'Select a Linode' } - renderOption={ - renderOption - ? (props, option, { selected }) => { - return ( -
  • - {renderOption(option, selected)} -
  • - ); - } - : undefined - } value={ typeof value === 'function' ? multiple && Array.isArray(value) diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx index a7e1f3de72e..424e4f7c87f 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { isPrivateIP } from 'src/utilities/ipUtils'; import { tail } from 'src/utilities/tail'; import { @@ -55,7 +55,7 @@ export interface IPAddressProps { } export const sortIPAddress = (ip1: string, ip2: string) => - (privateIPRegex.test(ip1) ? 1 : -1) - (privateIPRegex.test(ip2) ? 1 : -1); + (isPrivateIP(ip1) ? 1 : -1) - (isPrivateIP(ip2) ? 1 : -1); export const IPAddress = (props: IPAddressProps) => { const { diff --git a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx index 1684f27b21e..be63b6a4e08 100644 --- a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx @@ -15,7 +15,7 @@ import { handleFieldErrors, handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; -import { privateIPRegex, removePrefixLength } from 'src/utilities/ipUtils'; +import { isPrivateIP, removePrefixLength } from 'src/utilities/ipUtils'; import { StyledIPGrid, @@ -168,9 +168,7 @@ const EditSSHAccessDrawer = (props: EditSSHAccessDrawerProps) => { }, ...options // Remove Private IPs - .filter( - (option) => !privateIPRegex.test(option.value) - ) + .filter((option) => !isPrivateIP(option.value)) // Remove the prefix length from each option. .map((option) => ({ label: removePrefixLength(option.value), diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 6630b7509c9..2372102a9d3 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -1,96 +1,102 @@ -import { Box } from '@mui/material'; -import * as React from 'react'; +import React from 'react'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { privateIPRegex } from 'src/utilities/ipUtils'; +import { Box } from 'src/components/Box'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import type { Linode } from '@linode/api-v4/lib/linodes'; -import type { TextFieldProps } from 'src/components/TextField'; +import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils'; -interface ConfigNodeIPSelectProps { +interface Props { + /** + * Disables the select + */ disabled?: boolean; - errorText?: string; + /** + * Validation error text + */ + errorText: string | undefined; + /** + * Function that is called when the select's value changes + */ handleChange: (nodeIndex: number, ipAddress: null | string) => void; + /** + * Override the default input `id` for the select + */ inputId?: string; - nodeAddress?: string; + /** + * The selected private IP address + */ + nodeAddress: string | undefined; + /** + * The index of the config node in state + */ nodeIndex: number; - selectedRegion?: string; - textfieldProps: Omit; + /** + * The region for which to load Linodes and to show private IPs + * @note IPs won't load until a region is passed + */ + region: string | undefined; } -export const ConfigNodeIPSelect = React.memo( - (props: ConfigNodeIPSelectProps) => { - const { - handleChange: _handleChange, - inputId, - nodeAddress, - nodeIndex, - } = props; - const handleChange = (linode: Linode | null) => { - if (!linode) { - _handleChange(nodeIndex, null); - } +export const ConfigNodeIPSelect = React.memo((props: Props) => { + const { + disabled, + errorText, + handleChange, + inputId, + nodeAddress, + nodeIndex, + region, + } = props; - const thisLinodesPrivateIP = linode?.ipv4.find((ipv4) => - ipv4.match(privateIPRegex) - ); + const { data: linodes, error, isLoading } = useAllLinodesQuery( + {}, + { region }, + region !== undefined + ); - if (!thisLinodesPrivateIP) { - return; - } + const options = getPrivateIPOptions(linodes); - /** - * we can be sure the selection has a private IP because of the - * filterCondition prop in the render method below - */ - _handleChange(nodeIndex, thisLinodesPrivateIP); - }; - - return ( - { - /** - * if the Linode doesn't have an private IP OR if the Linode - * is in a different region that the NodeBalancer, don't show it - * in the select dropdown - */ - return ( - !!linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex)) && - linode.region === props.selectedRegion - ); - }} - renderOption={(linode, selected) => ( - <> - - - {linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex))} - -
    {linode.label}
    -
    - - - )} - renderOptionLabel={(linode) => - linode.ipv4.find((eachIP) => eachIP.match(privateIPRegex)) ?? '' - } - clearable - disabled={props.disabled} - errorText={props.errorText} - id={inputId} - label="IP Address" - noMarginTop - onSelectionChange={handleChange} - placeholder="Enter IP Address" - value={(linode) => linode.ipv4.some((ip) => ip === nodeAddress)} - /> - ); - } -); + return ( + ( +
  • + + + theme.font.bold} + > + {option.label} + + {option.linode.label} + + {selected && } + +
  • + )} + disabled={disabled} + errorText={errorText ?? error?.[0].reason} + id={inputId} + label="IP Address" + loading={isLoading} + noMarginTop + noOptionsText="No options - please ensure you have at least 1 Linode with a private IP located in the selected region." + onChange={(e, value) => handleChange(nodeIndex, value?.label ?? null)} + options={options} + placeholder="Enter IP Address" + value={options.find((o) => o.label === nodeAddress) ?? null} + /> + ); +}); diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts new file mode 100644 index 00000000000..e95b738d915 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts @@ -0,0 +1,32 @@ +import { linodeFactory } from 'src/factories'; + +import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils'; + +describe('getPrivateIPOptions', () => { + it('returns an empty array when linodes are undefined', () => { + expect(getPrivateIPOptions(undefined)).toStrictEqual([]); + }); + + it('returns an empty array when there are no Linodes', () => { + expect(getPrivateIPOptions([])).toStrictEqual([]); + }); + + it('returns an option for each private IPv4 on a Linode', () => { + const linode = linodeFactory.build({ ipv4: ['192.168.1.1', '172.16.0.1'] }); + + expect(getPrivateIPOptions([linode])).toStrictEqual([ + { label: '192.168.1.1', linode }, + { label: '172.16.0.1', linode }, + ]); + }); + + it('does not return an option for public IPv4s on a Linode', () => { + const linode = linodeFactory.build({ + ipv4: ['143.198.125.230', '192.168.1.1'], + }); + + expect(getPrivateIPOptions([linode])).toStrictEqual([ + { label: '192.168.1.1', linode }, + ]); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts new file mode 100644 index 00000000000..1dfc830bbf8 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts @@ -0,0 +1,36 @@ +import { isPrivateIP } from 'src/utilities/ipUtils'; + +import type { Linode } from '@linode/api-v4'; + +interface PrivateIPOption { + /** + * A private IPv4 address + */ + label: string; + /** + * The Linode associated with the private IPv4 address + */ + linode: Linode; +} + +/** + * Given an array of Linodes, this function returns an array of private + * IPv4 options intended to be used in a Select component. + */ +export const getPrivateIPOptions = (linodes: Linode[] | undefined) => { + if (!linodes) { + return []; + } + + const options: PrivateIPOption[] = []; + + for (const linode of linodes) { + for (const ip of linode.ipv4) { + if (isPrivateIP(ip)) { + options.push({ label: ip, linode }); + } + } + } + + return options; +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index 05f47659c98..c97f7bc1345 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -134,18 +134,13 @@ export const NodeBalancerConfigNode = React.memo( diff --git a/packages/manager/src/utilities/ipUtils.test.ts b/packages/manager/src/utilities/ipUtils.test.ts new file mode 100644 index 00000000000..371cbf7b855 --- /dev/null +++ b/packages/manager/src/utilities/ipUtils.test.ts @@ -0,0 +1,13 @@ +import { isPrivateIP } from './ipUtils'; + +describe('isPrivateIP', () => { + it('returns true for a private IPv4', () => { + expect(isPrivateIP('192.168.1.1')).toBe(true); + expect(isPrivateIP('172.16.5.12')).toBe(true); + }); + + it('returns false for a public IPv4', () => { + expect(isPrivateIP('45.79.245.236')).toBe(false); + expect(isPrivateIP('100.78.0.8')).toBe(false); + }); +}); diff --git a/packages/manager/src/utilities/ipUtils.ts b/packages/manager/src/utilities/ipUtils.ts index 74368b21f55..06392a895a2 100644 --- a/packages/manager/src/utilities/ipUtils.ts +++ b/packages/manager/src/utilities/ipUtils.ts @@ -1,3 +1,4 @@ +import { PRIVATE_IPv4_REGEX } from '@linode/validation'; import { parseCIDR, parse as parseIP } from 'ipaddr.js'; /** @@ -8,9 +9,12 @@ import { parseCIDR, parse as parseIP } from 'ipaddr.js'; export const removePrefixLength = (ip: string) => ip.replace(/\/\d+/, ''); /** - * Regex for determining if a string is a private IP Addresses + * Determines if an IPv4 address is private + * @returns true if the given IPv4 address is private */ -export const privateIPRegex = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/; +export const isPrivateIP = (ip: string) => { + return PRIVATE_IPv4_REGEX.test(ip); +}; export interface ExtendedIP { address: string; diff --git a/packages/validation/.changeset/pr-11069-added-1728444089255.md b/packages/validation/.changeset/pr-11069-added-1728444089255.md new file mode 100644 index 00000000000..42512f52050 --- /dev/null +++ b/packages/validation/.changeset/pr-11069-added-1728444089255.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +`PRIVATE_IPv4_REGEX` for determining if an IPv4 address is private ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/validation/.changeset/pr-11069-changed-1728444173048.md b/packages/validation/.changeset/pr-11069-changed-1728444173048.md new file mode 100644 index 00000000000..286ee280e1f --- /dev/null +++ b/packages/validation/.changeset/pr-11069-changed-1728444173048.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Updated `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 46d2d889e2b..4cc2c15793c 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -3,6 +3,8 @@ import { array, boolean, mixed, number, object, string } from 'yup'; const PORT_WARNING = 'Port must be between 1 and 65535.'; const LABEL_WARNING = 'Label must be between 3 and 32 characters.'; +export const PRIVATE_IPv4_REGEX = /^10\.|^172\.1[6-9]\.|^172\.2[0-9]\.|^172\.3[0-1]\.|^192\.168\.|^fd/; + export const nodeBalancerConfigNodeSchema = object({ label: string() .matches( @@ -16,10 +18,7 @@ export const nodeBalancerConfigNodeSchema = object({ address: string() .typeError('IP address is required.') .required('IP address is required.') - .matches( - /^192\.168\.\d{1,3}\.\d{1,3}$/, - 'Must be a valid private IPv4 address.' - ), + .matches(PRIVATE_IPv4_REGEX, 'Must be a valid private IPv4 address.'), port: number() .typeError('Port must be a number.') From 77174caad0103ec5e9db2abbc5a6936a390f97f6 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:49:43 -0400 Subject: [PATCH 03/64] upcoming: [M3-6538] - Add Interaction Tokens, Minimally Cleanup Theme Files (#11078) * upcoming: [M3-6538] - Add Interaction Tokens, Minimally Cleanup Theme Files * Add changeset --------- Co-authored-by: Jaalah Ramos --- .../pr-11078-upcoming-features-1728503718215.md | 5 +++++ .../components/ColorPalette/ColorPalette.tsx | 4 ---- .../DatabaseSummaryConnectionDetails.tsx | 2 +- .../Linodes/LinodeEntityDetail.styles.ts | 4 ++-- packages/manager/src/foundations/themes/dark.ts | 5 +---- .../manager/src/foundations/themes/index.ts | 17 ++++++++++------- .../manager/src/foundations/themes/light.ts | 6 ++---- 7 files changed, 21 insertions(+), 22 deletions(-) create mode 100644 packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md diff --git a/packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md b/packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md new file mode 100644 index 00000000000..b78f7edce7a --- /dev/null +++ b/packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Interaction Tokens, Minimally Cleanup Theme Files ([#11078](https://github.com/linode/manager/pull/11078)) diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index 9404371eea9..f6e27c7c7a5 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -123,10 +123,6 @@ export const ColorPalette = () => { alias: 'theme.bg.bgPaper', color: theme.bg.bgPaper, }, - { - alias: 'theme.bg.bgAccessRow', - color: theme.bg.bgAccessRow, - }, { alias: 'theme.bg.bgAccessRowTransparentGradient', color: theme.bg.bgAccessRowTransparentGradient, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index ac0409e1b0a..17b54795979 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -61,7 +61,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ '& span': { fontFamily: theme.font.bold, }, - background: theme.bg.bgAccessRow, + background: theme.interactionTokens.Background.Secondary, border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, padding: '8px 15px', }, diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index f59d4fb895c..4c7ec07a99e 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -157,7 +157,7 @@ export const StyledTableCell = styled(TableCell, { label: 'StyledTableCell' })( fontSize: 15, }, alignItems: 'center', - backgroundColor: theme.bg.bgAccessRow, + backgroundColor: theme.interactionTokens.Background.Secondary, color: theme.textColors.tableStatic, display: 'flex', fontFamily: '"UbuntuMono", monospace, sans-serif', @@ -180,7 +180,7 @@ export const StyledCopyTooltip = styled(CopyTooltip, { export const StyledGradientDiv = styled('div', { label: 'StyledGradientDiv' })( ({ theme }) => ({ '&:after': { - backgroundImage: `linear-gradient(to right, ${theme.bg.bgAccessRowTransparentGradient}, ${theme.bg.bgAccessRow});`, + backgroundImage: `linear-gradient(to right, ${theme.bg.bgAccessRowTransparentGradient}, ${theme.interactionTokens.Background.Secondary});`, bottom: 0, content: '""', height: '100%', diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 8a2d45524c5..5386f2d7877 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -2,7 +2,6 @@ import { Action, Badge, Button, - Chart, Color, Dropdown, Interaction, @@ -38,7 +37,6 @@ export const customDarkModeOptions = { bg: { app: Color.Neutrals[100], appBar: tempReplacementforColorNeutralsBlack, - bgAccessRow: Color.Neutrals[80], bgAccessRowTransparentGradient: 'rgb(69, 75, 84, .001)', bgPaper: Color.Neutrals[90], interactionBgPrimary: Interaction.Background.Secondary, @@ -58,7 +56,6 @@ export const customDarkModeOptions = { borderTypography: Color.Neutrals[80], divider: Color.Neutrals[80], }, - charts: { ...Chart }, color: { black: Color.Neutrals.White, blueDTwhite: Color.Neutrals.White, @@ -199,7 +196,6 @@ export const darkTheme: ThemeOptions = { bg: customDarkModeOptions.bg, borderColors: customDarkModeOptions.borderColors, breakpoints, - charts: customDarkModeOptions.charts, color: customDarkModeOptions.color, components: { MuiAppBar: { @@ -856,6 +852,7 @@ export const darkTheme: ThemeOptions = { color: Select.Hover.Text, }, }, + interactionTokens: Interaction, name: 'dark', notificationToast, palette: { diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts index ec0d31ce7f1..b9402e854ff 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/manager/src/foundations/themes/index.ts @@ -5,8 +5,11 @@ import { darkTheme } from 'src/foundations/themes/dark'; import { lightTheme } from 'src/foundations/themes/light'; import { deepMerge } from 'src/utilities/deepMerge'; -import type { Chart as ChartLight } from '@linode/design-language-system'; -import type { Chart as ChartDark } from '@linode/design-language-system/themes/dark'; +import type { + ChartTypes, + InteractionTypes as InteractionTypesLight, +} from '@linode/design-language-system'; +import type { InteractionTypes as InteractionTypesDark } from '@linode/design-language-system/themes/dark'; import type { latoWeb } from 'src/foundations/fonts'; // Types & Interfaces import type { @@ -23,9 +26,7 @@ import type { export type ThemeName = 'dark' | 'light'; -type ChartLightTypes = typeof ChartLight; -type ChartDarkTypes = typeof ChartDark; -type ChartTypes = MergeTypes; +type InteractionTypes = MergeTypes; type Fonts = typeof latoWeb; @@ -72,11 +73,12 @@ declare module '@mui/material/styles/createTheme' { applyTableHeaderStyles?: any; bg: BgColors; borderColors: BorderColors; - charts: ChartTypes; + chartTokens: ChartTypes; color: Colors; font: Fonts; graphs: any; inputStyles: any; + interactionTokens: InteractionTypes; name: ThemeName; notificationToast: NotificationToast; textColors: TextColors; @@ -91,11 +93,12 @@ declare module '@mui/material/styles/createTheme' { applyTableHeaderStyles?: any; bg?: DarkModeBgColors | LightModeBgColors; borderColors?: DarkModeBorderColors | LightModeBorderColors; - charts: ChartTypes; + chartTokens?: ChartTypes; color?: DarkModeColors | LightModeColors; font?: Fonts; graphs?: any; inputStyles?: any; + interactionTokens?: InteractionTypes; name: ThemeName; notificationToast?: NotificationToast; textColors?: DarkModeTextColors | LightModeTextColors; diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index e9cb069855c..9ca4057aa2c 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -17,12 +17,9 @@ import type { ThemeOptions } from '@mui/material/styles'; export const inputMaxWidth = 416; -export const charts = { ...Chart } as const; - export const bg = { app: Color.Neutrals[5], appBar: 'transparent', - bgAccessRow: Color.Neutrals[5], bgAccessRowTransparentGradient: 'rgb(255, 255, 255, .001)', bgPaper: Color.Neutrals.White, interactionBgPrimary: Interaction.Background.Secondary, @@ -242,7 +239,7 @@ export const lightTheme: ThemeOptions = { bg, borderColors, breakpoints, - charts, + chartTokens: Chart, color, components: { MuiAccordion: { @@ -1574,6 +1571,7 @@ export const lightTheme: ThemeOptions = { color: Select.Hover.Text, }, }, + interactionTokens: Interaction, name: 'light', // @todo remove this because we leverage pallete.mode now notificationToast, palette: { From d0927f8c24b4a5f7a5c443d45aa9251d639fff82 Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Fri, 11 Oct 2024 13:10:03 -0400 Subject: [PATCH 04/64] test: [M3-8444] - Add assertions for bucket details drawer tests (#10971) * Add assertions for bucket details drawer tests * Added changeset: Add assertions for bucket details drawer tests * Add one more assertion to check toggle is not visible for E2,E3 * update comments --- .../pr-10971-tests-1726764686101.md | 5 + .../bucket-create-gen2.spec.ts | 126 ++++++++++++++++-- 2 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-10971-tests-1726764686101.md diff --git a/packages/manager/.changeset/pr-10971-tests-1726764686101.md b/packages/manager/.changeset/pr-10971-tests-1726764686101.md new file mode 100644 index 00000000000..b904a51b98f --- /dev/null +++ b/packages/manager/.changeset/pr-10971-tests-1726764686101.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add assertions for bucket details drawer tests ([#10971](https://github.com/linode/manager/pull/10971)) 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 499ec5c2f3a..068b97e9ccb 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 @@ -5,6 +5,7 @@ import { mockGetBuckets, mockDeleteBucket, mockCreateBucket, + mockGetBucketAccess, mockCreateBucketError, } from 'support/intercepts/object-storage'; import { mockGetRegions } from 'support/intercepts/regions'; @@ -18,7 +19,7 @@ import { regionFactory, } from 'src/factories'; import { chooseRegion } from 'support/util/regions'; -import type { ObjectStorageEndpoint } from '@linode/api-v4'; +import type { ACLType, ObjectStorageEndpoint } from '@linode/api-v4'; describe('Object Storage Gen2 create bucket tests', () => { beforeEach(() => { @@ -71,14 +72,80 @@ describe('Object Storage Gen2 create bucket tests', () => { }), ]; + const mockAccess = { + acl: 'private' as ACLType, + acl_xml: '', + cors_enabled: true, + cors_xml: '', + }; + + const bucketRateLimitsNotice = + 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. Understand bucket rate limits.'; + const CORSNotice = + 'CORS (Cross Origin Sharing) is not available for endpoint types E2 and E3'; + + // For E0/E1, confirm CORS toggle and ACL selection are both present + // For E2/E3, confirm rate limit notice and table are present, ACL selection is present, CORS toggle is absent + const checkBucketDetailsDrawer = ( + bucketLabel: string, + endpointType: string + ) => { + ui.drawer.findByTitle(bucketLabel).within(() => { + if ( + endpointType === 'Standard (E3)' || + endpointType === 'Standard (E2)' + ) { + cy.contains(bucketRateLimitsNotice).should('be.visible'); + cy.get('[data-testid="bucket-rate-limit-table"]').should('be.visible'); + cy.contains(CORSNotice).should('be.visible'); + ui.toggle.find().should('not.exist'); + } else { + cy.get('[data-testid="bucket-rate-limit-table"]').should('not.exist'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); + cy.contains('CORS Enabled').should('be.visible'); + } + + // Verify that all ACL selection show up as options + cy.findByLabelText('Access Control List (ACL)') + .should('be.visible') + .should('have.value', 'Private') + .click(); + ui.autocompletePopper + .findByTitle('Public Read') + .should('be.visible') + .should('be.enabled'); + ui.autocompletePopper + .findByTitle('Authenticated Read') + .should('be.visible') + .should('be.enabled'); + ui.autocompletePopper + .findByTitle('Public Read/Write') + .should('be.visible') + .should('be.enabled'); + ui.autocompletePopper + .findByTitle('Private') + .should('be.visible') + .should('be.enabled') + .click(); + + // Close the Details drawer + cy.get('[data-qa-close-drawer="true"]').should('be.visible').click(); + }); + }; + /** * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E0 * Confirms all endpoints are displayed regardless if there's multiple of the same type * Confirms S3 endpoint hostname displayed to differentiate between identical options in the dropdown + * Confirms correct information displays in the details drawer for a bucket with endpoint E0 */ it('can create a bucket with E0 endpoint type', () => { const endpointTypeE0 = 'Legacy (E0)'; const bucketLabel = randomLabel(); + const bucketCluster = 'us-iad-12'; mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); @@ -94,6 +161,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ); mockGetRegions(mockRegions); + mockGetBucketAccess(bucketLabel, bucketCluster, mockAccess).as( + 'getBucketAccess' + ); cy.visitWithLogin('/object-storage/buckets/create'); cy.wait([ @@ -182,9 +252,15 @@ describe('Object Storage Gen2 create bucket tests', () => { .closest('tr') .within(() => { cy.findByText(mockRegion.label).should('be.visible'); - ui.button.findByTitle('Delete').should('be.visible').click(); + // Confirm that clicking "Details" button for the bucket opens details drawer + ui.button.findByTitle('Details').should('be.visible').click(); }); + checkBucketDetailsDrawer(bucketLabel, endpointTypeE0); + + // Delete the bucket to clean up + ui.button.findByTitle('Delete').should('be.visible').click(); + ui.dialog .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') @@ -205,10 +281,12 @@ describe('Object Storage Gen2 create bucket tests', () => { /** * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E1 + * Confirms correct information displays in the details drawer for a bucket with endpoint E1 */ it('can create a bucket with E1 endpoint type', () => { const endpointTypeE1 = 'Standard (E1)'; const bucketLabel = randomLabel(); + const bucketCluster = 'us-iad-12'; mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); @@ -224,6 +302,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ); mockGetRegions(mockRegions); + mockGetBucketAccess(bucketLabel, bucketCluster, mockAccess).as( + 'getBucketAccess' + ); cy.visitWithLogin('/object-storage/buckets/create'); cy.wait([ @@ -297,9 +378,15 @@ describe('Object Storage Gen2 create bucket tests', () => { .closest('tr') .within(() => { cy.findByText(mockRegion.label).should('be.visible'); - ui.button.findByTitle('Delete').should('be.visible').click(); + // Confirm that clicking "Details" button for the bucket opens details drawer + ui.button.findByTitle('Details').should('be.visible').click(); }); + checkBucketDetailsDrawer(bucketLabel, endpointTypeE1); + + // Delete the bucket to clean up + ui.button.findByTitle('Delete').should('be.visible').click(); + ui.dialog .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') @@ -320,10 +407,12 @@ describe('Object Storage Gen2 create bucket tests', () => { /** * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E2 + * Confirms correct information displays in the details drawer for a bucket with endpoint E2 */ it('can create a bucket with E2 endpoint type', () => { const endpointTypeE2 = 'Standard (E2)'; const bucketLabel = randomLabel(); + const bucketCluster = 'us-iad-12'; mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); @@ -339,6 +428,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ); mockGetRegions(mockRegions); + mockGetBucketAccess(bucketLabel, bucketCluster, mockAccess).as( + 'getBucketAccess' + ); cy.visitWithLogin('/object-storage/buckets/create'); cy.wait([ @@ -374,9 +466,7 @@ describe('Object Storage Gen2 create bucket tests', () => { // Confirm bucket rate limits text for E2 endpoint cy.findByText('Bucket Rate Limits').should('be.visible'); - cy.contains( - 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. Understand bucket rate limits.' - ).should('be.visible'); + cy.contains(bucketRateLimitsNotice).should('be.visible'); // Confirm bucket rate limit table should exist when E2 endpoint is selected cy.get('[data-testid="bucket-rate-limit-table"]').should('exist'); @@ -412,9 +502,15 @@ describe('Object Storage Gen2 create bucket tests', () => { .closest('tr') .within(() => { cy.findByText(mockRegion.label).should('be.visible'); - ui.button.findByTitle('Delete').should('be.visible').click(); + // Confirm that clicking "Details" button for the bucket opens details drawer + ui.button.findByTitle('Details').should('be.visible').click(); }); + checkBucketDetailsDrawer(bucketLabel, endpointTypeE2); + + // Delete the bucket to clean up + ui.button.findByTitle('Delete').should('be.visible').click(); + ui.dialog .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') @@ -435,10 +531,12 @@ describe('Object Storage Gen2 create bucket tests', () => { /** * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E3 + * Confirms correct information displays in the details drawer for a bucket with endpoint E3 */ it('can create a bucket with E3 endpoint type', () => { const endpointTypeE3 = 'Standard (E3)'; const bucketLabel = randomLabel(); + const bucketCluster = 'us-iad-12'; mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); @@ -454,6 +552,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ); mockGetRegions(mockRegions); + mockGetBucketAccess(bucketLabel, bucketCluster, mockAccess).as( + 'getBucketAccess' + ); cy.visitWithLogin('/object-storage/buckets/create'); cy.wait([ @@ -490,9 +591,7 @@ describe('Object Storage Gen2 create bucket tests', () => { // Confirm bucket rate limits text for E3 endpoint cy.findByText('Bucket Rate Limits').should('be.visible'); - cy.contains( - 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. Understand bucket rate limits.' - ).should('be.visible'); + cy.contains(bucketRateLimitsNotice).should('be.visible'); // Confirm bucket rate limit table should exist when E3 endpoint is selected cy.get('[data-testid="bucket-rate-limit-table"]').should('exist'); @@ -528,9 +627,14 @@ describe('Object Storage Gen2 create bucket tests', () => { .closest('tr') .within(() => { cy.findByText(mockRegion.label).should('be.visible'); - ui.button.findByTitle('Delete').should('be.visible').click(); + // Confirm that clicking "Details" button for the bucket opens details drawer + ui.button.findByTitle('Details').should('be.visible').click(); }); + checkBucketDetailsDrawer(bucketLabel, endpointTypeE3); + + // Delete the bucket to clean up + ui.button.findByTitle('Delete').should('be.visible').click(); ui.dialog .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') From f754a620d16f0621f237fefeccc3c7090061dc95 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:09:28 -0400 Subject: [PATCH 05/64] fix: [M3-8730] - Remove `@mui/system` imports (#11081) * fix all imports * ts perf is better * improve tsc pref more * theme fix @hkhalil-akamai * fix typecheck --------- Co-authored-by: Banks Nussman --- packages/manager/.eslintrc.cjs | 1 + .../manager/src/components/Avatar/Avatar.tsx | 4 +- .../manager/src/components/Button/Button.tsx | 3 +- packages/manager/src/components/Checkbox.tsx | 8 +-- .../src/components/CheckoutBar/styles.ts | 29 +++++------ .../CircleProgress/CircleProgress.tsx | 5 +- .../DescriptionList/DescriptionList.tsx | 5 +- .../components/DialogTitle/DialogTitle.tsx | 4 +- .../components/DownloadCSV/DownloadCSV.tsx | 4 +- .../src/components/H1Header/H1Header.tsx | 5 +- .../src/components/LineGraph/LineGraph.tsx | 2 +- .../PlacementGroupsSelect.tsx | 9 ++-- .../src/components/SelectionCard/CardBase.tsx | 10 ++-- .../SelectionCard/SelectionCard.tsx | 17 +++--- .../components/TabbedPanel/TabbedPanel.tsx | 5 +- .../src/components/TagCell/TagCell.tsx | 4 +- .../components/TextTooltip/TextTooltip.tsx | 4 +- .../manager/src/components/TooltipIcon.tsx | 6 +-- .../manager/src/features/Betas/BetaSignup.tsx | 2 +- .../DatabaseLanding/DatabaseLogo.tsx | 4 +- .../GlobalNotifications/ComplianceBanner.tsx | 6 +-- .../src/features/Linodes/AccessTable.tsx | 4 +- .../Linodes/LinodeSelect/LinodeSelect.tsx | 7 +-- .../LinodeRebuild/ImageEmptyState.tsx | 6 ++- .../LinodesLanding/LinodeRow/LinodeRow.tsx | 4 +- .../NodeBalancers/NodeBalancerSelect.tsx | 8 +-- .../AccessKeyLanding/CopyAllHostnames.tsx | 2 +- .../manager/src/features/TopMenu/TopMenu.tsx | 1 + .../VPCs/VPCDetail/AssignIPRanges.tsx | 6 +-- .../PlansPanel/DistributedRegionPlanTable.tsx | 5 +- .../manager/src/foundations/breakpoints.ts | 25 +++++---- .../manager/src/foundations/themes/dark.ts | 5 +- .../manager/src/foundations/themes/index.ts | 3 +- .../manager/src/foundations/themes/light.ts | 5 -- .../manager/src/utilities/deepMerge.test.ts | 52 ------------------- packages/manager/src/utilities/deepMerge.ts | 42 --------------- 36 files changed, 110 insertions(+), 202 deletions(-) delete mode 100644 packages/manager/src/utilities/deepMerge.test.ts delete mode 100644 packages/manager/src/utilities/deepMerge.ts diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 075f9e7f314..6bc2c2fdd9f 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -162,6 +162,7 @@ module.exports = { 'error', 'rxjs', '@mui/core', + '@mui/system', '@mui/icons-material', ], 'no-throw-literal': 'warn', diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx index e50bf8bf4fa..d84a53f170b 100644 --- a/packages/manager/src/components/Avatar/Avatar.tsx +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -6,7 +6,7 @@ import AkamaiWave from 'src/assets/logo/akamai-wave.svg'; import { usePreferences } from 'src/queries/profile/preferences'; import { useProfile } from 'src/queries/profile/profile'; -import type { SxProps } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material'; export const DEFAULT_AVATAR_SIZE = 28; @@ -23,7 +23,7 @@ export interface AvatarProps { /** * Optional styles * */ - sx?: SxProps; + sx?: SxProps; /** * Optional username to override the profile username; will display the first letter * */ diff --git a/packages/manager/src/components/Button/Button.tsx b/packages/manager/src/components/Button/Button.tsx index c347b33ad86..4dd8d635979 100644 --- a/packages/manager/src/components/Button/Button.tsx +++ b/packages/manager/src/components/Button/Button.tsx @@ -10,8 +10,7 @@ import { rotate360 } from '../../styles/keyframes'; import { omittedProps } from '../../utilities/omittedProps'; import type { ButtonProps as _ButtonProps } from '@mui/material/Button'; -import type { Theme } from '@mui/material/styles'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material/styles'; export type ButtonType = 'outlined' | 'primary' | 'secondary'; diff --git a/packages/manager/src/components/Checkbox.tsx b/packages/manager/src/components/Checkbox.tsx index fd2db5a9b8c..036779e0a61 100644 --- a/packages/manager/src/components/Checkbox.tsx +++ b/packages/manager/src/components/Checkbox.tsx @@ -1,6 +1,5 @@ -import _Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; -import { Theme, styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; +import _Checkbox from '@mui/material/Checkbox'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import CheckboxIcon from 'src/assets/icons/checkbox.svg'; @@ -8,6 +7,9 @@ import CheckboxCheckedIcon from 'src/assets/icons/checkboxChecked.svg'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { TooltipIcon } from 'src/components/TooltipIcon'; +import type { CheckboxProps } from '@mui/material/Checkbox'; +import type { SxProps, Theme } from '@mui/material/styles'; + interface Props extends CheckboxProps { /** * Styles applied to the `FormControlLabel`. Only works when `text` is defined. diff --git a/packages/manager/src/components/CheckoutBar/styles.ts b/packages/manager/src/components/CheckoutBar/styles.ts index 2e10b63f99e..fab15c53678 100644 --- a/packages/manager/src/components/CheckoutBar/styles.ts +++ b/packages/manager/src/components/CheckoutBar/styles.ts @@ -1,5 +1,4 @@ -import { useTheme } from '@mui/material/styles'; -import { styled } from '@mui/system'; +import { styled, useTheme } from '@mui/material/styles'; import { Button } from 'src/components/Button/Button'; @@ -10,21 +9,17 @@ const StyledButton = styled(Button)(({ theme }) => ({ }, })); -const StyledRoot = styled('div')(() => { - const theme = useTheme(); - - return { - minHeight: '24px', - minWidth: '24px', - [theme.breakpoints.down(1280)]: { - background: theme.color.white, - bottom: '0 !important' as '0', - left: '0 !important' as '0', - padding: theme.spacing(2), - position: 'relative !important' as 'relative', - }, - }; -}); +const StyledRoot = styled('div')(({ theme }) => ({ + minHeight: '24px', + minWidth: '24px', + [theme.breakpoints.down(1280)]: { + background: theme.color.white, + bottom: '0 !important' as '0', + left: '0 !important' as '0', + padding: theme.spacing(2), + position: 'relative !important' as 'relative', + }, +})); const StyledCheckoutSection = styled('div')(({ theme }) => ({ padding: '12px 0', diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.tsx index 115f41c5422..474e6e05c9c 100644 --- a/packages/manager/src/components/CircleProgress/CircleProgress.tsx +++ b/packages/manager/src/components/CircleProgress/CircleProgress.tsx @@ -1,11 +1,12 @@ import _CircularProgress from '@mui/material/CircularProgress'; -import { SxProps, styled } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { omittedProps } from 'src/utilities/omittedProps'; import type { CircularProgressProps } from '@mui/material/CircularProgress'; +import type { SxProps, Theme } from '@mui/material/styles'; interface CircleProgressProps extends Omit { /** @@ -24,7 +25,7 @@ interface CircleProgressProps extends Omit { /** * Additional styles to apply to the root element. */ - sx?: SxProps; + sx?: SxProps; } const SIZE_MAP = { diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.tsx index 575412bcd76..4711612dd7d 100644 --- a/packages/manager/src/components/DescriptionList/DescriptionList.tsx +++ b/packages/manager/src/components/DescriptionList/DescriptionList.tsx @@ -1,4 +1,3 @@ -import { SxProps } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -12,7 +11,7 @@ import { StyledDT, } from './DescriptionList.styles'; -import type { Breakpoint, Theme } from '@mui/material/styles'; +import type { Breakpoint, SxProps, Theme } from '@mui/material/styles'; import type { TooltipIconProps } from 'src/components/TooltipIcon'; type DescriptionListBaseProps = { @@ -67,7 +66,7 @@ type DescriptionListBaseProps = { /** * Additional styles to apply to the component. */ - sx?: SxProps; + sx?: SxProps; }; interface DescriptionListGridProps diff --git a/packages/manager/src/components/DialogTitle/DialogTitle.tsx b/packages/manager/src/components/DialogTitle/DialogTitle.tsx index 4233df20fba..62d1e7b50ff 100644 --- a/packages/manager/src/components/DialogTitle/DialogTitle.tsx +++ b/packages/manager/src/components/DialogTitle/DialogTitle.tsx @@ -6,14 +6,14 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { IconButton } from 'src/components/IconButton'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material'; interface DialogTitleProps { className?: string; id?: string; onClose?: () => void; subtitle?: string; - sx?: SxProps; + sx?: SxProps; title: string; } diff --git a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx index 4a0c5153b61..ace75c60c7f 100644 --- a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx +++ b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx @@ -1,4 +1,3 @@ -import { SxProps } from '@mui/system'; import * as React from 'react'; import { CSVLink } from 'react-csv'; @@ -6,6 +5,7 @@ import DownloadIcon from 'src/assets/icons/lke-download.svg'; import { Button } from 'src/components/Button/Button'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import type { SxProps, Theme } from '@mui/material/styles'; import type { ButtonType } from 'src/components/Button/Button'; interface DownloadCSVProps { @@ -17,7 +17,7 @@ interface DownloadCSVProps { filename: string; headers: { key: string; label: string }[]; onClick: (() => void) | ((e: React.MouseEvent) => void); - sx?: SxProps; + sx?: SxProps; text?: string; } diff --git a/packages/manager/src/components/H1Header/H1Header.tsx b/packages/manager/src/components/H1Header/H1Header.tsx index 990de603a58..c434cdd53e6 100644 --- a/packages/manager/src/components/H1Header/H1Header.tsx +++ b/packages/manager/src/components/H1Header/H1Header.tsx @@ -1,13 +1,14 @@ -import { SxProps } from '@mui/system'; import * as React from 'react'; import { Typography } from 'src/components/Typography'; +import type { SxProps, Theme } from '@mui/material/styles'; + interface H1HeaderProps { className?: string; dataQaEl?: string; renderAsSecondary?: boolean; - sx?: SxProps; + sx?: SxProps; title: string; } // Accessibility Feature: diff --git a/packages/manager/src/components/LineGraph/LineGraph.tsx b/packages/manager/src/components/LineGraph/LineGraph.tsx index f49657bbca2..cde35ce4004 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.tsx @@ -108,7 +108,7 @@ export interface LineGraphProps { /** * Custom styles for the table. */ - sxTableStyles?: SxProps; + sxTableStyles?: SxProps; /** * The suggested maximum y-axis value passed to **Chart,js**. */ diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx index b29ad6705da..01291148c28 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx @@ -1,15 +1,14 @@ -import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { TextFieldProps } from 'src/components/TextField'; import { hasPlacementGroupReachedCapacity } from 'src/features/PlacementGroups/utils'; import { useAllPlacementGroupsQuery } from 'src/queries/placementGroups'; import { PlacementGroupSelectOption } from './PlacementGroupSelectOption'; -import type { PlacementGroup, Region } from '@linode/api-v4'; -import type { SxProps } from '@mui/system'; +import type { APIError, PlacementGroup, Region } from '@linode/api-v4'; +import type { SxProps, Theme } from '@mui/material/styles'; +import type { TextFieldProps } from 'src/components/TextField'; export interface PlacementGroupsSelectProps { /** @@ -44,7 +43,7 @@ export interface PlacementGroupsSelectProps { /** * Any additional styles to apply to the root element. */ - sx?: SxProps; + sx?: SxProps; /** * Any additional props to pass to the TextField component. */ diff --git a/packages/manager/src/components/SelectionCard/CardBase.tsx b/packages/manager/src/components/SelectionCard/CardBase.tsx index 2584545f26c..09bb450ec65 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.tsx +++ b/packages/manager/src/components/SelectionCard/CardBase.tsx @@ -8,7 +8,7 @@ import { CardBaseSubheading, } from './CardBase.styles'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material/styles'; export interface CardBaseProps { checked?: boolean; @@ -17,10 +17,10 @@ export interface CardBaseProps { renderIcon?: () => JSX.Element; renderVariant?: () => JSX.Element | null; subheadings: (JSX.Element | string | undefined)[]; - sx?: SxProps; - sxHeading?: SxProps; - sxIcon?: SxProps; - sxSubheading?: SxProps; + sx?: SxProps; + sxHeading?: SxProps; + sxIcon?: SxProps; + sxSubheading?: SxProps; } export const CardBase = (props: CardBaseProps) => { const { diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index 214b9199715..4fdcbe58fa1 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -1,12 +1,13 @@ +import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme, styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; import * as React from 'react'; import { Tooltip } from 'src/components/Tooltip'; import { CardBase } from './CardBase'; +import type { SxProps, Theme } from '@mui/material/styles'; + export interface SelectionCardProps { /** * If true, the card will be selected and displayed as in a selected state. @@ -58,23 +59,23 @@ export interface SelectionCardProps { /** * Optional styles to apply to the root element. */ - sx?: SxProps; + sx?: SxProps; /** * Optional styles to apply to the root element of the card. */ - sxCardBase?: SxProps; + sxCardBase?: SxProps; /** * Optional styles to apply to the heading of the card. */ - sxCardBaseHeading?: SxProps; + sxCardBaseHeading?: SxProps; /** * Optional styles to apply to the icon of the card. */ - sxCardBaseIcon?: SxProps; + sxCardBaseIcon?: SxProps; /** * Optional styles to apply to the subheading of the card. */ - sxCardBaseSubheading?: SxProps; + sxCardBaseSubheading?: SxProps; /** * Optional styles to apply to the grid of the card. */ @@ -82,7 +83,7 @@ export interface SelectionCardProps { /** * Optional styles to apply to the tooltip of the card. */ - sxTooltip?: SxProps; + sxTooltip?: SxProps; /** * Optional text to set in a tooltip when hovering over the card. */ diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index f2f63505c20..73637a53b48 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -1,6 +1,5 @@ import HelpOutline from '@mui/icons-material/HelpOutline'; import { styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; import React, { useEffect, useState } from 'react'; import { Notice } from 'src/components/Notice/Notice'; @@ -15,6 +14,8 @@ import { Typography } from 'src/components/Typography'; import { Box } from '../Box'; +import type { SxProps, Theme } from '@mui/material/styles'; + export interface Tab { disabled?: boolean; render: (props: any) => JSX.Element | null; @@ -33,7 +34,7 @@ interface TabbedPanelProps { innerClass?: string; noPadding?: boolean; rootClass?: string; - sx?: SxProps; + sx?: SxProps; tabDisabledMessage?: string; tabs: Tab[]; value?: number; diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 1160534f14c..24c56689188 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -13,7 +13,7 @@ import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; import { TagDrawer } from './TagDrawer'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material/styles'; export interface TagCellProps { /** @@ -29,7 +29,7 @@ export interface TagCellProps { /** * Additional styles to apply to the tag list. */ - sx?: SxProps; + sx?: SxProps; /** * The list of tags to display. diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index 25e43179479..8cc4b47b4c2 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; -import type { SxProps } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material'; import type { TooltipProps } from '@mui/material/Tooltip'; import type { TypographyProps } from 'src/components/Typography'; @@ -30,7 +30,7 @@ export interface TextTooltipProps { */ placement?: TooltipProps['placement']; /** Optional custom styles */ - sxTypography?: SxProps; + sxTypography?: SxProps; /** The text to display inside the tooltip */ tooltipText: JSX.Element | string; /** diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index ab4c83c1d2a..60f960e3aea 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -11,7 +11,7 @@ import { IconButton } from 'src/components/IconButton'; import { Tooltip, tooltipClasses } from 'src/components/Tooltip'; import { omittedProps } from 'src/utilities/omittedProps'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material/styles'; import type { TooltipProps } from 'src/components/Tooltip'; type TooltipIconStatus = @@ -52,11 +52,11 @@ export interface TooltipIconProps /** * Pass specific styles to the Tooltip */ - sx?: SxProps; + sx?: SxProps; /** * Pass specific CSS styling for the SVG icon. */ - sxTooltipIcon?: SxProps; + sxTooltipIcon?: SxProps; /** * The tooltip's contents */ diff --git a/packages/manager/src/features/Betas/BetaSignup.tsx b/packages/manager/src/features/Betas/BetaSignup.tsx index 448d6dad3fa..f2e6fb2a3d3 100644 --- a/packages/manager/src/features/Betas/BetaSignup.tsx +++ b/packages/manager/src/features/Betas/BetaSignup.tsx @@ -1,4 +1,3 @@ -import { Stack } from '@mui/system'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; @@ -10,6 +9,7 @@ import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/Highligh import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useCreateAccountBetaMutation } from 'src/queries/account/betas'; import { useBetaQuery } from 'src/queries/betas'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx index 62a3cbe8b3d..eb1186d4918 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx @@ -8,10 +8,10 @@ import { Box } from 'src/components/Box'; import { Typography } from 'src/components/Typography'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import type { SxProps } from '@mui/material/styles'; +import type { SxProps, Theme } from '@mui/material/styles'; interface Props { - sx?: SxProps; + sx?: SxProps; } export const DatabaseLogo = ({ sx }: Props) => { diff --git a/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx b/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx index 0c30c3b6827..91b20aecc99 100644 --- a/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx @@ -1,4 +1,4 @@ -import { styled } from '@mui/system'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -50,7 +50,7 @@ export const ComplianceBanner = () => { ); }; -const StyledActionButton = styled(Button)(({}) => ({ +const StyledActionButton = styled(Button)({ marginLeft: 12, minWidth: 150, -})); +}); diff --git a/packages/manager/src/features/Linodes/AccessTable.tsx b/packages/manager/src/features/Linodes/AccessTable.tsx index a15630be892..0254e9c29eb 100644 --- a/packages/manager/src/features/Linodes/AccessTable.tsx +++ b/packages/manager/src/features/Linodes/AccessTable.tsx @@ -16,7 +16,7 @@ import { StyledTableRow, } from './LinodeEntityDetail.styles'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material/styles'; interface AccessTableRow { heading?: string; @@ -31,7 +31,7 @@ interface AccessTableProps { }; isVPCOnlyLinode: boolean; rows: AccessTableRow[]; - sx?: SxProps; + sx?: SxProps; title: string; } diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index 0b0de416bac..b04e1651b01 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -1,7 +1,5 @@ -import { APIError, Filter, Linode } from '@linode/api-v4'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { SxProps } from '@mui/system'; import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; @@ -9,6 +7,9 @@ import { CustomPopper } from 'src/components/Autocomplete/Autocomplete.styles'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; +import type { APIError, Filter, Linode } from '@linode/api-v4'; +import type { SxProps, Theme } from '@mui/material/styles'; + interface LinodeSelectProps { /** Determine whether isOptionEqualToValue prop should be defined for Autocomplete * component (to avoid "The value provided to Autocomplete is invalid [...]" console @@ -48,7 +49,7 @@ interface LinodeSelectProps { /* Displays an indication that the input is required. */ required?: boolean; /* Adds custom styles to the component. */ - sx?: SxProps; + sx?: SxProps; } export interface LinodeMultiSelectProps extends LinodeSelectProps { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx index 20217d6db0f..6b3270fdfac 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx @@ -1,14 +1,16 @@ -import { SxProps, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; +import type { SxProps, Theme } from '@mui/material/styles'; + interface Props { className?: string; errorText: string | undefined; - sx?: SxProps; + sx?: SxProps; } export const ImageEmptyState = (props: Props) => { diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 52000589cae..316ffdfe255 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -32,7 +32,7 @@ import { } from './LinodeRow.styles'; import type { LinodeHandlers } from '../LinodesLanding'; -import type { SxProps } from '@mui/system'; +import type { SxProps, Theme } from '@mui/material/styles'; import type { LinodeWithMaintenance } from 'src/utilities/linodes'; interface Props extends LinodeWithMaintenance { @@ -210,7 +210,7 @@ RenderFlag.displayName = `RenderFlag`; export const ProgressDisplay: React.FC<{ className?: string; progress: null | number; - sx?: SxProps; + sx?: SxProps; text: string | undefined; }> = (props) => { const { className, progress, sx, text } = props; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index 100c84b6cf2..ff22a80eccd 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -1,8 +1,5 @@ -import { NodeBalancer } from '@linode/api-v4'; -import { APIError } from '@linode/api-v4/lib/types'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { SxProps } from '@mui/system'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; @@ -10,6 +7,9 @@ import { CustomPopper } from 'src/components/Autocomplete/Autocomplete.styles'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; +import type { APIError, NodeBalancer } from '@linode/api-v4'; +import type { SxProps, Theme } from '@mui/material/styles'; + interface NodeBalancerSelectProps { /** Whether to display the clear icon. Defaults to `true`. */ clearable?: boolean; @@ -44,7 +44,7 @@ interface NodeBalancerSelectProps { /* Displays an indication that the input is required. */ required?: boolean; /* Adds custom styles to the component. */ - sx?: SxProps; + sx?: SxProps; } export interface NodeBalancerMultiSelectProps extends NodeBalancerSelectProps { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames.tsx index a2949093775..10353fb94c3 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames.tsx @@ -1,8 +1,8 @@ import { styled } from '@mui/material/styles'; -import { Box } from '@mui/system'; import copy from 'copy-to-clipboard'; import * as React from 'react'; +import { Box } from 'src/components/Box'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { InputLabel } from 'src/components/InputLabel'; import { Tooltip } from 'src/components/Tooltip'; diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index 4276ca7695f..c44a4249123 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -61,6 +61,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { void; includeDescriptionInTooltip?: boolean; ipRanges: ExtendedIP[]; ipRangesError?: string; - sx?: SxProps; + sx?: SxProps; } export const AssignIPRanges = (props: Props) => { diff --git a/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx b/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx index 5aab48e0aee..1f56b1ce1b5 100644 --- a/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx +++ b/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx @@ -1,5 +1,4 @@ import { styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; import React from 'react'; import { Box } from 'src/components/Box'; @@ -7,6 +6,8 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; +import type { SxProps, Theme } from '@mui/material/styles'; + interface DistributedRegionPlanTableProps { copy?: string; docsLink?: JSX.Element; @@ -15,7 +16,7 @@ interface DistributedRegionPlanTableProps { innerClass?: string; renderTable: () => React.JSX.Element; rootClass?: string; - sx?: SxProps; + sx?: SxProps; } export const DistributedRegionPlanTable = React.memo( diff --git a/packages/manager/src/foundations/breakpoints.ts b/packages/manager/src/foundations/breakpoints.ts index 2ab0686542d..aa7273db3f4 100644 --- a/packages/manager/src/foundations/breakpoints.ts +++ b/packages/manager/src/foundations/breakpoints.ts @@ -1,11 +1,18 @@ -import createBreakpoints from '@mui/system/createTheme/createBreakpoints'; +import { Chart } from '@linode/design-language-system'; +import { createTheme } from '@mui/material'; -export const breakpoints = createBreakpoints({ - values: { - lg: 1280, - md: 960, - sm: 600, - xl: 1920, - xs: 0, +// 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, + }, }, -}); + chartTokens: Chart, + name: 'light', +}).breakpoints; diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 5386f2d7877..8025974c2fb 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -200,12 +200,9 @@ export const darkTheme: ThemeOptions = { components: { MuiAppBar: { styleOverrides: { - colorDefault: { - backgroundColor: 'transparent', - }, root: { backgroundColor: tempReplacementforColorNeutralsBlack, - border: 0, + color: primaryColors.text, }, }, }, diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts index b9402e854ff..a04bb90c75f 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/manager/src/foundations/themes/index.ts @@ -3,7 +3,6 @@ import { createTheme } from '@mui/material/styles'; // Themes & Brands import { darkTheme } from 'src/foundations/themes/dark'; import { lightTheme } from 'src/foundations/themes/light'; -import { deepMerge } from 'src/utilities/deepMerge'; import type { ChartTypes, @@ -107,4 +106,4 @@ declare module '@mui/material/styles/createTheme' { } export const light = createTheme(lightTheme); -export const dark = createTheme(deepMerge(lightTheme, darkTheme)); +export const dark = createTheme(lightTheme, darkTheme); diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 9ca4057aa2c..713381b6bb0 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -302,13 +302,8 @@ export const lightTheme: ThemeOptions = { }, MuiAppBar: { styleOverrides: { - colorDefault: { - backgroundColor: 'inherit', - }, root: { backgroundColor: bg.bgPaper, - borderLeft: 0, - borderTop: 0, color: primaryColors.text, position: 'relative', }, diff --git a/packages/manager/src/utilities/deepMerge.test.ts b/packages/manager/src/utilities/deepMerge.test.ts deleted file mode 100644 index 3cfd083ffff..00000000000 --- a/packages/manager/src/utilities/deepMerge.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { deepMerge } from './deepMerge'; - -describe('deepMerge', () => { - it('should merge two objects', () => { - const target = { a: 1, b: 2 }; - const source = { b: 3, c: 4 }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: 1, b: 3, c: 4 }); - }); - - it('should merge two nested objects', () => { - const target = { a: { b: 1, c: 2 } }; - const source = { a: { c: 3, d: 4 } }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: { b: 1, c: 3, d: 4 } }); - }); - - it('should merge two deeply nested objects', () => { - const target = { a: { b: { c: 1, d: 2 } } }; - const source = { a: { b: { d: 3, e: 4 } } }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: { b: { c: 1, d: 3, e: 4 } } }); - }); - - it('should merge two objects and arrays', () => { - const target = { a: [1, 2], b: 3 }; - const source = { a: [4, 5], c: 6 }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: [4, 5], b: 3, c: 6 }); - }); - - it('should merge two deeply nested objects and arrays', () => { - const target = { a: { b: [1, 2], c: 3 } }; - const source = { a: { b: [4, 5], d: 6 } }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: { b: [4, 5], c: 3, d: 6 } }); - }); - - it('should merge two objects with different types', () => { - const target = { a: 1, b: 2 }; - const source = { a: [3, 4], c: 5 }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: [3, 4], b: 2, c: 5 }); - }); - - it('should merge two objects with different types and nested objects', () => { - const target = { a: { b: 1, c: 2 } }; - const source = { a: [3, 4], d: 5 }; - const result = deepMerge(target, source); - expect(result).toEqual({ a: [3, 4], d: 5 }); - }); -}); diff --git a/packages/manager/src/utilities/deepMerge.ts b/packages/manager/src/utilities/deepMerge.ts deleted file mode 100644 index b46d4336323..00000000000 --- a/packages/manager/src/utilities/deepMerge.ts +++ /dev/null @@ -1,42 +0,0 @@ -type ObjectType = Record; - -const isObject = (item: unknown): item is ObjectType => { - return item !== null && typeof item === 'object' && !Array.isArray(item); -}; - -/** - * Deep merge two objects. Arrays won't be merged. - * Warning: we want to make light usage of this util and consider using a better tool for complex deep merging. - * - * @param target Object to merge into - * @param source Object to merge from - * @returns Merged object - */ - -export const deepMerge = ( - target: T, - source: S -): T & S => { - if (Array.isArray(source)) { - return (source as unknown) as T & S; - } - - const output = { ...target } as T & S; - if (isObject(target) && isObject(source)) { - Object.keys(source).forEach((key) => { - if (isObject((source as any)[key])) { - if (!(key in target)) { - Object.assign(output, { [key]: (source as any)[key] }); - } else { - ((output as any)[key] as unknown) = deepMerge( - (target as any)[key] as ObjectType, - (source as any)[key] as ObjectType - ); - } - } else { - Object.assign(output, { [key]: (source as any)[key] }); - } - }); - } - return output; -}; From efa85802c21e63e41dbce3517826090c8486e6dd Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:28:21 -0400 Subject: [PATCH 06/64] test: [M3-7863] - Use `happy-dom` instead of `jsdom` in unit tests (#11085) * use `happy-dom` * first batch of fixes * another batch of fixes * another batch of fixes * fix ui package color * fix database test flake with longer timeout * try something * try running on macOS * oops * try different config * clean up test config options * try more forks * try more forks * go back to working config * run on ubuntu * Added changeset: Use `happy-dom` instead of `jsdom` in unit tests * add changeset for ui * fix last flake * feedback --------- Co-authored-by: Banks Nussman --- .../pr-11085-tests-1728657019139.md | 5 + packages/manager/package.json | 2 +- .../src/components/Avatar/Avatar.test.tsx | 6 +- .../src/components/BetaChip/BetaChip.test.tsx | 2 +- .../DescriptionList/DescriptionList.test.tsx | 2 +- .../HighlightedMarkdown.test.tsx.snap | 78 +++---- .../src/components/Notice/Notice.test.tsx | 4 +- .../manager/src/components/Tabs/Tab.test.tsx | 2 +- .../TextTooltip/TextTooltip.test.tsx | 2 +- .../DatabaseCreate/DatabaseCreate.test.tsx | 4 +- .../DatabaseLanding/DatabaseLanding.test.tsx | 36 ++-- .../CreateCluster/HAControlPlane.test.tsx | 6 +- .../Linodes/CloneLanding/Disks.test.tsx | 8 +- .../Linodes/LinodeCreate/VPC/VPC.test.tsx | 27 +-- .../Linodes/LinodeCreate/index.test.tsx | 4 +- .../LinodeIPAddressRow.test.tsx | 53 ++--- .../LinodeSettings/VPCPanel.test.tsx | 10 +- .../NodeBalancerConfigPanel.test.tsx | 9 +- .../NodeBalancerConfigurations.test.tsx | 22 +- .../NodeBalancerActionMenu.test.tsx | 20 +- .../NodeBalancerTableRow.test.tsx | 12 +- .../CreateOAuthClientDrawer.test.tsx | 8 +- .../features/Volumes/VolumeCreate.test.tsx | 2 +- .../src/utilities/omittedProps.test.tsx | 2 +- packages/manager/vite.config.ts | 3 +- .../pr-11085-tests-1728657169966.md | 5 + .../src/components/BetaChip/BetaChip.test.tsx | 2 +- packages/ui/vitest.config.ts | 2 +- yarn.lock | 196 +++--------------- 29 files changed, 214 insertions(+), 320 deletions(-) create mode 100644 packages/manager/.changeset/pr-11085-tests-1728657019139.md create mode 100644 packages/ui/.changeset/pr-11085-tests-1728657169966.md diff --git a/packages/manager/.changeset/pr-11085-tests-1728657019139.md b/packages/manager/.changeset/pr-11085-tests-1728657019139.md new file mode 100644 index 00000000000..882e40644d2 --- /dev/null +++ b/packages/manager/.changeset/pr-11085-tests-1728657019139.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Use `happy-dom` instead of `jsdom` in unit tests ([#11085](https://github.com/linode/manager/pull/11085)) diff --git a/packages/manager/package.json b/packages/manager/package.json index bd7aa7e825d..468da7d863f 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -195,7 +195,7 @@ "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", "glob": "^10.3.1", - "jsdom": "^24.1.1", + "happy-dom": "^15.7.4", "junit2json": "^3.1.4", "lint-staged": "^15.2.9", "mocha-junit-reporter": "^2.2.1", diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx index 65e4ab1baa0..e8f7ae51d3a 100644 --- a/packages/manager/src/components/Avatar/Avatar.test.tsx +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -31,7 +31,7 @@ describe('Avatar', () => { const avatarStyles = getComputedStyle(avatar); expect(getByTestId('avatar-letter')).toHaveTextContent('M'); - expect(avatarStyles.backgroundColor).toBe('rgb(1, 116, 188)'); // theme.color.primary.dark (#0174bc) + expect(avatarStyles.backgroundColor).toBe('#0174bc'); // theme.color.primary.dark (#0174bc) }); it('should render a background color from props', () => { @@ -48,8 +48,8 @@ describe('Avatar', () => { const avatarTextStyles = getComputedStyle(avatarText); // Confirm background color contrasts with text color. - expect(avatarStyles.backgroundColor).toBe('rgb(0, 0, 0)'); // black - expect(avatarTextStyles.color).toBe('rgb(255, 255, 255)'); // white + expect(avatarStyles.backgroundColor).toBe('#000000'); // black + expect(avatarTextStyles.color).toBe('#fff'); // white }); it('should render the first letter of username from props', async () => { diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx index 39d28178640..69d7d499fe2 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/manager/src/components/BetaChip/BetaChip.test.tsx @@ -17,7 +17,7 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); + expect(betaChip).toHaveStyle('background-color: #108ad6'); }); it('triggers an onClick callback', () => { diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx index 6fc2fd2fe20..477d27088f0 100644 --- a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx +++ b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx @@ -32,7 +32,7 @@ describe('Description List', () => { it('has it title bolded', () => { const { getByText } = renderWithTheme(); const title = getByText('Random title'); - expect(title).toHaveStyle('font-family: "LatoWebBold",sans-serif'); + expect(title).toHaveStyle('font-family: LatoWebBold, sans-serif'); }); it('renders a column by default', () => { diff --git a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap index 238b90d44c9..09a0177c34b 100644 --- a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap +++ b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap @@ -4,52 +4,52 @@ exports[`HighlightedMarkdown component > should highlight text consistently 1`]

    -

    - Some markdown -

    - + > +

    + Some markdown +

    + -
    -    
    -      
    -        const
    -      
    -       x = 
    -      
    +      
             
    -          function
    +          const
             
    -        (
    +         x = 
             
    -        ) 
    -      
    -      { 
    -      
    -        return
    -      
    -       
    -      
    -        true
    -      
    -      ; }
    +          class="hljs-function"
    +        >
    +          
    +            function
    +          
    +          (
    +          
    +          ) 
    +        
    +        { 
    +        
    +          return
    +        
    +         
    +        
    +          true
    +        
    +        ; }
     
    -    
    -  
    -

    + + +

    `; diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index e7d536cd907..029e9d86d10 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -11,8 +11,8 @@ describe('Notice Component', () => { const notice = container.firstChild; expect(notice).toHaveStyle('margin-bottom: 24px'); - expect(notice).toHaveStyle('margin-left: 0'); - expect(notice).toHaveStyle('margin-top: 0'); + expect(notice).toHaveStyle('margin-left: 0px'); + expect(notice).toHaveStyle('margin-top: 0px'); }); it('renders with text', () => { diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index 6463053b864..4dc53cd77da 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -20,7 +20,7 @@ describe('Tab Component', () => { expect(tabElement).toHaveStyle(` display: inline-flex; - color: rgb(0, 156, 222); + color: #0174bc; `); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index 301c5787ca5..ba592caabed 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -56,7 +56,7 @@ describe('TextTooltip', () => { const displayText = getByText(props.displayText); - expect(displayText).toHaveStyle('color: rgb(0, 156, 222)'); + expect(displayText).toHaveStyle('color: #0174bc'); expect(displayText).toHaveStyle('font-size: 18px'); }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index 40b0bf1c6ce..eee0966bf78 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -24,7 +24,9 @@ describe('Database Create', () => { const { getAllByTestId, getAllByText } = renderWithTheme( ); - await waitForElementToBeRemoved(getAllByTestId(loadingTestId)); + await waitForElementToBeRemoved(getAllByTestId(loadingTestId), { + timeout: 10_000, + }); getAllByText('Cluster Label'); getAllByText('Database Engine'); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index a7230c71e9f..d4c3f11d44d 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -1,4 +1,4 @@ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { fireEvent } from '@testing-library/react'; import { waitForElementToBeRemoved } from '@testing-library/react'; import { DateTime } from 'luxon'; @@ -71,6 +71,7 @@ describe('Database Table Row', () => { describe('Database Table', () => { it('should render database landing table with items', async () => { + const database = databaseInstanceFactory.build({ status: 'active' }); const mockAccount = accountFactory.build({ capabilities: [managedDBBetaCapability], }); @@ -81,32 +82,25 @@ describe('Database Table', () => { ); server.use( http.get(databaseInstancesEndpoint, () => { - const databases = databaseInstanceFactory.buildList(1, { - status: 'active', - }); - return HttpResponse.json(makeResourcePage(databases)); + return HttpResponse.json(makeResourcePage([database])); }) ); - const { getAllByText, getByTestId, queryAllByText } = renderWithTheme( - - ); + const { getByText } = renderWithTheme(); - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + // wait for API data to load + await waitFor(() => expect(getByText(database.label)).toBeVisible(), { + timeout: 10_000, + }); + expect(getByText('Active')).toBeVisible(); // Static text and table column headers - getAllByText('Cluster Label'); - getAllByText('Status'); - getAllByText('Configuration'); - getAllByText('Engine'); - getAllByText('Region'); - getAllByText('Created'); - - // Check to see if the mocked API data rendered in the table - queryAllByText('Active'); + expect(getByText('Cluster Label')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Configuration')).toBeVisible(); + expect(getByText('Engine')).toBeVisible(); + expect(getByText('Region')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); }); it('should render database landing with empty state', async () => { diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx index b8f995c02f1..94968d34b79 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; @@ -42,11 +42,11 @@ describe('HAControlPlane', () => { await findByText(/\$60\.00/); }); - it('should call the handleChange function on change', () => { + it('should call the handleChange function on change', async () => { const { getByTestId } = renderWithTheme(); const haRadioButton = getByTestId('ha-radio-button-yes'); - fireEvent.click(haRadioButton); + await userEvent.click(haRadioButton); expect(props.setHighAvailability).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx b/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx index dc55a90af9e..396cc774603 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx +++ b/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx @@ -33,7 +33,7 @@ describe('Disks', () => { const { getByTestId } = render(wrapWithTheme()); disks.forEach((eachDisk) => { const checkbox = getByTestId(`checkbox-${eachDisk.id}`).parentNode; - fireEvent.click(checkbox as any); + fireEvent.click(checkbox!); expect(mockHandleSelect).toHaveBeenCalledWith(eachDisk.id); }); }); @@ -47,10 +47,10 @@ describe('Disks', () => { }); it('checks the disk if the associated config is selected', () => { - const { getByTestId } = render( + const { getByRole } = render( wrapWithTheme() ); - const checkbox: any = getByTestId('checkbox-19040624').firstElementChild; - expect(checkbox).toHaveAttribute('checked'); + const checkbox = getByRole('checkbox', { name: '512 MB Swap Image' }); + expect(checkbox).toBeChecked(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx index eb193d24815..a500440cfb3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx @@ -69,7 +69,7 @@ describe('VPC', () => { it('renders VPC IPv4, NAT checkboxes, and IP Ranges inputs when a subnet is selected', async () => { const { - getByLabelText, + getByRole, getByText, } = renderWithThemeAndHookFormContext({ component: , @@ -82,13 +82,15 @@ describe('VPC', () => { }); expect( - getByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode in the VPC' - ) + getByRole('checkbox', { + name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', + }) ).toBeInTheDocument(); expect( - getByLabelText('Assign a public IPv4 address for this Linode') + getByRole('checkbox', { + name: 'Assign a public IPv4 address for this Linode', + }) ).toBeInTheDocument(); expect(getByText('Assign additional IPv4 ranges')).toBeInTheDocument(); @@ -96,7 +98,7 @@ describe('VPC', () => { it('should check the VPC IPv4 if a "ipv4.vpc" is null/undefined', async () => { const { - getByLabelText, + getByRole, } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { @@ -112,15 +114,16 @@ describe('VPC', () => { }); expect( - getByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode in the VPC' - ) + getByRole('checkbox', { + name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', + }) ).toBeChecked(); }); it('should uncheck the VPC IPv4 if a "ipv4.vpc" is a string value and show the VPC IP TextField', async () => { const { getByLabelText, + getByRole, } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { @@ -132,9 +135,9 @@ describe('VPC', () => { }); expect( - getByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode in the VPC' - ) + getByRole('checkbox', { + name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', + }) ).not.toBeChecked(); expect(getByLabelText('VPC IPv4 (required)')).toBeVisible(); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx index 7e53195eacc..dbde52b4812 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx @@ -23,10 +23,10 @@ describe('Linode Create', () => { }); it('Should not render the region select when creating from a backup', () => { - const { queryByText } = renderWithTheme(, { + const { queryByLabelText } = renderWithTheme(, { MemoryRouter: { initialEntries: ['/linodes/create?type=Backups'] }, }); - expect(queryByText('Region')).toBeNull(); + expect(queryByLabelText('Region')).toBeNull(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx index cb5dd2b9beb..12c95cd82e9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { LinodeConfigInterfaceFactoryWithVPC } from 'src/factories/linodeConfigInterfaceFactory'; @@ -10,7 +10,9 @@ import { import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; -import { IPAddressRowHandlers, LinodeIPAddressRow } from './LinodeIPAddressRow'; +import { LinodeIPAddressRow } from './LinodeIPAddressRow'; + +import type { IPAddressRowHandlers} from './LinodeIPAddressRow'; const ips = linodeIPFactory.build(); const ipDisplay = ipResponseToDisplayRows(ips)[0]; @@ -27,8 +29,8 @@ const handlers: IPAddressRowHandlers = { }; describe('LinodeIPAddressRow', () => { - it('should render a Linode IP Address row', () => { - const { getAllByText } = renderWithTheme( + it('should render a Linode IP Address row', async () => { + const { getAllByText, getByLabelText } = renderWithTheme( wrapWithTableBody( { ) ); + // open the action menu + await userEvent.click( + getByLabelText('Action menu for IP Address [object Object]') + ); + getAllByText(ipDisplay.address); getAllByText(ipDisplay.type); getAllByText(ipDisplay.gateway); @@ -70,7 +77,7 @@ describe('LinodeIPAddressRow', () => { }); it('should disable the row if disabled is true and display a tooltip', async () => { - const { findByRole, getByTestId } = renderWithTheme( + const { getAllByLabelText, getByLabelText, getByTestId } = renderWithTheme( wrapWithTableBody( { ) ); - const deleteBtn = getByTestId('action-menu-item-delete'); - expect(deleteBtn).toHaveAttribute('aria-disabled', 'true'); - fireEvent.mouseEnter(deleteBtn); - const publicIpsUnassignedTooltip = await findByRole('tooltip'); - expect(publicIpsUnassignedTooltip).toContainHTML( - PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT + // open the action menu + await userEvent.click( + getByLabelText('Action menu for IP Address [object Object]') ); - const editRDNSBtn = getByTestId('action-menu-item-edit-rdns'); + const deleteBtn = getByTestId('Delete'); + expect(deleteBtn).toHaveAttribute('aria-disabled', 'true'); + + const editRDNSBtn = getByTestId('Edit RDNS'); expect(editRDNSBtn).toHaveAttribute('aria-disabled', 'true'); - fireEvent.mouseEnter(editRDNSBtn); - const publicIpsUnassignedTooltip2 = await findByRole('tooltip'); - expect(publicIpsUnassignedTooltip2).toContainHTML( - PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT - ); + expect(getAllByLabelText(PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT)).toHaveLength(2); }); - it('should not disable the row if disabled is false', () => { - const { getAllByRole } = renderWithTheme( + it('should not disable the row if disabled is false', async () => { + const { getByLabelText, getByTestId } = renderWithTheme( wrapWithTableBody( { ) ); - const buttons = getAllByRole('button'); - const deleteBtn = buttons[1]; - expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true'); + // open the action menu + await userEvent.click( + getByLabelText('Action menu for IP Address [object Object]') + ); + + expect(getByTestId('Delete')).toBeEnabled(); - const editRDNSBtn = buttons[3]; - expect(editRDNSBtn).not.toHaveAttribute('aria-disabled', 'true'); + expect(getByTestId('Edit RDNS')).toBeEnabled(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx index 2255e3115ec..dda6abc4339 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx @@ -212,9 +212,9 @@ describe('VPCPanel', () => { await waitFor(() => { expect( - wrapper.getByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode in the VPC' - ) + wrapper.getByRole('checkbox', { + name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', + }) ).not.toBeChecked(); // Using regex here to account for the "(required)" indicator. expect(wrapper.getByLabelText(/^VPC IPv4.*/)).toHaveValue('10.0.4.3'); @@ -244,7 +244,9 @@ describe('VPCPanel', () => { await waitFor(() => { expect( - wrapper.getByLabelText('Assign a public IPv4 address for this Linode') + wrapper.getByRole('checkbox', { + name: 'Assign a public IPv4 address for this Linode', + }) ).toBeChecked(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx index fa06be32953..064e76f616b 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx @@ -85,6 +85,7 @@ const proxyProtocol = 'Proxy Protocol'; describe('NodeBalancerConfigPanel', () => { it('renders the NodeBalancerConfigPanel', () => { const { + getAllByLabelText, getByLabelText, getByText, queryByLabelText, @@ -101,7 +102,13 @@ describe('NodeBalancerConfigPanel', () => { expect(getByLabelText('Label')).toBeVisible(); expect(getByLabelText('IP Address')).toBeVisible(); expect(getByLabelText('Weight')).toBeVisible(); - expect(getByLabelText('Port')).toBeVisible(); + + const portTextFields = getAllByLabelText('Port'); + expect(portTextFields).toHaveLength(2); // There is a port field for the config and a port field for the one node + for (const field of portTextFields) { + expect(field).toBeVisible(); + } + expect(getByText('Listen on this port.')).toBeVisible(); expect(getByText('Active Health Checks')).toBeVisible(); expect( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx index d82c1156bf9..d4480664a67 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx @@ -44,13 +44,15 @@ describe('NodeBalancerConfigurations', () => { }) ); - const { getByLabelText, getByTestId, getByText } = renderWithTheme( - , - { - MemoryRouter: memoryRouter, - routePath, - } - ); + const { + getAllByLabelText, + getByLabelText, + getByTestId, + getByText, + } = renderWithTheme(, { + MemoryRouter: memoryRouter, + routePath, + }); expect(getByTestId(loadingTestId)).toBeInTheDocument(); @@ -65,7 +67,11 @@ describe('NodeBalancerConfigurations', () => { expect(getByLabelText('Label')).toBeInTheDocument(); expect(getByLabelText('IP Address')).toBeInTheDocument(); expect(getByLabelText('Weight')).toBeInTheDocument(); - expect(getByLabelText('Port')).toBeInTheDocument(); + const portTextFields = getAllByLabelText('Port'); + expect(portTextFields).toHaveLength(2); // There is a port field for the config and a port field for the one node + for (const field of portTextFields) { + expect(field).toBeInTheDocument(); + } expect(getByText('Listen on this port.')).toBeInTheDocument(); expect(getByText('Active Health Checks')).toBeInTheDocument(); expect( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx index e950688d3d4..61407c83bf6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -12,25 +12,31 @@ const props = { }; describe('NodeBalancerActionMenu', () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - it('renders the NodeBalancerActionMenu', () => { - const { getByText } = renderWithTheme( + it('renders the NodeBalancerActionMenu', async () => { + const { getByLabelText, getByText } = renderWithTheme( ); + // Open the Action Menu + await userEvent.click( + getByLabelText(`Action menu for NodeBalancer ${props.nodeBalancerId}`) + ); + expect(getByText('Configurations')).toBeVisible(); expect(getByText('Settings')).toBeVisible(); expect(getByText('Delete')).toBeVisible(); }); it('triggers the action to delete the NodeBalancer', async () => { - const { getByText } = renderWithTheme( + const { getByLabelText, getByText } = renderWithTheme( ); + // Open the Action Menu + await userEvent.click( + getByLabelText(`Action menu for NodeBalancer ${props.nodeBalancerId}`) + ); + const deleteButton = getByText('Delete'); await userEvent.click(deleteButton); expect(props.toggleDialog).toHaveBeenCalled(); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index 42d3acaad73..f09fae2088d 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -20,11 +20,19 @@ describe('NodeBalancerTableRow', () => { vi.resetAllMocks(); }); - it('renders the NodeBalancer table row', () => { - const { getByText } = renderWithTheme(); + it('renders the NodeBalancer table row', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); expect(getByText('nodebalancer-id-1')).toBeVisible(); expect(getByText('0.0.0.0')).toBeVisible(); + + // Open the Action Menu + await userEvent.click( + getByLabelText(`Action menu for NodeBalancer ${props.id}`) + ); + expect(getByText('Configurations')).toBeVisible(); expect(getByText('Settings')).toBeVisible(); expect(getByText('Delete')).toBeVisible(); diff --git a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx index 59d5955191f..1697d16479f 100644 --- a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx @@ -27,11 +27,11 @@ describe('Create API Token Drawer', () => { getByText('Cancel'); }); it('Should show client side validation errors', async () => { - const { getByText } = renderWithTheme( + const { getByRole, getByText } = renderWithTheme( ); - const submit = getByText('Create'); + const submit = getByRole('button', { name: 'Create' }); await userEvent.click(submit); @@ -47,7 +47,7 @@ describe('Create API Token Drawer', () => { }) ); - const { getAllByTestId, getByText } = renderWithTheme( + const { getAllByTestId, getByRole } = renderWithTheme( ); @@ -56,7 +56,7 @@ describe('Create API Token Drawer', () => { const labelField = textFields[0]; const callbackUrlField = textFields[1]; - const submit = getByText('Create'); + const submit = getByRole('button', { name: 'Create' }); await userEvent.type(labelField, 'my-oauth-client'); await userEvent.type(callbackUrlField, 'http://localhost:3000'); diff --git a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx index 0e611ed9fb7..57352b996da 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx @@ -55,6 +55,6 @@ describe('VolumeCreate', () => { flags: { blockStorageEncryption: true }, }); - await findByText(encryptVolumeSectionHeader); + await findByText(encryptVolumeSectionHeader, {}, { timeout: 5_000 }); }); }); diff --git a/packages/manager/src/utilities/omittedProps.test.tsx b/packages/manager/src/utilities/omittedProps.test.tsx index 56a046e1220..b8921875e9e 100644 --- a/packages/manager/src/utilities/omittedProps.test.tsx +++ b/packages/manager/src/utilities/omittedProps.test.tsx @@ -35,7 +35,7 @@ describe('omittedProps utility', () => { expect(component).not.toHaveAttribute('extraProp'); expect(component).not.toHaveAttribute('anotherProp'); - expect(component).toHaveStyle('color: rgb(255, 0, 0)'); + expect(component).toHaveStyle('color: red'); }); }); diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index afab3254c56..296323066bc 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -37,8 +37,7 @@ export default defineConfig({ 'src/**/*.utils.{js,jsx,ts,tsx}', ], }, - pool: 'forks', - environment: 'jsdom', + environment: 'happy-dom', globals: true, setupFiles: './src/testSetup.ts', }, diff --git a/packages/ui/.changeset/pr-11085-tests-1728657169966.md b/packages/ui/.changeset/pr-11085-tests-1728657169966.md new file mode 100644 index 00000000000..107ae6b952a --- /dev/null +++ b/packages/ui/.changeset/pr-11085-tests-1728657169966.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Tests +--- + +Use `happy-dom` instead of `jsdom` in unit tests ([#11085](https://github.com/linode/manager/pull/11085)) diff --git a/packages/ui/src/components/BetaChip/BetaChip.test.tsx b/packages/ui/src/components/BetaChip/BetaChip.test.tsx index c4da709edd5..4f922765477 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.test.tsx @@ -18,7 +18,7 @@ describe('BetaChip', () => { const { getByTestId } = render(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: rgb(25, 118, 210)'); + expect(betaChip).toHaveStyle('background-color: #1976d2'); }); it('triggers an onClick callback', () => { diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 95754d431b5..c4ce34c1442 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - environment: 'jsdom', + environment: 'happy-dom', setupFiles: './testSetup.ts', }, }); diff --git a/yarn.lock b/yarn.lock index 39b3c3fa612..7fef5d8b381 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2922,13 +2922,6 @@ acorn@^8.12.0, acorn@^8.12.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== -agent-base@^7.0.2, agent-base@^7.1.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" - integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== - dependencies: - debug "^4.3.4" - aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -4015,13 +4008,6 @@ css.escape@^1.5.1: resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== -cssstyle@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.1.0.tgz#161faee382af1bafadb6d3867a92a19bcb4aea70" - integrity sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA== - dependencies: - rrweb-cssom "^0.7.1" - csstype@^2.5.7: version "2.6.21" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e" @@ -4186,14 +4172,6 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -data-urls@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" - integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== - dependencies: - whatwg-mimetype "^4.0.0" - whatwg-url "^14.0.0" - data-view-buffer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" @@ -4233,13 +4211,6 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@~4.3.6: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - debug@^3.1.0: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -4247,16 +4218,18 @@ debug@^3.1.0: dependencies: ms "^2.1.1" +debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@~4.3.6: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + decimal.js-light@^2.4.1: version "2.5.1" resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== -decimal.js@^10.4.3: - version "10.4.3" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" - integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== - decode-named-character-reference@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" @@ -4483,7 +4456,7 @@ enquirer@^2.3.5, enquirer@^2.3.6: ansi-colors "^4.1.1" strip-ansi "^6.0.1" -entities@^4.4.0: +entities@^4.4.0, entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -5815,6 +5788,15 @@ graphql@^16.8.1: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== +happy-dom@^15.7.4: + version "15.7.4" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-15.7.4.tgz#05aade59c1d307336001b7004c76dfc6a829f220" + integrity sha512-r1vadDYGMtsHAAsqhDuk4IpPvr6N8MGKy5ntBo7tSdim+pWDxus2PNqOcOt8LuDZ4t3KJHE+gCuzupcx/GKnyQ== + dependencies: + entities "^4.5.0" + webidl-conversions "^7.0.0" + whatwg-mimetype "^3.0.0" + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -5936,13 +5918,6 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" -html-encoding-sniffer@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" - integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== - dependencies: - whatwg-encoding "^3.1.1" - html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -5972,14 +5947,6 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" -http-proxy-agent@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" - integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== - dependencies: - agent-base "^7.1.0" - debug "^4.3.4" - http-signature@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.4.0.tgz#dee5a9ba2bf49416abc544abd6d967f6a94c8c3f" @@ -5989,14 +5956,6 @@ http-signature@~1.4.0: jsprim "^2.0.2" sshpk "^1.18.0" -https-proxy-agent@^7.0.5: - version "7.0.5" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" - integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== - dependencies: - agent-base "^7.0.2" - debug "4" - human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -6024,13 +5983,6 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -6355,11 +6307,6 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" -is-potential-custom-element-name@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" - integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== - is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -6572,33 +6519,6 @@ jsdoc-type-pratt-parser@^4.0.0: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" integrity sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg== -jsdom@^24.1.1: - version "24.1.3" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.1.3.tgz#88e4a07cb9dd21067514a619e9f17b090a394a9f" - integrity sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ== - dependencies: - cssstyle "^4.0.1" - data-urls "^5.0.0" - decimal.js "^10.4.3" - form-data "^4.0.0" - html-encoding-sniffer "^4.0.0" - http-proxy-agent "^7.0.2" - https-proxy-agent "^7.0.5" - is-potential-custom-element-name "^1.0.1" - nwsapi "^2.2.12" - parse5 "^7.1.2" - rrweb-cssom "^0.7.1" - saxes "^6.0.0" - symbol-tree "^3.2.4" - tough-cookie "^4.1.4" - w3c-xmlserializer "^5.0.0" - webidl-conversions "^7.0.0" - whatwg-encoding "^3.1.1" - whatwg-mimetype "^4.0.0" - whatwg-url "^14.0.0" - ws "^8.18.0" - xml-name-validator "^5.0.0" - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -7744,11 +7664,6 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" -nwsapi@^2.2.12: - version "2.2.12" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.12.tgz#fb6af5c0ec35b27b4581eb3bbad34ec9e5c696f8" - integrity sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w== - object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -7955,13 +7870,6 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" - integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== - dependencies: - entities "^4.4.0" - parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -8234,7 +8142,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: +punycode@^2.1.0, punycode@^2.1.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -8861,11 +8769,6 @@ rollup@^4.19.0, rollup@^4.20.0: "@rollup/rollup-win32-x64-msvc" "4.22.4" fsevents "~2.3.2" -rrweb-cssom@^0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b" - integrity sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg== - run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -8916,7 +8819,7 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -8926,13 +8829,6 @@ sax@>=0.6.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== -saxes@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" - integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== - dependencies: - xmlchars "^2.2.0" - scheduler@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" @@ -9484,11 +9380,6 @@ symbol-observable@^1.0.4: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== -symbol-tree@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" - integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== - table@^5.2.3: version "5.4.6" resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" @@ -9671,13 +9562,6 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -tr46@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" - integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== - dependencies: - punycode "^2.3.1" - tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -10181,13 +10065,6 @@ vitest@^2.1.1: vite-node "2.1.1" why-is-node-running "^2.3.0" -w3c-xmlserializer@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" - integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== - dependencies: - xml-name-validator "^5.0.0" - warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" @@ -10215,30 +10092,15 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== -whatwg-encoding@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" - integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== - dependencies: - iconv-lite "0.6.3" - whatwg-fetch@>=0.10.0: version "3.6.20" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== -whatwg-mimetype@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" - integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== - -whatwg-url@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6" - integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== - dependencies: - tr46 "^5.0.0" - webidl-conversions "^7.0.0" +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== whatwg-url@^5.0.0: version "5.0.0" @@ -10382,16 +10244,11 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@^8.18.0, ws@^8.2.3: +ws@^8.2.3: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== -xml-name-validator@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" - integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== - xml2js@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" @@ -10410,11 +10267,6 @@ xmlbuilder@~11.0.0: resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== -xmlchars@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" - integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" From d37a032cb366b2dd3e618f01bc90c92bbbd9c6f8 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:35:41 -0400 Subject: [PATCH 07/64] change: [M#-8735] - Increase Cloud Manager `node.js` memory allocation (development jobs) (#11084) * Increase CM nodejs and TS memory limit * Added changeset: Increase Cloud Manager node.js memory allocation (development jobs) --- .../manager/.changeset/pr-11084-changed-1728577518914.md | 5 +++++ packages/manager/package.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11084-changed-1728577518914.md diff --git a/packages/manager/.changeset/pr-11084-changed-1728577518914.md b/packages/manager/.changeset/pr-11084-changed-1728577518914.md new file mode 100644 index 00000000000..9fc53b987bf --- /dev/null +++ b/packages/manager/.changeset/pr-11084-changed-1728577518914.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Increase Cloud Manager node.js memory allocation (development jobs) ([#11084](https://github.com/linode/manager/pull/11084)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 468da7d863f..3df82bda7b7 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -86,7 +86,7 @@ "zxcvbn": "^4.4.2" }, "scripts": { - "start": "concurrently --raw \"vite\" \"tsc --watch --preserveWatchOutput\"", + "start": "concurrently --raw \"NODE_OPTIONS='--max-old-space-size=4096' vite\" \"NODE_OPTIONS='--max-old-space-size=4096' tsc --watch --preserveWatchOutput\"", "start:expose": "concurrently --raw \"vite --host\" \"tsc --watch --preserveWatchOutput\"", "start:ci": "vite preview --port 3000 --host", "lint": "yarn run eslint . --ext .js,.ts,.tsx --quiet", @@ -95,7 +95,7 @@ "precommit": "lint-staged && yarn typecheck", "test": "vitest run", "test:debug": "node --inspect-brk scripts/test.js --runInBand", - "storybook": "storybook dev -p 6006", + "storybook": "NODE_OPTIONS='--max-old-space-size=4096' storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", "build-storybook": "storybook build", "cy:run": "cypress run -b chrome", From 054ea88a450a0d8f5537c03848c50341b39e46e8 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Mon, 14 Oct 2024 12:11:17 +0530 Subject: [PATCH 08/64] feat: [M3-8703] - Disable Create VPC button with tooltip text on empty state Landing Page for restricted users (#11052) * feat: [M3-8703] - Disable Create VPC button with tooltip text on empty state Landing Page for restricted users * Added changeset: Disable Create VPC button with tooltip text on empty state Landing Page for restricted users ([#11052](https://github.com/linode/manager/pull/11052)) * Added changeset: Disable Create VPC button with tooltip text on empty state Landing Page for restricted users * Changed changeset: Disable Create VPC button with tooltip text on empty state Landing Page for restricted users * refactor: [M3-8703] Making variables more descriptive --- .../.changeset/pr-11052-changed-1728305995058.md | 5 +++++ .../src/features/VPCs/VPCLanding/VPCEmptyState.tsx | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 packages/manager/.changeset/pr-11052-changed-1728305995058.md diff --git a/packages/manager/.changeset/pr-11052-changed-1728305995058.md b/packages/manager/.changeset/pr-11052-changed-1728305995058.md new file mode 100644 index 00000000000..b799c9bd432 --- /dev/null +++ b/packages/manager/.changeset/pr-11052-changed-1728305995058.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Disable Create VPC button with tooltip text on empty state Landing Page for restricted users ([#11052](https://github.com/linode/manager/pull/11052)) diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEmptyState.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEmptyState.tsx index 858e9315b1d..721707b9b9f 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEmptyState.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEmptyState.tsx @@ -4,6 +4,8 @@ import { useHistory } from 'react-router-dom'; import VPC from 'src/assets/icons/entityIcons/vpc.svg'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { gettingStartedGuides } from 'src/features/VPCs/VPCLanding/VPCLandingEmptyStateData'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { sendEvent } from 'src/utilities/analytics/utils'; import { headers, linkAnalyticsEvent } from './VPCEmptyStateData'; @@ -11,11 +13,16 @@ import { headers, linkAnalyticsEvent } from './VPCEmptyStateData'; export const VPCEmptyState = () => { const { push } = useHistory(); + const isVPCCreationRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_vpcs', + }); + return ( { sendEvent({ action: 'Click:button', @@ -24,6 +31,11 @@ export const VPCEmptyState = () => { }); push('/vpcs/create'); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'VPCs', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} From 2ad67fcdba93349d4b9f954061c0ff493f2f0018 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Mon, 14 Oct 2024 12:11:32 +0530 Subject: [PATCH 09/64] feat: [M3-8703] - Disable Create VPC button with tooltip text on Landing Page for restricted users (#11063) * feat: [M3-8703] - Disable Create VPC button with tooltip text on Landing Page for restricted users * Added changeset: Disable Create VPC button with tooltip text on Landing Page for restricted users ([#11063](https://github.com/linode/manager/pull/11063)) * Added changeset: Disable Create Image button with tooltip text on Landing Page for restricted users * refactor: [M3-8703] Making variables more descriptive Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * refactor: [M3-8703] Making variables more descriptive --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- .../.changeset/pr-11063-added-1728386681970.md | 5 +++++ .../src/features/VPCs/VPCLanding/VPCLanding.tsx | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/manager/.changeset/pr-11063-added-1728386681970.md diff --git a/packages/manager/.changeset/pr-11063-added-1728386681970.md b/packages/manager/.changeset/pr-11063-added-1728386681970.md new file mode 100644 index 00000000000..0a633ef71e6 --- /dev/null +++ b/packages/manager/.changeset/pr-11063-added-1728386681970.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Disable Create VPC button with tooltip text on Landing Page for restricted users ([#11063](https://github.com/linode/manager/pull/11063)) diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx index a107aaa4cf2..5a212586d7a 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -16,8 +16,10 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useVPCsQuery } from 'src/queries/vpcs/vpcs'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { VPCDeleteDialog } from './VPCDeleteDialog'; import { VPCEditDrawer } from './VPCEditDrawer'; @@ -73,6 +75,10 @@ const VPCLanding = () => { history.push(VPC_CREATE_ROUTE); }; + const isVPCCreationRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_vpcs', + }); + if (error) { return ( { docsLink={VPC_DOCS_LINK} onButtonClick={createVPC} title={VPC_LABEL} + buttonDataAttrs={{ + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'VPCs', + }), + }} + disabledCreateButton={isVPCCreationRestricted} /> From 0c7592e3a610834355d438cc46c6a9ab50d6b082 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Tue, 15 Oct 2024 22:17:51 +0530 Subject: [PATCH 10/64] feat: [M3-8704] - Disable Create Firewall button with tooltip text on Landing Page for restricted users (#11094) * feat: [M3-8704] - Disable Create Firewall button with tooltip text on Landing Page for restricted users * Added changeset: Disable Create Firewall button with tooltip text on Landing Page for restricted users --- .../.changeset/pr-11094-fixed-1728923124356.md | 5 +++++ .../Firewalls/FirewallLanding/FirewallLanding.tsx | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/manager/.changeset/pr-11094-fixed-1728923124356.md diff --git a/packages/manager/.changeset/pr-11094-fixed-1728923124356.md b/packages/manager/.changeset/pr-11094-fixed-1728923124356.md new file mode 100644 index 00000000000..e08a8954220 --- /dev/null +++ b/packages/manager/.changeset/pr-11094-fixed-1728923124356.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disable Create Firewall button with tooltip text on Landing Page for restricted users ([#11094](https://github.com/linode/manager/pull/11094)) diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 415f3ed482a..96b3bef8dba 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -19,8 +19,10 @@ import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useFirewallsQuery } from 'src/queries/firewalls'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; import { FirewallDialog } from './FirewallDialog'; @@ -73,6 +75,10 @@ const FirewallLanding = () => { (firewall) => firewall.id === selectedFirewallId ); + const isFirewallsCreationRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_firewalls', + }); + const openModal = (mode: Mode, id: number) => { setSelectedFirewallId(id); setDialogMode(mode); @@ -148,8 +154,16 @@ const FirewallLanding = () => { breadcrumbProps={{ pathname: '/firewalls' }} docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-cloud-firewalls" entity="Firewall" + disabledCreateButton={isFirewallsCreationRestricted} onButtonClick={onOpenCreateDrawer} title="Firewalls" + buttonDataAttrs={{ + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Firewalls', + }), + }} />
    From 4fd4376e8a57882b13ccc6797f909ff078bc90a6 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:45:25 -0500 Subject: [PATCH 11/64] change: [M3-7887] - VPC Not Recommended Configuration Tooltip Text Revision (#11098) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * chore: [M3-8662] - Update Github Actions actions (#11009) * update actions * add changeset --------- Co-authored-by: Banks Nussman * update revised copy * Added changeset: VPC Not Recommended Configuration Tooltip Text Revision * Update packages/manager/.changeset/pr-11098-changed-1728938497573.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../.changeset/pr-11098-changed-1728938497573.md | 6 ++++++ .../src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx | 10 +++------- packages/manager/src/features/VPCs/constants.ts | 3 --- 3 files changed, 9 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-11098-changed-1728938497573.md diff --git a/packages/manager/.changeset/pr-11098-changed-1728938497573.md b/packages/manager/.changeset/pr-11098-changed-1728938497573.md new file mode 100644 index 00000000000..f09ce53669f --- /dev/null +++ b/packages/manager/.changeset/pr-11098-changed-1728938497573.md @@ -0,0 +1,6 @@ +--- +"@linode/manager": Changed +--- + +Revise VPC Not Recommended Configuration Tooltip Text + ([#11098](https://github.com/linode/manager/pull/11098)) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 6d51617e316..d6c162886bd 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -19,7 +19,6 @@ import { capitalizeAllWords } from 'src/utilities/capitalize'; import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; import { - NETWORK_INTERFACES_GUIDE_URL, VPC_REBOOT_MESSAGE, WARNING_ICON_UNRECOMMENDED_CONFIG, } from '../constants'; @@ -121,12 +120,9 @@ export const SubnetLinodeRow = (props: Props) => { - This Linode is using an unrecommended configuration profile. Update - its configuration profile to avoid connectivity issues. Read our{' '} - - Configuration Profiles - {' '} - guide for more information. + This Linode is using a configuration profile with a Networking + setting that is not recommended. To avoid potential connectivity + issues, edit the Linode’s configuration. } icon={} diff --git a/packages/manager/src/features/VPCs/constants.ts b/packages/manager/src/features/VPCs/constants.ts index 7d9e93fdbbf..c07e3b17ffb 100644 --- a/packages/manager/src/features/VPCs/constants.ts +++ b/packages/manager/src/features/VPCs/constants.ts @@ -59,9 +59,6 @@ export const NOT_NATTED_HELPER_TEXT = 'The Linode will not be able to access the internet. If this Linode needs access to the internet, we recommend checking the “Assign a public IPv4 address for this Linode” checkbox which will enable 1:1 NAT on the VPC interface.'; // Links -export const NETWORK_INTERFACES_GUIDE_URL = - 'https://techdocs.akamai.com/cloud-computing/docs/manage-configuration-profiles-on-a-compute-instance'; - export const VPC_DOCS_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/vpc'; From 8621de36fe9fd243c411e19f60d78b20d4deec2d Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Wed, 16 Oct 2024 03:02:20 +0530 Subject: [PATCH 12/64] upcoming: [DI-21322] - Retain Resource selections during expand / collapse of filter button (#11068) * upcoming: [DI-21322] - Use deep equal logic for bug fix * upcoming: [DI-21322] - PR comments * upcoming: [DI-21322] - As per dev * upcoming: [DI-21322] - ESlint issue fixes --------- Co-authored-by: vmangalr --- ...r-11068-upcoming-features-1728916409850.md | 5 ++ .../CloudPulse/Utils/FilterBuilder.test.ts | 37 +++++++++++ .../CloudPulse/Utils/FilterBuilder.ts | 66 +++++++++++++++++++ .../shared/CloudPulseResourcesSelect.tsx | 4 +- 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md diff --git a/packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md b/packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md new file mode 100644 index 00000000000..dbe9182d101 --- /dev/null +++ b/packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Retain resource selection while expand or collapse the filter button ([#11068](https://github.com/linode/manager/pull/11068)) diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 1c4b5894d7d..6687ebbb561 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -2,6 +2,7 @@ import { dashboardFactory } from 'src/factories'; import { databaseQueries } from 'src/queries/databases/databases'; import { RESOURCES } from './constants'; +import { deepEqual } from './FilterBuilder'; import { buildXFilter, checkIfAllMandatoryFiltersAreSelected, @@ -264,3 +265,39 @@ it('test constructAdditionalRequestFilters method', () => { expect(result).toBeDefined(); expect(result.length).toEqual(0); }); + +it('returns true for identical primitive values', () => { + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual('test', 'test')).toBe(true); + expect(deepEqual(true, true)).toBe(true); +}); + +it('returns false for different primitive values', () => { + expect(deepEqual(1, 2)).toBe(false); + expect(deepEqual('test', 'other')).toBe(false); + expect(deepEqual(true, false)).toBe(false); +}); + +it('returns true for identical objects', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 2 } }; + expect(deepEqual(obj1, obj2)).toBe(true); +}); + +it('returns false for different objects', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 3 } }; + expect(deepEqual(obj1, obj2)).toBe(false); +}); + +it('returns true for identical arrays', () => { + const arr1 = [1, 2, 3]; + const arr2 = [1, 2, 3]; + expect(deepEqual(arr1, arr2)).toBe(true); +}); + +it('returns false for different arrays', () => { + const arr1 = [1, 2, 3]; + const arr2 = [1, 2, 4]; + expect(deepEqual(arr1, arr2)).toBe(false); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 2a0ee2cd64a..dc9ec47fe01 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -373,3 +373,69 @@ const getDependentFiltersByFilterKey = ( : configuration.filterKey ); }; + +/** + * @param obj1 The first object to be compared + * @param obj2 The second object to be compared + * @returns True if, both are equal else false + */ +export const deepEqual = (obj1: T, obj2: T): boolean => { + if (obj1 === obj2) { + return true; // Identical references or values + } + + // If either is null or undefined, or they are not of object type, return false + if ( + obj1 === null || + obj2 === null || + typeof obj1 !== 'object' || + typeof obj2 !== 'object' + ) { + return false; + } + + // Handle array comparison separately + if (Array.isArray(obj1) && Array.isArray(obj2)) { + return compareArrays(obj1, obj2); + } + + // Ensure both objects have the same number of keys + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) { + return false; + } + + // Recursively check each key + for (const key of keys1) { + if (!(key in obj2)) { + return false; + } + // Recursive deep equal check + if (!deepEqual((obj1 as any)[key], (obj2 as any)[key])) { + return false; + } + } + + return true; +}; + +/** + * @param arr1 Array for comparison + * @param arr2 Array for comparison + * @returns True if, both the arrays are equal, else false + */ +const compareArrays = (arr1: T[], arr2: T[]): boolean => { + if (arr1.length !== arr2.length) { + return false; + } + + for (let i = 0; i < arr1.length; i++) { + if (!deepEqual(arr1[i], arr2[i])) { + return false; + } + } + + return true; +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index f3055326b98..e47338a09d1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -4,6 +4,8 @@ import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { themes } from 'src/utilities/theme'; +import { deepEqual } from '../Utils/FilterBuilder'; + import type { Filter, FilterValue } from '@linode/api-v4'; export interface CloudPulseResources { @@ -129,7 +131,7 @@ function compareProps( return false; } } - if (prevProps.xFilter !== nextProps.xFilter) { + if (!deepEqual(prevProps.xFilter, nextProps.xFilter)) { return false; } From f6f5e97bc2cb48dafa7e17a31e82bf0bcca8973e Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:00:29 -0400 Subject: [PATCH 13/64] =?UTF-8?q?refactor:=20[M3-8640]=20=E2=80=93=20Move?= =?UTF-8?q?=20Theme=20layer=20from=20`manager`=20package=20to=20`ui`=20pac?= =?UTF-8?q?kage=20(#11092)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 +--- .../pr-11092-tech-stories-1728680192850.md | 5 +++++ .../cypress/support/component/setup.tsx | 19 +++++++++---------- .../cypress/support/util/components.ts | 4 ++-- packages/manager/src/LinodeThemeWrapper.tsx | 3 ++- .../components/Button/StyledActionButton.ts | 3 +-- .../HighlightedMarkdown.tsx | 2 +- .../components/PrimaryNav/SideMenu.test.tsx | 2 +- .../KubernetesPlanSelection.test.tsx | 10 ++++------ .../Linodes/LinodeCreate/Security.tsx | 2 +- .../features/Linodes/LinodeCreate/VPC/VPC.tsx | 2 +- .../NodeBalancerTableRow.test.tsx | 2 +- .../SupportTicketDetail.test.tsx | 2 +- .../PlansPanel/PlanSelection.test.tsx | 4 ++-- packages/manager/src/utilities/theme.ts | 8 ++++---- .../pr-11092-added-1728931799888.md | 5 +++++ .../src/foundations/breakpoints.ts | 0 .../{manager => ui}/src/foundations/fonts.ts | 0 packages/ui/src/foundations/index.ts | 3 +++ .../src/foundations/themes/dark.ts | 4 ++-- .../src/foundations/themes/index.ts | 11 ++++++----- .../src/foundations/themes/light.ts | 4 ++-- packages/ui/src/index.ts | 1 + 23 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md create mode 100644 packages/ui/.changeset/pr-11092-added-1728931799888.md rename packages/{manager => ui}/src/foundations/breakpoints.ts (100%) rename packages/{manager => ui}/src/foundations/fonts.ts (100%) create mode 100644 packages/ui/src/foundations/index.ts rename packages/{manager => ui}/src/foundations/themes/dark.ts (99%) rename packages/{manager => ui}/src/foundations/themes/index.ts (92%) rename packages/{manager => ui}/src/foundations/themes/light.ts (99%) diff --git a/docker-compose.yml b/docker-compose.yml index 5285fdd036d..494f64db0de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,9 +67,7 @@ x-e2e-volumes: &default-volumes - ./.git:/home/node/app/.git - ./cache:/home/node/app/cache - - ./packages/manager:/home/node/app/packages/manager - - ./packages/validation:/home/node/app/packages/validation - - ./packages/api-v4:/home/node/app/packages/api-v4 + - ./packages:/home/node/app/packages - ./package.json:/home/node/app/package.json - ./node_modules:/home/node/app/node_modules diff --git a/packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md b/packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md new file mode 100644 index 00000000000..6add695d908 --- /dev/null +++ b/packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Moved `src/foundations` directory from `manager` package to new `ui` package ([#11092](https://github.com/linode/manager/pull/11092)) diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx index cb1e039d375..33b816548fc 100644 --- a/packages/manager/cypress/support/component/setup.tsx +++ b/packages/manager/cypress/support/component/setup.tsx @@ -13,22 +13,21 @@ // https://on.cypress.io/configuration // *********************************************************** -import * as React from 'react'; +import { queryClientFactory } from '@src/queries/base'; +import { QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/cypress/add-commands'; +import 'cypress-axe'; import { mount } from 'cypress/react18'; - -import { Provider } from 'react-redux'; import { LDProvider } from 'launchdarkly-react-client-sdk'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { queryClientFactory } from '@src/queries/base'; -import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { SnackbarProvider } from 'notistack'; +import * as React from 'react'; +import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; -import { storeFactory } from 'src/store'; -import '@testing-library/cypress/add-commands'; -import { ThemeName } from 'src/foundations/themes'; +import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; +import { storeFactory } from 'src/store'; -import 'cypress-axe'; +import type { ThemeName } from '@linode/ui'; /** * Mounts a component with a Cloud Manager theme applied. diff --git a/packages/manager/cypress/support/util/components.ts b/packages/manager/cypress/support/util/components.ts index 0fe9bd25692..ce2e95794da 100644 --- a/packages/manager/cypress/support/util/components.ts +++ b/packages/manager/cypress/support/util/components.ts @@ -2,8 +2,8 @@ * @file Utilities for component testing. */ -import { MountReturn } from 'cypress/react18'; -import type { ThemeName } from 'src/foundations/themes'; +import type { ThemeName } from '@linode/ui'; +import type { MountReturn } from 'cypress/react18'; /** * Array of themes for which to test components. diff --git a/packages/manager/src/LinodeThemeWrapper.tsx b/packages/manager/src/LinodeThemeWrapper.tsx index 74e5d054aec..bbae51a017f 100644 --- a/packages/manager/src/LinodeThemeWrapper.tsx +++ b/packages/manager/src/LinodeThemeWrapper.tsx @@ -1,9 +1,10 @@ import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; import * as React from 'react'; -import { ThemeName } from './foundations/themes'; import { themes, useColorMode } from './utilities/theme'; +import type { ThemeName } from '@linode/ui'; + interface Props { children: React.ReactNode; /** Allows theme to be overwritten. Used for Storybook theme switching */ diff --git a/packages/manager/src/components/Button/StyledActionButton.ts b/packages/manager/src/components/Button/StyledActionButton.ts index 008257f5a50..6753b8408db 100644 --- a/packages/manager/src/components/Button/StyledActionButton.ts +++ b/packages/manager/src/components/Button/StyledActionButton.ts @@ -1,7 +1,6 @@ +import { latoWeb } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { latoWeb } from 'src/foundations/fonts'; - import { Button } from './Button'; /** diff --git a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx index dc9e9cc87c1..95f9aa966d1 100644 --- a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx +++ b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx @@ -14,7 +14,7 @@ import { unsafe_MarkdownIt } from 'src/utilities/markdown'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import { useColorMode } from 'src/utilities/theme'; -import type { ThemeName } from 'src/foundations/themes'; +import type { ThemeName } from '@linode/ui'; import type { SanitizeOptions } from 'src/utilities/sanitizeHTML'; hljs.registerLanguage('apache', apache); diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.test.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.test.tsx index 089201f2414..4f011df2392 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.test.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.test.tsx @@ -1,7 +1,7 @@ +import { breakpoints } from '@linode/ui'; import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; -import { breakpoints } from 'src/foundations/breakpoints'; import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; import { SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_WIDTH, SideMenu } from './SideMenu'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx index 5fa50d0d33f..d21b6d35cc8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx @@ -1,21 +1,19 @@ +import { breakpoints } from '@linode/ui'; import { fireEvent, render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { extendedTypeFactory } from 'src/factories/types'; import { LIMITED_AVAILABILITY_COPY } from 'src/features/components/PlansPanel/constants'; -import { breakpoints } from 'src/foundations/breakpoints'; +import { PlanWithAvailability } from 'src/features/components/PlansPanel/types'; import { renderWithTheme, resizeScreenSize, wrapWithTableBody, } from 'src/utilities/testHelpers'; -import { - KubernetesPlanSelection, - KubernetesPlanSelectionProps, -} from './KubernetesPlanSelection'; +import { KubernetesPlanSelection } from './KubernetesPlanSelection'; -import type { PlanWithAvailability } from 'src/features/components/PlansPanel/types'; +import type { KubernetesPlanSelectionProps } from './KubernetesPlanSelection'; const planHeader = 'Dedicated 20 GB'; const baseHourlyPrice = '$0.015'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx index 547f26f4422..36c86b2c3dc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx @@ -1,3 +1,4 @@ +import { inputMaxWidth } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -15,7 +16,6 @@ import { Paper } from 'src/components/Paper'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Skeleton } from 'src/components/Skeleton'; import { Typography } from 'src/components/Typography'; -import { inputMaxWidth } from 'src/foundations/themes/light'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useRegionsQuery } from 'src/queries/regions/regions'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx index 9656629d303..483cb67c548 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx @@ -1,3 +1,4 @@ +import { inputMaxWidth } from '@linode/ui'; import React, { useState } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -20,7 +21,6 @@ import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, } from 'src/features/VPCs/constants'; import { VPCCreateDrawer } from 'src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer'; -import { inputMaxWidth } from 'src/foundations/themes/light'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useVPCQuery, useVPCsQuery } from 'src/queries/vpcs/vpcs'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index f09fae2088d..32d60ff80d6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -1,8 +1,8 @@ +import { breakpoints } from '@linode/ui'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { nodeBalancerFactory } from 'src/factories'; -import { breakpoints } from 'src/foundations/breakpoints'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx index 1f106e874ac..2a63dd161f3 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx @@ -1,3 +1,4 @@ +import { breakpoints } from '@linode/ui'; import { render, screen } from '@testing-library/react'; import * as React from 'react'; @@ -5,7 +6,6 @@ import { supportReplyFactory, supportTicketFactory, } from 'src/factories/support'; -import { breakpoints } from 'src/foundations/breakpoints'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx b/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx index e141aaa62d2..09e4048ed6d 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanSelection.test.tsx @@ -1,3 +1,4 @@ +import { breakpoints } from '@linode/ui'; import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; @@ -6,10 +7,9 @@ import { planSelectionTypeFactory, } from 'src/factories/types'; import { LIMITED_AVAILABILITY_COPY } from 'src/features/components/PlansPanel/constants'; -import { breakpoints } from 'src/foundations/breakpoints'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { resizeScreenSize } from 'src/utilities/testHelpers'; import { wrapWithTableBody } from 'src/utilities/testHelpers'; -import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlanSelection } from './PlanSelection'; diff --git a/packages/manager/src/utilities/theme.ts b/packages/manager/src/utilities/theme.ts index 9c2257c6ddb..7de52ec1697 100644 --- a/packages/manager/src/utilities/theme.ts +++ b/packages/manager/src/utilities/theme.ts @@ -1,12 +1,12 @@ -import { Theme } from '@mui/material/styles'; +import { dark, light } from '@linode/ui'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { dark, light } from 'src/foundations/themes'; - -import type { ThemeName } from 'src/foundations/themes'; import { useAuthentication } from 'src/hooks/useAuthentication'; import { usePreferences } from 'src/queries/profile/preferences'; +import type { ThemeName } from '@linode/ui'; +import type { Theme } from '@mui/material/styles'; + export type ThemeChoice = 'dark' | 'light' | 'system'; export const themes: Record = { dark, light }; diff --git a/packages/ui/.changeset/pr-11092-added-1728931799888.md b/packages/ui/.changeset/pr-11092-added-1728931799888.md new file mode 100644 index 00000000000..97433b22de6 --- /dev/null +++ b/packages/ui/.changeset/pr-11092-added-1728931799888.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Added +--- + +Themes, fonts, and breakpoints previously located in `manager` package ([#11092](https://github.com/linode/manager/pull/11092)) diff --git a/packages/manager/src/foundations/breakpoints.ts b/packages/ui/src/foundations/breakpoints.ts similarity index 100% rename from packages/manager/src/foundations/breakpoints.ts rename to packages/ui/src/foundations/breakpoints.ts diff --git a/packages/manager/src/foundations/fonts.ts b/packages/ui/src/foundations/fonts.ts similarity index 100% rename from packages/manager/src/foundations/fonts.ts rename to packages/ui/src/foundations/fonts.ts diff --git a/packages/ui/src/foundations/index.ts b/packages/ui/src/foundations/index.ts new file mode 100644 index 00000000000..29d492e9c07 --- /dev/null +++ b/packages/ui/src/foundations/index.ts @@ -0,0 +1,3 @@ +export * from './breakpoints'; +export * from './fonts'; +export * from './themes'; diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts similarity index 99% rename from packages/manager/src/foundations/themes/dark.ts rename to packages/ui/src/foundations/themes/dark.ts index 8025974c2fb..a4ffa1f4ba6 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -10,8 +10,8 @@ import { TextField, } from '@linode/design-language-system/themes/dark'; -import { breakpoints } from 'src/foundations/breakpoints'; -import { latoWeb } from 'src/foundations/fonts'; +import { breakpoints } from '../breakpoints'; +import { latoWeb } from '../fonts'; import type { ThemeOptions } from '@mui/material/styles'; diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts similarity index 92% rename from packages/manager/src/foundations/themes/index.ts rename to packages/ui/src/foundations/themes/index.ts index a04bb90c75f..a874f0c0bba 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -1,27 +1,27 @@ import { createTheme } from '@mui/material/styles'; // Themes & Brands -import { darkTheme } from 'src/foundations/themes/dark'; -import { lightTheme } from 'src/foundations/themes/light'; +import { darkTheme } from './dark'; +import { lightTheme, inputMaxWidth as _inputMaxWidth } from './light'; import type { ChartTypes, InteractionTypes as InteractionTypesLight, } from '@linode/design-language-system'; import type { InteractionTypes as InteractionTypesDark } from '@linode/design-language-system/themes/dark'; -import type { latoWeb } from 'src/foundations/fonts'; +import type { latoWeb } from '../fonts'; // Types & Interfaces import type { customDarkModeOptions, notificationToast as notificationToastDark, -} from 'src/foundations/themes/dark'; +} from './dark'; import type { bg, borderColors, color, notificationToast, textColors, -} from 'src/foundations/themes/light'; +} from './light'; export type ThemeName = 'dark' | 'light'; @@ -105,5 +105,6 @@ declare module '@mui/material/styles/createTheme' { } } +export const inputMaxWidth = _inputMaxWidth; export const light = createTheme(lightTheme); export const dark = createTheme(lightTheme, darkTheme); diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts similarity index 99% rename from packages/manager/src/foundations/themes/light.ts rename to packages/ui/src/foundations/themes/light.ts index 713381b6bb0..b42e5ade10d 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -10,8 +10,8 @@ import { Select, } from '@linode/design-language-system'; -import { breakpoints } from 'src/foundations/breakpoints'; -import { latoWeb } from 'src/foundations/fonts'; +import { breakpoints } from '../breakpoints'; +import { latoWeb } from '../fonts'; import type { ThemeOptions } from '@mui/material/styles'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 07635cbbc8e..43f9ba85b57 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1 +1,2 @@ export * from './components'; +export * from './foundations'; From 671d716133aaeaa311b6fb0cc457d6ca722b6dd6 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 15 Oct 2024 19:20:48 -0400 Subject: [PATCH 14/64] test: [M3-8756] - Fix Cypress SMTP support ticket test failure (#11106) * Use mock Linode lacking SMTP capability to fix support ticket test * Added changeset: Fix failing SMTP support ticket test by using mock Linode data --- .../pr-11106-tests-1729028269027.md | 5 + .../open-support-ticket.spec.ts | 171 +++++++++--------- 2 files changed, 89 insertions(+), 87 deletions(-) create mode 100644 packages/manager/.changeset/pr-11106-tests-1729028269027.md diff --git a/packages/manager/.changeset/pr-11106-tests-1729028269027.md b/packages/manager/.changeset/pr-11106-tests-1729028269027.md new file mode 100644 index 00000000000..24b0b25aebb --- /dev/null +++ b/packages/manager/.changeset/pr-11106-tests-1729028269027.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix failing SMTP support ticket test by using mock Linode data ([#11106](https://github.com/linode/manager/pull/11106)) diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index ec286a59281..2277e9f296e 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -39,11 +39,9 @@ import { EntityType, TicketType, } from 'src/features/Support/SupportTickets/SupportTicketDialog'; -import { createTestLinode } from 'support/util/linodes'; -import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; import { mockCreateLinodeAccountLimitError, + mockGetLinodeDetails, mockGetLinodes, } from 'support/intercepts/linodes'; import { mockGetDomains } from 'support/intercepts/domains'; @@ -52,12 +50,6 @@ import { linodeCreatePage } from 'support/ui/pages'; import { chooseRegion } from 'support/util/regions'; describe('open support tickets', () => { - after(() => { - cleanUp(['linodes']); - }); - - authenticate(); - /* * - Opens a Help & Support ticket using mock API data. * - Confirms that "Severity" field is not present when feature flag is disabled. @@ -250,94 +242,99 @@ describe('open support tickets', () => { status: 'new', }); + // Mock a Linode instance that is lacking the `SMTP Enabled` capability. + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + capabilities: [], + }); + mockGetAccount(mockAccount); mockCreateSupportTicket(mockSMTPTicket).as('createTicket'); mockGetSupportTickets([]); mockGetSupportTicket(mockSMTPTicket); mockGetSupportTicketReplies(mockSMTPTicket.id, []); + mockGetLinodes([mockLinode]); + mockGetLinodeDetails(mockLinode.id, mockLinode); - cy.visitWithLogin('/support/tickets'); + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.findByText('open a support ticket').should('be.visible').click(); + + // Fill out ticket form. + ui.dialog + .findByTitle('Contact Support: SMTP Restriction Removal') + .should('be.visible') + .within(() => { + cy.findByText(SMTP_DIALOG_TITLE).should('be.visible'); + cy.findByText(SMTP_HELPER_TEXT).should('be.visible'); + + // Confirm summary, customer name, and company name fields are pre-populated with user account data. + cy.findByLabelText('Title', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.summary + mockLinode.label); + + cy.findByLabelText('First and last name', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.customerName); + + cy.findByLabelText('Business or company name', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.companyName); + + ui.button + .findByTitle('Open Ticket') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm validation errors display when trying to submit without required fields. + cy.findByText('Use case is required.'); + cy.findByText('Email domains are required.'); + cy.findByText('Links to public information are required.'); + + // Complete the rest of the form. + cy.get('[data-qa-ticket-use-case]') + .should('be.visible') + .click() + .type(mockFormFields.useCase); + + cy.get('[data-qa-ticket-email-domains]') + .should('be.visible') + .click() + .type(mockFormFields.emailDomains); + + cy.get('[data-qa-ticket-public-info]') + .should('be.visible') + .click() + .type(mockFormFields.publicInfo); - cy.defer(() => createTestLinode({ booted: true })).then((linode) => { - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('open a support ticket').should('be.visible').click(); - - // Fill out ticket form. - ui.dialog - .findByTitle('Contact Support: SMTP Restriction Removal') - .should('be.visible') - .within(() => { - cy.findByText(SMTP_DIALOG_TITLE).should('be.visible'); - cy.findByText(SMTP_HELPER_TEXT).should('be.visible'); - - // Confirm summary, customer name, and company name fields are pre-populated with user account data. - cy.findByLabelText('Title', { exact: false }) - .should('be.visible') - .should('have.value', mockFormFields.summary + linode.label); - - cy.findByLabelText('First and last name', { exact: false }) - .should('be.visible') - .should('have.value', mockFormFields.customerName); - - cy.findByLabelText('Business or company name', { exact: false }) - .should('be.visible') - .should('have.value', mockFormFields.companyName); - - ui.button - .findByTitle('Open Ticket') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - - // Confirm validation errors display when trying to submit without required fields. - cy.findByText('Use case is required.'); - cy.findByText('Email domains are required.'); - cy.findByText('Links to public information are required.'); - - // Complete the rest of the form. - cy.get('[data-qa-ticket-use-case]') - .should('be.visible') - .click() - .type(mockFormFields.useCase); - - cy.get('[data-qa-ticket-email-domains]') - .should('be.visible') - .click() - .type(mockFormFields.emailDomains); - - cy.get('[data-qa-ticket-public-info]') - .should('be.visible') - .click() - .type(mockFormFields.publicInfo); - - // Confirm there is no description field or file upload section. - cy.findByText('Description').should('not.exist'); - cy.findByText('Attach a File').should('not.exist'); - - ui.button - .findByTitle('Open Ticket') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that ticket create payload contains the expected data. - cy.wait('@createTicket').then((xhr) => { - expect(xhr.request.body?.summary).to.eq( - mockSMTPTicket.summary + linode.label - ); - expect(xhr.request.body?.description).to.eq(mockSMTPTicket.description); + // Confirm there is no description field or file upload section. + cy.findByText('Description').should('not.exist'); + cy.findByText('Attach a File').should('not.exist'); + + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); }); - // Confirm the new ticket is listed with the expected information upon redirecting to the details page. - cy.url().should('endWith', `support/tickets/${mockSMTPTicket.id}`); - cy.contains(`#${mockSMTPTicket.id}: SMTP Restriction Removal`).should( - 'be.visible' + // Confirm that ticket create payload contains the expected data. + cy.wait('@createTicket').then((xhr) => { + expect(xhr.request.body?.summary).to.eq( + mockSMTPTicket.summary + mockLinode.label ); - Object.values(SMTP_FIELD_NAME_TO_LABEL_MAP).forEach((fieldLabel) => { - cy.findByText(fieldLabel).should('be.visible'); - }); + expect(xhr.request.body?.description).to.eq(mockSMTPTicket.description); + }); + + // Confirm the new ticket is listed with the expected information upon redirecting to the details page. + cy.url().should('endWith', `support/tickets/${mockSMTPTicket.id}`); + cy.contains(`#${mockSMTPTicket.id}: SMTP Restriction Removal`).should( + 'be.visible' + ); + Object.values(SMTP_FIELD_NAME_TO_LABEL_MAP).forEach((fieldLabel) => { + cy.findByText(fieldLabel).should('be.visible'); }); }); From e96c819d3928d0f5666fea8829915ddc1f39930d Mon Sep 17 00:00:00 2001 From: subsingh-akamai Date: Wed, 16 Oct 2024 10:06:07 +0530 Subject: [PATCH 15/64] test: [M3-8609] - Cypress test for non-empty Linode landing page with restricted user (#11060) * Initial commit for adding new test checks restricted user with no access cannot see existing linode and cannot create linode landing * Initial commit for adding new test checks restricted user with no access cannot see existing linode and cannot create linode landing * Removed no_access token test * Resolved pre-commit error * Added changeset: Cypress test for non-empty Linode landing page with restricted user * Removed unwanted omment * Removed it.only * Worked on review comments to use mockGetLinodes and cy.wait() for mock profiles * Reverting it.only from test --- .../pr-11060-tests-1728375821627.md | 5 + .../smoke-linode-landing-table.spec.ts | 95 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/manager/.changeset/pr-11060-tests-1728375821627.md diff --git a/packages/manager/.changeset/pr-11060-tests-1728375821627.md b/packages/manager/.changeset/pr-11060-tests-1728375821627.md new file mode 100644 index 00000000000..3ba2b3c44e6 --- /dev/null +++ b/packages/manager/.changeset/pr-11060-tests-1728375821627.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Cypress test for non-empty Linode landing page with restricted user ([#11060](https://github.com/linode/manager/pull/11060)) diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index df9e3afb392..e1bbfeb6e7b 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -494,3 +494,98 @@ describe('linode landing checks for empty state', () => { .should('be.visible'); }); }); + +describe('linode landing checks for non-empty state with restricted user', () => { + beforeEach(() => { + // Mock setup to display the Linode landing page in an non-empty state + const mockLinodes: Linode[] = new Array(1).fill(null).map( + (_item: null, index: number): Linode => { + return linodeFactory.build({ + label: `Linode ${index}`, + region: chooseRegion().id, + tags: [index % 2 == 0 ? 'even' : 'odd', 'nums'], + }); + } + ); + + mockGetLinodes(mockLinodes).as('getLinodes'); + + // Alias the mockLinodes array + cy.wrap(mockLinodes).as('mockLinodes'); + }); + + it('checks restricted user with read access has no access to create linode and can see existing linodes', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + // simulating a default user without the ability to add Linodes. + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_linodes: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + + // Intercept and alias the mock requests + cy.intercept('GET', apiMatcher('profile'), (req) => { + req.reply(mockProfile); + }).as('getProfile'); + + cy.intercept('GET', apiMatcher('profile/grants'), (req) => { + req.reply(mockGrants); + }).as('getProfileGrants'); + + // Login and wait for application to load + cy.visitWithLogin(routes.linodeLanding); + cy.wait('@getLinodes'); + cy.url().should('endWith', routes.linodeLanding); + + // Wait for the mock requests to complete + cy.wait('@getProfile'); + cy.wait('@getProfileGrants'); + + // Assert that Create Linode button is visible and disabled + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .and('be.disabled') + .trigger('mouseover'); + + // Assert that tooltip is visible with message + ui.tooltip + .findByText( + "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." + ) + .should('be.visible'); + + // Assert that List of Liondes table exist + cy.get('table[aria-label="List of Linodes"]').should('exist'); + + // Assert that Docs link exist + cy.get( + 'a[aria-label="Docs - link opens in a new tab"][data-testid="external-link"]' + ).should('exist'); + + // Assert that the correct number of Linode entries are present in the table + cy.get('@mockLinodes').then((mockLinodes) => { + // Assert that the correct number of Linode entries are present in the table + cy.get('table[aria-label="List of Linodes"] tbody tr').should( + 'have.length', + mockLinodes.length + ); + + // Assert that each Linode entry is present in the table + mockLinodes.forEach((linode) => { + cy.get('table[aria-label="List of Linodes"] tbody tr').should( + 'contain', + linode.label + ); + }); + }); + }); +}); From 436c010d9216cdfb6d7f4634e1b4fcaf76c06d5d Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 16 Oct 2024 09:28:03 -0400 Subject: [PATCH 16/64] fix: `AppSelect.test.tsx` test flake (#11104) * add timeout to flakey test * Added changeset: Fix `AppSelect.test.tsx` test flake --------- Co-authored-by: Banks Nussman --- packages/manager/.changeset/pr-11104-tests-1729020207783.md | 5 +++++ .../Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11104-tests-1729020207783.md diff --git a/packages/manager/.changeset/pr-11104-tests-1729020207783.md b/packages/manager/.changeset/pr-11104-tests-1729020207783.md new file mode 100644 index 00000000000..4bb1c860164 --- /dev/null +++ b/packages/manager/.changeset/pr-11104-tests-1729020207783.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix `AppSelect.test.tsx` test flake ([#11104](https://github.com/linode/manager/pull/11104)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx index 182f7ec02fe..ed272f47f61 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx @@ -60,7 +60,9 @@ describe('Marketplace', () => { await waitFor(() => { expect(getByPlaceholderText('Select category')).not.toBeDisabled(); - }); + }, + { timeout: 5_000 } + ); const select = getByPlaceholderText('Select category'); From 4e7cb48a4befc9b2d4ea65b9da05c156fae270ab Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Wed, 16 Oct 2024 19:29:17 +0530 Subject: [PATCH 17/64] Upcoming: [DI-20844] - Tooltip for widget level filters and icons, beta feedbacks and CSS changes for CloudPulse (#11062) * upcoming: [DI-20800] - Tooltip changes * upcoming: [DI-20800] - Tooltip and publishing the resource selection onClose from Autocomplete * upcoming: [DI-20800] - Resource Selection close state handling updates * upcoming: [DI-20800] - Tooltip code refactoring * upcoming: [DI-20800] - Tooltip code refactoring * upcoming: [DI-20800] - Global Filters * upcoming: [DI-20800] - As per dev * upcoming: [DI-20800] - Code clean up and refactoring * upcoming: [DI-20800] - UT fixes * upcoming: [DI-20800] - Code clean up and review comments * upcoming: [DI-20800] - Mock related changes * upcoming: [DI-20800] - Code clean up and refactoring * upcoming: [DI-21254] - Handle unit using reference values * upcoming: [DI-21254] - Handle unit using reference values * Update metrics api end point * Updated non-null assertions * upcoming: [DI-20800] - Code clean up and refactoring * upcoming: [DI-20800] - Add Changeset * upcoming: [DI-20973] - Updated zeroth state message in contextual view * upcoming: [DI-21359] - Added auto interval option as defaut interval value if pref is not available * upcoming: [DI-21359] - Updated autoIntervalOptions variable instead of hardcoded value * upcoming: [DI-20800] - Quick code refactoring * upcoming: [DI-20800] - We don't need memo here in CloudPulseWidget * upcoming: [DI-20973] - Updated cloudpulse icon contextual view * upcoming: [DI-20973] - Updated test cases * upcoming: [DI-20800] - use theme spacing for height calculation * upcoming: [DI-20800] - aria-label for global refresh * upcoming: [DI-20800] - aria-label for zoomers * upcoming: [DI-20800] - PR review comments initial changes * upcoming: [DI-20800] - PR review comments * upcoming: [DI-20800] - PR review comments * upcoming: [DI-20844] - Remove height * upcoming: [DI-20844] - Remove comment * upcoming: [DI-20844] - Using reusable sx and removing styled component * upcoming: [DI-20844] - Fix eslint and use common tooltip component * upcoming: [DI-20844] - Remove unused variables * upcoming: [DI-20844] - Remove arrow * upcoming: [DI-20844] - Added explaining comments * upcoming: [DI-20844] - Added UTs for tooltips * upcoming: [DI-20844] - Updated UT's * upcoming: [DI-20844] - PR comments --------- Co-authored-by: vmangalr Co-authored-by: nikhagra-akamai --- ...r-11062-upcoming-features-1728390800736.md | 5 ++ .../CloudPulseDashboardWithFilters.test.tsx | 2 +- .../CloudPulseDashboardWithFilters.tsx | 16 ++--- .../CloudPulse/Overview/GlobalFilters.tsx | 27 ++++---- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 14 ++--- .../CloudPulse/Utils/UserPreference.ts | 3 +- .../CloudPulse/Widget/CloudPulseWidget.tsx | 18 ++++-- .../Widget/CloudPulseWidgetRenderer.tsx | 11 ++-- .../CloudPulseAggregateFunction.test.tsx | 17 +++-- .../CloudPulseAggregateFunction.tsx | 51 ++++++++------- .../CloudPulseIntervalSelect.test.tsx | 10 ++- .../components/CloudPulseIntervalSelect.tsx | 62 +++++++++---------- .../Widget/components/CloudPulseLineGraph.tsx | 4 +- .../Widget/components/Zoomer.test.tsx | 6 +- .../CloudPulse/Widget/components/Zoomer.tsx | 49 ++++++++------- .../shared/CloudPulseResourcesSelect.tsx | 21 ++++++- .../shared/CloudPulseTimeRangeSelect.tsx | 8 ++- .../shared/CloudPulseTooltip.test.tsx | 20 ++++++ .../CloudPulse/shared/CloudPulseTooltip.tsx | 30 +++++++++ .../manager/src/queries/cloudpulse/metrics.ts | 4 +- 20 files changed, 232 insertions(+), 146 deletions(-) create mode 100644 packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.tsx diff --git a/packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md b/packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md new file mode 100644 index 00000000000..d6f2cbc0ced --- /dev/null +++ b/packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add tooltip for widget level filters and icons, address beta demo feedbacks and CSS changes for CloudPulse ([#11062](https://github.com/linode/manager/pull/11062)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx index 68bf5750647..e4c64282ec7 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx @@ -11,7 +11,7 @@ const queryMocks = vi.hoisted(() => ({ })); const circleProgress = 'circle-progress'; -const mandatoryFiltersError = 'Mandatory Filters not Selected'; +const mandatoryFiltersError = 'Select filters to visualize metrics.'; vi.mock('src/queries/cloudpulse/dashboards', async () => { const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index e8f65dd6842..4e6d958e87b 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,14 +1,13 @@ -import { Grid, styled } from '@mui/material'; +import { Grid } from '@mui/material'; import React from 'react'; -import CloudPulseIcon from 'src/assets/icons/entityIcons/monitor.svg'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Paper } from 'src/components/Paper'; -import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; +import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { @@ -65,7 +64,7 @@ export const CloudPulseDashboardWithFilters = React.memo( const renderPlaceHolder = (title: string) => { return ( - + ); }; @@ -148,16 +147,9 @@ export const CloudPulseDashboardWithFilters = React.memo( })} /> ) : ( - renderPlaceHolder('Mandatory Filters not Selected') + renderPlaceHolder('Select filters to visualize metrics.') )} ); } ); - -// keeping it here to avoid recreating -const StyledPlaceholder = styled(Placeholder, { - label: 'StyledPlaceholder', -})({ - flex: 'auto', -}); diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index cfe4a9abeff..320e8e1280d 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -9,6 +9,7 @@ import { Divider } from 'src/components/Divider'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; +import { CloudPulseTooltip } from '../shared/CloudPulseTooltip'; import { DASHBOARD_ID, REFRESH, TIME_DURATION } from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; @@ -109,18 +110,20 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { label="Select Time Range" savePreferences /> - - - + + + + + diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 29eabd3a7a9..44b4192fdf4 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -1,6 +1,3 @@ -import { styled } from '@mui/material'; - -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { isToday } from 'src/utilities/isToday'; import { getMetrics } from 'src/utilities/statMetrics'; @@ -24,6 +21,7 @@ import type { TimeDuration, Widgets, } from '@linode/api-v4'; +import type { Theme } from '@mui/material'; import type { DataSet } from 'src/components/LineGraph/LineGraph'; import type { CloudPulseResourceTypeMapFlag, FlagSet } from 'src/featureFlags'; @@ -336,11 +334,11 @@ export const isDataEmpty = (data: DataSet[]): boolean => { }; /** - * Returns an autocomplete with updated styles according to UX, this will be used at widget level + * + * @param theme mui theme + * @returns The style needed for widget level autocomplete filters */ -export const StyledWidgetAutocomplete = styled(Autocomplete, { - label: 'StyledAutocomplete', -})(({ theme }) => ({ +export const getAutocompleteWidgetStyles = (theme: Theme) => ({ '&& .MuiFormControl-root': { minWidth: '90px', [theme.breakpoints.down('sm')]: { @@ -348,4 +346,4 @@ export const StyledWidgetAutocomplete = styled(Autocomplete, { }, width: '90px', }, -})); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts index 18550ae5f25..69d193c87e8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts +++ b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts @@ -5,7 +5,7 @@ import { usePreferences, } from 'src/queries/profile/preferences'; -import { DASHBOARD_ID, TIME_DURATION, WIDGETS } from './constants'; +import { DASHBOARD_ID, WIDGETS } from './constants'; import type { AclpConfig, AclpWidget } from '@linode/api-v4'; @@ -37,7 +37,6 @@ export const useAclpPreference = (): AclpPreferenceObject => { if (keys.includes(DASHBOARD_ID)) { currentPreferences = { ...data, - [TIME_DURATION]: currentPreferences[TIME_DURATION], [WIDGETS]: {}, }; } else { diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 22f29cb5989..44d5dbcdb39 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -13,7 +13,11 @@ import { } from '../Utils/CloudPulseWidgetUtils'; import { AGGREGATE_FUNCTION, SIZE, TIME_GRANULARITY } from '../Utils/constants'; import { constructAdditionalRequestFilters } from '../Utils/FilterBuilder'; -import { convertValueToUnit, formatToolTip } from '../Utils/unitConversion'; +import { + convertValueToUnit, + formatToolTip, + generateCurrentUnit, +} from '../Utils/unitConversion'; import { useAclpPreference } from '../Utils/UserPreference'; import { convertStringToCamelCasesWithSpaces } from '../Utils/utils'; import { CloudPulseAggregateFunction } from './components/CloudPulseAggregateFunction'; @@ -140,6 +144,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { widget: widgetProp, } = props; const flags = useFlags(); + const scaledWidgetUnit = React.useRef(generateCurrentUnit(unit)); const jweTokenExpiryError = 'Token expired'; @@ -237,8 +242,6 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { let legendRows: LegendRow[] = []; let today: boolean = false; - - let currentUnit = unit; if (!isLoading && metricsList) { const generatedData = generateGraphData({ flags, @@ -255,7 +258,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { data = generatedData.dimensions; legendRows = generatedData.legendRowsData; today = generatedData.today; - currentUnit = generatedData.unit; + scaledWidgetUnit.current = generatedData.unit; // here state doesn't matter, as this is always the latest re-render } const metricsApiCallError = error?.[0]?.reason; @@ -276,7 +279,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { padding={1} > - {convertStringToCamelCasesWithSpaces(widget.label)} ({currentUnit} + {convertStringToCamelCasesWithSpaces(widget.label)} ( + {scaledWidgetUnit.current} {unit.endsWith('ps') ? '/s' : ''}) { ? metricsApiCallError ?? 'Error while rendering graph' : undefined } + formatData={(data: number) => + convertValueToUnit(data, scaledWidgetUnit.current) + } legendRows={ legendRows && legendRows.length > 0 ? legendRows : undefined } ariaLabel={ariaLabel ? ariaLabel : ''} data={data} - formatData={(data: number) => convertValueToUnit(data, currentUnit)} formatTooltip={(value: number) => formatToolTip(value, unit)} gridSize={widget.size} loading={isLoading || metricsApiCallError === jweTokenExpiryError} // keep loading until we fetch the refresh token diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index d7dcd399161..b8aa623ddba 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -8,6 +8,7 @@ import { createObjectCopy } from '../Utils/utils'; import { CloudPulseWidget } from './CloudPulseWidget'; import { allIntervalOptions, + autoIntervalOption, getInSeconds, getIntervalIndex, } from './components/CloudPulseIntervalSelect'; @@ -80,7 +81,7 @@ export const RenderWidgets = React.memo( serviceType: dashboard?.service_type ?? '', timeStamp: manualRefreshTimeStamp, unit: widget.unit ?? '%', - widget: { ...widget }, + widget: { ...widget, time_granularity: autoIntervalOption }, }; if (savePref) { graphProp.widget = setPreferredWidgetPlan(graphProp.widget); @@ -104,17 +105,13 @@ export const RenderWidgets = React.memo( pref.aggregateFunction ?? widgetObj.aggregate_function, size: pref.size ?? widgetObj.size, time_granularity: { - ...(pref.timeGranularity ?? widgetObj.time_granularity), + ...(pref.timeGranularity ?? autoIntervalOption), }, }; } else { return { ...widgetObj, - time_granularity: { - label: 'Auto', - unit: 'Auto', - value: -1, - }, + time_granularity: autoIntervalOption, }; } }; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx index e40a09c699e..13677a9ec79 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx @@ -3,37 +3,42 @@ import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { convertStringToCamelCasesWithSpaces } from '../../Utils/utils'; import { CloudPulseAggregateFunction } from './CloudPulseAggregateFunction'; import type { AggregateFunctionProperties } from './CloudPulseAggregateFunction'; -const aggregateFunctionChange = (_selectedAggregateFunction: string) => {}; const availableAggregateFunctions = ['max', 'min', 'avg']; const defaultAggregateFunction = 'avg'; const props: AggregateFunctionProperties = { availableAggregateFunctions, defaultAggregateFunction, - onAggregateFuncChange: aggregateFunctionChange, + onAggregateFuncChange: vi.fn(), }; describe('Cloud Pulse Aggregate Function', () => { it('should check for the selected value in aggregate function dropdown', () => { - const { getByRole } = renderWithTheme( + const { getByRole, getByTestId } = renderWithTheme( ); const dropdown = getByRole('combobox'); - expect(dropdown).toHaveAttribute('value', defaultAggregateFunction); + expect(dropdown).toHaveAttribute( + 'value', + convertStringToCamelCasesWithSpaces(defaultAggregateFunction) + ); + + expect(getByTestId('Aggregation function')).toBeInTheDocument(); // test id for tooltip }); it('should select the aggregate function on click', () => { renderWithTheme(); fireEvent.click(screen.getByRole('button', { name: 'Open' })); - fireEvent.click(screen.getByRole('option', { name: 'min' })); + fireEvent.click(screen.getByRole('option', { name: 'Min' })); - expect(screen.getByRole('combobox')).toHaveAttribute('value', 'min'); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Min'); }); }); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx index c01d13723d4..2c989971fe3 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx @@ -1,6 +1,10 @@ import React from 'react'; -import { StyledWidgetAutocomplete } from '../../Utils/CloudPulseWidgetUtils'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +import { CloudPulseTooltip } from '../../shared/CloudPulseTooltip'; +import { getAutocompleteWidgetStyles } from '../../Utils/CloudPulseWidgetUtils'; +import { convertStringToCamelCasesWithSpaces } from '../../Utils/utils'; export interface AggregateFunctionProperties { /** @@ -41,6 +45,7 @@ export const CloudPulseAggregateFunction = React.memo( }; } ); + const defaultValue = availableAggregateFunc.find( (obj) => obj.label === defaultAggregateFunction @@ -52,26 +57,30 @@ export const CloudPulseAggregateFunction = React.memo( ] = React.useState(defaultValue); return ( - { - return option.label == value.label; - }} - onChange={(e, selectedAggregateFunc: AggregateFunction) => { - setSelectedAggregateFunction(selectedAggregateFunc); - onAggregateFuncChange(selectedAggregateFunc.label); - }} - textFieldProps={{ - hideLabel: true, - }} - autoHighlight - disableClearable - fullWidth={false} - label="Select an Aggregate Function" - noMarginTop={true} - options={availableAggregateFunc} - sx={{ width: '100%' }} - value={selectedAggregateFunction} - /> + + { + return convertStringToCamelCasesWithSpaces(option.label); // options needed to be display in Caps first + }} + isOptionEqualToValue={(option, value) => { + return option.label === value.label; + }} + onChange={(e, selectedAggregateFunc) => { + setSelectedAggregateFunction(selectedAggregateFunc); + onAggregateFuncChange(selectedAggregateFunc.label); + }} + textFieldProps={{ + hideLabel: true, + }} + autoHighlight + disableClearable + label="Select an Aggregate Function" + noMarginTop={true} + options={availableAggregateFunc} + sx={getAutocompleteWidgetStyles} + value={selectedAggregateFunction} + /> + ); } ); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx index f2ae44b21ac..1694ca0fd51 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx @@ -4,19 +4,15 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseIntervalSelect } from './CloudPulseIntervalSelect'; -import type { TimeGranularity } from '@linode/api-v4'; - describe('Interval select component', () => { - const intervalSelectionChange = (_selectedInterval: TimeGranularity) => { }; - it('should check for the selected value in interval select dropdown', () => { const scrape_interval = '30s'; const default_interval = { unit: 'min', value: 5 }; - const { getByRole } = renderWithTheme( + const { getByRole, getByTestId } = renderWithTheme( ); @@ -24,5 +20,7 @@ describe('Interval select component', () => { const dropdown = getByRole('combobox'); expect(dropdown).toHaveAttribute('value', '5 min'); + + expect(getByTestId('Data aggregation interval')).toBeInTheDocument(); // test id for tooltip }); }); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx index 29570e268ad..e96abfb95b5 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx @@ -1,6 +1,9 @@ import React from 'react'; -import { StyledWidgetAutocomplete } from '../../Utils/CloudPulseWidgetUtils'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +import { CloudPulseTooltip } from '../../shared/CloudPulseTooltip'; +import { getAutocompleteWidgetStyles } from '../../Utils/CloudPulseWidgetUtils'; import type { TimeGranularity } from '@linode/api-v4'; @@ -68,7 +71,7 @@ export const allIntervalOptions: IntervalOptions[] = [ }, ]; -const autoIntervalOption: IntervalOptions = { +export const autoIntervalOption: IntervalOptions = { label: 'Auto', unit: 'Auto', value: -1, @@ -119,35 +122,32 @@ export const CloudPulseIntervalSelect = React.memo( ); return ( - { - return option?.value === value?.value && option?.unit === value?.unit; - }} - onChange={( - _: React.SyntheticEvent, - selectedInterval: IntervalOptions - ) => { - setSelectedInterval(selectedInterval); - onIntervalChange({ - unit: selectedInterval?.unit, - value: selectedInterval?.value, - }); - }} - textFieldProps={{ - hideLabel: true, - }} - autoHighlight - disableClearable - fullWidth={false} - label="Select an Interval" - noMarginTop={true} - options={[autoIntervalOption, ...availableIntervalOptions]} - sx={{ width: { xs: '100%' } }} - value={selectedInterval} - /> + + { + return ( + option?.value === value?.value && option?.unit === value?.unit + ); + }} + onChange={(e, selectedInterval) => { + setSelectedInterval(selectedInterval); + onIntervalChange({ + unit: selectedInterval?.unit, + value: selectedInterval?.value, + }); + }} + textFieldProps={{ + hideLabel: true, + }} + autoHighlight + disableClearable + label="Select an Interval" + noMarginTop={true} + options={[autoIntervalOption, ...availableIntervalOptions]} + sx={getAutocompleteWidgetStyles} + value={selectedInterval} + /> + ); } ); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 4547d306b41..5c3ce2fd3fd 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -49,10 +49,10 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { border: 0, }, backgroundColor: theme.bg.offWhite, - height: `calc(${theme.spacing(14)} + 3px)`, // 115px maxHeight: `calc(${theme.spacing(14)} + 3px)`, + minHeight: `calc(${theme.spacing(10)})`, overflow: 'auto', - padding: theme.spacing(1), + paddingLeft: theme.spacing(1), }} ariaLabel={ariaLabel} data={data} diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx index 6b6f327c3be..40ab57a50f3 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx @@ -9,20 +9,22 @@ import type { ZoomIconProperties } from './Zoomer'; describe('Cloud Pulse Zoomer', () => { it('Should render zoomer with zoom-out button', () => { const props: ZoomIconProperties = { - handleZoomToggle: (_zoomInValue: boolean) => {}, + handleZoomToggle: vi.fn(), zoomIn: false, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-out')).toBeInTheDocument(); + expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip }), it('Should render zoomer with zoom-in button', () => { const props: ZoomIconProperties = { - handleZoomToggle: (_zoomInValue: boolean) => {}, + handleZoomToggle: vi.fn(), zoomIn: true, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-in')).toBeInTheDocument(); + expect(getByTestId('Minimize')).toBeInTheDocument(); // test id for tooltip }); }); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx index 9bd854fa6d3..e54155352ff 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx @@ -4,6 +4,8 @@ import * as React from 'react'; import ZoomInMap from 'src/assets/icons/zoomin.svg'; import ZoomOutMap from 'src/assets/icons/zoomout.svg'; +import { CloudPulseTooltip } from '../../shared/CloudPulseTooltip'; + export interface ZoomIconProperties { className?: string; handleZoomToggle: (zoomIn: boolean) => void; @@ -20,37 +22,38 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { const ToggleZoomer = () => { if (props.zoomIn) { return ( + + handleClick(false)} + > + + + + ); + } + + return ( + handleClick(false)} + aria-label="Zoom Out" + data-testid="zoom-out" + onClick={() => handleClick(true)} > - + - ); - } - - return ( - handleClick(true)} - > - - + ); }; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index e47338a09d1..4b43d137019 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -52,6 +52,13 @@ export const CloudPulseResourcesSelect = React.memo( CloudPulseResources[] >(); + /** + * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below + * When the autocomplete is already closed, we should publish the resources on clear action and deselect action as well since onclose will not be triggered at that time + * When the autocomplete is open, we should publish any resources on clear action until the autocomplete is close + */ + const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete + const getResourcesList = React.useMemo(() => { return resources && resources.length > 0 ? resources : []; }, [resources]); @@ -81,9 +88,19 @@ export const CloudPulseResourcesSelect = React.memo( return ( { + onChange={(e, resourceSelections) => { setSelectedResources(resourceSelections); - handleResourcesSelection(resourceSelections, savePreferences); + + if (!isAutocompleteOpen.current) { + handleResourcesSelection(resourceSelections, savePreferences); + } + }} + onClose={() => { + isAutocompleteOpen.current = false; + handleResourcesSelection(selectedResources ?? [], savePreferences); + }} + onOpen={() => { + isAutocompleteOpen.current = true; }} placeholder={ selectedResources?.length ? '' : placeholder || 'Select a Resource' diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index 41f7949f66e..6a37d417c1a 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -43,7 +43,7 @@ export const CloudPulseTimeRangeSelect = React.memo( return options[0]; } return options.find((o) => o.label === defaultValue) || options[0]; - }, []); + }, [defaultValue]); const [selectedTimeRange, setSelectedTimeRange] = React.useState< Item >(getDefaultValue()); @@ -58,8 +58,12 @@ export const CloudPulseTimeRangeSelect = React.memo( false ); } + + if (item !== selectedTimeRange) { + setSelectedTimeRange(item); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // need to execute only once, during mounting of this component + }, [defaultValue]); // need to execute when there is change in default value const handleChange = (item: Item) => { setSelectedTimeRange(item); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx new file mode 100644 index 00000000000..a113c38da40 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import { Typography } from 'src/components/Typography'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseTooltip } from './CloudPulseTooltip'; + +describe('Cloud Pulse Tooltip Component Tests', () => { + it('renders the tooltip', async () => { + const screen = renderWithTheme( + + Test + + ); + + expect( + await screen.container.querySelector('[data-qa-tooltip="Test"]') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.tsx new file mode 100644 index 00000000000..4a056c27509 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Tooltip } from 'src/components/Tooltip'; + +import type { TooltipProps } from '@mui/material'; + +export const CloudPulseTooltip = React.memo((props: TooltipProps) => { + const { children, placement, title } = props; + + return ( + + {children} + + ); +}); diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts index 88b6adf02d9..4f6f23622ab 100644 --- a/packages/manager/src/queries/cloudpulse/metrics.ts +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -84,9 +84,7 @@ export const fetchCloudPulseMetrics = ( Authorization: `Bearer ${token}`, }, method: 'POST', - url: `https://metrics-query.aclp.linode.com/v1/monitor/services/${encodeURIComponent( - serviceType! - )}/metrics`, + url: `${readApiEndpoint}${encodeURIComponent(serviceType)}/metrics`, }; return axiosInstance From 5bcefcfee87c50bef51e21291f6d72e158481362 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 16 Oct 2024 10:36:17 -0400 Subject: [PATCH 18/64] change: [M3-8749] - Add and use new cloud-init icon (#11100) * add and use the new icon * Added changeset: Add and use new cloud-init icon --------- Co-authored-by: Banks Nussman --- .../manager/.changeset/pr-11100-changed-1729010084175.md | 5 +++++ packages/manager/src/assets/icons/cloud-init.svg | 4 ++++ packages/manager/src/components/ImageSelect/ImageOption.tsx | 6 +++--- .../manager/src/components/ImageSelectv2/ImageOptionv2.tsx | 6 ++++-- 4 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-11100-changed-1729010084175.md create mode 100644 packages/manager/src/assets/icons/cloud-init.svg diff --git a/packages/manager/.changeset/pr-11100-changed-1729010084175.md b/packages/manager/.changeset/pr-11100-changed-1729010084175.md new file mode 100644 index 00000000000..e7ad10fb5e3 --- /dev/null +++ b/packages/manager/.changeset/pr-11100-changed-1729010084175.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Add and use new cloud-init icon ([#11100](https://github.com/linode/manager/pull/11100)) diff --git a/packages/manager/src/assets/icons/cloud-init.svg b/packages/manager/src/assets/icons/cloud-init.svg new file mode 100644 index 00000000000..b348e0fabb2 --- /dev/null +++ b/packages/manager/src/assets/icons/cloud-init.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index c619d845fa2..362382fe9cd 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -1,7 +1,7 @@ -import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import * as React from 'react'; +import React from 'react'; import { makeStyles } from 'tss-react/mui'; +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; import { Option } from 'src/components/EnhancedSelect/components/Option'; @@ -82,7 +82,7 @@ export const ImageOption = (props: ImageOptionProps) => { )} {flags.metadata && data.isCloudInitCompatible && ( - + )} diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index f383832e6e2..f34e5da413f 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -1,6 +1,6 @@ -import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import React from 'react'; +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { useFlags } from 'src/hooks/useFlags'; @@ -47,7 +47,9 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { )} {flags.metadata && image.capabilities.includes('cloud-init') && ( - +
    + +
    )} {isSelected && } From ed30c6e1bbd1946cb26aed6d86a1d0c94187fab3 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:58:10 -0400 Subject: [PATCH 19/64] refactor: [M3-8712] - Tanstack Reat Router rollout setup + betas routes migration (#11049) * POC: progressive migration * dev tools integration and save progress * linting rules and betas reat router removal * useParams for beta * yupe issues * type issues * fix test * fix tests * msw parity * oops mocking * Wrap up testing * Useful comments * moar cleanup * Added changeset: Migrate /betas routes to Tanstack Router * feedback @bnussman-akamai * testing migration improvements * missing import in test * fix dynamic import conflicts (hopefully) * temp debug * Revert "temp debug" This reverts commit 65653a7e1e8d06bc093c4c3a7f94383e6f03037f. * remove cleanUp and autenticate from `longview.spec.ts` to temp test * revert MSW config changes * feedback @bnussman-akamai * remove det tools * oof, the testing * cleanup * Type cleanup and more tests * oops revert test experiment * wrapping up: e2e and cleanup * feedback @jaalah-akamai * feedback @bnussman-akamai test routing * oop unskip test * feedback @bnussman-akamai --------- Co-authored-by: Banks Nussman --- .../pr-11049-tech-stories-1728329839703.md | 5 + packages/manager/.eslintrc.cjs | 44 +++++ .../cypress/e2e/core/account/betas.spec.ts | 3 + .../core/account/smoke-enroll-beta.spec.ts | 68 +++++++ .../cypress/support/intercepts/betas.ts | 61 ++++++ packages/manager/src/App.tsx | 10 +- packages/manager/src/MainContent.tsx | 22 ++- packages/manager/src/Router.tsx | 3 - packages/manager/src/components/Link.tsx | 7 +- .../src/features/Betas/BetaDetails.test.tsx | 58 ++++-- .../src/features/Betas/BetaDetails.tsx | 15 +- .../features/Betas/BetaDetailsList.test.tsx | 35 ++-- .../src/features/Betas/BetaDetailsList.tsx | 42 ++-- .../manager/src/features/Betas/BetaSignup.tsx | 30 ++- .../src/features/Betas/BetasLanding.test.tsx | 12 +- .../src/features/Betas/BetasLanding.tsx | 10 +- packages/manager/src/features/Betas/index.tsx | 19 -- .../LongviewDetail/LongviewDetail.tsx | 47 +++-- .../src/features/Search/SearchLanding.tsx | 16 +- packages/manager/src/mocks/serverHandlers.ts | 19 +- packages/manager/src/queries/betas.ts | 17 +- packages/manager/src/routes/betas.tsx | 20 +- packages/manager/src/routes/index.tsx | 20 +- packages/manager/src/routes/longview.tsx | 43 ++-- packages/manager/src/routes/root.ts | 16 +- packages/manager/src/routes/routes.test.tsx | 96 +++++---- packages/manager/src/routes/search.tsx | 8 +- packages/manager/src/routes/types.ts | 1 - packages/manager/src/routes/utils/allPaths.ts | 63 ++---- .../src/utilities/testHelpers.test.tsx | 184 ++++++++++++++++++ .../manager/src/utilities/testHelpers.tsx | 130 ++++++++++++- 31 files changed, 878 insertions(+), 246 deletions(-) create mode 100644 packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md create mode 100644 packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts create mode 100644 packages/manager/cypress/support/intercepts/betas.ts delete mode 100644 packages/manager/src/features/Betas/index.tsx create mode 100644 packages/manager/src/utilities/testHelpers.test.tsx diff --git a/packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md b/packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md new file mode 100644 index 00000000000..e8ef18d564a --- /dev/null +++ b/packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Migrate /betas routes to Tanstack Router ([#11049](https://github.com/linode/manager/pull/11049)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 6bc2c2fdd9f..d8262479e86 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -84,6 +84,50 @@ module.exports = { 'testing-library/await-async-query': 'off', }, }, + // restrict usage of react-router-dom during migration to tanstack/react-router + // TODO: TanStack Router - remove this override when migration is complete + { + files: [ + // for each new features added to the migration router, add its directory here + 'src/features/Betas/*', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + importNames: [ + // intentionally not including in this list as this will be updated last globally + 'useNavigate', + 'useParams', + 'useLocation', + 'useHistory', + 'useRouteMatch', + 'matchPath', + 'MemoryRouter', + 'Route', + 'RouteProps', + 'Switch', + 'Redirect', + 'RouteComponentProps', + 'withRouter', + ], + message: + 'Please use routing utilities from @tanstack/react-router.', + name: 'react-router-dom', + }, + { + importNames: ['renderWithTheme'], + message: + 'Please use the wrapWithThemeAndRouter helper function for testing components being migrated to TanStack Router.', + name: 'src/utilities/testHelpers', + }, + ], + }, + ], + }, + }, ], parser: '@typescript-eslint/parser', // Specifies the ESLint parser parserOptions: { diff --git a/packages/manager/cypress/e2e/core/account/betas.spec.ts b/packages/manager/cypress/e2e/core/account/betas.spec.ts index cf3375fb402..e936c41e700 100644 --- a/packages/manager/cypress/e2e/core/account/betas.spec.ts +++ b/packages/manager/cypress/e2e/core/account/betas.spec.ts @@ -7,6 +7,9 @@ import { ui } from 'support/ui'; import { mockGetUserPreferences } from 'support/intercepts/profile'; // TODO Delete feature flag mocks when feature flag is removed. +beforeEach(() => { + cy.tag('method:e2e'); +}); describe('Betas landing page', () => { /* * - Confirms that Betas nav item is present when feature is enabled. diff --git a/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts b/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts new file mode 100644 index 00000000000..b341be1c977 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts @@ -0,0 +1,68 @@ +import { accountBetaFactory, betaFactory } from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { DateTime } from 'luxon'; +import { + mockGetAccountBetas, + mockGetBetas, + mockGetBeta, + mockPostBeta, +} from 'support/intercepts/betas'; + +authenticate(); + +beforeEach(() => { + cy.tag('method:e2e'); +}); + +describe('Enroll in a Beta Program', () => { + it('checks the Beta Programs page', () => { + mockAppendFeatureFlags({ + selfServeBetas: true, + }).as('getFeatureFlags'); + const currentlyEnrolledBeta = accountBetaFactory.build({ + id: '12345', + enrolled: DateTime.now().minus({ days: 10 }).toISO(), + started: DateTime.now().minus({ days: 11 }).toISO(), + }); + const availableBetas = betaFactory.buildList(2); + const historicalBetas = accountBetaFactory.buildList(2, { + id: '1234', + label: 'Historical Beta', + started: DateTime.now().minus({ days: 15 }).toISO(), + enrolled: DateTime.now().minus({ days: 10 }).toISO(), + ended: DateTime.now().minus({ days: 5 }).toISO(), + }); + + const accountBetas = [currentlyEnrolledBeta, ...historicalBetas]; + + mockGetAccountBetas(accountBetas).as('getAccountBetas'); + mockGetBetas(availableBetas).as('getBetas'); + mockGetBeta(availableBetas[0]).as('getBeta'); + mockPostBeta(availableBetas[0]).as('postBeta'); + + cy.visitWithLogin('/betas'); + cy.wait('@getBetas'); + cy.wait('@getAccountBetas'); + + cy.get('[data-qa-beta-details="enrolled-beta"]').should('have.length', 1); + cy.get('[data-qa-beta-details="available-beta"]').should('have.length', 2); + cy.get('[data-qa-beta-details="historical-beta"]').should('have.length', 1); + + cy.get('[data-qa-beta-details="available-beta"]') + .first() + .within(() => { + cy.get('button').click(); + }); + cy.wait('@getBeta'); + + cy.url().should('include', '/betas/signup/beta-1'); + cy.findByRole('button', { name: 'Sign Up' }).should('be.disabled'); + cy.findByText('I agree to the terms').click(); + cy.findByRole('button', { name: 'Sign Up' }).should('be.enabled').click(); + + cy.wait('@postBeta'); + cy.url().should('include', '/betas'); + cy.url().should('not.include', 'signup'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/betas.ts b/packages/manager/cypress/support/intercepts/betas.ts new file mode 100644 index 00000000000..384961da39e --- /dev/null +++ b/packages/manager/cypress/support/intercepts/betas.ts @@ -0,0 +1,61 @@ +/** + * @file Mocks and intercepts related to notification and event handling. + */ + +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; + +import type { Beta } from '@linode/api-v4'; + +/** + * Intercepts GET request to fetch account betas (the ones the user has opted into) and mocks response. + * + * @param betas - Array of Betas with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('account/betas'), + paginateResponse(betas) + ); +}; + +/** + * Intercepts GET request to fetch available betas (all betas available to the user). + * + * @param betas - Array of Betas with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetBetas = (betas: Beta[]): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`betas`), paginateResponse(betas)); +}; + +/** + * Intercepts GET request to fetch a beta and mocks response. + * + * @param beta - Beta with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetBeta = (beta: Beta): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`betas/${beta.id}`), + makeResponse(beta) + ); +}; + +/** + * Intercepts POST request to enroll in a beta and mocks response. + * + * @param beta - Beta with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockPostBeta = (beta: Beta): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`account/betas`), makeResponse(beta)); +}; diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 6ed20994f08..f45319729b7 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -48,13 +48,11 @@ const BaseApp = withDocumentTitleProvider( + {/** + * Eventually we will have the here in place of + * + */} - {/* - * This will be our new entry point - * Leaving this commented out so reviewers can test the app with the new routing at any point by replacing - * MainContent with . - - */} ); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index faf51f6d9c5..c328b9bb2a4 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,4 +1,5 @@ import Grid from '@mui/material/Unstable_Grid2'; +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'; @@ -7,7 +8,6 @@ import Logo from 'src/assets/logo/akamai-logo.svg'; import { Box } from 'src/components/Box'; import { MainContentBanner } from 'src/components/MainContentBanner'; import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; -import { NotFound } from 'src/components/NotFound'; import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; @@ -19,7 +19,6 @@ import { useNotificationContext, } from 'src/features/NotificationCenter/NotificationCenterContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; -import { useFlags } from 'src/hooks/useFlags'; import { useMutatePreferences, usePreferences, @@ -35,8 +34,10 @@ import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; import { useProfile } from './queries/profile/profile'; +import { migrationRouter } from './routes'; import type { Theme } from '@mui/material/styles'; +import type { AnyRouter } from '@tanstack/react-router'; const useStyles = makeStyles()((theme: Theme) => ({ activationWrapper: { @@ -182,7 +183,6 @@ const AccountActivationLanding = React.lazy( ); const Firewalls = React.lazy(() => import('src/features/Firewalls')); const Databases = React.lazy(() => import('src/features/Databases')); -const BetaRoutes = React.lazy(() => import('src/features/Betas')); const VPC = React.lazy(() => import('src/features/VPCs')); const PlacementGroups = React.lazy(() => import('src/features/PlacementGroups').then((module) => ({ @@ -198,7 +198,6 @@ const CloudPulse = React.lazy(() => export const MainContent = () => { const { classes, cx } = useStyles(); - const flags = useFlags(); const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); @@ -356,9 +355,6 @@ export const MainContent = () => { {isDatabasesEnabled && ( )} - {flags.selfServeBetas && ( - - )} {isACLPEnabled && ( { {/** We don't want to break any bookmarks. This can probably be removed eventually. */} - + {/** + * This is the catch all routes that allows TanStack Router to take over. + * When a route is not found here, it will be handled by the migration router, which in turns handles the NotFound component. + * It is currently set to the migration router in order to incrementally migrate the app to the new routing. + * This is a temporary solution until we are ready to fully migrate to TanStack Router. + */} + + + diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index a10be660d77..7d89f4fa297 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,7 +1,6 @@ import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; -import { useFlags } from 'src/hooks/useFlags'; import { useGlobalErrors } from 'src/hooks/useGlobalErrors'; import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; @@ -16,7 +15,6 @@ export const Router = () => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isACLPEnabled } = useIsACLPEnabled(); const globalErrors = useGlobalErrors(); - const flags = useFlags(); // Update the router's context router.update({ @@ -26,7 +24,6 @@ export const Router = () => { isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, - selfServeBetas: flags.selfServeBetas, }, }); diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index ef2b8dfcd21..4bffa1946e4 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -11,9 +11,10 @@ import { } from 'src/utilities/link'; import { omitProps } from 'src/utilities/omittedProps'; +import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; import type { LinkProps as _LinkProps } from 'react-router-dom'; -export interface LinkProps extends _LinkProps { +export interface LinkProps extends Omit<_LinkProps, 'to'> { /** * This property can override the value of the copy passed by default to the aria label from the children. * This is useful when the text of the link is unavailable, not descriptive enough, or a single icon is used as the child. @@ -45,7 +46,7 @@ export interface LinkProps extends _LinkProps { * @example "/profile/display" * @example "https://linode.com" */ - to: string; + to: TanStackLinkProps['to'] | (string & {}); } /** @@ -101,6 +102,7 @@ export const Link = React.forwardRef( 'accessibleAriaLabel', 'external', 'forceCopyColor', + 'to', ]); return shouldOpenInNewTab ? ( @@ -144,6 +146,7 @@ export const Link = React.forwardRef( className )} ref={ref} + to={to as string} /> ); } diff --git a/packages/manager/src/features/Betas/BetaDetails.test.tsx b/packages/manager/src/features/Betas/BetaDetails.test.tsx index a75a8931444..005b96dd4a9 100644 --- a/packages/manager/src/features/Betas/BetaDetails.test.tsx +++ b/packages/manager/src/features/Betas/BetaDetails.test.tsx @@ -1,12 +1,26 @@ import { DateTime } from 'luxon'; import * as React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import BetaDetails from './BetaDetails'; describe('BetaDetails', () => { - it('should be able to display all fields for an AccountBeta type', () => { + beforeAll(() => { + vi.useFakeTimers({ + shouldAdvanceTime: true, + }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + + it('should be able to display all fields for an AccountBeta type', async () => { + const date = new Date(2024, 9, 1); + + vi.useFakeTimers(); + vi.setSystemTime(date); + const dates = { ended: DateTime.now().minus({ days: 30 }), started: DateTime.now().minus({ days: 60 }), @@ -20,14 +34,16 @@ describe('BetaDetails', () => { started: dates.started.toISO(), }; - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + + ); getByText(RegExp(beta.label)); getByText(RegExp(dates.started.toISODate())); getByText(RegExp(dates.ended.toISODate())); getByText(RegExp(beta.description)); }); - it('should not display the end date field if the beta does not have an ended property', () => { + it('should not display the end date field if the beta does not have an ended property', async () => { const beta = { description: 'A cool beta program', enrolled: DateTime.now().minus({ days: 60 }).toISO(), @@ -36,22 +52,26 @@ describe('BetaDetails', () => { more_info: 'https://linode.com', started: DateTime.now().minus({ days: 60 }).toISO(), }; - const { queryByText } = renderWithTheme(); + const { queryByText } = await renderWithThemeAndRouter( + + ); expect(queryByText(/End Date:/i)).toBeNull(); }); - it('should not display the more info field if the beta does not have an more_info property', () => { + it('should not display the more info field if the beta does not have an more_info property', async () => { const beta = { description: 'A cool beta program', id: 'beta', label: 'Beta', started: DateTime.now().minus({ days: 60 }).toISO(), }; - const { queryByText } = renderWithTheme(); + const { queryByText } = await renderWithThemeAndRouter( + + ); expect(queryByText(/More Info:/i)).toBeNull(); }); - it('should not display the Sign Up button if the beta has already been enrolled in', () => { + it('should not display the Sign Up button if the beta has already been enrolled in', async () => { const accountBeta = { description: 'A cool beta program', enrolled: DateTime.now().minus({ days: 60 }).toISO(), @@ -67,20 +87,22 @@ describe('BetaDetails', () => { started: DateTime.now().minus({ days: 60 }).toISO(), }; - const { queryByText: queryAccountBetaByText } = renderWithTheme( - + const { + queryByText: queryAccountBetaByText, + } = await renderWithThemeAndRouter( + ); const accountBetaSignUpButton = queryAccountBetaByText('Sign Up'); expect(accountBetaSignUpButton).toBeNull(); - const { queryByText: queryBetaByText } = renderWithTheme( - + const { queryByText: queryBetaByText } = await renderWithThemeAndRouter( + ); const betaSignUpButton = queryBetaByText('Sign Up'); expect(betaSignUpButton).not.toBeNull(); }); - it('should not display the started date if the beta has been enrolled in', () => { + it('should not display the started date if the beta has been enrolled in', async () => { const accountBeta = { description: 'A cool beta program', enrolled: DateTime.now().minus({ days: 60 }).toISO(), @@ -96,14 +118,16 @@ describe('BetaDetails', () => { started: DateTime.now().minus({ days: 60 }).toISO(), }; - const { queryByText: queryAccountBetaByText } = renderWithTheme( - + const { + queryByText: queryAccountBetaByText, + } = await renderWithThemeAndRouter( + ); const accountBetaStartDate = queryAccountBetaByText('Start Date:'); expect(accountBetaStartDate).toBeNull(); - const { queryByText: queryBetaByText } = renderWithTheme( - + const { queryByText: queryBetaByText } = await renderWithThemeAndRouter( + ); const betaStartDate = queryBetaByText('Start Date:'); expect(betaStartDate).not.toBeNull(); diff --git a/packages/manager/src/features/Betas/BetaDetails.tsx b/packages/manager/src/features/Betas/BetaDetails.tsx index e428a2777a1..811a24cee3b 100644 --- a/packages/manager/src/features/Betas/BetaDetails.tsx +++ b/packages/manager/src/features/Betas/BetaDetails.tsx @@ -1,20 +1,21 @@ -import { AccountBeta } from '@linode/api-v4/lib/account'; -import { Beta } from '@linode/api-v4/lib/betas'; -import { Stack } from 'src/components/Stack'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { Button } from 'src/components/Button/Button'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Link } from 'src/components/Link'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; +import type { AccountBeta, Beta } from '@linode/api-v4'; + interface Props { beta: AccountBeta | Beta; + dataQA: string; } const BetaDetails = (props: Props) => { - const history = useHistory(); + const navigate = useNavigate(); let more_info = undefined; let enrolled = undefined; if ('more_info' in props.beta) { @@ -25,6 +26,7 @@ const BetaDetails = (props: Props) => { } const { beta: { description, ended, id, label, started }, + dataQA, } = props; const startDate = !enrolled ? ( @@ -41,6 +43,7 @@ const BetaDetails = (props: Props) => { return ( { {enrolled ? null : ( ); + + expect(container.firstChild).toHaveClass('MuiButtonBase-root'); + + expect(getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('renderWithThemeAndRouter', () => { + it('should render the component with theme and router', async () => { + const TestComponent = () =>
    Test
    ; + const { getByText, router } = await renderWithThemeAndRouter( + + ); + + expect(router.state.location.pathname).toBe('/'); + + await waitFor(() => { + router.navigate({ + params: { betaId: 'beta' }, + to: '/betas/signup/$betaId', + }); + }); + + expect(router.state.location.pathname).toBe('/betas/signup/beta'); + expect(getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('wrapWithStore', () => { + it('should wrap the component with Redux store', () => { + const TestComponent = () =>
    Test
    ; + const wrapped = wrapWithStore({ children: }); + render(wrapped); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('wrapWithTableBody', () => { + it('should wrap the component with table and tbody', () => { + const TestComponent = () => ( +
    + + + ); + const wrapped = wrapWithTableBody(); + render(wrapped); + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(screen.getByText('Test').closest('table')).toBeInTheDocument(); + }); + }); + + describe('renderWithThemeAndFormik', () => { + it('renders the component within Formik context', () => { + const TestComponent = () => ( + {}} + > + {({ handleSubmit, values }) => ( +
    + + + + )} +
    + ); + + const { container } = renderWithThemeAndFormik(, { + initialValues: { testInput: 'initial value' }, + onSubmit: vi.fn(), + }); + + expect(container.querySelector('form')).toBeInTheDocument(); + expect(container.querySelector('input[name="testInput"]')).toHaveValue( + 'initial value' + ); + expect( + container.querySelector('button[type="submit"]') + ).toBeInTheDocument(); + }); + }); + + describe('renderWithThemeAndHookFormContext', () => { + it('should render the component with theme and react-hook-form', () => { + const TestComponent = () =>
    Test
    ; + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('withMarkup', () => { + it('should find text with markup', () => { + const { getByText } = render( +
    + Hello World +
    + ); + const getTextWithMarkup = withMarkup(getByText); + expect(getTextWithMarkup('Hello World')).toBeInTheDocument(); + }); + }); + + describe('assertOrder', () => { + it('should assert the order of elements', () => { + const { container } = render( +
    +

    First

    +

    Second

    +

    Third

    +
    + ); + expect(() => + assertOrder(container, '[data-testid]', ['First', 'Second', 'Third']) + ).not.toThrow(); + expect(() => + assertOrder(container, '[data-testid]', ['Third', 'Second', 'First']) + ).toThrow(); + }); + }); +}); diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 07818e547a6..0ae3424c2bf 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -1,5 +1,12 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render } from '@testing-library/react'; +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; +import { act, render, waitFor } from '@testing-library/react'; import mediaQuery from 'css-mediaquery'; import { Formik } from 'formik'; import { LDProvider } from 'launchdarkly-react-client-sdk'; @@ -15,9 +22,12 @@ import thunk from 'redux-thunk'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { queryClientFactory } from 'src/queries/base'; import { setupInterceptors } from 'src/request'; +import { migrationRouteTree } from 'src/routes'; import { defaultState, storeFactory } from 'src/store'; import type { QueryClient } from '@tanstack/react-query'; +// TODO: Tanstack Router - replace AnyRouter once migration is complete. +import type { AnyRootRoute, AnyRouter } from '@tanstack/react-router'; import type { MatcherFunction, RenderResult } from '@testing-library/react'; import type { FormikConfig, FormikValues } from 'formik'; import type { FieldValues, UseFormProps } from 'react-hook-form'; @@ -100,6 +110,9 @@ export const wrapWithTheme = (ui: any, options: Options = {}) => { options={{ bootstrap: options.flags }} > + {/** + * TODO Tanstack Router - remove amy routing routing wrapWithTheme + */} {routePath ? ( {uiToRender} @@ -115,6 +128,121 @@ export const wrapWithTheme = (ui: any, options: Options = {}) => { ); }; +interface OptionsWithRouter + extends Omit { + initialRoute?: string; + routeTree?: AnyRootRoute; + router?: AnyRouter; +} + +/** + * We don't always need to use the router in our tests. When we do, due to the async nature of TanStack Router, we need to use this helper function. + * The reason we use this instead of extending renderWithTheme is because of having to make all tests async. + * It seems unnecessary to refactor all tests to async when we don't need to access the router at all. + * + * In order to use this, you must await the result of the function. + * + * @example + * const { getByText, router } = await renderWithThemeAndRouter( + * , { + * initialRoute: '/route', + * } + * ); + * + * // Assert the initial route + * expect(router.state.location.pathname).toBe('/route'); + * + * // from here, you can use the router to navigate + * await waitFor(() => + * router.navigate({ + * params: { betaId: beta.id }, + * to: '/path/to/something', + * }) + * ); + * + * // And assert + * expect(router.state.location.pathname).toBe('/path/to/something'); + * + * // and test the UI + * getByText('Some text'); + */ +export const wrapWithThemeAndRouter = ( + ui: React.ReactNode, + options: OptionsWithRouter = {} +) => { + const { + customStore, + initialRoute = '/', + queryClient: passedQueryClient, + } = options; + const queryClient = passedQueryClient ?? queryClientFactory(); + const storeToPass = customStore ? baseStore(customStore) : storeFactory(); + + setupInterceptors( + configureStore([thunk])(defaultState) + ); + + const rootRoute = createRootRoute({}); + const indexRoute = createRoute({ + component: () => ui, + getParentRoute: () => rootRoute, + path: initialRoute, + }); + const router: AnyRouter = createRouter({ + history: createMemoryHistory({ + initialEntries: [initialRoute], + }), + routeTree: rootRoute.addChildren([indexRoute]), + }); + + return ( + + + + + + + + + + + + ); +}; + +export const renderWithThemeAndRouter = async ( + ui: React.ReactNode, + options: OptionsWithRouter = {} +): Promise => { + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: [options.initialRoute || '/'], + }), + routeTree: options.routeTree || migrationRouteTree, + }); + + let renderResult: RenderResult; + + await act(async () => { + renderResult = render(wrapWithThemeAndRouter(ui, { ...options, router })); + + // Wait for the router to be ready + await waitFor(() => expect(router.state.status).toBe('idle')); + }); + + return { + ...renderResult!, + rerender: (ui) => + renderResult.rerender(wrapWithThemeAndRouter(ui, { ...options, router })), + router, + }; +}; + /** * Wraps children with just the Redux Store. This is * useful for testing React hooks that need to access From 85212bd56652253b61045f2aefb79b594350e228 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:59:05 -0400 Subject: [PATCH 20/64] test: Fix LKE update test failure following feature flag update (#11113) * Fix LKE update test failure by mocking APL to be disabled * Added changeset: Mock APL feature flag to be disabled in LKE update tests --------- Co-authored-by: Joe D'Amore --- .../pr-11113-tests-1729099850186.md | 5 + .../e2e/core/kubernetes/lke-update.spec.ts | 2280 +++++++++-------- 2 files changed, 1159 insertions(+), 1126 deletions(-) create mode 100644 packages/manager/.changeset/pr-11113-tests-1729099850186.md diff --git a/packages/manager/.changeset/pr-11113-tests-1729099850186.md b/packages/manager/.changeset/pr-11113-tests-1729099850186.md new file mode 100644 index 00000000000..93e47dfd08d --- /dev/null +++ b/packages/manager/.changeset/pr-11113-tests-1729099850186.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock APL feature flag to be disabled in LKE update tests ([#11113](https://github.com/linode/manager/pull/11113)) 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 8b78f9907d8..f56f0e93cb8 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -32,1284 +32,1312 @@ import { ui } from 'support/ui'; import { randomIp, randomLabel } from 'support/util/random'; import { getRegionById } from 'support/util/regions'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; const mockNodePools = nodePoolFactory.buildList(2); describe('LKE cluster updates', () => { - /* - * - Confirms UI flow of upgrading a cluster to high availability control plane using mocked data. - * - Confirms that user is shown a warning and agrees to billing changes before upgrading. - * - Confirms that details page updates accordingly after upgrading to high availability. - */ - it('can upgrade to high availability', () => { - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - control_plane: { - high_availability: false, - }, + // TODO Add LKE update tests to cover flows when APL is enabled. + describe('APL disabled', () => { + beforeEach(() => { + // Mock the APL feature flag to be disabled. + mockAppendFeatureFlags({ + apl: false, + }); }); - const mockClusterWithHA = { - ...mockCluster, - control_plane: { - high_availability: true, - }, - }; - - const haUpgradeWarnings = [ - 'All nodes will be deleted and new nodes will be created to replace them.', - 'Any local storage (such as ’hostPath’ volumes) will be erased.', - 'This may take several minutes, as nodes will be replaced on a rolling basis.', - ]; - - const haUpgradeAgreement = - 'I agree to the additional fee on my monthly bill and understand HA upgrade can only be reversed by deleting my cluster'; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); - mockUpdateCluster(mockCluster.id, mockClusterWithHA).as('updateCluster'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); - - // Initiate high availability upgrade and agree to changes. - ui.button - .findByTitle('Upgrade to HA') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Upgrade to High Availability') - .should('be.visible') - .within(() => { - haUpgradeWarnings.forEach((warning: string) => { - cy.findByText(warning).should('be.visible'); - }); - - cy.findByText(haUpgradeAgreement, { exact: false }) - .should('be.visible') - .closest('label') - .click(); - - ui.button - .findByTitle('Upgrade to HA') - .should('be.visible') - .should('be.enabled') - .click(); + /* + * - Confirms UI flow of upgrading a cluster to high availability control plane using mocked data. + * - Confirms that user is shown a warning and agrees to billing changes before upgrading. + * - Confirms that details page updates accordingly after upgrading to high availability. + */ + it('can upgrade to high availability', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + control_plane: { + high_availability: false, + }, }); - // Confirm toast message appears and HA Cluster chip is shown. - cy.wait('@updateCluster'); - ui.toast.assertMessage('Enabled HA Control Plane'); - cy.findByText('HA CLUSTER').should('be.visible'); - cy.findByText('Upgrade to HA').should('not.exist'); - }); + const mockClusterWithHA = { + ...mockCluster, + control_plane: { + high_availability: true, + }, + }; + + const haUpgradeWarnings = [ + 'All nodes will be deleted and new nodes will be created to replace them.', + 'Any local storage (such as ’hostPath’ volumes) will be erased.', + 'This may take several minutes, as nodes will be replaced on a rolling basis.', + ]; + + const haUpgradeAgreement = + 'I agree to the additional fee on my monthly bill and understand HA upgrade can only be reversed by deleting my cluster'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockUpdateCluster(mockCluster.id, mockClusterWithHA).as('updateCluster'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // Initiate high availability upgrade and agree to changes. + ui.button + .findByTitle('Upgrade to HA') + .should('be.visible') + .should('be.enabled') + .click(); - /* - * - Confirms UI flow of upgrading Kubernetes version using mocked API requests. - * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date. - * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date. - */ - it('can upgrade kubernetes version from the details page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; - - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: oldVersion, - }); + ui.dialog + .findByTitle('Upgrade to High Availability') + .should('be.visible') + .within(() => { + haUpgradeWarnings.forEach((warning: string) => { + cy.findByText(warning).should('be.visible'); + }); + + cy.findByText(haUpgradeAgreement, { exact: false }) + .should('be.visible') + .closest('label') + .click(); - const mockClusterUpdated = { - ...mockCluster, - k8s_version: newVersion, - }; - - const upgradePrompt = 'A new version of Kubernetes is available (1.26).'; - - const upgradeNotes = [ - 'Once the upgrade is complete you will need to recycle all nodes in your cluster', - // Confirm that the old version and new version are both shown. - oldVersion, - newVersion, - ]; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); - mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); - - // Confirm that upgrade prompt is shown. - cy.findByText(upgradePrompt).should('be.visible'); - ui.button - .findByTitle('Upgrade Version') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle( - `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}` - ) - .should('be.visible') - .within(() => { - upgradeNotes.forEach((note: string) => { - cy.findAllByText(note, { exact: false }).should('be.visible'); + ui.button + .findByTitle('Upgrade to HA') + .should('be.visible') + .should('be.enabled') + .click(); }); - ui.button - .findByTitle('Upgrade Version') - .should('be.visible') - .should('be.enabled') - .click(); + // Confirm toast message appears and HA Cluster chip is shown. + cy.wait('@updateCluster'); + ui.toast.assertMessage('Enabled HA Control Plane'); + cy.findByText('HA CLUSTER').should('be.visible'); + cy.findByText('Upgrade to HA').should('not.exist'); + }); + + /* + * - Confirms UI flow of upgrading Kubernetes version using mocked API requests. + * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date. + * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date. + */ + it('can upgrade kubernetes version from the details page', () => { + const oldVersion = '1.25'; + const newVersion = '1.26'; + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: oldVersion, }); - // Wait for API response and assert toast message is shown. - cy.wait('@updateCluster'); + const mockClusterUpdated = { + ...mockCluster, + k8s_version: newVersion, + }; + + const upgradePrompt = 'A new version of Kubernetes is available (1.26).'; + + const upgradeNotes = [ + 'Once the upgrade is complete you will need to recycle all nodes in your cluster', + // Confirm that the old version and new version are both shown. + oldVersion, + newVersion, + ]; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // Confirm that upgrade prompt is shown. + cy.findByText(upgradePrompt).should('be.visible'); + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); - // Verify the banner goes away because the version update has happened - cy.findByText(upgradePrompt).should('not.exist'); + ui.dialog + .findByTitle( + `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}` + ) + .should('be.visible') + .within(() => { + upgradeNotes.forEach((note: string) => { + cy.findAllByText(note, { exact: false }).should('be.visible'); + }); - mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); + }); - const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes'; + // Wait for API response and assert toast message is shown. + cy.wait('@updateCluster'); - ui.dialog - .findByTitle(stepTwoDialogTitle) - .should('be.visible') - .within(() => { - cy.findByText('Kubernetes version has been updated successfully.', { - exact: false, - }).should('be.visible'); + // Verify the banner goes away because the version update has happened + cy.findByText(upgradePrompt).should('not.exist'); - cy.findByText( - 'For the changes to take full effect you must recycle the nodes in your cluster.', - { exact: false } - ).should('be.visible'); + mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); - ui.button - .findByTitle('Recycle All Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - }); + const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes'; - // Verify clicking the "Recycle All Nodes" makes an API call - cy.wait('@recycleAllNodes'); + ui.dialog + .findByTitle(stepTwoDialogTitle) + .should('be.visible') + .within(() => { + cy.findByText('Kubernetes version has been updated successfully.', { + exact: false, + }).should('be.visible'); + + cy.findByText( + 'For the changes to take full effect you must recycle the nodes in your cluster.', + { exact: false } + ).should('be.visible'); + + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Verify the upgrade dialog closed - cy.findByText(stepTwoDialogTitle).should('not.exist'); + // Verify clicking the "Recycle All Nodes" makes an API call + cy.wait('@recycleAllNodes'); - // Verify the banner is still gone after the flow - cy.findByText(upgradePrompt).should('not.exist'); + // Verify the upgrade dialog closed + cy.findByText(stepTwoDialogTitle).should('not.exist'); - // Verify the version is correct after the update - cy.findByText(`Version ${newVersion}`); + // Verify the banner is still gone after the flow + cy.findByText(upgradePrompt).should('not.exist'); - ui.toast.findByMessage('Recycle started successfully.'); - }); + // Verify the version is correct after the update + cy.findByText(`Version ${newVersion}`); - it('can upgrade the kubernetes version from the landing page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; - - const cluster = kubernetesClusterFactory.build({ - k8s_version: oldVersion, + ui.toast.findByMessage('Recycle started successfully.'); }); - const updatedCluster = { ...cluster, k8s_version: newVersion }; + it('can upgrade the kubernetes version from the landing page', () => { + const oldVersion = '1.25'; + const newVersion = '1.26'; - mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); - mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); - mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); + const cluster = kubernetesClusterFactory.build({ + k8s_version: oldVersion, + }); - cy.visitWithLogin(`/kubernetes/clusters`); + const updatedCluster = { ...cluster, k8s_version: newVersion }; - cy.wait(['@getClusters', '@getVersions']); + mockGetClusters([cluster]).as('getClusters'); + mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); + mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); - cy.findByText(oldVersion).should('be.visible'); + cy.visitWithLogin(`/kubernetes/clusters`); - cy.findByText('UPGRADE').should('be.visible').should('be.enabled').click(); + cy.wait(['@getClusters', '@getVersions']); - ui.dialog - .findByTitle( - `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` - ) - .should('be.visible'); + cy.findByText(oldVersion).should('be.visible'); - mockGetClusters([updatedCluster]).as('getClusters'); + cy.findByText('UPGRADE') + .should('be.visible') + .should('be.enabled') + .click(); - ui.button - .findByTitle('Upgrade Version') - .should('be.visible') - .should('be.enabled') - .click(); + ui.dialog + .findByTitle( + `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + ) + .should('be.visible'); - cy.wait(['@updateCluster', '@getClusters']); + mockGetClusters([updatedCluster]).as('getClusters'); - ui.dialog - .findByTitle('Step 2: Recycle All Cluster Nodes') - .should('be.visible'); + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); - ui.button - .findByTitle('Recycle All Nodes') - .should('be.visible') - .should('be.enabled') - .click(); + cy.wait(['@updateCluster', '@getClusters']); - cy.wait('@recycleAllNodes'); + ui.dialog + .findByTitle('Step 2: Recycle All Cluster Nodes') + .should('be.visible'); - ui.toast.assertMessage('Recycle started successfully.'); + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); - cy.findByText(newVersion).should('be.visible'); - }); + cy.wait('@recycleAllNodes'); - /* - * - Confirms node, node pool, and cluster recycling UI flow using mocked API data. - * - Confirms that user is warned that recycling recreates nodes and may take a while. - */ - it('can recycle nodes', () => { - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - }); + ui.toast.assertMessage('Recycle started successfully.'); - const mockKubeLinode = kubeLinodeFactory.build(); - - const mockNodePool = nodePoolFactory.build({ - count: 1, - type: 'g6-standard-1', - nodes: [mockKubeLinode], + cy.findByText(newVersion).should('be.visible'); }); - const mockLinode = linodeFactory.build({ - label: randomLabel(), - id: mockKubeLinode.instance_id ?? undefined, - }); + /* + * - Confirms node, node pool, and cluster recycling UI flow using mocked API data. + * - Confirms that user is warned that recycling recreates nodes and may take a while. + */ + it('can recycle nodes', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); - const recycleWarningSubstrings = [ - 'will be deleted', - 'will be created', - 'local storage (such as ’hostPath’ volumes) will be erased', - 'may take several minutes', - ]; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetLinodes([mockLinode]).as('getLinodes'); - mockGetKubernetesVersions().as('getVersions'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); - - // Recycle individual node. - ui.button - .findByTitle('Recycle') - .should('be.visible') - .should('be.enabled') - .click(); - - mockRecycleNode(mockCluster.id, mockKubeLinode.id).as('recycleNode'); - ui.dialog - .findByTitle(`Recycle ${mockKubeLinode.id}?`) - .should('be.visible') - .within(() => { - recycleWarningSubstrings.forEach((warning: string) => { - cy.findByText(warning, { exact: false }).should('be.visible'); - }); + const mockKubeLinode = kubeLinodeFactory.build(); - ui.button - .findByTitle('Recycle') - .should('be.visible') - .should('be.enabled') - .click(); + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: 'g6-standard-1', + nodes: [mockKubeLinode], }); - cy.wait('@recycleNode'); - ui.toast.assertMessage('Node queued for recycling.'); - - ui.button - .findByTitle('Recycle Pool Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - - mockRecycleNodePool(mockCluster.id, mockNodePool.id).as('recycleNodePool'); - ui.dialog - .findByTitle('Recycle node pool?') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Recycle Pool Nodes') - .should('be.visible') - .should('be.enabled') - .click(); + const mockLinode = linodeFactory.build({ + label: randomLabel(), + id: mockKubeLinode.instance_id ?? undefined, }); - cy.wait('@recycleNodePool'); - ui.toast.assertMessage( - `Recycled all nodes in node pool ${mockNodePool.id}` - ); - - ui.button - .findByTitle('Recycle All Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - - mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); - ui.dialog - .findByTitle('Recycle all nodes in cluster?') - .should('be.visible') - .within(() => { - recycleWarningSubstrings.forEach((warning: string) => { - cy.findByText(warning, { exact: false }).should('be.visible'); - }); - - ui.button - .findByTitle('Recycle All Cluster Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - }); + const recycleWarningSubstrings = [ + 'will be deleted', + 'will be created', + 'local storage (such as ’hostPath’ volumes) will be erased', + 'may take several minutes', + ]; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetLinodes([mockLinode]).as('getLinodes'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); + + // Recycle individual node. + ui.button + .findByTitle('Recycle') + .should('be.visible') + .should('be.enabled') + .click(); - cy.wait('@recycleAllNodes'); - ui.toast.assertMessage('All cluster nodes queued for recycling'); - }); + mockRecycleNode(mockCluster.id, mockKubeLinode.id).as('recycleNode'); + ui.dialog + .findByTitle(`Recycle ${mockKubeLinode.id}?`) + .should('be.visible') + .within(() => { + recycleWarningSubstrings.forEach((warning: string) => { + cy.findByText(warning, { exact: false }).should('be.visible'); + }); - /* - * - Confirms UI flow when enabling and disabling node pool autoscaling using mocked API responses. - * - Confirms that errors are shown when attempting to autoscale using invalid values. - * - Confirms that UI updates to reflect node pool autoscale state. - */ - it('can toggle autoscaling', () => { - const autoscaleMin = 3; - const autoscaleMax = 10; - - const minWarning = - 'Minimum must be between 1 and 99 nodes and cannot be greater than Maximum.'; - const maxWarning = 'Maximum must be between 1 and 100 nodes.'; - - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - }); + ui.button + .findByTitle('Recycle') + .should('be.visible') + .should('be.enabled') + .click(); + }); - const mockNodePool = nodePoolFactory.build({ - count: 1, - type: 'g6-standard-1', - nodes: kubeLinodeFactory.buildList(1), - }); + cy.wait('@recycleNode'); + ui.toast.assertMessage('Node queued for recycling.'); - const mockNodePoolAutoscale = { - ...mockNodePool, - autoscaler: { - enabled: true, - min: autoscaleMin, - max: autoscaleMax, - }, - }; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); - - // Click "Autoscale Pool", enable autoscaling, and set min and max values. - mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( - 'toggleAutoscale' - ); - mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as( - 'getNodePools' - ); - ui.button - .findByTitle('Autoscale Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Autoscale Pool') - .should('be.visible') - .within(() => { - cy.findByText('Autoscaler').should('be.visible').click(); - - cy.findByLabelText('Min') - .should('be.visible') - .click() - .clear() - .type(`${autoscaleMin}`); + ui.button + .findByTitle('Recycle Pool Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + + mockRecycleNodePool(mockCluster.id, mockNodePool.id).as( + 'recycleNodePool' + ); + ui.dialog + .findByTitle('Recycle node pool?') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Recycle Pool Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByText(minWarning).should('be.visible'); + cy.wait('@recycleNodePool'); + ui.toast.assertMessage( + `Recycled all nodes in node pool ${mockNodePool.id}` + ); - cy.findByLabelText('Max') - .should('be.visible') - .click() - .clear() - .type('101'); + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); - cy.findByText(minWarning).should('not.exist'); - cy.findByText(maxWarning).should('be.visible'); + mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); + ui.dialog + .findByTitle('Recycle all nodes in cluster?') + .should('be.visible') + .within(() => { + recycleWarningSubstrings.forEach((warning: string) => { + cy.findByText(warning, { exact: false }).should('be.visible'); + }); - cy.findByLabelText('Max') - .should('be.visible') - .click() - .clear() - .type(`${autoscaleMax}`); + ui.button + .findByTitle('Recycle All Cluster Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByText(minWarning).should('not.exist'); - cy.findByText(maxWarning).should('not.exist'); + cy.wait('@recycleAllNodes'); + ui.toast.assertMessage('All cluster nodes queued for recycling'); + }); - ui.button.findByTitle('Save Changes').should('be.visible').click(); + /* + * - Confirms UI flow when enabling and disabling node pool autoscaling using mocked API responses. + * - Confirms that errors are shown when attempting to autoscale using invalid values. + * - Confirms that UI updates to reflect node pool autoscale state. + */ + it('can toggle autoscaling', () => { + const autoscaleMin = 3; + const autoscaleMax = 10; + + const minWarning = + 'Minimum must be between 1 and 99 nodes and cannot be greater than Maximum.'; + const maxWarning = 'Maximum must be between 1 and 100 nodes.'; + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, }); - // Wait for API response and confirm that UI updates to reflect autoscale. - cy.wait(['@toggleAutoscale', '@getNodePools']); - ui.toast.assertMessage( - `Autoscaling updated for Node Pool ${mockNodePool.id}.` - ); - cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( - 'be.visible' - ); - - // Click "Autoscale Pool" again and disable autoscaling. - mockUpdateNodePool(mockCluster.id, mockNodePool).as('toggleAutoscale'); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - ui.button - .findByTitle('Autoscale Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Autoscale Pool') - .should('be.visible') - .within(() => { - cy.findByText('Autoscaler').should('be.visible').click(); - - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.enabled') - .click(); + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: 'g6-standard-1', + nodes: kubeLinodeFactory.buildList(1), }); - // Wait for API response and confirm that UI updates to reflect no autoscale. - cy.wait(['@toggleAutoscale', '@getNodePools']); - ui.toast.assertMessage( - `Autoscaling updated for Node Pool ${mockNodePool.id}.` - ); - cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( - 'not.exist' - ); - }); - - /* - * - Confirms node pool resize UI flow using mocked API responses. - * - Confirms that pool size can be increased and decreased. - * - Confirms that user is warned when decreasing node pool size. - * - Confirms that UI updates to reflect new node pool size. - */ - it('can resize pools', () => { - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - }); - - const mockNodePoolResized = nodePoolFactory.build({ - count: 3, - type: 'g6-standard-1', - nodes: kubeLinodeFactory.buildList(3), - }); + const mockNodePoolAutoscale = { + ...mockNodePool, + autoscaler: { + enabled: true, + min: autoscaleMin, + max: autoscaleMax, + }, + }; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // Click "Autoscale Pool", enable autoscaling, and set min and max values. + mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( + 'toggleAutoscale' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as( + 'getNodePools' + ); + ui.button + .findByTitle('Autoscale Pool') + .should('be.visible') + .should('be.enabled') + .click(); - const mockNodePoolInitial = { - ...mockNodePoolResized, - count: 1, - nodes: [mockNodePoolResized.nodes[0]], - }; - - const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( - (node: PoolNodeResponse): Linode => { - return linodeFactory.build({ - id: node.instance_id ?? undefined, - ipv4: [randomIp()], - }); - } - ); - - const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; - - const decreaseSizeWarning = - 'Resizing to fewer nodes will delete random nodes from the pool.'; - const nodeSizeRecommendation = - 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.'; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( - 'getNodePools' - ); - mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetKubernetesVersions().as('getVersions'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); - - // Confirm that nodes are listed with correct details. - mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { - cy.get(`tr[data-qa-node-row="${node.id}"]`) + ui.dialog + .findByTitle('Autoscale Pool') .should('be.visible') .within(() => { - const nodeLinode = mockLinodes.find( - (linode: Linode) => linode.id === node.instance_id - ); - if (nodeLinode) { - cy.findByText(nodeLinode.label).should('be.visible'); - cy.findByText(nodeLinode.ipv4[0]).should('be.visible'); - ui.button - .findByTitle('Recycle') - .should('be.visible') - .should('be.enabled'); - } - }); - }); + cy.findByText('Autoscaler').should('be.visible').click(); - // Click "Resize Pool" and increase size to 3 nodes. - ui.button - .findByTitle('Resize Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( - 'resizeNodePool' - ); - mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( - 'getNodePools' - ); - ui.drawer - .findByTitle(mockNodePoolDrawerTitle) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.disabled'); + cy.findByLabelText('Min') + .should('be.visible') + .click() + .clear() + .type(`${autoscaleMin}`); - cy.findByText('Resized pool: $12/month (1 node at $12/month)').should( - 'be.visible' - ); + cy.findByText(minWarning).should('be.visible'); - cy.findByLabelText('Add 1') - .should('be.visible') - .should('be.enabled') - .click() - .click(); + cy.findByLabelText('Max') + .should('be.visible') + .click() + .clear() + .type('101'); - cy.findByLabelText('Edit Quantity').should('have.value', '3'); - cy.findByText('Resized pool: $36/month (3 nodes at $12/month)').should( - 'be.visible' - ); + cy.findByText(minWarning).should('not.exist'); + cy.findByText(maxWarning).should('be.visible'); - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.enabled') - .click(); - }); + cy.findByLabelText('Max') + .should('be.visible') + .click() + .clear() + .type(`${autoscaleMax}`); - cy.wait(['@resizeNodePool', '@getNodePools']); + cy.findByText(minWarning).should('not.exist'); + cy.findByText(maxWarning).should('not.exist'); - // Confirm that new nodes are listed with correct info. - mockLinodes.forEach((mockLinode: Linode) => { - cy.findByText(mockLinode.label) + ui.button.findByTitle('Save Changes').should('be.visible').click(); + }); + + // Wait for API response and confirm that UI updates to reflect autoscale. + cy.wait(['@toggleAutoscale', '@getNodePools']); + ui.toast.assertMessage( + `Autoscaling updated for Node Pool ${mockNodePool.id}.` + ); + cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( + 'be.visible' + ); + + // Click "Autoscale Pool" again and disable autoscaling. + mockUpdateNodePool(mockCluster.id, mockNodePool).as('toggleAutoscale'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + ui.button + .findByTitle('Autoscale Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Autoscale Pool') .should('be.visible') - .closest('tr') .within(() => { - cy.findByText(mockLinode.ipv4[0]).should('be.visible'); + cy.findByText('Autoscaler').should('be.visible').click(); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); }); + + // Wait for API response and confirm that UI updates to reflect no autoscale. + cy.wait(['@toggleAutoscale', '@getNodePools']); + ui.toast.assertMessage( + `Autoscaling updated for Node Pool ${mockNodePool.id}.` + ); + cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( + 'not.exist' + ); }); - // Click "Resize Pool" and decrease size back to 1 node. - ui.button - .findByTitle('Resize Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateNodePool(mockCluster.id, mockNodePoolInitial).as( - 'resizeNodePool' - ); - mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( - 'getNodePools' - ); - ui.drawer - .findByTitle(mockNodePoolDrawerTitle) - .should('be.visible') - .within(() => { - cy.findByLabelText('Subtract 1') - .should('be.visible') - .should('be.enabled') - .click() - .click(); + /* + * - Confirms node pool resize UI flow using mocked API responses. + * - Confirms that pool size can be increased and decreased. + * - Confirms that user is warned when decreasing node pool size. + * - Confirms that UI updates to reflect new node pool size. + */ + it('can resize pools', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); - cy.findByText(decreaseSizeWarning).should('be.visible'); - cy.findByText(nodeSizeRecommendation).should('be.visible'); + const mockNodePoolResized = nodePoolFactory.build({ + count: 3, + type: 'g6-standard-1', + nodes: kubeLinodeFactory.buildList(3), + }); - ui.button - .findByTitle('Save Changes') + const mockNodePoolInitial = { + ...mockNodePoolResized, + count: 1, + nodes: [mockNodePoolResized.nodes[0]], + }; + + const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( + (node: PoolNodeResponse): Linode => { + return linodeFactory.build({ + id: node.instance_id ?? undefined, + ipv4: [randomIp()], + }); + } + ); + + const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; + + const decreaseSizeWarning = + 'Resizing to fewer nodes will delete random nodes from the pool.'; + const nodeSizeRecommendation = + 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); + + // Confirm that nodes are listed with correct details. + mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { + cy.get(`tr[data-qa-node-row="${node.id}"]`) .should('be.visible') - .should('be.enabled') - .click(); + .within(() => { + const nodeLinode = mockLinodes.find( + (linode: Linode) => linode.id === node.instance_id + ); + if (nodeLinode) { + cy.findByText(nodeLinode.label).should('be.visible'); + cy.findByText(nodeLinode.ipv4[0]).should('be.visible'); + ui.button + .findByTitle('Recycle') + .should('be.visible') + .should('be.enabled'); + } + }); }); - cy.wait(['@resizeNodePool', '@getNodePools']); - cy.get('[data-qa-node-row]').should('have.length', 1); - }); + // Click "Resize Pool" and increase size to 3 nodes. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( + 'getNodePools' + ); + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); - /* - * - Confirms kubeconfig reset UI flow using mocked API responses. - * - Confirms that user is warned of repercussions before resetting config. - * - Confirms that toast appears confirming kubeconfig has reset. - */ - it('can reset kubeconfig', () => { - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - }); + cy.findByText('Resized pool: $12/month (1 node at $12/month)').should( + 'be.visible' + ); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); - mockResetKubeconfig(mockCluster.id).as('resetKubeconfig'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - const resetWarnings = [ - 'This will delete and regenerate the cluster’s Kubeconfig file', - 'You will no longer be able to access this cluster via your previous Kubeconfig file', - 'This action cannot be undone', - ]; - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); - - // Click "Reset" button, proceed through confirmation dialog. - cy.findByText('Reset').should('be.visible').click(); - ui.dialog - .findByTitle('Reset Cluster Kubeconfig?') - .should('be.visible') - .within(() => { - resetWarnings.forEach((warning: string) => { - cy.findByText(warning, { exact: false }).should('be.visible'); + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click() + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '3'); + cy.findByText( + 'Resized pool: $36/month (3 nodes at $12/month)' + ).should('be.visible'); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); }); - ui.button - .findByTitle('Reset Kubeconfig') + cy.wait(['@resizeNodePool', '@getNodePools']); + + // Confirm that new nodes are listed with correct info. + mockLinodes.forEach((mockLinode: Linode) => { + cy.findByText(mockLinode.label) .should('be.visible') - .should('be.enabled') - .click(); + .closest('tr') + .within(() => { + cy.findByText(mockLinode.ipv4[0]).should('be.visible'); + }); }); - // Wait for API response and assert toast message appears. - cy.wait('@resetKubeconfig'); - ui.toast.assertMessage('Successfully reset Kubeconfig'); - }); - - /* - * - Confirms UI flow when adding and deleting node pools. - * - Confirms that user cannot delete a node pool when there is only 1 pool. - * - Confirms that details page updates to reflect change when pools are added or deleted. - */ - it('can add and delete node pools', () => { - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - }); + // Click "Resize Pool" and decrease size back to 1 node. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolInitial).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + cy.findByLabelText('Subtract 1') + .should('be.visible') + .should('be.enabled') + .click() + .click(); + + cy.findByText(decreaseSizeWarning).should('be.visible'); + cy.findByText(nodeSizeRecommendation).should('be.visible'); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); - const mockNodePool = nodePoolFactory.build({ - type: 'g6-dedicated-4', + cy.wait(['@resizeNodePool', '@getNodePools']); + cy.get('[data-qa-node-row]').should('have.length', 1); }); - const mockNewNodePool = nodePoolFactory.build({ - type: 'g6-dedicated-2', - }); + /* + * - Confirms kubeconfig reset UI flow using mocked API responses. + * - Confirms that user is warned of repercussions before resetting config. + * - Confirms that toast appears confirming kubeconfig has reset. + */ + it('can reset kubeconfig', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); - mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockDeleteNodePool(mockCluster.id, mockNewNodePool.id).as('deleteNodePool'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); - - // Assert that initial node pool is shown on the page. - cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); - - // "Delete Pool" button should be disabled when only 1 node pool exists. - ui.button - .findByTitle('Delete Pool') - .should('be.visible') - .should('be.disabled'); - - // Add a new node pool, select plan, submit form in drawer. - ui.button - .findByTitle('Add a Node Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( - 'getNodePools' - ); - ui.drawer - .findByTitle(`Add a Node Pool: ${mockCluster.label}`) - .should('be.visible') - .within(() => { - cy.findByText('Dedicated 4 GB') - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByLabelText('Add 1').should('be.visible').click(); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockResetKubeconfig(mockCluster.id).as('resetKubeconfig'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + const resetWarnings = [ + 'This will delete and regenerate the cluster’s Kubeconfig file', + 'You will no longer be able to access this cluster via your previous Kubeconfig file', + 'This action cannot be undone', + ]; + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // Click "Reset" button, proceed through confirmation dialog. + cy.findByText('Reset').should('be.visible').click(); + ui.dialog + .findByTitle('Reset Cluster Kubeconfig?') + .should('be.visible') + .within(() => { + resetWarnings.forEach((warning: string) => { + cy.findByText(warning, { exact: false }).should('be.visible'); }); - ui.button - .findByTitle('Add pool') - .should('be.visible') - .should('be.enabled') - .click(); - }); + ui.button + .findByTitle('Reset Kubeconfig') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Wait for API responses and confirm that both node pools are shown. - cy.wait(['@addNodePool', '@getNodePools']); - cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); - cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('be.visible'); - - // Delete the newly added node pool. - cy.get(`[data-qa-node-pool-id="${mockNewNodePool.id}"]`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete Pool') - .should('be.visible') - .should('be.enabled') - .click(); + // Wait for API response and assert toast message appears. + cy.wait('@resetKubeconfig'); + ui.toast.assertMessage('Successfully reset Kubeconfig'); + }); + + /* + * - Confirms UI flow when adding and deleting node pools. + * - Confirms that user cannot delete a node pool when there is only 1 pool. + * - Confirms that details page updates to reflect change when pools are added or deleted. + */ + it('can add and delete node pools', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, }); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - ui.dialog - .findByTitle('Delete Node Pool?') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); + const mockNodePool = nodePoolFactory.build({ + type: 'g6-dedicated-4', }); - // Confirm node pool is deleted, original node pool still exists, and - // delete pool button is once again disabled. - cy.wait(['@deleteNodePool', '@getNodePools']); - cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); - cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('not.exist'); + const mockNewNodePool = nodePoolFactory.build({ + type: 'g6-dedicated-2', + }); - ui.button - .findByTitle('Delete Pool') - .should('be.visible') - .should('be.disabled'); - }); -}); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); + mockDeleteNodePool(mockCluster.id, mockNewNodePool.id).as( + 'deleteNodePool' + ); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // Assert that initial node pool is shown on the page. + cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); + + // "Delete Pool" button should be disabled when only 1 node pool exists. + ui.button + .findByTitle('Delete Pool') + .should('be.visible') + .should('be.disabled'); -describe('LKE cluster updates for DC-specific prices', () => { - /* - * - Confirms node pool resize UI flow using mocked API responses. - * - Confirms that pool size can be increased and decreased. - * - Confirms that drawer reflects prices in regions with DC-specific pricing. - * - Confirms that details page updates total cluster price with DC-specific pricing. - */ - it('can resize pools with DC-specific prices', () => { - const dcSpecificPricingRegion = getRegionById('us-east'); - const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); - - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, - control_plane: { - high_availability: false, - }, - }); + // Add a new node pool, select plan, submit form in drawer. + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( + 'getNodePools' + ); + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Dedicated 4 GB') + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText('Add 1').should('be.visible').click(); + }); + + ui.button + .findByTitle('Add pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); - const mockNodePoolResized = nodePoolFactory.build({ - count: 3, - type: mockPlanType.id, - nodes: kubeLinodeFactory.buildList(3), - }); + // Wait for API responses and confirm that both node pools are shown. + cy.wait(['@addNodePool', '@getNodePools']); + cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('be.visible'); - const mockNodePoolInitial = { - ...mockNodePoolResized, - count: 1, - nodes: [mockNodePoolResized.nodes[0]], - }; - - const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( - (node: PoolNodeResponse): Linode => { - return linodeFactory.build({ - id: node.instance_id ?? undefined, - ipv4: [randomIp()], - region: dcSpecificPricingRegion.id, - type: mockPlanType.id, + // Delete the newly added node pool. + cy.get(`[data-qa-node-pool-id="${mockNewNodePool.id}"]`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Delete Pool') + .should('be.visible') + .should('be.enabled') + .click(); }); - } - ); - - const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( - 'getNodePools' - ); - mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetKubernetesVersions().as('getVersions'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait([ - '@getCluster', - '@getNodePools', - '@getLinodes', - '@getVersions', - '@getLinodeType', - ]); - - // Confirm that nodes are visible. - mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { - cy.get(`tr[data-qa-node-row="${node.id}"]`) + + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + ui.dialog + .findByTitle('Delete Node Pool?') .should('be.visible') .within(() => { - const nodeLinode = mockLinodes.find( - (linode: Linode) => linode.id === node.instance_id - ); - if (nodeLinode) { - cy.findByText(nodeLinode.label).should('be.visible'); - } + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); }); - }); - // Confirm total price is listed in Kube Specs. - cy.findByText('$14.40/month').should('be.visible'); - - // Click "Resize Pool" and increase size to 3 nodes. - ui.button - .findByTitle('Resize Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( - 'resizeNodePool' - ); - mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( - 'getNodePools' - ); - ui.drawer - .findByTitle(mockNodePoolDrawerTitle) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.disabled'); + // Confirm node pool is deleted, original node pool still exists, and + // delete pool button is once again disabled. + cy.wait(['@deleteNodePool', '@getNodePools']); + cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('not.exist'); - cy.findByText( - 'Current pool: $14.40/month (1 node at $14.40/month)' - ).should('be.visible'); - cy.findByText( - 'Resized pool: $14.40/month (1 node at $14.40/month)' - ).should('be.visible'); + ui.button + .findByTitle('Delete Pool') + .should('be.visible') + .should('be.disabled'); + }); + }); - cy.findByLabelText('Add 1') - .should('be.visible') - .should('be.enabled') - .click() - .click() - .click(); - - cy.findByLabelText('Edit Quantity').should('have.value', '4'); - cy.findByText( - 'Current pool: $14.40/month (1 node at $14.40/month)' - ).should('be.visible'); - cy.findByText( - 'Resized pool: $57.60/month (4 nodes at $14.40/month)' - ).should('be.visible'); - - cy.findByLabelText('Subtract 1') - .should('be.visible') - .should('be.enabled') - .click(); + describe('LKE cluster updates for DC-specific prices', () => { + /* + * - Confirms node pool resize UI flow using mocked API responses. + * - Confirms that pool size can be increased and decreased. + * - Confirms that drawer reflects prices in regions with DC-specific pricing. + * - Confirms that details page updates total cluster price with DC-specific pricing. + */ + it('can resize pools with DC-specific prices', () => { + const dcSpecificPricingRegion = getRegionById('us-east'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); - cy.findByLabelText('Edit Quantity').should('have.value', '3'); - cy.findByText( - 'Resized pool: $43.20/month (3 nodes at $14.40/month)' - ).should('be.visible'); + const mockNodePoolResized = nodePoolFactory.build({ + count: 3, + type: mockPlanType.id, + nodes: kubeLinodeFactory.buildList(3), + }); - ui.button - .findByTitle('Save Changes') + const mockNodePoolInitial = { + ...mockNodePoolResized, + count: 1, + nodes: [mockNodePoolResized.nodes[0]], + }; + + const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( + (node: PoolNodeResponse): Linode => { + return linodeFactory.build({ + id: node.instance_id ?? undefined, + ipv4: [randomIp()], + region: dcSpecificPricingRegion.id, + type: mockPlanType.id, + }); + } + ); + + const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getVersions', + '@getLinodeType', + ]); + + // Confirm that nodes are visible. + mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { + cy.get(`tr[data-qa-node-row="${node.id}"]`) .should('be.visible') - .should('be.enabled') - .click(); + .within(() => { + const nodeLinode = mockLinodes.find( + (linode: Linode) => linode.id === node.instance_id + ); + if (nodeLinode) { + cy.findByText(nodeLinode.label).should('be.visible'); + } + }); }); - cy.wait(['@resizeNodePool', '@getNodePools']); - - // Confirm total price updates in Kube Specs. - cy.findByText('$43.20/month').should('be.visible'); - }); - - /* - * - Confirms UI flow when adding node pools using mocked API responses. - * - Confirms that drawer reflects prices in regions with DC-specific pricing. - * - Confirms that details page updates total cluster price with DC-specific pricing. - */ - it('can add node pools with DC-specific prices', () => { - const dcSpecificPricingRegion = getRegionById('us-east'); - - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, - control_plane: { - high_availability: false, - }, - }); + // Confirm total price is listed in Kube Specs. + cy.findByText('$14.40/month').should('be.visible'); - const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); + // Click "Resize Pool" and increase size to 3 nodes. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( + 'getNodePools' + ); + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + cy.findByText( + 'Current pool: $14.40/month (1 node at $14.40/month)' + ).should('be.visible'); + cy.findByText( + 'Resized pool: $14.40/month (1 node at $14.40/month)' + ).should('be.visible'); + + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click() + .click() + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '4'); + cy.findByText( + 'Current pool: $14.40/month (1 node at $14.40/month)' + ).should('be.visible'); + cy.findByText( + 'Resized pool: $57.60/month (4 nodes at $14.40/month)' + ).should('be.visible'); + + cy.findByLabelText('Subtract 1') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '3'); + cy.findByText( + 'Resized pool: $43.20/month (3 nodes at $14.40/month)' + ).should('be.visible'); + + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); - const mockNewNodePool = nodePoolFactory.build({ - count: 2, - type: mockPlanType.id, - nodes: kubeLinodeFactory.buildList(2), - }); + cy.wait(['@resizeNodePool', '@getNodePools']); - const mockNodePool = nodePoolFactory.build({ - count: 1, - type: mockPlanType.id, - nodes: kubeLinodeFactory.buildList(1), + // Confirm total price updates in Kube Specs. + cy.findByText('$43.20/month').should('be.visible'); }); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); - mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetLinodeTypes(dcPricingMockLinodeTypes); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); - - // Assert that initial node pool is shown on the page. - cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( - 'be.visible' - ); - - // Confirm total price is listed in Kube Specs. - cy.findByText('$14.40/month').should('be.visible'); - - // Add a new node pool, select plan, submit form in drawer. - ui.button - .findByTitle('Add a Node Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( - 'getNodePools' - ); - - ui.drawer - .findByTitle(`Add a Node Pool: ${mockCluster.label}`) - .should('be.visible') - .within(() => { - cy.findByText('Shared CPU') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findByText(mockPlanType.formattedLabel) - .should('be.visible') - .closest('tr') - .within(() => { - // Assert that DC-specific prices are displayed the plan table, then add a node pool with 2 linodes. - cy.findByText('$14.40').should('be.visible'); - cy.findByText('$0.021').should('be.visible'); - cy.findByLabelText('Add 1').should('be.visible').click().click(); - }); + /* + * - Confirms UI flow when adding node pools using mocked API responses. + * - Confirms that drawer reflects prices in regions with DC-specific pricing. + * - Confirms that details page updates total cluster price with DC-specific pricing. + */ + it('can add node pools with DC-specific prices', () => { + const dcSpecificPricingRegion = getRegionById('us-east'); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); - // Assert that DC-specific prices are displayed as helper text. - cy.contains( - 'This pool will add $28.80/month (2 nodes at $14.40/month) to this cluster.' - ).should('be.visible'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); - ui.button - .findByTitle('Add pool') - .should('be.visible') - .should('be.enabled') - .click(); + const mockNewNodePool = nodePoolFactory.build({ + count: 2, + type: mockPlanType.id, + nodes: kubeLinodeFactory.buildList(2), }); - // Wait for API responses. - cy.wait(['@addNodePool', '@getNodePools']); - - // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool. - cy.findByText('$43.20/month').should('be.visible'); - }); + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: mockPlanType.id, + nodes: kubeLinodeFactory.buildList(1), + }); - /* - * - Confirms node pool resize UI flow using mocked API responses. - * - Confirms that pool size can be changed. - * - Confirms that drawer reflects $0 pricing. - * - Confirms that details page still shows $0 pricing after resizing. - */ - it('can resize pools with region prices of $0', () => { - const dcSpecificPricingRegion = getRegionById('us-southeast'); - const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); - - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, - control_plane: { - high_availability: false, - }, - }); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); + mockGetLinodeTypes(dcPricingMockLinodeTypes); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getLinodeType', + ]); + + // Assert that initial node pool is shown on the page. + cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + 'be.visible' + ); + + // Confirm total price is listed in Kube Specs. + cy.findByText('$14.40/month').should('be.visible'); + + // Add a new node pool, select plan, submit form in drawer. + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); - const mockNodePoolResized = nodePoolFactory.build({ - count: 3, - type: mockPlanType.id, - nodes: kubeLinodeFactory.buildList(3), - }); + mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( + 'getNodePools' + ); - const mockNodePoolInitial = { - ...mockNodePoolResized, - count: 1, - nodes: [mockNodePoolResized.nodes[0]], - }; - - const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( - (node: PoolNodeResponse): Linode => { - return linodeFactory.build({ - id: node.instance_id ?? undefined, - ipv4: [randomIp()], - region: dcSpecificPricingRegion.id, - type: mockPlanType.id, - }); - } - ); - - const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; - - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( - 'getNodePools' - ); - mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetKubernetesVersions().as('getVersions'); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait([ - '@getCluster', - '@getNodePools', - '@getLinodes', - '@getVersions', - '@getLinodeType', - ]); - - // Confirm that nodes are visible. - mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { - cy.get(`tr[data-qa-node-row="${node.id}"]`) + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) .should('be.visible') .within(() => { - const nodeLinode = mockLinodes.find( - (linode: Linode) => linode.id === node.instance_id - ); - if (nodeLinode) { - cy.findByText(nodeLinode.label).should('be.visible'); - } + cy.findByText('Shared CPU') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText(mockPlanType.formattedLabel) + .should('be.visible') + .closest('tr') + .within(() => { + // Assert that DC-specific prices are displayed the plan table, then add a node pool with 2 linodes. + cy.findByText('$14.40').should('be.visible'); + cy.findByText('$0.021').should('be.visible'); + cy.findByLabelText('Add 1').should('be.visible').click().click(); + }); + + // Assert that DC-specific prices are displayed as helper text. + cy.contains( + 'This pool will add $28.80/month (2 nodes at $14.40/month) to this cluster.' + ).should('be.visible'); + + ui.button + .findByTitle('Add pool') + .should('be.visible') + .should('be.enabled') + .click(); }); + + // Wait for API responses. + cy.wait(['@addNodePool', '@getNodePools']); + + // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool. + cy.findByText('$43.20/month').should('be.visible'); }); - // Confirm total price is listed in Kube Specs. - cy.findByText('$0.00/month').should('be.visible'); - - // Click "Resize Pool" and increase size to 4 nodes. - ui.button - .findByTitle('Resize Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( - 'resizeNodePool' - ); - mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( - 'getNodePools' - ); - - ui.drawer - .findByTitle(mockNodePoolDrawerTitle) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.disabled'); + /* + * - Confirms node pool resize UI flow using mocked API responses. + * - Confirms that pool size can be changed. + * - Confirms that drawer reflects $0 pricing. + * - Confirms that details page still shows $0 pricing after resizing. + */ + it('can resize pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); - cy.findByText('Current pool: $0/month (1 node at $0/month)').should( - 'be.visible' - ); - cy.findByText('Resized pool: $0/month (1 node at $0/month)').should( - 'be.visible' - ); + const mockNodePoolResized = nodePoolFactory.build({ + count: 3, + type: mockPlanType.id, + nodes: kubeLinodeFactory.buildList(3), + }); - cy.findByLabelText('Add 1') - .should('be.visible') - .should('be.enabled') - .click() - .click() - .click(); - - cy.findByLabelText('Edit Quantity').should('have.value', '4'); - cy.findByText('Current pool: $0/month (1 node at $0/month)').should( - 'be.visible' - ); - cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should( - 'be.visible' - ); - - ui.button - .findByTitle('Save Changes') + const mockNodePoolInitial = { + ...mockNodePoolResized, + count: 1, + nodes: [mockNodePoolResized.nodes[0]], + }; + + const mockLinodes: Linode[] = mockNodePoolResized.nodes.map( + (node: PoolNodeResponse): Linode => { + return linodeFactory.build({ + id: node.instance_id ?? undefined, + ipv4: [randomIp()], + region: dcSpecificPricingRegion.id, + type: mockPlanType.id, + }); + } + ); + + const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getVersions', + '@getLinodeType', + ]); + + // Confirm that nodes are visible. + mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { + cy.get(`tr[data-qa-node-row="${node.id}"]`) .should('be.visible') - .should('be.enabled') - .click(); + .within(() => { + const nodeLinode = mockLinodes.find( + (linode: Linode) => linode.id === node.instance_id + ); + if (nodeLinode) { + cy.findByText(nodeLinode.label).should('be.visible'); + } + }); }); - cy.wait(['@resizeNodePool', '@getNodePools']); + // Confirm total price is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); - // Confirm total price is still $0 in Kube Specs. - cy.findByText('$0.00/month').should('be.visible'); - }); + // Click "Resize Pool" and increase size to 4 nodes. + ui.button + .findByTitle('Resize Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as( + 'resizeNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as( + 'getNodePools' + ); + + ui.drawer + .findByTitle(mockNodePoolDrawerTitle) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); - /* - * - Confirms UI flow when adding node pools using mocked API responses. - * - Confirms that drawer reflects $0 prices. - * - Confirms that details page still shows $0 pricing after adding node pool. - */ - it('can add node pools with region prices of $0', () => { - const dcSpecificPricingRegion = getRegionById('us-southeast'); - - const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); - - const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, - control_plane: { - high_availability: false, - }, - }); + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); - const mockNewNodePool = nodePoolFactory.build({ - count: 2, - type: mockPlanType.id, - nodes: kubeLinodeFactory.buildList(2), - }); + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click() + .click() + .click(); + + cy.findByLabelText('Edit Quantity').should('have.value', '4'); + cy.findByText('Current pool: $0/month (1 node at $0/month)').should( + 'be.visible' + ); + cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should( + 'be.visible' + ); - const mockNodePool = nodePoolFactory.build({ - count: 1, - type: mockPlanType.id, - nodes: kubeLinodeFactory.buildList(1), + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@resizeNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); }); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); - mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetLinodeTypes(dcPricingMockLinodeTypes); - mockGetDashboardUrl(mockCluster.id); - mockGetApiEndpoints(mockCluster.id); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); - - // Assert that initial node pool is shown on the page. - cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( - 'be.visible' - ); - - // Confirm total price of $0 is listed in Kube Specs. - cy.findByText('$0.00/month').should('be.visible'); - - // Add a new node pool, select plan, submit form in drawer. - ui.button - .findByTitle('Add a Node Pool') - .should('be.visible') - .should('be.enabled') - .click(); - - mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( - 'getNodePools' - ); - - ui.drawer - .findByTitle(`Add a Node Pool: ${mockCluster.label}`) - .should('be.visible') - .within(() => { - cy.findByText('Shared CPU') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findByText('Linode 2 GB') - .should('be.visible') - .closest('tr') - .within(() => { - // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes. - cy.findAllByText('$0').should('have.length', 2); - cy.findByLabelText('Add 1').should('be.visible').click().click(); - }); + /* + * - Confirms UI flow when adding node pools using mocked API responses. + * - Confirms that drawer reflects $0 prices. + * - Confirms that details page still shows $0 pricing after adding node pool. + */ + it('can add node pools with region prices of $0', () => { + const dcSpecificPricingRegion = getRegionById('us-southeast'); + + const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, + control_plane: { + high_availability: false, + }, + }); - // Assert that $0 prices are displayed as helper text. - cy.contains( - 'This pool will add $0/month (2 nodes at $0/month) to this cluster.' - ).should('be.visible'); + const mockNewNodePool = nodePoolFactory.build({ + count: 2, + type: mockPlanType.id, + nodes: kubeLinodeFactory.buildList(2), + }); - ui.button - .findByTitle('Add pool') - .should('be.visible') - .should('be.enabled') - .click(); + const mockNodePool = nodePoolFactory.build({ + count: 1, + type: mockPlanType.id, + nodes: kubeLinodeFactory.buildList(1), }); - // Wait for API responses. - cy.wait(['@addNodePool', '@getNodePools']); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); + mockGetLinodeTypes(dcPricingMockLinodeTypes); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getLinodeType', + ]); + + // Assert that initial node pool is shown on the page. + cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + 'be.visible' + ); + + // Confirm total price of $0 is listed in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + + // Add a new node pool, select plan, submit form in drawer. + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as( + 'getNodePools' + ); - // Confirm total price is still $0 in Kube Specs. - cy.findByText('$0.00/month').should('be.visible'); + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Shared CPU') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Linode 2 GB') + .should('be.visible') + .closest('tr') + .within(() => { + // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes. + cy.findAllByText('$0').should('have.length', 2); + cy.findByLabelText('Add 1').should('be.visible').click().click(); + }); + + // Assert that $0 prices are displayed as helper text. + cy.contains( + 'This pool will add $0/month (2 nodes at $0/month) to this cluster.' + ).should('be.visible'); + + ui.button + .findByTitle('Add pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for API responses. + cy.wait(['@addNodePool', '@getNodePools']); + + // Confirm total price is still $0 in Kube Specs. + cy.findByText('$0.00/month').should('be.visible'); + }); }); }); From a431f9c7267856f64de61e0a690b56d6ff673b17 Mon Sep 17 00:00:00 2001 From: ankitaakamai Date: Thu, 17 Oct 2024 02:21:52 +0530 Subject: [PATCH 21/64] DI-20837 - Handle new label property for services while selecting dashboard and Node-type filter update in DbasS (#11082) * DI-20837 - Handle new label property for services while selecting dashboard * small eslint fix * upcoming: [DI-20837] - adjusted service factory * upcoming: [DI-21138] - Dbass node type filter change --------- Co-authored-by: venkatmano-akamai --- packages/api-v4/src/cloudpulse/types.ts | 1 + .../src/factories/cloudpulse/services.ts | 8 +++++ packages/manager/src/factories/index.ts | 1 + .../features/CloudPulse/Utils/FilterConfig.ts | 2 +- .../ReusableDashboardFilterUtils.test.ts | 10 +++--- .../shared/CloudPulseDashboardSelect.test.tsx | 5 +-- .../shared/CloudPulseDashboardSelect.tsx | 13 ++++--- packages/manager/src/mocks/serverHandlers.ts | 34 ++++++++++++++----- 8 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 packages/manager/src/factories/cloudpulse/services.ts diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 9d24aca5857..19a5149c76a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -126,6 +126,7 @@ export interface CloudPulseMetricsList { export interface ServiceTypes { service_type: string; + label: string; } export interface ServiceTypesList { diff --git a/packages/manager/src/factories/cloudpulse/services.ts b/packages/manager/src/factories/cloudpulse/services.ts new file mode 100644 index 00000000000..d86e225a2d7 --- /dev/null +++ b/packages/manager/src/factories/cloudpulse/services.ts @@ -0,0 +1,8 @@ +import Factory from 'src/factories/factoryProxy'; + +import type { ServiceTypes } from '@linode/api-v4'; + +export const serviceTypesFactory = Factory.Sync.makeFactory({ + label: Factory.each((i) => `Factory ServiceType-${i}`), + service_type: Factory.each((i) => `Factory ServiceType-${i}`), +}); diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index fc50d4ca726..4a442b6d171 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -52,6 +52,7 @@ export * from './vlans'; export * from './volume'; export * from './vpcs'; export * from './dashboards'; +export * from './cloudpulse/services'; // Convert factory output to our itemsById pattern export const normalizeEntities = (entities: any[]) => { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index cb2049952c1..9034ab46ca8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -121,7 +121,7 @@ export const DBAAS_CONFIG: Readonly = { }, { configuration: { - filterKey: 'role', + filterKey: 'node_type', filterType: 'string', isFilterable: true, // isFilterable -- this determines whether you need to pass it metrics api isMetricsFilter: false, // if it is false, it will go as a part of filter params, else global filter diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts index 3bdfea69058..56b4e1e060f 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts @@ -71,7 +71,7 @@ it('test checkMandatoryFiltersSelected method for role', () => { result = checkMandatoryFiltersSelected({ dashboardObj: { ...mockDashboard, service_type: 'dbaas' }, - filterValue: { region: 'us-east', role: 'primary' }, + filterValue: { node_type: 'primary', region: 'us-east' }, resource: 1, timeDuration: { unit: 'min', value: 30 }, }); @@ -83,12 +83,12 @@ it('test constructDimensionFilters method', () => { mockDashboard.service_type = 'dbaas'; const result = constructDimensionFilters({ dashboardObj: mockDashboard, - filterValue: { role: 'primary' }, + filterValue: { node_type: 'primary' }, resource: 1, }); expect(result.length).toEqual(1); - expect(result[0].filterKey).toEqual('role'); + expect(result[0].filterKey).toEqual('node_type'); expect(result[0].filterValue).toEqual('primary'); }); @@ -99,13 +99,13 @@ it('test checkIfFilterNeededInMetricsCall method', () => { result = checkIfFilterNeededInMetricsCall('resource_id', 'linode'); expect(result).toEqual(false); // not needed as dimension filter - result = checkIfFilterNeededInMetricsCall('role', 'dbaas'); + result = checkIfFilterNeededInMetricsCall('node_type', 'dbaas'); expect(result).toEqual(true); result = checkIfFilterNeededInMetricsCall('engine', 'dbaas'); expect(result).toEqual(false); - result = checkIfFilterNeededInMetricsCall('role', 'xyz'); // xyz service type + result = checkIfFilterNeededInMetricsCall('node_type', 'xyz'); // xyz service type expect(result).toEqual(false); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx index 602e96d97aa..664394ebafb 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; -import { dashboardFactory } from 'src/factories'; +import { dashboardFactory, serviceTypesFactory } from 'src/factories'; import * as utils from 'src/features/CloudPulse/Utils/utils'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -19,6 +19,7 @@ const queryMocks = vi.hoisted(() => ({ useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), })); const mockDashboard = dashboardFactory.build(); +const mockServiceTypesList = serviceTypesFactory.build(); vi.mock('src/queries/cloudpulse/dashboards', async () => { const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); @@ -46,7 +47,7 @@ queryMocks.useCloudPulseDashboardsQuery.mockReturnValue({ queryMocks.useCloudPulseServiceTypes.mockReturnValue({ data: { - data: [{ service_type: 'linode' }], + data: [mockServiceTypesList], }, }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index decead7c591..81526099c4d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -30,6 +30,9 @@ export const CloudPulseDashboardSelect = React.memo( } = useCloudPulseServiceTypes(true); const serviceTypes: string[] = formattedServiceTypes(serviceTypesList); + const serviceTypeMap: Map = new Map( + serviceTypesList?.data.map((item) => [item.service_type, item.label]) + ); const { data: dashboardsList, @@ -66,6 +69,7 @@ export const CloudPulseDashboardSelect = React.memo( (a, b) => -b.service_type.localeCompare(a.service_type) ); }; + // Once the data is loaded, set the state variable with value stored in preferences React.useEffect(() => { // only call this code when the component is rendered initially @@ -90,11 +94,10 @@ export const CloudPulseDashboardSelect = React.memo( }} renderGroup={(params) => ( - - {params.group} + + {serviceTypeMap.has(params.group) + ? serviceTypeMap.get(params.group) + : params.group} {params.children} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4d07f405e18..2d092465043 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -85,6 +85,7 @@ import { promoFactory, regionAvailabilityFactory, securityQuestionsFactory, + serviceTypesFactory, stackScriptFactory, staticObjects, subnetFactory, @@ -110,10 +111,12 @@ import { pickRandom } from 'src/utilities/random'; import type { AccountMaintenance, CreateObjectStorageKeyPayload, + Dashboard, FirewallStatus, NotificationType, ObjectStorageEndpointTypes, SecurityQuestionsPayload, + ServiceTypesList, TokenRequest, UpdateImageRegionsPayload, User, @@ -2307,25 +2310,40 @@ export const handlers = [ return HttpResponse.json(response); }), http.get('*/monitor/services', () => { - const response = { - data: [{ service_type: 'linode' }], + const response: ServiceTypesList = { + data: [ + serviceTypesFactory.build({ + label: 'Linode', + service_type: 'linode', + }), + serviceTypesFactory.build({ + label: 'Databases', + service_type: 'dbaas', + }), + ], }; return HttpResponse.json(response); }), - http.get('*/monitor/services/:serviceType/dashboards', () => { + http.get('*/monitor/services/:serviceType/dashboards', ({ params }) => { const response = { - data: [ + data: [] as Dashboard[], + }; + if (params.serviceType === 'linode') { + response.data.push( dashboardFactory.build({ label: 'Linode Dashboard', service_type: 'linode', - }), + }) + ); + } else if (params.serviceType === 'dbaas') { + response.data.push( dashboardFactory.build({ label: 'DBaaS Dashboard', service_type: 'dbaas', - }), - ], - }; + }) + ); + } return HttpResponse.json(response); }), From 98a260c6e8aa1aa228ab34fced31077783c18b43 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:56:19 -0700 Subject: [PATCH 22/64] fix: [M3-8748] - Markdown cheatsheet link in Support ticket interface has an expired domain (#11101) * Update docs link to valid domain * Added changeset: Link to expired Markdown cheatsheet domain * Switch to a better docs link --- .../manager/.changeset/pr-11101-fixed-1729011064940.md | 5 +++++ .../SupportTicketDetail/TabbedReply/MarkdownReference.tsx | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-11101-fixed-1729011064940.md diff --git a/packages/manager/.changeset/pr-11101-fixed-1729011064940.md b/packages/manager/.changeset/pr-11101-fixed-1729011064940.md new file mode 100644 index 00000000000..2536d253581 --- /dev/null +++ b/packages/manager/.changeset/pr-11101-fixed-1729011064940.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Link to expired Markdown cheatsheet domain ([#11101](https://github.com/linode/manager/pull/11101)) diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx index 22f28aa5677..e0c27ecc042 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx @@ -1,10 +1,11 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ example: { backgroundColor: theme.name === 'dark' ? theme.bg.white : theme.bg.offWhite, @@ -30,7 +31,7 @@ export const MarkdownReference = (props: Props) => { You can use Markdown to format your{' '} {props.isReply ? 'reply' : 'question'}. For more examples see this{' '} - + Markdown cheatsheet From f534be78554e2aa65cf314fee0e090c553606f12 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:56:37 -0400 Subject: [PATCH 23/64] test: [M3-8744] - Reduce Cypress flakiness in Placement Group deletion tests (#11107) * Reduce Cypress flakiness related to Placement Group delete dialog React re-rendering * Added changeset: Reduce flakiness of Placement Group deletion Cypress tests --------- Co-authored-by: Joe D'Amore --- .../pr-11107-tests-1729033939339.md | 5 + .../delete-placement-groups.spec.ts | 131 ++++++++++++------ 2 files changed, 96 insertions(+), 40 deletions(-) create mode 100644 packages/manager/.changeset/pr-11107-tests-1729033939339.md diff --git a/packages/manager/.changeset/pr-11107-tests-1729033939339.md b/packages/manager/.changeset/pr-11107-tests-1729033939339.md new file mode 100644 index 00000000000..e3f36bbdef5 --- /dev/null +++ b/packages/manager/.changeset/pr-11107-tests-1729033939339.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Reduce flakiness of Placement Group deletion Cypress tests ([#11107](https://github.com/linode/manager/pull/11107)) diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index b08c39cb67b..d065bf1140f 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -174,9 +174,11 @@ describe('Placement Group deletion', () => { ); cy.visitWithLogin('/placement-groups'); - cy.wait('@getPlacementGroups'); + cy.wait(['@getPlacementGroups', '@getLinodes']); - // Click "Delete" button next to the mock Placement Group. + // Click "Delete" button next to the mock Placement Group, and initially mock + // an API error response and confirm that the error message is displayed in the + // deletion modal. cy.findByText(mockPlacementGroup.label) .should('be.visible') .closest('tr') @@ -188,31 +190,72 @@ describe('Placement Group deletion', () => { .click(); }); - // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + // The Placement Groups landing page fires off a Linode GET request upon + // clicking the "Delete" button so that Cloud knows which Linodes are assigned + // to the selected Placement Group. + cy.wait('@getLinodes'); + mockUnassignPlacementGroupLinodesError( mockPlacementGroup.id, PlacementGroupErrorMessage ).as('UnassignPlacementGroupError'); + // Close dialog and re-open it. This is a workaround to prevent Cypress + // failures triggered by React re-rendering after fetching Linodes. + // + // Tanstack Query is configured to respond with cached data for the `useAllLinodes` + // query hook while awaiting the HTTP request response. Because the Placement + // Groups landing page fetches Linodes upon opening the deletion modal, there + // is a brief period of time where Linode labels are rendered using cached data, + // then re-rendered after the real API request resolves. This re-render occasionally + // triggers Cypress failures. + // + // Opening the deletion modal for the same Placement Group a second time + // does not trigger another HTTP GET request, this helps circumvent the + // issue because the cached/problematic HTTP request is already long resolved + // and there is less risk of a re-render occurring while Cypress interacts + // with the dialog. + // + // TODO Consider removing this workaround after M3-8717 is implemented. ui.dialog .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) .should('be.visible') .within(() => { - cy.get('[data-qa-selection-list]').within(() => { - // Select the first Linode to unassign - const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + ui.drawerCloseButton.find().click(); + }); - cy.findByText(mockLinodeToUnassign.label) - .should('be.visible') - .closest('li') - .within(() => { - ui.button - .findByTitle('Unassign') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]') + .should('be.visible') + .within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .closest('li') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); cy.wait('@UnassignPlacementGroupError'); cy.findByText(PlacementGroupErrorMessage).should('be.visible'); @@ -265,7 +308,10 @@ describe('Placement Group deletion', () => { .click(); }); - cy.wait('@unassignLinode'); + // Cloud fires off 2 requests to fetch Linodes: once before the unassignment, + // and again after. Wait for both of these requests to resolve to reduce the + // risk of a re-render occurring when unassigning the next Linode. + cy.wait(['@unassignLinode', '@getLinodes', '@getLinodes']); cy.findByText(mockLinode.label).should('not.exist'); }); }); @@ -444,7 +490,7 @@ describe('Placement Group deletion', () => { ); cy.visitWithLogin('/placement-groups'); - cy.wait('@getPlacementGroups'); + cy.wait(['@getPlacementGroups', '@getLinodes']); // Click "Delete" button next to the mock Placement Group. cy.findByText(mockPlacementGroup.label) @@ -458,12 +504,36 @@ describe('Placement Group deletion', () => { .click(); }); + // The Placement Groups landing page fires off a Linode GET request upon + // clicking the "Delete" button so that Cloud knows which Linodes are assigned + // to the selected Placement Group. + cy.wait('@getLinodes'); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. mockUnassignPlacementGroupLinodesError( mockPlacementGroup.id, PlacementGroupErrorMessage ).as('UnassignPlacementGroupError'); + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + ui.drawerCloseButton.find().should('be.visible').click(); + }); + + // Click "Delete" button next to the mock Placement Group again. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) .should('be.visible') @@ -498,7 +568,7 @@ describe('Placement Group deletion', () => { 'not.exist' ); - // Click "Delete" button next to the mock Placement Group to reopen the dialog + // Click "Delete" button next to the mock Placement Group to reopen the dialog. cy.findByText(mockPlacementGroup.label) .should('be.visible') .closest('tr') @@ -510,31 +580,12 @@ describe('Placement Group deletion', () => { .click(); }); - // Confirm deletion warning appears and that form cannot be submitted - // while Linodes are assigned. + // Confirm that the error message from the previous attempt is no longer present. ui.dialog .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) .should('be.visible') .within(() => { - // ensure error message not exist when reopening the dialog cy.findByText(PlacementGroupErrorMessage).should('not.exist'); - - // Unassign each Linode. - cy.get('[data-qa-selection-list]').within(() => { - // Select the first Linode to unassign - const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; - - cy.findByText(mockLinodeToUnassign.label) - .should('be.visible') - .closest('li') - .within(() => { - ui.button - .findByTitle('Unassign') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); }); }); }); From 621e1ec3d0d9fdfc45db7561878027cdcdc18934 Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:00:45 -0700 Subject: [PATCH 24/64] feat: [M3-7445] - Public IP Addresses Tooltip and LISH Display (#11070) * feat: [M3-7445] - Public IP Addresses Tooltip and LISH Display * Enable LISH console text * Updates AccessTable unit tests * Add changeset * Update UI based on conversation with UX and updates unit tests * Remove unnecessary file --- .../pr-11070-changed-1728495168369.md | 5 +++ .../support/intercepts/object-storage.ts | 2 +- .../src/features/Linodes/AccessTable.test.tsx | 45 ++++++++++++++----- .../src/features/Linodes/AccessTable.tsx | 16 +++---- .../Linodes/LinodeEntityDetailBody.tsx | 1 + .../LinodeSelect/LinodeSelect.test.tsx | 1 - .../LinodeIPAddressRow.test.tsx | 7 ++- .../LinodeNetworkingActionMenu.tsx | 14 +++--- .../Linodes/LinodesLanding/IPAddress.tsx | 4 +- ...oltip.tsx => PublicIPAddressesTooltip.tsx} | 8 ++-- .../NodeBalancers/ConfigNodeIPSelect.tsx | 1 - 11 files changed, 64 insertions(+), 40 deletions(-) create mode 100644 packages/manager/.changeset/pr-11070-changed-1728495168369.md rename packages/manager/src/features/Linodes/{PublicIpsUnassignedTooltip.tsx => PublicIPAddressesTooltip.tsx} (67%) diff --git a/packages/manager/.changeset/pr-11070-changed-1728495168369.md b/packages/manager/.changeset/pr-11070-changed-1728495168369.md new file mode 100644 index 00000000000..eac7baa8f1b --- /dev/null +++ b/packages/manager/.changeset/pr-11070-changed-1728495168369.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update Public IP Addresses tooltip and enable LISH console text ([#11070](https://github.com/linode/manager/pull/11070)) diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 8dfcbfa8ed7..7c304178e47 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -544,7 +544,7 @@ export const mockGetBucket = ( ); }; - /* Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket, and mocks response. +/* Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket, and mocks response. * * @param label - Object storage bucket label. * @param cluster - Object storage bucket cluster. diff --git a/packages/manager/src/features/Linodes/AccessTable.test.tsx b/packages/manager/src/features/Linodes/AccessTable.test.tsx index dbe22dc2faf..92f564fb722 100644 --- a/packages/manager/src/features/Linodes/AccessTable.test.tsx +++ b/packages/manager/src/features/Linodes/AccessTable.test.tsx @@ -2,7 +2,7 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { linodeFactory } from 'src/factories'; -import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; +import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AccessTable } from './AccessTable'; @@ -10,7 +10,7 @@ import { AccessTable } from './AccessTable'; const linode = linodeFactory.build(); describe('AccessTable', () => { - it('should disable copy button and display help icon tooltip if isVPCOnlyLinode is true', async () => { + it('should display help icon tooltip if isVPCOnlyLinode is true', async () => { const { findByRole, getAllByRole } = renderWithTheme( { const buttons = getAllByRole('button'); const helpIconButton = buttons[0]; - const copyButtons = buttons.slice(1); fireEvent.mouseEnter(helpIconButton); - const publicIpsUnassignedTooltip = await findByRole('tooltip'); - expect(publicIpsUnassignedTooltip).toContainHTML( - PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT + const publicIPAddressesTooltip = await findByRole('tooltip'); + expect(publicIPAddressesTooltip).toContainHTML( + PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT ); + }); + + it('should not disable copy button if isVPCOnlyLinode is false', () => { + const { getAllByRole } = renderWithTheme( + <> + + + + + ); + + const copyButtons = getAllByRole('button'); copyButtons.forEach((copyButton) => { - expect(copyButton).toBeDisabled(); + expect(copyButton).not.toBeDisabled(); }); }); - it('should not disable copy button if isVPCOnlyLinode is false', () => { - const { getAllByRole } = renderWithTheme( + it('should disable copy buttons for Public IP Addresses if isVPCOnlyLinode is true', () => { + const { container } = renderWithTheme( ); - const copyButtons = getAllByRole('button'); + const copyButtons = container.querySelectorAll('[data-qa-copy-btn]'); copyButtons.forEach((copyButton) => { - expect(copyButton).not.toBeDisabled(); + expect(copyButton).toBeDisabled(); }); }); }); diff --git a/packages/manager/src/features/Linodes/AccessTable.tsx b/packages/manager/src/features/Linodes/AccessTable.tsx index 0254e9c29eb..4c886256df6 100644 --- a/packages/manager/src/features/Linodes/AccessTable.tsx +++ b/packages/manager/src/features/Linodes/AccessTable.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; -import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; +import { PublicIPAddressesTooltip } from 'src/features/Linodes/PublicIPAddressesTooltip'; import { StyledColumnLabelGrid, @@ -37,20 +37,20 @@ interface AccessTableProps { export const AccessTable = React.memo((props: AccessTableProps) => { const { footer, gridSize, isVPCOnlyLinode, rows, sx, title } = props; + + const isDisabled = isVPCOnlyLinode && title.includes('Public IP Address'); + return ( - {title}{' '} - {isVPCOnlyLinode && - title.includes('Public IP Address') && - PublicIpsUnassignedTooltip} + {title} {isDisabled && PublicIPAddressesTooltip} {rows.map((thisRow) => { return thisRow.text ? ( - + {thisRow.heading ? ( {thisRow.heading} @@ -60,12 +60,12 @@ export const AccessTable = React.memo((props: AccessTableProps) => { diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx index 487fc7e1dab..9eb76ca4d39 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx @@ -170,6 +170,7 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { )} + { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx index 12c95cd82e9..6e82eb2be09 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx @@ -7,12 +7,12 @@ import { ipResponseToDisplayRows, vpcConfigInterfaceToDisplayRows, } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses'; -import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; +import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { LinodeIPAddressRow } from './LinodeIPAddressRow'; -import type { IPAddressRowHandlers} from './LinodeIPAddressRow'; +import type { IPAddressRowHandlers } from './LinodeIPAddressRow'; const ips = linodeIPFactory.build(); const ipDisplay = ipResponseToDisplayRows(ips)[0]; @@ -100,7 +100,7 @@ describe('LinodeIPAddressRow', () => { const editRDNSBtn = getByTestId('Edit RDNS'); expect(editRDNSBtn).toHaveAttribute('aria-disabled', 'true'); - expect(getAllByLabelText(PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT)).toHaveLength(2); + expect(getAllByLabelText(PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT)).toHaveLength(2); }); it('should not disable the row if disabled is false', async () => { @@ -116,7 +116,6 @@ describe('LinodeIPAddressRow', () => { ) ); - // open the action menu await userEvent.click( getByLabelText('Action menu for IP Address [object Object]') diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index b9be41758e6..0b425405105 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -1,5 +1,4 @@ -import { IPAddress, IPRange } from '@linode/api-v4/lib/networking'; -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { isEmpty } from 'ramda'; import * as React from 'react'; @@ -7,10 +6,11 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Box } from 'src/components/Box'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; - -import { IPTypes } from './types'; +import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip'; +import type { IPTypes } from './types'; +import type { IPAddress, IPRange } from '@linode/api-v4/lib/networking'; +import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { @@ -71,7 +71,7 @@ export const LinodeNetworkingActionMenu = (props: Props) => { tooltip: readOnly ? readOnlyTooltip : isVPCOnlyLinode - ? PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT + ? PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT : isOnlyPublicIP ? isOnlyPublicIPTooltip : undefined, @@ -88,7 +88,7 @@ export const LinodeNetworkingActionMenu = (props: Props) => { tooltip: readOnly ? readOnlyTooltip : isVPCOnlyLinode - ? PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT + ? PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT : undefined, } : null, diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx index 424e4f7c87f..2bfd5a19ea1 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; -import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; +import { PublicIPAddressesTooltip } from 'src/features/Linodes/PublicIPAddressesTooltip'; import { isPrivateIP } from 'src/utilities/ipUtils'; import { tail } from 'src/utilities/tail'; @@ -90,7 +90,7 @@ export const IPAddress = (props: IPAddressProps) => { const renderCopyIcon = (ip: string) => { if (disabled) { - return PublicIpsUnassignedTooltip; + return PublicIPAddressesTooltip; } return ( diff --git a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx b/packages/manager/src/features/Linodes/PublicIPAddressesTooltip.tsx similarity index 67% rename from packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx rename to packages/manager/src/features/Linodes/PublicIPAddressesTooltip.tsx index 39b958dd984..2024c4611dd 100644 --- a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx +++ b/packages/manager/src/features/Linodes/PublicIPAddressesTooltip.tsx @@ -9,14 +9,14 @@ const sxTooltipIcon = { paddingLeft: '4px', }; -export const PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT = - 'The Public IP Addresses have been unassigned from the configuration profile.'; +export const PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT = + 'The noted Public IP Addresses are provisionally reserved but not assigned to the network interfaces in this configuration profile.'; -export const PublicIpsUnassignedTooltip = ( +export const PublicIPAddressesTooltip = ( - {PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT}{' '} + {PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT}{' '} Learn more diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 2372102a9d3..70061b5e2b8 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -41,7 +41,6 @@ interface Props { region: string | undefined; } - export const ConfigNodeIPSelect = React.memo((props: Props) => { const { disabled, From dda6bf987b408b290b41be1624717affc82e7507 Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:08:22 -0700 Subject: [PATCH 25/64] feat: [M3-7158] - Update NodeJS naming to Node.js for Marketplace (#11086) * feat: [M3-7158] - Update NodeJS naming to Node.js for Marketplace * Revert changes to hardcoded `name` in oneClickAppsv2.tsx * Add changeset * Update the logic to display `label` vs `name` * Update e2e test to use `stackscript.label` instead of `app.name` * Revert changes to getMarketplaceAppLabel() and fix failing unit test in AppDetailDrawer * Remove `name` field from oneClickApps in favor of stackscript label * PR feedback and merge latest from develop branch --- .../pr-11086-tech-stories-1729012343535.md | 5 + .../core/oneClickApps/one-click-apps.spec.ts | 17 +- .../manager/src/factories/stackscripts.ts | 1 - .../Tabs/Marketplace/AppDetailDrawer.test.tsx | 2 +- .../Tabs/Marketplace/AppDetailDrawer.tsx | 45 ++--- .../Tabs/Marketplace/AppSection.tsx | 2 +- .../Tabs/Marketplace/utilities.ts | 1 - .../features/OneClickApps/oneClickAppsv2.ts | 167 +++--------------- .../src/features/OneClickApps/types.ts | 1 - 9 files changed, 67 insertions(+), 174 deletions(-) create mode 100644 packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md diff --git a/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md b/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md new file mode 100644 index 00000000000..8628e0411de --- /dev/null +++ b/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update NodeJS naming to Node.js for Marketplace ([#11086](https://github.com/linode/manager/pull/11086)) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index e490f0ffa4f..ad8f3a6e28a 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -34,17 +34,20 @@ describe('OneClick Apps (OCA)', () => { // For every Marketplace app defined in Cloud Manager, make sure the API returns // the nessesary StackScript and that the app renders on the page. for (const stackscriptId in oneClickApps) { - const stackscript = stackScripts.find((s) => s.id === +stackscriptId); - const app = oneClickApps[stackscriptId]; + const stackscript = stackScripts.find( + (stackScript) => stackScript.id === +stackscriptId + ); if (!stackscript) { throw new Error( - `Cloud Manager's fetch to GET /v4/linode/stackscripts did not recieve a StackScript with ID ${stackscriptId}. We expected that StackScript to be in the response for the Marketplace app named "${app.name}".` + `Cloud Manager's fetch to GET /v4/linode/stackscripts did not receive a StackScript with ID ${stackscriptId}. We expected a StackScript to be in the response.` ); } + const displayLabel = getMarketplaceAppLabel(stackscript.label); + // Using `findAllByText` because some apps may be duplicatd under different sections - cy.findAllByText(getMarketplaceAppLabel(app.name)).should('exist'); + cy.findAllByText(displayLabel).should('exist'); } }); }); @@ -81,7 +84,9 @@ describe('OneClick Apps (OCA)', () => { } cy.findByTestId('one-click-apps-container').within(() => { - cy.findAllByLabelText(`Info for "${candidateApp.name}"`) + cy.findAllByLabelText( + `Info for "${getMarketplaceAppLabel(candidateStackScript.label)}"` + ) .first() .scrollIntoView() .should('be.visible') @@ -90,7 +95,7 @@ describe('OneClick Apps (OCA)', () => { }); ui.drawer - .findByTitle(candidateApp.name) + .findByTitle(getMarketplaceAppLabel(candidateStackScript.label)) .should('be.visible') .within(() => { cy.findByText(candidateApp.description).should('be.visible'); diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts index 090db396c83..87e8f4bb4e6 100644 --- a/packages/manager/src/factories/stackscripts.ts +++ b/packages/manager/src/factories/stackscripts.ts @@ -36,7 +36,6 @@ export const oneClickAppFactory = Factory.Sync.makeFactory({ }, description: 'A test app', logo_url: 'nodejs.svg', - name: 'Test App', summary: 'A test app', website: 'https://www.linode.com', }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx index 362721bd586..3f9b0826df6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx @@ -23,7 +23,7 @@ describe('AppDetailDrawer', () => { ); // Verify title renders - expect(await findByText('WordPress')).toBeVisible(); + expect(await findByText(stackscript.label)).toBeVisible(); // Verify description renders expect( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx index 3bebdd27c8a..ddbfc43bae3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx @@ -10,7 +10,7 @@ import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; -import { useMarketplaceApps } from './utilities'; +import { getMarketplaceAppLabel, useMarketplaceApps } from './utilities'; import type { Theme } from '@mui/material/styles'; @@ -69,11 +69,13 @@ export const AppDetailDrawer = (props: Props) => { const { classes } = useStyles(); const { apps } = useMarketplaceApps(); - const selectedApp = apps.find((app) => app.stackscript.id === stackScriptId) - ?.details; + const selectedApp = apps.find((app) => app.stackscript.id === stackScriptId); + const displayLabel = selectedApp + ? getMarketplaceAppLabel(selectedApp?.stackscript.label) + : ''; const gradient = { - backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.colors.start}, #${selectedApp?.colors.end})`, + backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.details?.colors.start}, #${selectedApp?.details?.colors.end})`, }; return ( @@ -109,59 +111,62 @@ export const AppDetailDrawer = (props: Props) => { {`${selectedApp.name} - {selectedApp.summary} + + {selectedApp?.details.summary} + - {selectedApp.website && ( + {selectedApp?.details.website && ( Website - {selectedApp.website} + {selectedApp?.details.website} )} - {selectedApp.related_guides && ( + {selectedApp?.details.related_guides && ( Guides - {selectedApp.related_guides.map((link, idx) => ( + {selectedApp?.details.related_guides.map((link, idx) => ( {sanitizeHTML({ @@ -173,13 +178,13 @@ export const AppDetailDrawer = (props: Props) => { )} - {selectedApp.tips && ( + {selectedApp?.details.tips && ( Tips - {selectedApp.tips.map((tip, idx) => ( + {selectedApp?.details.tips.map((tip, idx) => ( {tip} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx index 834a4fbecd2..26d1a8c098e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx @@ -20,10 +20,10 @@ interface Props { export const AppSection = (props: Props) => { const { + apps, onOpenDetailsDrawer, onSelect, selectedStackscriptId, - apps, title, } = props; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts index f83c696c1ef..34319797d94 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts @@ -116,7 +116,6 @@ const getDoesMarketplaceAppMatchQuery = ( const searchableAppFields = [ String(app.stackscript.id), app.stackscript.label, - app.details.name, app.details.alt_name, app.details.alt_description, ...app.details.categories, diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index 61094ec2475..19c2a99220a 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -17,7 +17,6 @@ export const oneClickApps: Record = { }, description: `With 60 million users around the globe, WordPress is the industry standard for custom websites such as blogs, news sites, personal websites, and anything in-between. With a focus on best in class usability and flexibility, you can have a customized website up and running in minutes.`, logo_url: 'wordpress.svg', - name: 'WordPress', related_guides: [ { href: @@ -39,7 +38,6 @@ export const oneClickApps: Record = { }, description: `Drupal is a content management system (CMS) designed for building custom websites for personal and business use. Built for high performance and scalability, Drupal provides the necessary tools to create rich, interactive community websites with forums, user blogs, and private messaging. Drupal also has support for personal publishing projects and can power podcasts, blogs, and knowledge-based systems, all within a single, unified platform.`, logo_url: 'drupal.svg', - name: 'Drupal', related_guides: [ { href: @@ -61,7 +59,6 @@ export const oneClickApps: Record = { description: `The LAMP stack consists of the Linux operating system, the Apache HTTP Server, the MySQL relational database management system, and the PHP programming language. This software environment is a foundation for popular PHP application frameworks like WordPress, Drupal, and Laravel. Upload your existing PHP application code to your new app or use a PHP framework to write a new application on the Linode.`, logo_url: 'lamp.svg', - name: 'LAMP', related_guides: [ { href: @@ -83,7 +80,6 @@ export const oneClickApps: Record = { and Node.js, which serves as the run-time environment for your application. All of these technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications. Upload your existing MERN website code to your new Linode, or use MERN's scaffolding tool to start writing new web applications on the Linode.`, logo_url: 'mern.svg', - name: 'MERN', related_guides: [ { href: @@ -104,7 +100,6 @@ export const oneClickApps: Record = { description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, logo_url: 'wireguard.svg', - name: 'WireGuard®', related_guides: [ { href: @@ -126,7 +121,6 @@ export const oneClickApps: Record = { description: `GitLab is a complete solution for all aspects of your software development. At its core, GitLab serves as your centralized Git repository. GitLab also features built-in tools that represent every task in your development workflow, from planning to testing to releasing. Self-hosting your software development with GitLab offers total control of your codebase. At the same time, its familiar interface will ease collaboration for you and your team. GitLab is the most popular self-hosted Git repository, so you'll benefit from a robust set of integrated tools and an active community.`, logo_url: 'gitlab.svg', - name: 'GitLab', related_guides: [ { href: @@ -148,7 +142,6 @@ export const oneClickApps: Record = { }, description: `With WooCommerce, you can securely sell both digital and physical goods, and take payments via major credit cards, bank transfers, PayPal, and other providers like Stripe. With more than 300 extensions to choose from, WooCommerce is extremely flexible.`, logo_url: 'woocommerce.svg', - name: 'WooCommerce', related_guides: [ { href: @@ -171,7 +164,6 @@ export const oneClickApps: Record = { taming forests, and venturing out to sea. Choose a home from the varied list of biomes like ice worlds, flower plains, and jungles. Build ancient castles or modern mega cities, and fill them with redstone circuit contraptions and villagers. Fight off nightly invasions of Skeletons, Zombies, and explosive Creepers, or adventure to the End and the Nether to summon the fabled End Dragon and the chaotic Wither. If that is not enough, Minecraft is also highly moddable and customizable. You decide the rules when hosting your own Minecraft server for you and your friends to play together in this highly addictive game.`, logo_url: 'minecraft.svg', - name: 'Minecraft: Java Edition', related_guides: [ { href: @@ -192,7 +184,6 @@ export const oneClickApps: Record = { }, description: `OpenVPN is a widely trusted, free, and open-source virtual private network application. OpenVPN creates network tunnels between groups of computers that are not on the same local network, and it uses OpenSSL to encrypt your traffic.`, logo_url: 'openvpn.svg', - name: 'OpenVPN', related_guides: [ { href: @@ -213,7 +204,6 @@ export const oneClickApps: Record = { }, description: `Plesk is a leading WordPress and website management platform and control panel. Plesk lets you build and manage multiple websites from a single dashboard to configure web services, email, and other applications. Plesk features hundreds of extensions, plus a complete WordPress toolkit. Use the Plesk One-Click App to manage websites hosted on your Linode.`, logo_url: 'plesk.svg', - name: 'Plesk', related_guides: [ { href: @@ -236,7 +226,6 @@ export const oneClickApps: Record = { }, description: `The cPanel & WHM® Marketplace App streamlines publishing and managing a website on your Linode. cPanel & WHM is a Linux® based web hosting control panel and platform that helps you create and manage websites, servers, databases and more with a suite of hosting automation and optimization tools.`, logo_url: 'cpanel.svg', - name: 'cPanel', related_guides: [ { href: @@ -259,7 +248,6 @@ export const oneClickApps: Record = { description: 'Shadowsocks is a lightweight SOCKS5 web proxy tool. A full setup requires a Linode server to host the Shadowsocks daemon, and a client installed on PC, Mac, Linux, or a mobile device. Unlike other proxy software, Shadowsocks traffic is designed to be both indiscernible from other traffic to third-party monitoring tools, and also able to disguise itself as a normal direct connection. Data passing through Shadowsocks is encrypted for additional security and privacy.', logo_url: 'shadowsocks.svg', - name: 'Shadowsocks', related_guides: [ { href: @@ -281,7 +269,6 @@ export const oneClickApps: Record = { }, description: `LEMP provides a platform for applications that is compatible with the LAMP stack for nearly all applications; however, because NGINX is able to serve more pages at once with a more predictable memory usage profile, it may be more suited to high demand situations.`, logo_url: 'lemp.svg', - name: 'LEMP', related_guides: [ { href: @@ -301,7 +288,6 @@ export const oneClickApps: Record = { }, description: `MySQL, or MariaDB for Linux operating systems, is primarily used for web and server applications, including as a component of the industry-standard LAMP and LEMP stacks.`, logo_url: 'mysql.svg', - name: 'MySQL/MariaDB', related_guides: [ { href: @@ -322,7 +308,6 @@ export const oneClickApps: Record = { }, description: `Jenkins is an open source automation tool which can build, test, and deploy your infrastructure.`, logo_url: 'jenkins.svg', - name: 'Jenkins', related_guides: [ { href: @@ -344,7 +329,6 @@ export const oneClickApps: Record = { }, description: `Docker is a tool that enables you to create, deploy, and manage lightweight, stand-alone packages that contain everything needed to run an application (code, libraries, runtime, system settings, and dependencies).`, logo_url: 'docker.svg', - name: 'Docker', related_guides: [ { href: @@ -365,7 +349,6 @@ export const oneClickApps: Record = { }, description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

    *Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, logo_url: 'redis.svg', - name: 'Marketplace App for Redis®', related_guides: [ { href: @@ -388,7 +371,6 @@ export const oneClickApps: Record = { }, description: `Intuitive web interface for MySQL and MariaDB operations, including importing/exporting data, administering multiple servers, and global database search.`, logo_url: 'phpmyadmin.svg', - name: 'phpMyAdmin', related_guides: [ { href: @@ -409,7 +391,6 @@ export const oneClickApps: Record = { }, description: `Rails is a web application development framework written in the Ruby programming language. It is designed to make programming web applications easier by giving every developer a number of common tools they need to get started. Ruby on Rails empowers you to accomplish more with less code.`, logo_url: 'rubyonrails.svg', - name: 'Ruby on Rails', related_guides: [ { href: @@ -430,7 +411,6 @@ export const oneClickApps: Record = { }, description: `Django is a web development framework for the Python programming language. It enables rapid development, while favoring pragmatic and clean design.`, logo_url: 'django.svg', - name: 'Django', related_guides: [ { href: @@ -451,7 +431,6 @@ export const oneClickApps: Record = { }, description: `Flask is a lightweight WSGI web application framework written in Python. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.`, logo_url: 'flask.svg', - name: 'Flask', related_guides: [ { href: @@ -472,7 +451,6 @@ export const oneClickApps: Record = { }, description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`, logo_url: 'postgresql.svg', - name: 'PostgreSQL', related_guides: [ { href: @@ -493,7 +471,6 @@ export const oneClickApps: Record = { }, description: `MEAN is a full-stack JavaScript-based framework which accelerates web application development much faster than other frameworks. All involved technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications.`, logo_url: 'mean.svg', - name: 'MEAN', related_guides: [ { href: @@ -515,7 +492,6 @@ export const oneClickApps: Record = { }, description: `Nextcloud AIO stands for Nextcloud All In One, and provides easy deployment and maintenance for popular Nextcloud tools. AIO includes Nextcloud, Nextcloud Office, OnlyOffice, and high-performance backend features.`, logo_url: 'nextcloud.svg', - name: 'Nextcloud', related_guides: [ { href: @@ -535,7 +511,6 @@ export const oneClickApps: Record = { }, description: `All aspects of running a radio station in one web interface so you can start your own station. Manage media, create playlists, and interact with listeners on one free platform.`, logo_url: 'azuracast.svg', - name: 'Azuracast', related_guides: [ { href: @@ -557,7 +532,6 @@ export const oneClickApps: Record = { }, description: `Organize, stream, and share your media library with friends, in addition to free live TV in 220+ countries.`, logo_url: 'plex.svg', - name: 'Plex', related_guides: [ { href: @@ -579,7 +553,6 @@ export const oneClickApps: Record = { }, description: `Secure, stable, and free alternative to popular video conferencing services. Use built-in features to limit meeting access with passwords or stream on YouTube so anyone can attend.`, logo_url: 'jitsi.svg', - name: 'Jitsi', related_guides: [ { href: @@ -600,7 +573,6 @@ export const oneClickApps: Record = { }, description: `Connect and scale applications with asynchronous messaging and highly available work queues, all controlled through an intuitive management UI.`, logo_url: 'rabbitmq.svg', - name: 'RabbitMQ', related_guides: [ { href: @@ -621,7 +593,6 @@ export const oneClickApps: Record = { }, description: `Launch a sleek forum with robust integrations to popular tools like Slack and WordPress to start more conversations.`, logo_url: 'discourse.svg', - name: 'Discourse', related_guides: [ { href: @@ -644,7 +615,6 @@ export const oneClickApps: Record = { }, description: `Lightweight control panel with a suite of features to streamline app management.`, logo_url: 'webuzo.svg', - name: 'Webuzo', related_guides: [ { href: @@ -666,7 +636,6 @@ export const oneClickApps: Record = { }, description: `Launch a portable development environment to speed up tests, downloads, and more.`, logo_url: 'vscodeserver.svg', - name: 'VS Code Server', related_guides: [ { href: @@ -687,7 +656,6 @@ export const oneClickApps: Record = { }, description: `Self-hosted Git service built and maintained by a large developer community.`, logo_url: 'gitea.svg', - name: 'Gitea', related_guides: [ { href: @@ -708,7 +676,6 @@ export const oneClickApps: Record = { }, description: `Use Kepler Builder to easily design and build sites in WordPress - no coding or design knowledge necessary.`, logo_url: 'keplerbuilder.svg', - name: 'Kepler Builder', related_guides: [ { href: @@ -729,7 +696,6 @@ export const oneClickApps: Record = { }, description: `Access your desktop from any device with a browser to keep your desktop hosted in the cloud.`, logo_url: 'guacamole.svg', - name: 'Guacamole', related_guides: [ { href: @@ -750,7 +716,6 @@ export const oneClickApps: Record = { }, description: `File synchronization across multiple users’ computers and other devices to keep everyone working without interruption.`, logo_url: 'filecloud.svg', - name: 'FileCloud', related_guides: [ { href: @@ -772,7 +737,6 @@ export const oneClickApps: Record = { }, description: `Turnkey solution for running apps like WordPress, Rocket.Chat, NextCloud, GitLab, and OpenVPN.`, logo_url: 'cloudron.svg', - name: 'Cloudron', related_guides: [ { href: @@ -794,7 +758,6 @@ export const oneClickApps: Record = { }, description: `Accelerated and scalable hosting for WordPress. Includes OpenLiteSpeed, PHP, MySQL Server, WordPress, and LiteSpeed Cache.`, logo_url: 'openlitespeedwordpress.svg', - name: 'OpenLiteSpeed WordPress', related_guides: [ { href: @@ -815,7 +778,6 @@ export const oneClickApps: Record = { }, description: `Save time on securing your Linode by deploying an instance pre-configured with some basic security best practices: limited user account access, hardened SSH, and Fail2Ban for SSH Login Protection.`, logo_url: 'secureyourserver.svg', - name: 'Secure Your Server', related_guides: [ { href: @@ -836,7 +798,6 @@ export const oneClickApps: Record = { }, description: `Reduce setup time required to host websites and applications, including popular tools like OpenLiteSpeed WordPress.`, logo_url: 'cyberpanel.svg', - name: 'CyberPanel', related_guides: [ { href: @@ -857,7 +818,6 @@ export const oneClickApps: Record = { }, description: `Simplify Docker deployments and make containerization easy for anyone to use. Please note: Yacht is still in alpha and is not recommended for production use.`, logo_url: 'yacht.svg', - name: 'Yacht', related_guides: [ { href: @@ -878,7 +838,6 @@ export const oneClickApps: Record = { }, description: `Monitor, track performance and maintain availability for network servers, devices, services and other IT resources– all in one tool.`, logo_url: 'zabbix.svg', - name: 'Zabbix', related_guides: [ { href: @@ -899,7 +858,6 @@ export const oneClickApps: Record = { }, description: `Host multiple sites on a single server while managing apps, firewall, databases, backups, system users, cron jobs, SSL and email– all in an intuitive interface.`, logo_url: 'serverwand.svg', - name: 'ServerWand', related_guides: [ { href: @@ -921,7 +879,6 @@ export const oneClickApps: Record = { }, description: `Open source alternative to paid ticket management solutions with essential features including a streamlined task list, project and client management, and ticket prioritization.`, logo_url: 'peppermint.svg', - name: 'Peppermint', related_guides: [ { href: @@ -943,7 +900,6 @@ export const oneClickApps: Record = { }, description: `Self-hosted free version to optimize and record video streaming for webinars, gaming, and more.`, logo_url: 'antmediaserver.svg', - name: 'Ant Media Server: Community Edition', related_guides: [ { href: @@ -964,7 +920,6 @@ export const oneClickApps: Record = { }, description: `A live streaming and chat server for use with existing popular broadcasting software.`, logo_url: 'owncast.svg', - name: 'Owncast', related_guides: [ { href: @@ -986,7 +941,6 @@ export const oneClickApps: Record = { }, description: `Robust open-source learning platform enabling online education for more than 200 million users around the world. Create personalized learning environments within a secure and integrated system built for all education levels with an intuitive interface, drag-and-drop features, and accessible documentation.`, logo_url: 'moodle.svg', - name: 'Moodle', related_guides: [ { href: @@ -1008,7 +962,6 @@ export const oneClickApps: Record = { }, description: `Feature-rich alternative control panel for users who need critical control panel functionality but don’t need to pay for more niche premium features. aaPanel is open source and consistently maintained with weekly updates.`, logo_url: 'aapanel.svg', - name: 'aaPanel', related_guides: [ { href: @@ -1030,7 +983,6 @@ export const oneClickApps: Record = { }, description: `Popular data-to-everything platform with advanced security, observability, and automation features for machine learning and AI.`, logo_url: 'splunk.svg', - name: 'Splunk', related_guides: [ { href: @@ -1053,7 +1005,6 @@ export const oneClickApps: Record = { }, description: `Chevereto is a full-featured image sharing solution that acts as an alternative to services like Google Photos or Flickr. Optimize image hosting by using external cloud storage (like Linode’s S3-compatible Object Storage) and connect to Chevereto using API keys.`, logo_url: 'chevereto.svg', - name: 'Chevereto', related_guides: [ { href: @@ -1076,7 +1027,6 @@ export const oneClickApps: Record = { }, description: `Securely share and collaborate Linode S3 object storage files/folders with your internal or external users such as customers, partners, vendors, etc with fine access control and a simple interface. Nirvashare easily integrates with many external identity providers such as Active Directory, GSuite, AWS SSO, KeyClock, etc.`, logo_url: 'nirvashare.svg', - name: 'NirvaShare', related_guides: [ { href: @@ -1099,7 +1049,6 @@ export const oneClickApps: Record = { }, description: `All-in-one interface for scripting and monitoring databases, including MySQL, MariaDB, Percona, PostgreSQL, Galera Cluster and more. Easily deploy database instances, manage with an included CLI, and automate performance monitoring.`, logo_url: 'clustercontrol.svg', - name: 'ClusterControl', related_guides: [ { href: @@ -1121,7 +1070,6 @@ export const oneClickApps: Record = { }, description: `Powerful and customizable backups for several websites and data all in the same interface. JetBackup integrates with any control panel via API, and has native support for cPanel and DirectAdmin. Easily backup your data to storage you already use, including Linode’s S3-compatible Object Storage.`, logo_url: 'jetbackup.svg', - name: 'JetBackup', related_guides: [ { href: @@ -1143,7 +1091,6 @@ export const oneClickApps: Record = { }, description: `Open source registry for images and containers. Linode recommends using Harbor with Linode Kubernetes Engine (LKE).`, logo_url: 'harbor.svg', - name: 'Harbor', related_guides: [ { href: @@ -1164,7 +1111,6 @@ export const oneClickApps: Record = { }, description: `Put data privacy first with an alternative to programs like Slack and Microsoft Teams.`, logo_url: 'rocketchat.svg', - name: 'Rocket.Chat', related_guides: [ { href: @@ -1186,7 +1132,6 @@ export const oneClickApps: Record = { }, description: `Infrastructure monitoring solution to detect threats, intrusion attempts, unauthorized user actions, and provide security analytics.`, logo_url: 'wazuh.svg', - name: 'Wazuh', related_guides: [ { href: @@ -1207,7 +1152,6 @@ export const oneClickApps: Record = { }, description: `Test the security posture of a client or application using client-side vectors, all powered by a simple API. This project is developed solely for lawful research and penetration testing.`, logo_url: 'beef.svg', - name: 'BeEF', related_guides: [ { href: @@ -1229,7 +1173,6 @@ export const oneClickApps: Record = { }, description: `Simple deployment for OLS web server, Python LSAPI, and CertBot.`, logo_url: 'openlitespeeddjango.svg', - name: 'OpenLiteSpeed Django', related_guides: [ { href: @@ -1250,7 +1193,6 @@ export const oneClickApps: Record = { }, description: `Easy setup to run Ruby apps in the cloud and take advantage of OpenLiteSpeed server features like SSL, HTTP/3 support, and RewriteRules.`, logo_url: 'openlitespeedrails.svg', - name: 'OpenLiteSpeed Rails', related_guides: [ { href: @@ -1272,7 +1214,6 @@ export const oneClickApps: Record = { }, description: `High-performance open source web server with Node and CertBot, in addition to features like HTTP/3 support and easy SSL setup.`, logo_url: 'openlitespeednodejs.svg', - name: 'OpenLiteSpeed NodeJS', related_guides: [ { href: @@ -1280,7 +1221,7 @@ export const oneClickApps: Record = { title: 'Deploy OpenLiteSpeed Node.js through the Linode Marketplace', }, ], - summary: 'OLS web server with NodeJS JavaScript runtime environment.', + summary: 'OLS web server with Node.js JavaScript runtime environment.', website: 'https://docs.litespeedtech.com/cloud/images/nodejs/', }, 923032: { @@ -1293,7 +1234,6 @@ export const oneClickApps: Record = { }, description: `High-performance LiteSpeed web server equipped with WHM/cPanel and WHM LiteSpeed Plugin.`, logo_url: 'litespeedcpanel.svg', - name: 'LiteSpeed cPanel', related_guides: [ { href: @@ -1315,7 +1255,6 @@ export const oneClickApps: Record = { }, description: `Akaunting is a universal accounting software that helps small businesses run more efficiently. Track expenses, generate reports, manage your books, and get the other essential features to run your business from a single dashboard.`, logo_url: 'akaunting.svg', - name: 'Akaunting', related_guides: [ { href: @@ -1337,7 +1276,6 @@ export const oneClickApps: Record = { }, description: `Restyaboard is an open-source alternative to Trello, but with additional smart features like offline sync, diff /revisions, nested comments, multiple view layouts, chat, and more.`, logo_url: 'restyaboard.svg', - name: 'Restyaboard', related_guides: [ { href: @@ -1358,7 +1296,6 @@ export const oneClickApps: Record = { }, description: `Feature-rich, self-hosted VPN based on WireGuard® protocol, plus convenient features like single sign-on, real-time bandwidth monitoring, and unlimited users/devices.`, logo_url: 'warpspeed.svg', - name: 'WarpSpeed', related_guides: [ { href: @@ -1379,7 +1316,6 @@ export const oneClickApps: Record = { }, description: `UTunnel VPN is a robust cloud-based VPN server software solution. With UTunnel VPN, businesses could easily set up secure remote access to their business network. UTunnel comes with a host of business-centric features including site-to-site connectivity, single sign-on integration, 2-factor authentication, etc.`, logo_url: 'utunnel.svg', - name: 'UTunnel VPN', related_guides: [ { href: @@ -1401,7 +1337,6 @@ export const oneClickApps: Record = { }, description: `User-friendly VPN for both individual and commercial use. Choose from three pricing plans.`, logo_url: 'pritunl.svg', - name: 'Pritunl', related_guides: [ { href: @@ -1422,7 +1357,6 @@ export const oneClickApps: Record = { }, description: `VictoriaMetrics is designed to collect, store, and process real-time metrics.`, logo_url: 'victoriametricssingle.svg', - name: 'VictoriaMetrics Single', related_guides: [ { href: @@ -1444,7 +1378,6 @@ export const oneClickApps: Record = { }, description: `Protect your network and devices from unwanted content. Avoid ads in non-browser locations with a free, lightweight, and comprehensive privacy solution you can self-host.`, logo_url: 'pihole.svg', - name: 'Pi-hole', related_guides: [ { href: @@ -1466,7 +1399,6 @@ export const oneClickApps: Record = { }, description: `Uptime Kuma is self-hosted alternative to Uptime Robot. Get real-time performance insights for HTTP(s), TCP/ HTTP(s) Keyword, Ping, DNS Record, and more. Monitor everything you need in one UI dashboard, or customize how you receive alerts with a wide range of supported integrations.`, logo_url: 'uptimekuma.svg', - name: 'Uptime Kuma', related_guides: [ { href: @@ -1487,7 +1419,6 @@ export const oneClickApps: Record = { }, description: `Build websites on a CMS that prioritizes speed and simplicity over customization and integration support. Create your content in Markdown and take advantage of powerful taxonomy to customize relationships between pages and other content.`, logo_url: 'grav.svg', - name: 'Grav', related_guides: [ { href: @@ -1507,14 +1438,13 @@ export const oneClickApps: Record = { end: '333333', start: '3d853c', }, - description: `NodeJS is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`, + description: `Node.js is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`, logo_url: 'nodejs.svg', - name: 'NodeJS', related_guides: [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/nodejs/', - title: 'Deploy NodeJS through the Linode Marketplace', + title: 'Deploy Node.js through the Linode Marketplace', }, ], summary: @@ -1531,7 +1461,6 @@ export const oneClickApps: Record = { }, description: `Build applications without writing a single line of code. Saltcorn is a free platform that allows you to build an app with an intuitive point-and-click, drag-and-drop UI.`, logo_url: 'saltcorn.svg', - name: 'Saltcorn', related_guides: [ { href: @@ -1553,7 +1482,6 @@ export const oneClickApps: Record = { }, description: `Odoo is a free and comprehensive business app suite of tools that seamlessly integrate. Choose what you need to manage your business on a single platform, including a CRM, email marketing tools, essential project management functions, and more.`, logo_url: 'odoo.svg', - name: 'Odoo', related_guides: [ { href: @@ -1575,7 +1503,6 @@ export const oneClickApps: Record = { }, description: `Create boards, assign tasks, and keep projects moving with a free and robust alternative to tools like Trello and Asana.`, logo_url: 'focalboard.svg', - name: 'Focalboard', related_guides: [ { href: @@ -1596,7 +1523,6 @@ export const oneClickApps: Record = { }, description: `Free industry-standard monitoring tools that work better together. Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed. Use Grafana to create visuals, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`, logo_url: 'prometheusgrafana.svg', - name: 'Prometheus & Grafana', related_guides: [ { href: @@ -1617,7 +1543,6 @@ export const oneClickApps: Record = { }, description: `Free open source CMS optimized for building custom functionality and design.`, logo_url: 'joomla.svg', - name: 'Joomla', related_guides: [ { href: @@ -1639,7 +1564,6 @@ export const oneClickApps: Record = { }, description: `Ant Media Server makes it easy to set up a video streaming platform with ultra low latency. The Enterprise edition supports WebRTC Live Streaming in addition to CMAF and HLS streaming. Set up live restreaming to social media platforms to reach more viewers.`, logo_url: 'antmediaserver.svg', - name: 'Ant Media Server: Enterprise Edition', related_guides: [ { href: @@ -1662,7 +1586,6 @@ export const oneClickApps: Record = { }, description: `Capture your thoughts and securely access them from any device with a highly customizable note-taking software.`, logo_url: 'joplin.svg', - name: 'Joplin', related_guides: [ { href: @@ -1683,7 +1606,6 @@ export const oneClickApps: Record = { }, description: `Stream live audio or video while maximizing customer engagement with advanced built-in features. Liveswitch provides real-time monitoring, audience polling, and end-to-end (E2E) data encryption.`, logo_url: 'liveswitch.svg', - name: 'LiveSwitch', related_guides: [ { href: @@ -1705,7 +1627,6 @@ export const oneClickApps: Record = { }, description: `Deploy Node.js, Ruby, Python, PHP, Go, and Java applications via an intuitive control panel. Easily set up free SSL certificates, run commands with an in-browser terminal, and push your code from Github to accelerate development.`, logo_url: 'easypanel.svg', - name: 'Easypanel', related_guides: [ { href: @@ -1727,7 +1648,6 @@ export const oneClickApps: Record = { }, description: `Kali Linux is an open source, Debian-based Linux OS that has become an industry-standard tool for penetration testing and security audits. Kali includes hundreds of free tools for reverse engineering, penetration testing and more. Kali prioritizes simplicity, making security best practices more accessible to everyone from cybersecurity professionals to hobbyists.`, logo_url: 'kalilinux.svg', - name: 'Kali Linux', related_guides: [ { href: @@ -1751,7 +1671,6 @@ export const oneClickApps: Record = { description: 'Budibase is a modern, open source low-code platform for building modern business applications in minutes. Build, design and automate business apps, such as: admin panels, forms, internal tools, client portals and more. Before Budibase, it could take developers weeks to build simple CRUD apps; with Budibase, building CRUD apps takes minutes. When self-hosting please follow best practices for securing, updating and backing up your server.', logo_url: 'budibase.svg', - name: 'Budibase', related_guides: [ { href: @@ -1774,7 +1693,6 @@ export const oneClickApps: Record = { description: 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', logo_url: 'nomad.svg', - name: 'HashiCorp Nomad', related_guides: [ { href: @@ -1796,7 +1714,6 @@ export const oneClickApps: Record = { description: 'HashiCorp Vault is an open source, centralized secrets management system. It provides a secure and reliable way of storing and distributing secrets like API keys, access tokens, and passwords.', logo_url: 'vault.svg', - name: 'HashiCorp Vault', related_guides: [ { href: @@ -1817,7 +1734,6 @@ export const oneClickApps: Record = { }, description: `Microweber is an easy Drag and Drop website builder and a powerful CMS of a new generation, based on the PHP Laravel Framework.`, logo_url: 'microweber.svg', - name: 'Microweber', related_guides: [ { href: @@ -1838,7 +1754,6 @@ export const oneClickApps: Record = { }, description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`, logo_url: 'postgresqlmarketplaceocc.svg', - name: 'PostgreSQL Cluster', related_guides: [ { href: @@ -1859,7 +1774,6 @@ export const oneClickApps: Record = { }, description: `Galera provides a performant multi-master/active-active database solution with synchronous replication, to achieve high availability.`, logo_url: 'galeramarketplaceocc.svg', - name: 'Galera Cluster', related_guides: [ { href: @@ -1880,7 +1794,6 @@ export const oneClickApps: Record = { }, description: `Mastodon is an open-source and decentralized micro-blogging platform, supporting federation and public access to the server.`, logo_url: 'mastodon.svg', - name: 'Mastodon', related_guides: [ { href: @@ -1903,7 +1816,6 @@ export const oneClickApps: Record = { }, description: `Programmatically author, schedule, and monitor workflows with a Python-based tool. Airflow provides full insight into the status and logs of your tasks, all in a modern web application.`, logo_url: 'apacheairflow.svg', - name: 'Apache Airflow', related_guides: [ { href: @@ -1925,7 +1837,6 @@ export const oneClickApps: Record = { }, description: `Harden your web applications and APIs against OWASP Top 10 attacks. Haltdos makes it easy to manage WAF settings and review logs in an intuitive web-based GUI.`, logo_url: 'haltdos.svg', - name: 'HaltDOS Community WAF', related_guides: [ { href: @@ -1947,7 +1858,6 @@ export const oneClickApps: Record = { }, description: `Superinsight provides a simple SQL interface to store and search unstructured data. Superinsight is built on top of PostgreSQL to take advantage of powerful extensions and features, plus the ability to run machine learning operations using SQL statements.`, logo_url: 'superinsight.svg', - name: 'Superinsight', related_guides: [ { href: @@ -1969,7 +1879,6 @@ export const oneClickApps: Record = { }, description: `Provision multicloud clusters, containerize applications, and build DevOps pipelines. Gopaddle’s suite of templates and integrations helps eliminate manual errors and automate Kubernetes application releases.`, logo_url: 'gopaddle.svg', - name: 'Gopaddle', related_guides: [ { href: @@ -1991,7 +1900,6 @@ export const oneClickApps: Record = { }, description: `Self-host a password manager designed to simplify and secure your digital life. Passky is a streamlined version of paid password managers designed for everyone to use.`, logo_url: 'passky.svg', - name: 'Passky', related_guides: [ { href: @@ -2012,7 +1920,6 @@ export const oneClickApps: Record = { }, description: `Create and collaborate on text documents, spreadsheets, and presentations compatible with popular file types including .docx, .xlsx, and more. Additional features include real-time editing, paragraph locking while co-editing, and version history.`, logo_url: 'onlyoffice.svg', - name: 'ONLYOFFICE Docs', related_guides: [ { href: @@ -2033,7 +1940,6 @@ export const oneClickApps: Record = { }, description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

    *Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, logo_url: 'redissentinelmarketplaceocc.svg', - name: 'Marketplace App for Redis® Sentinel Cluster', related_guides: [ { href: @@ -2056,7 +1962,6 @@ export const oneClickApps: Record = { }, description: `LAMP-stack-based server application that allows you to access your files from anywhere in a secure way.`, logo_url: 'owncloud.svg', - name: 'ownCloud', related_guides: [ { href: @@ -2079,7 +1984,6 @@ export const oneClickApps: Record = { }, description: `A self-hosted Firebase alternative for web, mobile & Flutter developers.`, logo_url: 'appwrite.svg', - name: 'Appwrite', related_guides: [ { href: @@ -2102,7 +2006,6 @@ export const oneClickApps: Record = { }, description: `Self-hosted database for a variety of management projects.`, logo_url: 'seatable.svg', - name: 'Seatable', related_guides: [ { href: @@ -2126,7 +2029,6 @@ export const oneClickApps: Record = { description: 'Illa Builder is a Retool open-source alternative, with low-code UI components for self-hosting the development of internal tools.', logo_url: 'illabuilder.svg', - name: 'Illa Builder', related_guides: [ { href: @@ -2149,7 +2051,6 @@ export const oneClickApps: Record = { description: 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', logo_url: 'nomad.svg', - name: 'HashiCorp Nomad Cluster', related_guides: [ { href: @@ -2172,7 +2073,6 @@ export const oneClickApps: Record = { description: 'A simple deployment of multiple clients to horizontally scale an existing Nomad One-Click Cluster.', logo_url: 'nomad.svg', - name: 'HashiCorp Nomad Clients Cluster', related_guides: [ { href: @@ -2194,7 +2094,6 @@ export const oneClickApps: Record = { }, description: `MainConcept FFmpeg Plugins Demo is suited for both VOD and live production workflows, with advanced features such as Hybrid GPU acceleration and xHE-AAC audio format.`, logo_url: 'mainconcept.svg', - name: 'MainConcept FFmpeg Plugins Demo', related_guides: [ { href: @@ -2217,7 +2116,6 @@ export const oneClickApps: Record = { }, description: `MainConcept Live Encoder Demo is a powerful all-in-one encoding engine designed to simplify common broadcast and OTT video workflows.`, logo_url: 'mainconcept.svg', - name: 'MainConcept Live Encoder Demo', related_guides: [ { href: @@ -2239,7 +2137,6 @@ export const oneClickApps: Record = { }, description: `MainConcept P2 AVC ULTRA Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Panasonic camera formats like P2 AVC-Intra, P2 AVC LongG and AVC-intra RP2027.v1 and AAC High Efficiency v2 formats into an MP4 container.`, logo_url: 'mainconcept.svg', - name: 'MainConcept P2 AVC ULTRA Transcoder Demo', related_guides: [ { href: @@ -2262,7 +2159,6 @@ export const oneClickApps: Record = { }, description: `MainConcept XAVC Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XAVC-Intra, XAVC Long GOP and XAVC-S.`, logo_url: 'mainconcept.svg', - name: 'MainConcept XAVC Transcoder Demo', related_guides: [ { href: @@ -2285,7 +2181,6 @@ export const oneClickApps: Record = { }, description: `MainConcept XDCAM Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XDCAM HD, XDCAM EX, XDCAM IMX and DVCAM (XDCAM DV).`, logo_url: 'mainconcept.svg', - name: 'MainConcept XDCAM Transcoder Demo', related_guides: [ { href: @@ -2308,7 +2203,6 @@ export const oneClickApps: Record = { }, description: `SimpleX Chat - The first messaging platform that has no user identifiers of any kind - 100% private by design. SMP server is the relay server used to pass messages in SimpleX network. XFTP is a new file transfer protocol focussed on meta-data protection. This One-Click APP will deploy both SMP and XFTP servers.`, logo_url: 'simplexchat.svg', - name: 'SimpleX Chat', related_guides: [ { href: @@ -2330,7 +2224,6 @@ export const oneClickApps: Record = { description: 'JupyterLab is a cutting-edge web-based, interactive development environment, geared towards data science, machine learning and other scientific computing workflows.', logo_url: 'jupyter.svg', - name: 'JupyterLab', related_guides: [ { href: @@ -2352,7 +2245,6 @@ export const oneClickApps: Record = { description: 'NATS is a distributed PubSub technology that enables applications to securely communicate across any combination of cloud vendors, on-premise, edge, web and mobile, and devices.', logo_url: 'nats.svg', - name: 'NATS Single Node', related_guides: [ { href: @@ -2363,28 +2255,6 @@ export const oneClickApps: Record = { summary: 'Cloud native application messaging service.', website: 'https://nats.io', }, - 1439640: { - alt_description: 'Open Source Secrets Manager', - alt_name: 'Passbolt CE', - categories: ['Security'], - colors: { - end: 'D40101', - start: '171717', - }, - description: `Passbolt Community Edition is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`, - logo_url: 'passbolt.svg', - name: 'Passbolt Community Edition', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/', - title: - 'Deploy Passbolt Community Edition through the Linode Marketplace', - }, - ], - summary: 'Open-source password manager for teams and businesses.', - website: 'https://www.passbolt.com/', - }, 1329462: { alt_description: 'LinuxGSM is a command line utility that simplifies self-hosting multiplayer game servers.', @@ -2396,7 +2266,6 @@ export const oneClickApps: Record = { }, description: `Self hosted multiplayer game servers.`, logo_url: 'linuxgsm.svg', - name: 'LinuxGSM', related_guides: [ { href: @@ -2418,7 +2287,6 @@ export const oneClickApps: Record = { }, description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`, logo_url: 'jitsi.svg', - name: 'Jitsi Cluster', related_guides: [ { href: @@ -2440,7 +2308,6 @@ export const oneClickApps: Record = { description: 'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.', logo_url: 'glusterfs.svg', - name: 'GlusterFS Cluster', related_guides: [ { href: @@ -2463,7 +2330,6 @@ export const oneClickApps: Record = { description: `Distributed, masterless, replicating NoSQL database cluster.`, isNew: true, logo_url: 'apachecassandra.svg', - name: 'Apache Cassandra Cluster', related_guides: [ { href: @@ -2485,7 +2351,6 @@ export const oneClickApps: Record = { }, description: `Couchbase Enterprise Server is a high-performance NoSQL database, built for scale. Couchbase Server is designed with memory-first architecture, built-in cache and workload isolation.`, logo_url: 'couchbase.svg', - name: 'Couchbase Cluster', related_guides: [ { href: @@ -2507,7 +2372,6 @@ export const oneClickApps: Record = { }, description: `Apache Kafka supports a wide range of applications from log aggregation to real-time analytics. Kafka provides a foundation for building data pipelines, event-driven architectures, or stream processing applications.`, logo_url: 'apachekafka.svg', - name: 'Apache Kafka Cluster', related_guides: [ { href: @@ -2529,7 +2393,6 @@ export const oneClickApps: Record = { description: `High performance, BSD license key/value database.`, isNew: true, logo_url: 'valkey.svg', - name: 'Valkey', related_guides: [ { href: @@ -2551,7 +2414,6 @@ export const oneClickApps: Record = { description: `OSI approved open source secrets platform.`, isNew: true, logo_url: 'openbao.svg', - name: 'OpenBao', related_guides: [ { href: @@ -2573,7 +2435,6 @@ export const oneClickApps: Record = { description: `Time series database supporting native query and visualization.`, isNew: true, logo_url: 'influxdb.svg', - name: 'InfluxDB', related_guides: [ { href: @@ -2596,7 +2457,6 @@ export const oneClickApps: Record = { description: `Fast, open-source unified analytics engine for large-scale data processing.`, isNew: true, logo_url: 'apachespark.svg', - name: 'Apache Spark Cluster', related_guides: [ { href: @@ -2607,4 +2467,25 @@ export const oneClickApps: Record = { summary: 'Unified analytics engine for big data processing.', website: 'https://spark.apache.org/', }, + 1439640: { + alt_description: 'Open Source Secrets Manager', + alt_name: 'Passbolt CE', + categories: ['Security'], + colors: { + end: 'D40101', + start: '171717', + }, + description: `Passbolt Community Edition is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`, + logo_url: 'passbolt.svg', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/', + title: + 'Deploy Passbolt Community Edition through the Linode Marketplace', + }, + ], + summary: 'Open-source password manager for teams and businesses.', + website: 'https://www.passbolt.com/', + }, }; diff --git a/packages/manager/src/features/OneClickApps/types.ts b/packages/manager/src/features/OneClickApps/types.ts index 6b57f977b8e..0cfdaa0bf8f 100644 --- a/packages/manager/src/features/OneClickApps/types.ts +++ b/packages/manager/src/features/OneClickApps/types.ts @@ -11,7 +11,6 @@ export interface OCA { */ isNew?: boolean; logo_url: string; - name: string; related_guides?: Doc[]; summary: string; tips?: string[]; From 072c371fd24e01508c0927b6039852e8bccfd91f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 16 Oct 2024 18:18:38 -0400 Subject: [PATCH 26/64] fix: [M3-8752] - Region Multi Select spacing issues (#11103) * fix region multi select spacing * add changeset --------- Co-authored-by: Banks Nussman --- .../pr-11103-fixed-1729099711508.md | 5 +++ packages/manager/src/components/Flag.tsx | 16 ++++++--- .../RegionSelect/RegionMultiSelect.tsx | 34 +++++-------------- .../components/RegionSelect/RegionOption.tsx | 33 ++++++++---------- .../RegionSelect/RegionSelect.styles.ts | 6 ---- .../components/RegionSelect/RegionSelect.tsx | 5 +-- .../AccessKeyRegions/SelectedRegionsList.tsx | 15 +++----- 7 files changed, 47 insertions(+), 67 deletions(-) create mode 100644 packages/manager/.changeset/pr-11103-fixed-1729099711508.md diff --git a/packages/manager/.changeset/pr-11103-fixed-1729099711508.md b/packages/manager/.changeset/pr-11103-fixed-1729099711508.md new file mode 100644 index 00000000000..fc06956fdba --- /dev/null +++ b/packages/manager/.changeset/pr-11103-fixed-1729099711508.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Region Multi Select spacing issues ([#11103](https://github.com/linode/manager/pull/11103)) diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx index 84decd01815..bd01d381754 100644 --- a/packages/manager/src/components/Flag.tsx +++ b/packages/manager/src/components/Flag.tsx @@ -2,13 +2,16 @@ import { styled } from '@mui/material/styles'; import 'flag-icons/css/flag-icons.min.css'; import React from 'react'; +import { Box } from './Box'; + +import type { BoxProps } from './Box'; import type { Country } from '@linode/api-v4'; const COUNTRY_FLAG_OVERRIDES = { uk: 'gb', }; -interface Props { +interface Props extends BoxProps { country: Country; } @@ -16,9 +19,14 @@ interface Props { * Flag icons are provided by the [flag-icons](https://www.npmjs.com/package/flag-icon) package */ export const Flag = (props: Props) => { - const country = props.country.toLowerCase(); + const { country, ...rest } = props; - return ; + return ( + + ); }; const getFlagClass = (country: Country | string) => { @@ -30,7 +38,7 @@ const getFlagClass = (country: Country | string) => { return country; }; -const StyledFlag = styled('div', { label: 'StyledFlag' })(({ theme }) => ({ +const StyledFlag = styled(Box, { label: 'StyledFlag' })(({ theme }) => ({ boxShadow: theme.palette.mode === 'light' ? `0px 0px 0px 1px #00000010` : undefined, fontSize: '1.5rem', diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index ea4fd248fd0..3239fe19231 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -2,18 +2,15 @@ import CloseIcon from '@mui/icons-material/Close'; import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; import { Flag } from 'src/components/Flag'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import { StyledListItem } from '../Autocomplete/Autocomplete.styles'; +import { Stack } from '../Stack'; import { RegionOption } from './RegionOption'; -import { - StyledAutocompleteContainer, - StyledFlagContainer, -} from './RegionSelect.styles'; +import { StyledAutocompleteContainer } from './RegionSelect.styles'; import { getRegionOptions, isRegionOptionUnavailable, @@ -25,29 +22,16 @@ import type { } from './RegionSelect.types'; import type { Region } from '@linode/api-v4'; -interface LabelComponentProps { +interface RegionChipLabelProps { region: Region; } -const SelectedRegion = ({ region }: LabelComponentProps) => { +const RegionChipLabel = ({ region }: RegionChipLabelProps) => { return ( - - ({ - marginRight: theme.spacing(1 / 2), - transform: 'scale(0.8)', - })} - > - - + + {region.label} ({region.id}) - + ); }; @@ -56,6 +40,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { SelectedRegionsList, currentCapability, disabled, + disabledRegions: disabledRegionsFromProps, errorText, helperText, isClearable, @@ -67,7 +52,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { selectedIds, sortRegionOptions, width, - disabledRegions: disabledRegionsFromProps, ...rest } = props; @@ -145,7 +129,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { data-testid={option.id} deleteIcon={} key={index} - label={} + label={} onDelete={() => handleRemoveOption(option.id)} /> )); diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 407ce7f9306..14f4fdc66a8 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -5,12 +5,12 @@ import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.s import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; +import { Stack } from 'src/components/Stack'; import { Tooltip } from 'src/components/Tooltip'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { SelectedIcon, - StyledFlagContainer, StyledListItem, sxDistributedRegionIcon, } from './RegionSelect.styles'; @@ -70,24 +70,21 @@ export const RegionOption = ({ isRegionDisabled ? e.preventDefault() : onClick ? onClick(e) : null } aria-disabled={undefined} - data-qa-disabled-item={isRegionDisabled} className={isRegionDisabled ? `${className} Mui-disabled` : className} + data-qa-disabled-item={isRegionDisabled} > - <> - - - - - {isGeckoLAEnabled ? region.label : `${region.label} (${region.id})`} - {displayDistributedRegionIcon && ( - -  (This region is a distributed region.) - - )} - {isRegionDisabled && isRegionDisabledReason && ( - {isRegionDisabledReason} - )} - + + + {isGeckoLAEnabled ? region.label : `${region.label} (${region.id})`} + {displayDistributedRegionIcon && ( + +  (This region is a distributed region.) + + )} + {isRegionDisabled && isRegionDisabledReason && ( + {isRegionDisabledReason} + )} + {isGeckoLAEnabled && `(${region.id})`} {selected && } {displayDistributedRegionIcon && ( @@ -98,7 +95,7 @@ export const RegionOption = ({ text="This region is a distributed region." /> )} - + ); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index 088908408c6..8ed59451213 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -68,12 +68,6 @@ export const StyledDistributedRegionBox = styled(Box, { }, })); -export const StyledFlagContainer = styled('div', { - label: 'RegionSelectFlagContainer', -})(({ theme }) => ({ - marginRight: theme.spacing(1), -})); - export const StyledLParentListItem = styled(ListItem, { label: 'RegionSelectParentListItem', })(() => ({ diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index cb126df244a..5d67de51d21 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -15,7 +15,6 @@ import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer, StyledDistributedRegionBox, - StyledFlagContainer, sxDistributedRegionIcon, } from './RegionSelect.styles'; import { @@ -155,9 +154,7 @@ export const RegionSelect = < endAdornment: EndAdornment, required, startAdornment: selectedRegion && ( - - - + ), }, tooltipText, diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx index 50ff7ccd614..571139055ea 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx @@ -2,13 +2,10 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; -import { StyledFlagContainer } from 'src/components/RegionSelect/RegionSelect.styles'; -import { - RemovableItem, - RemovableSelectionsList, -} from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; import type { Region } from '@linode/api-v4'; +import type { RemovableItem } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; interface SelectedRegionsProps { onRemove: (region: string) => void; @@ -25,12 +22,10 @@ const SelectedRegion = ({ selection }: LabelComponentProps) => { sx={{ alignItems: 'center', display: 'flex', - flexGrow: 1, + gap: 1, }} > - - - + {selection.label} ({selection.id})
    ); @@ -46,11 +41,11 @@ export const SelectedRegionsList = ({ return ( ); }; From 8ce11fe653bfa69e1711815e8c19b1b53d2a7e5f Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 17 Oct 2024 17:54:00 +0530 Subject: [PATCH 27/64] upcoming: [DI-21270] - Added the Alerts tab (#11064) * upcoming: [DI-21270] - Added the Alerts tab * Upcoming: [DI-21270] - Addressed the review comments * Upcoming : [DI:21270] - Added the custom type for the Tab with isEnabled property and memoized the filtering of enabled flags * Upcoming: [DI:21270] - Improved the names for clarity --- packages/manager/src/featureFlags.ts | 6 + .../AlertsLanding/AlertsDefinitionLanding.tsx | 25 ++++ .../Alerts/AlertsLanding/AlertsLanding.tsx | 75 ++++++++++++ .../features/CloudPulse/CloudPulseTabs.tsx | 115 +++++++++++------- 4 files changed, 178 insertions(+), 43 deletions(-) create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 444dadd7e16..41ba5d6516f 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -81,8 +81,14 @@ interface DesignUpdatesBannerFlag extends BaseFeatureFlag { link: string; } +interface AclpAlerting { + alertDefinitions: boolean; + notificationChannels: boolean; + recentActivity: boolean; +} export interface Flags { aclp: AclpFlag; + aclpAlerting: AclpAlerting; aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; apiMaintenance: APIMaintenance; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx new file mode 100644 index 00000000000..381eb9cf31f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; + +export const AlertDefinitionLanding = () => { + return ( + + + + ); +}; + +const AlertDefinition = () => { + return ( + + Alert Definition + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx new file mode 100644 index 00000000000..72a1f01c157 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { + Redirect, + Route, + Switch, + useLocation, + useRouteMatch, +} from 'react-router-dom'; + +import { Box } from 'src/components/Box'; +import { Paper } from 'src/components/Paper'; +import { TabLinkList } from 'src/components/Tabs/TabLinkList'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { useFlags } from 'src/hooks/useFlags'; + +import { AlertDefinitionLanding } from './AlertsDefinitionLanding'; + +import type { EnabledAlertTab } from '../../CloudPulseTabs'; + +export const AlertsLanding = React.memo(() => { + const flags = useFlags(); + const { url } = useRouteMatch(); + const { pathname } = useLocation(); + const alertTabs = React.useMemo( + () => [ + { + isEnabled: Boolean(flags.aclpAlerting?.alertDefinitions), + tab: { routeName: `${url}/definitions`, title: 'Definitions' }, + }, + ], + [url, flags.aclpAlerting] + ); + const accessibleTabs = React.useMemo( + () => + alertTabs + .filter((alertTab) => alertTab.isEnabled) + .map((alertTab) => alertTab.tab), + [alertTabs] + ); + const activeTabIndex = React.useMemo( + () => + Math.max( + accessibleTabs.findIndex((tab) => pathname.startsWith(tab.routeName)), + 0 + ), + [accessibleTabs, pathname] + ); + return ( + + + + + + + + + + + + ); +}); diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx index ddcb223c17f..418bee1322c 100644 --- a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx +++ b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx @@ -1,57 +1,86 @@ -import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { matchPath } from 'react-router-dom'; +import { + Redirect, + Route, + Switch, + useLocation, + useRouteMatch, +} from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; -import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { useFlags } from 'src/hooks/useFlags'; +import { AlertsLanding } from './Alerts/AlertsLanding/AlertsLanding'; import { CloudPulseDashboardLanding } from './Dashboard/CloudPulseDashboardLanding'; -import type { RouteComponentProps } from 'react-router-dom'; -type Props = RouteComponentProps<{}>; +import type { Tab } from 'src/components/Tabs/TabLinkList'; -export const CloudPulseTabs = React.memo((props: Props) => { - const tabs = [ - { - routeName: `${props.match.url}/dashboards`, - title: 'Dashboards', - }, - ]; - - const matches = (p: string) => { - return Boolean(matchPath(p, { path: props.location.pathname })); - }; - - const navToURL = (index: number) => { - props.history.push(tabs[index].routeName); - }; - - return ( - matches(tab.routeName)), +export type EnabledAlertTab = { + isEnabled: boolean; + tab: Tab; +}; +export const CloudPulseTabs = () => { + const flags = useFlags(); + const { url } = useRouteMatch(); + const { pathname } = useLocation(); + const alertTabs = React.useMemo( + () => [ + { + isEnabled: true, + tab: { + routeName: `${url}/dashboards`, + title: 'Dashboards', + }, + }, + { + isEnabled: Boolean( + flags.aclpAlerting?.alertDefinitions || + flags.aclpAlerting?.recentActivity || + flags.aclpAlerting?.notificationChannels + ), + tab: { + routeName: `${url}/alerts`, + title: 'Alerts', + }, + }, + ], + [url, flags.aclpAlerting] + ); + const accessibleTabs = React.useMemo( + () => + alertTabs + .filter((alertTab) => alertTab.isEnabled) + .map((alertTab) => alertTab.tab), + [alertTabs] + ); + const activeTabIndex = React.useMemo( + () => + Math.max( + accessibleTabs.findIndex((tab) => pathname.startsWith(tab.routeName)), 0 - )} - onChange={navToURL} - > - + ), + [accessibleTabs, pathname] + ); + return ( + + }> - - - - - + + + + + - + ); -}); - -const StyledTabs = styled(Tabs, { - label: 'StyledTabs', -})(() => ({ - marginTop: 0, -})); +}; From e91cafe560ff73f4fac751468b133e120882d44b Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Thu, 17 Oct 2024 18:08:51 +0530 Subject: [PATCH 28/64] fix: [M3-8408] - Change of heading from "Invoice" to "Tax Invoice" for UAE customers (#11097) * fix: [M3-8408] - Change of heading from "Invoice" to "Tax Invoice" for UAE customers * Added changeset: Change of Heading from Invoice to Tax Invoice for UAE Customers * Change Changeset Description Co-authored-by: Purvesh Makode * Change Changeset Type Co-authored-by: Purvesh Makode --------- Co-authored-by: Purvesh Makode --- packages/manager/.changeset/pr-11097-fixed-1728926799806.md | 5 +++++ .../src/features/Billing/PdfGenerator/PdfGenerator.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11097-fixed-1728926799806.md diff --git a/packages/manager/.changeset/pr-11097-fixed-1728926799806.md b/packages/manager/.changeset/pr-11097-fixed-1728926799806.md new file mode 100644 index 00000000000..93400dceb54 --- /dev/null +++ b/packages/manager/.changeset/pr-11097-fixed-1728926799806.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Invoice heading from 'Invoice' to 'Tax Invoice' for UAE Customers ([#11097](https://github.com/linode/manager/pull/11097)) diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts index 1acc134b281..823b68a9835 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts @@ -301,7 +301,10 @@ export const printInvoice = async ( doc, Math.max(leftHeaderYPosition, rightHeaderYPosition) + 12, { - text: `Invoice: #${invoiceId}`, + text: + account.country === 'AE' + ? `Tax Invoice: #${invoiceId}` + : `Invoice: #${invoiceId}`, } ); From bc9d4245a8fe1382a1a2fa7f629c6540fd171d91 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Thu, 17 Oct 2024 18:10:37 +0530 Subject: [PATCH 29/64] feat: [M3-8703] - Disable VPC Action Buttons for Restricted Users when they do not have access or have read-only access. (#11083) * feat: [M3-8703] - Disable VPC Action Buttons for Restricted Users with None And ReadOnly Permission. * Added changeset: Disable VPC Action buttons when do not have access or have read-only access. * Add new Check in the useIsResourceRestricted logic --- packages/manager/.changeset/pr-11083-fixed-1728564903243.md | 5 +++++ packages/manager/src/hooks/useIsResourceRestricted.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11083-fixed-1728564903243.md diff --git a/packages/manager/.changeset/pr-11083-fixed-1728564903243.md b/packages/manager/.changeset/pr-11083-fixed-1728564903243.md new file mode 100644 index 00000000000..f99dd4694f6 --- /dev/null +++ b/packages/manager/.changeset/pr-11083-fixed-1728564903243.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disable VPC Action buttons when do not have access or have read-only access. ([#11083](https://github.com/linode/manager/pull/11083)) diff --git a/packages/manager/src/hooks/useIsResourceRestricted.ts b/packages/manager/src/hooks/useIsResourceRestricted.ts index 997fcf6afb2..6caa3f97d75 100644 --- a/packages/manager/src/hooks/useIsResourceRestricted.ts +++ b/packages/manager/src/hooks/useIsResourceRestricted.ts @@ -19,7 +19,9 @@ export const useIsResourceRestricted = ({ if (!grants) { return false; } - return grants[grantType].some( - (grant) => grant.id === id && grant.permissions === grantLevel + return ( + grants[grantType].some( + (grant) => grant.id === id && grant.permissions === grantLevel + ) || !grants[grantType].some((grant) => grant.id === id) ); }; From d442759507480ca212a0dc1aba3ab2b3c46ad4be Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 17 Oct 2024 09:06:13 -0500 Subject: [PATCH 30/64] fix: [M3-7197] - "Support Ticket" button in network tab not working properly (#11074) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * chore: [M3-8662] - Update Github Actions actions (#11009) * update actions * add changeset --------- Co-authored-by: Banks Nussman * fix: [M3-7197] - "Support Ticket" button in network tab not working properly * Create pr-11074-fixed-1728476792585.md --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman --- .../pr-11074-fixed-1728476792585.md | 5 ++ .../LinodeNetworking/AddIPDrawer.tsx | 37 ++++-------- .../LinodeNetworking/ExplainerCopy.test.tsx | 60 +++++++++++++++++++ .../LinodeNetworking/ExplainerCopy.tsx | 43 +++++++++++++ 4 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 packages/manager/.changeset/pr-11074-fixed-1728476792585.md create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx diff --git a/packages/manager/.changeset/pr-11074-fixed-1728476792585.md b/packages/manager/.changeset/pr-11074-fixed-1728476792585.md new file mode 100644 index 00000000000..5fd48a4c63f --- /dev/null +++ b/packages/manager/.changeset/pr-11074-fixed-1728476792585.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +"Support Ticket" button in network tab not working properly ([#11074](https://github.com/linode/manager/pull/11074)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index c03017df1d7..57642f09997 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -19,10 +19,12 @@ import { } from 'src/queries/linodes/networking'; import { useCreateIPv6RangeMutation } from 'src/queries/networking/networking'; +import { ExplainerCopy } from './ExplainerCopy'; + import type { IPv6Prefix } from '@linode/api-v4/lib/networking'; import type { Item } from 'src/components/EnhancedSelect/Select'; -type IPType = 'v4Private' | 'v4Public'; +export type IPType = 'v4Private' | 'v4Public'; const ipOptions: Item[] = [ { label: 'Public', value: 'v4Public' }, @@ -34,27 +36,6 @@ const prefixOptions = [ { label: '/56', value: '56' }, ]; -// @todo: Pre-fill support tickets. -const explainerCopy: Record = { - v4Private: ( - <> - Add a private IP address to your Linode. Data sent explicitly to and from - private IP addresses in the same data center does not incur transfer quota - usage. To ensure that the private IP is properly configured once added, - it’s best to reboot your Linode. - - ), - v4Public: ( - <> - Public IP addresses, over and above the one included with each Linode, - incur an additional monthly charge. If you need an additional Public IP - Address you must request one. Please open a{' '} - Support Ticket if you have not done so - already. - - ), -}; - const IPv6ExplanatoryCopy = { 56: ( <> @@ -70,7 +51,7 @@ const IPv6ExplanatoryCopy = { ), }; -const tooltipCopy: Record = { +const tooltipCopy: Record = { v4Private: 'This Linode already has a private IP address.', v4Public: null, }; @@ -197,11 +178,11 @@ export const AddIPDrawer = (props: Props) => { {ipOptions.map((option, idx) => ( } - data-qa-radio={option.label} disabled={ option.value === 'v4Private' && linodeIsInDistributedRegion } + control={} + data-qa-radio={option.label} key={idx} label={option.label} value={option.value} @@ -209,7 +190,11 @@ export const AddIPDrawer = (props: Props) => { ))} - {selectedIPv4 && {explainerCopy[selectedIPv4]}} + {selectedIPv4 && ( + + + + )} {_tooltipCopy ? ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx new file mode 100644 index 00000000000..5281267a1f9 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import * as React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ExplainerCopy } from './ExplainerCopy'; + +const queryMocks = vi.hoisted(() => ({ + useLinodeQuery: vi.fn().mockReturnValue({ data: undefined }), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + return { + ...actual, + useLinodeQuery: queryMocks.useLinodeQuery, + }; +}); + +describe('ExplainerCopy Component', () => { + const linodeId = 1234; + + beforeEach(() => { + queryMocks.useLinodeQuery.mockReturnValue({ + data: { label: 'Test Linode' }, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the correct content for v4Private IPType', () => { + renderWithTheme(); + + expect( + screen.getByText(/Add a private IP address to your Linode/i) + ).toBeVisible(); + expect( + screen.getByText(/Data sent explicitly to and from private IP addresses/i) + ).toBeVisible(); + }); + + it('renders the correct content for v4Public IPType with SupportLink', () => { + renderWithTheme(); + + expect( + screen.getByText(/Public IP addresses, over and above the one included/i) + ).toBeVisible(); + expect(screen.getByRole('link', { name: 'Support Ticket' })).toBeVisible(); + }); + + it('displays no content when an unknown IPType is provided', () => { + renderWithTheme(); + + expect(screen.queryByText(/Add a private IP address/i)).toBeNull(); + expect(screen.queryByText(/Support Ticket/)).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx new file mode 100644 index 00000000000..7328db2ddbe --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { SupportLink } from 'src/components/SupportLink'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; + +import type { IPType } from './AddIPDrawer'; + +interface ExplainerCopyProps { + ipType: IPType; + linodeId: number; +} + +export const ExplainerCopy = ({ ipType, linodeId }: ExplainerCopyProps) => { + const { data: linode } = useLinodeQuery(linodeId); + + switch (ipType) { + case 'v4Private': + return ( + <> + Add a private IP address to your Linode. Data sent explicitly to and + from private IP addresses in the same data center does not incur + transfer quota usage. To ensure that the private IP is properly + configured once added, it’s best to reboot your Linode. + + ); + case 'v4Public': + return ( + <> + Public IP addresses, over and above the one included with each Linode, + incur an additional monthly charge. If you need an additional Public + IP Address you must request one. Please open a{' '} + {' '} + if you have not done so already. + + ); + default: + return null; + } +}; From b574f5ef8e4e2a79220d75fe5f04779ba54d4aef Mon Sep 17 00:00:00 2001 From: corya-akamai <136115382+corya-akamai@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:41:45 -0400 Subject: [PATCH 31/64] fix: [UIE-8181] - DBaaS enable restricted beta users (#11114) --- packages/manager/src/featureFlags.ts | 1 + .../DatabaseBackups/DatabaseBackups.tsx | 8 +- .../DatabaseResize/DatabaseResize.test.tsx | 4 +- .../DatabaseResize/DatabaseResize.tsx | 45 ++-- .../DatabaseLanding/DatabaseEmptyState.tsx | 9 +- .../DatabaseLanding/DatabaseLanding.tsx | 14 +- .../DatabaseLandingEmptyStateData.tsx | 4 - .../DatabaseLanding/DatabaseLandingTable.tsx | 4 +- .../DatabaseLanding/DatabaseLogo.tsx | 6 +- .../Databases/DatabaseLanding/DatabaseRow.tsx | 4 +- .../src/features/Databases/utilities.test.ts | 235 +++++++++++++++--- .../src/features/Databases/utilities.ts | 97 +++++--- .../src/queries/databases/databases.ts | 6 +- 13 files changed, 307 insertions(+), 130 deletions(-) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 41ba5d6516f..a157f0c3dc2 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -101,6 +101,7 @@ export interface Flags { databaseResize: boolean; databases: boolean; dbaasV2: BetaFeatureFlag; + dbaasV2MonitorMetrics: BetaFeatureFlag; disableLargestGbPlans: boolean; disallowImageUploadToNonObjRegions: boolean; gecko2: GeckoFeatureFlag; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 513d881ddf7..c150abd0bfa 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -78,7 +78,7 @@ export const DatabaseBackups = (props: Props) => { databaseId: string; engine: Engine; }>(); - const { isV2GAUser } = useIsDatabasesEnabled(); + const { isDatabasesV2GA } = useIsDatabasesEnabled(); const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false); const [selectedDate, setSelectedDate] = React.useState(null); @@ -86,7 +86,7 @@ export const DatabaseBackups = (props: Props) => { null ); const [versionOption, setVersionOption] = React.useState( - isV2GAUser ? 'newest' : 'dateTime' + isDatabasesV2GA ? 'newest' : 'dateTime' ); const { @@ -143,7 +143,7 @@ export const DatabaseBackups = (props: Props) => { Restore a Backup - {isV2GAUser ? ( + {isDatabasesV2GA ? ( The newest full backup plus incremental is selected by default. Or, select any date and time within the last 10 days you want to create @@ -159,7 +159,7 @@ export const DatabaseBackups = (props: Props) => { {unableToRestoreCopy && ( )} - {isV2GAUser && ( + {isDatabasesV2GA && ( { }); }); - describe('on rendering of page and isDatabasesGAEnabled is true and the Shared CPU tab is preselected ', () => { + describe('on rendering of page and isDatabasesV2GA is true and the Shared CPU tab is preselected ', () => { beforeEach(() => { // Mock database types const standardTypes = [ @@ -369,7 +369,7 @@ describe('database resize', () => { }); }); - describe('on rendering of page and isDatabasesGAEnabled is true and the Dedicated CPU tab is preselected', () => { + describe('on rendering of page and isDatabasesV2GA is true and the Dedicated CPU tab is preselected', () => { beforeEach(() => { // Mock database types const mockDedicatedTypes = [ diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index ffe15698e5a..fbdc01d70cf 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -97,10 +97,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { ] = React.useState(false); const [selectedTab, setSelectedTab] = React.useState(0); - const { - isDatabasesV2Enabled, - isDatabasesGAEnabled, - } = useIsDatabasesEnabled(); + const { isDatabasesV2Enabled, isDatabasesV2GA } = useIsDatabasesEnabled(); const [clusterSize, setClusterSize] = React.useState( database.cluster_size ); @@ -122,11 +119,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { const onResize = () => { const payload: UpdateDatabasePayload = {}; - if ( - clusterSize && - clusterSize > database.cluster_size && - isDatabasesGAEnabled - ) { + if (clusterSize && clusterSize > database.cluster_size && isDatabasesV2GA) { payload.cluster_size = clusterSize; } @@ -163,30 +156,26 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { {summaryText ? ( <> - {isDatabasesGAEnabled + {isDatabasesV2GA ? 'Resized Cluster: ' + summaryText.plan : summaryText.plan} {' '} - {isDatabasesGAEnabled ? ( + {isDatabasesV2GA ? ( {summaryText.basePrice} ) : null} - + {' '} {summaryText.numberOfNodes} Node {summaryText.numberOfNodes > 1 ? 's' : ''} - {!isDatabasesGAEnabled ? ': ' : ' - HA '} + {!isDatabasesV2GA ? ': ' : ' - HA '} {summaryText.price} - ) : isDatabasesGAEnabled ? ( + ) : isDatabasesV2GA ? ( <> Resized Cluster:{' '} Please select a plan or set the number of nodes. @@ -263,7 +252,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { setSummaryText({ numberOfNodes: clusterSize, plan: formatStorageUnits(selectedPlanType.label), - price: isDatabasesGAEnabled + price: isDatabasesV2GA ? `$${price?.monthly}/month` : `$${price?.monthly}/month or $${price?.hourly}/hour`, basePrice: currentPlanPrice, @@ -287,7 +276,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { } const engineType = database.engine.split('/')[0] as Engine; // When only a higher node selection is made and plan has not been changed - if (isDatabasesGAEnabled && nodeSelected && isSamePlanSelected) { + if (isDatabasesV2GA && nodeSelected && isSamePlanSelected) { setSummaryAndPrices(database.type, engineType, dbTypes); } // No plan selection or plan selection is unchanged @@ -357,7 +346,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { Current Cluster: {currentPlan?.heading} {' '} - + {currentPlanPrice} @@ -379,7 +368,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { ); setSelectedTab(initialTab); - if (isDatabasesGAEnabled) { + if (isDatabasesV2GA) { const engineType = database.engine.split('/')[0] as Engine; const nodePricingDetails = { double: currentPlan?.engines[engineType]?.find( @@ -418,7 +407,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { return; } // Clear plan and related info when when 2 nodes option is selected for incompatible plan. - if (isDatabasesGAEnabled && selectedTab === 0 && clusterSize === 2) { + if (isDatabasesV2GA && selectedTab === 0 && clusterSize === 2) { setClusterSize(undefined); setPlanSelected(undefined); setNodePricing(undefined); @@ -536,7 +525,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { tabDisabledMessage="Resizing a 2-nodes cluster is only allowed with Dedicated plans." types={displayTypes} /> - {isDatabasesGAEnabled && ( + {isDatabasesV2GA && ( <> @@ -573,13 +562,13 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { ({ - marginBottom: isDatabasesGAEnabled ? theme.spacing(2) : 0, + marginBottom: isDatabasesV2GA ? theme.spacing(2) : 0, })} variant="h2" > - Summary {isDatabasesGAEnabled ? database.label : ''} + Summary {isDatabasesV2GA ? database.label : ''} - {isDatabasesGAEnabled && currentPlan ? currentSummary : null} + {isDatabasesV2GA && currentPlan ? currentSummary : null} {resizeSummary} diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx index f7bf2997dad..2825dcd8996 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx @@ -10,20 +10,23 @@ import { linkAnalyticsEvent, youtubeLinkData, } from 'src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData'; +import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; export const DatabaseEmptyState = () => { const { push } = useHistory(); - const { isDatabasesV2Enabled, isV2GAUser } = useIsDatabasesEnabled(); + const { isDatabasesV2Enabled, isDatabasesV2GA } = useIsDatabasesEnabled(); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_databases', }); - if (!isDatabasesV2Enabled || !isV2GAUser) { - headers.logo = ''; + if (isDatabasesV2Enabled || isDatabasesV2GA) { + headers.logo = ( + + ); } return ( diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 2f57fc258e5..80c6bb91b6d 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -37,9 +37,9 @@ const DatabaseLanding = () => { const { isDatabasesV2Enabled, - isV2ExistingBetaUser, - isV2GAUser, - isV2NewBetaUser, + isUserExistingBeta, + isDatabasesV2GA, + isUserNewBeta, } = useIsDatabasesEnabled(); const { isLoading: isTypeLoading } = useDatabaseTypesQuery({ @@ -47,7 +47,7 @@ const DatabaseLanding = () => { }); const isDefaultEnabled = - isV2ExistingBetaUser || isV2NewBetaUser || isV2GAUser; + isUserExistingBeta || isUserNewBeta || isDatabasesV2GA; const { handleOrderChange: newDatabaseHandleOrderChange, @@ -97,7 +97,7 @@ const DatabaseLanding = () => { ['+order_by']: legacyDatabaseOrderBy, }; - if (isV2ExistingBetaUser || isV2GAUser) { + if (isUserExistingBeta || isDatabasesV2GA) { legacyDatabasesFilter['platform'] = 'rdbms-legacy'; } @@ -111,7 +111,7 @@ const DatabaseLanding = () => { page_size: legacyDatabasesPagination.pageSize, }, legacyDatabasesFilter, - !isV2NewBetaUser + !isUserNewBeta ); const error = newDatabasesError || legacyDatabasesError; @@ -134,7 +134,7 @@ const DatabaseLanding = () => { return ; } - const isV2Enabled = isDatabasesV2Enabled || isV2GAUser; + const isV2Enabled = isDatabasesV2Enabled || isDatabasesV2GA; const showTabs = isV2Enabled && !!legacyDatabases?.data.length; const isNewDatabase = isV2Enabled && !!newDatabases?.data.length; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx index 4dac8b5e649..a27baa54d92 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx @@ -1,6 +1,3 @@ -import React from 'react'; - -import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import { docsLink, guidesMoreLinkText, @@ -17,7 +14,6 @@ import type { export const headers: ResourcesHeaders = { description: "Deploy popular database engines such as MySQL and PostgreSQL using Linode's performant, reliable, and fully managed database solution.", - logo: , subtitle: 'Fully managed cloud database clusters', title: 'Databases', }; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index f198043094f..3dbc125ad59 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -38,7 +38,7 @@ const DatabaseLandingTable = ({ orderBy, }: Props) => { const { data: events } = useInProgressEvents(); - const { isV2GAUser } = useIsDatabasesEnabled(); + const { isDatabasesV2GA } = useIsDatabasesEnabled(); const dbPlatformType = isNewDatabase ? 'new' : 'legacy'; const pagination = usePagination(1, preferenceKey, dbPlatformType); @@ -146,7 +146,7 @@ const DatabaseLandingTable = ({ Created - {isV2GAUser && isNewDatabase && } + {isDatabasesV2GA && isNewDatabase && } diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx index eb1186d4918..eecbb5df3e8 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx @@ -17,7 +17,7 @@ interface Props { export const DatabaseLogo = ({ sx }: Props) => { const theme = useTheme(); - const { isV2GAUser } = useIsDatabasesEnabled(); + const { isDatabasesV2GA } = useIsDatabasesEnabled(); return ( { sx={sx ? sx : { margin: '20px' }} > - {!isV2GAUser && ( + {!isDatabasesV2GA && ( { sx={{ color: theme.palette.mode === 'light' ? theme.color.headline : '', display: 'flex', - marginTop: !isV2GAUser ? theme.spacing(1) : '', + marginTop: !isDatabasesV2GA ? theme.spacing(1) : '', }} component="span" > diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index d9e66e5f7c7..031bf088fb4 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -66,7 +66,7 @@ export const DatabaseRow = ({ const plan = types?.find((t: DatabaseType) => t.id === type); const formattedPlan = plan && formatStorageUnits(plan.label); const actualRegion = regions?.find((r) => r.id === region); - const { isV2GAUser } = useIsDatabasesEnabled(); + const { isDatabasesV2GA } = useIsDatabasesEnabled(); const configuration = cluster_size === 1 ? ( @@ -107,7 +107,7 @@ export const DatabaseRow = ({ })} - {isV2GAUser && isNewDatabase && ( + {isDatabasesV2GA && isNewDatabase && ( { }); }; +const queryMocks = vi.hoisted(() => ({ + useDatabaseTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/databases/databases', () => ({ + useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, +})); + describe('useIsDatabasesEnabled', () => { - it('should return false for an unrestricted user without the account capability', async () => { + it('should return correctly for non V1/V2 user', async () => { const { result } = setup([], { dbaasV2: { beta: true, enabled: true } }); await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(false); @@ -37,14 +44,14 @@ describe('useIsDatabasesEnabled', () => { expect(result.current.isDatabasesV2Enabled).toBe(false); expect(result.current.isDatabasesV2Beta).toBe(false); - expect(result.current.isV2ExistingBetaUser).toBe(false); - expect(result.current.isV2NewBetaUser).toBe(false); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(false); - expect(result.current.isV2GAUser).toBe(false); + expect(result.current.isDatabasesV2GA).toBe(false); }); }); - it('should return true for an unrestricted user with the account capability V1', async () => { + it('should return correctly for V1 user', async () => { const { result } = setup(['Managed Databases'], { dbaasV2: { beta: false, enabled: false }, }); @@ -55,14 +62,14 @@ describe('useIsDatabasesEnabled', () => { expect(result.current.isDatabasesV2Enabled).toBe(false); expect(result.current.isDatabasesV2Beta).toBe(false); - expect(result.current.isV2ExistingBetaUser).toBe(false); - expect(result.current.isV2NewBetaUser).toBe(false); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(false); - expect(result.current.isV2GAUser).toBe(false); + expect(result.current.isDatabasesV2GA).toBe(false); }); }); - it('should return true for a new unrestricted user with the account capability V2 and beta feature flag', async () => { + it('should return correctly for V2 new user beta', async () => { const { result } = setup(['Managed Databases Beta'], { dbaasV2: { beta: true, enabled: true }, }); @@ -73,16 +80,16 @@ describe('useIsDatabasesEnabled', () => { expect(result.current.isDatabasesV2Enabled).toBe(true); expect(result.current.isDatabasesV2Beta).toBe(true); - expect(result.current.isV2ExistingBetaUser).toBe(false); - expect(result.current.isV2NewBetaUser).toBe(true); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(true); - expect(result.current.isV2GAUser).toBe(false); + expect(result.current.isDatabasesV2GA).toBe(false); }); }); - it('should return false for a new unrestricted user with the account capability V2 and no beta feature flag', async () => { + it('should return correctly for V2 new user no beta', async () => { const { result } = setup(['Managed Databases Beta'], { - dbaasV2: { beta: true, enabled: false }, + dbaasV2: { beta: false, enabled: false }, }); await waitFor(() => { @@ -91,14 +98,14 @@ describe('useIsDatabasesEnabled', () => { expect(result.current.isDatabasesV2Enabled).toBe(false); expect(result.current.isDatabasesV2Beta).toBe(false); - expect(result.current.isV2ExistingBetaUser).toBe(false); - expect(result.current.isV2NewBetaUser).toBe(false); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(false); - expect(result.current.isV2GAUser).toBe(false); + expect(result.current.isDatabasesV2GA).toBe(false); }); }); - it('should return true for an existing unrestricted user with the account capability V1 & V2 and beta feature flag', async () => { + it('should return correctly for V1 & V2 existing user beta', async () => { const { result } = setup(['Managed Databases', 'Managed Databases Beta'], { dbaasV2: { beta: true, enabled: true }, }); @@ -109,14 +116,14 @@ describe('useIsDatabasesEnabled', () => { expect(result.current.isDatabasesV2Enabled).toBe(true); expect(result.current.isDatabasesV2Beta).toBe(true); - expect(result.current.isV2ExistingBetaUser).toBe(true); - expect(result.current.isV2NewBetaUser).toBe(false); + expect(result.current.isUserExistingBeta).toBe(true); + expect(result.current.isUserNewBeta).toBe(false); - expect(result.current.isV2GAUser).toBe(false); + expect(result.current.isDatabasesV2GA).toBe(false); }); }); - it('should return true for an existing unrestricted user with the account capability V1 and no beta feature flag', async () => { + it('should return correctly for V1 existing user GA', async () => { const { result } = setup(['Managed Databases'], { dbaasV2: { beta: false, enabled: true }, }); @@ -127,45 +134,195 @@ describe('useIsDatabasesEnabled', () => { expect(result.current.isDatabasesV2Enabled).toBe(false); expect(result.current.isDatabasesV2Beta).toBe(false); - expect(result.current.isV2ExistingBetaUser).toBe(false); - expect(result.current.isV2NewBetaUser).toBe(false); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(false); - expect(result.current.isV2GAUser).toBe(true); + expect(result.current.isDatabasesV2GA).toBe(true); }); }); - it('should return true for a restricted user who can not load account but can load database engines', async () => { + it('should return correctly for V1 restricted user non-beta', async () => { server.use( http.get('*/v4/account', () => { return HttpResponse.json({}, { status: 403 }); - }), - http.get('*/v4beta/databases/engines', () => { - return HttpResponse.json(makeResourcePage([])); }) ); + // default + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: null, + }); + + // legacy + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: databaseTypeFactory.buildList(1), + }); + + const flags = { dbaasV2: { beta: true, enabled: true } }; + const { result } = renderHook(() => useIsDatabasesEnabled(), { - wrapper: wrapWithTheme, + wrapper: (ui) => wrapWithTheme(ui, { flags }), }); - await waitFor(() => expect(result.current.isDatabasesEnabled).toBe(true)); + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 1, + ...[{ platform: 'rdbms-default' }, true] + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 2, + ...[{ platform: 'rdbms-legacy' }, true] + ); + + await waitFor(() => { + expect(result.current.isDatabasesEnabled).toBe(true); + expect(result.current.isDatabasesV1Enabled).toBe(true); + expect(result.current.isDatabasesV2Enabled).toBe(false); + + expect(result.current.isDatabasesV2Beta).toBe(false); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(false); + + expect(result.current.isDatabasesV2GA).toBe(false); + }); }); - it('should return false for a restricted user who can not load account and database engines', async () => { + it('should return correctly for V1 & V2 restricted user existing beta', async () => { server.use( http.get('*/v4/account', () => { return HttpResponse.json({}, { status: 403 }); - }), - http.get('*/v4beta/databases/engines', () => { - return HttpResponse.json({}, { status: 404 }); }) ); + // default + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: databaseTypeFactory.buildList(1), + }); + + // legacy + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: databaseTypeFactory.buildList(1), + }); + + const flags = { dbaasV2: { beta: true, enabled: true } }; + const { result } = renderHook(() => useIsDatabasesEnabled(), { - wrapper: wrapWithTheme, + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 1, + ...[{ platform: 'rdbms-default' }, true] + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 2, + ...[{ platform: 'rdbms-legacy' }, true] + ); + + await waitFor(() => { + expect(result.current.isDatabasesEnabled).toBe(true); + expect(result.current.isDatabasesV1Enabled).toBe(true); + expect(result.current.isDatabasesV2Enabled).toBe(true); + + expect(result.current.isDatabasesV2Beta).toBe(true); + expect(result.current.isUserExistingBeta).toBe(true); + expect(result.current.isUserNewBeta).toBe(false); + + expect(result.current.isDatabasesV2GA).toBe(false); + }); + }); + + it('should return correctly for V2 restricted user new beta', async () => { + server.use( + http.get('*/v4/account', () => { + return HttpResponse.json({}, { status: 403 }); + }) + ); + + // default + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: databaseTypeFactory.buildList(1), }); - await waitFor(() => expect(result.current.isDatabasesEnabled).toBe(false)); + // legacy + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: null, + }); + + const flags = { dbaasV2: { beta: true, enabled: true } }; + + const { result } = renderHook(() => useIsDatabasesEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 1, + ...[{ platform: 'rdbms-default' }, true] + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 2, + ...[{ platform: 'rdbms-legacy' }, true] + ); + + await waitFor(() => { + expect(result.current.isDatabasesEnabled).toBe(true); + expect(result.current.isDatabasesV1Enabled).toBe(false); + expect(result.current.isDatabasesV2Enabled).toBe(true); + + expect(result.current.isDatabasesV2Beta).toBe(true); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(true); + + expect(result.current.isDatabasesV2GA).toBe(false); + }); + }); + + it('should return correctly for V2 restricted user GA', async () => { + server.use( + http.get('*/v4/account', () => { + return HttpResponse.json({}, { status: 403 }); + }) + ); + + // default + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: databaseTypeFactory.buildList(1), + }); + + // legacy + queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ + data: null, + }); + + const flags = { dbaasV2: { beta: false, enabled: true } }; + + const { result } = renderHook(() => useIsDatabasesEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 1, + ...[{ platform: 'rdbms-default' }, true] + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( + 2, + ...[{ platform: 'rdbms-legacy' }, true] + ); + + await waitFor(() => { + expect(result.current.isDatabasesEnabled).toBe(true); + expect(result.current.isDatabasesV1Enabled).toBe(false); + expect(result.current.isDatabasesV2Enabled).toBe(true); + + expect(result.current.isDatabasesV2Beta).toBe(false); + expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(false); + + expect(result.current.isDatabasesV2GA).toBe(true); + }); }); }); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 2842a0d96a5..ce4fe9bc149 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -1,14 +1,29 @@ +import type { DatabaseInstance } from '@linode/api-v4'; import { DatabaseFork } from '@linode/api-v4'; import { DateTime } from 'luxon'; - import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account/account'; -import { useDatabaseEnginesQuery } from 'src/queries/databases/databases'; +import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; - import { databaseEngineMap } from './DatabaseLanding/DatabaseRow'; -import type { DatabaseInstance } from '@linode/api-v4'; +export interface IsDatabasesEnabled { + isDatabasesEnabled: boolean; + isDatabasesV1Enabled: boolean; + isDatabasesV2Enabled: boolean; + isDatabasesV2Beta: boolean; + isDatabasesV2GA: boolean; + /** + * Temporary variable to be removed post GA release + */ + isUserExistingBeta: boolean; + /** + * Temporary variable to be removed post GA release + */ + isUserNewBeta: boolean; + isDatabasesMonitorEnabled: boolean; + isDatabasesMonitorBeta: boolean; +} /** * A hook to determine if Databases should be visible to the user. @@ -18,20 +33,30 @@ import type { DatabaseInstance } from '@linode/api-v4'; * * For users who don't have permission to load /v4/account * (who are restricted users without account read access), - * we must check if they can load Database Engines as a workaround. - * If these users can successfully fetch database engines, we will + * we must check if they can load Database Types as a workaround. + * If these users can successfully fetch database types, we will * show databases. */ -export const useIsDatabasesEnabled = () => { - const { data: account } = useAccount(); +export const useIsDatabasesEnabled = (): IsDatabasesEnabled => { + const flags = useFlags(); + const hasV2Flag: boolean = !!flags.dbaasV2?.enabled; + const hasV2BetaFlag: boolean = hasV2Flag && flags.dbaasV2?.beta === true; + const hasV2GAFlag: boolean = hasV2Flag && flags.dbaasV2?.beta === false; + const { data: account } = useAccount(); // If we don't have permission to GET /v4/account, // we need to try fetching Database engines to know if the user has databases enabled. const checkRestrictedUser = !account; - const { data: engines } = useDatabaseEnginesQuery(checkRestrictedUser); - const flags = useFlags(); - const isBeta = !!flags.dbaasV2?.beta; + const { data: types } = useDatabaseTypesQuery( + { platform: 'rdbms-default' }, + checkRestrictedUser + ); + + const { data: legacyTypes } = useDatabaseTypesQuery( + { platform: 'rdbms-legacy' }, + checkRestrictedUser + ); if (account) { const isDatabasesV1Enabled = isFeatureEnabledV2( @@ -42,43 +67,45 @@ export const useIsDatabasesEnabled = () => { const isDatabasesV2Enabled = isFeatureEnabledV2( 'Managed Databases Beta', - !!flags.dbaasV2?.enabled, + hasV2Flag, account?.capabilities ?? [] ); - const isV2ExistingBetaUser = - isBeta && isDatabasesV1Enabled && isDatabasesV2Enabled; - - const isV2NewBetaUser = - isBeta && !isDatabasesV1Enabled && isDatabasesV2Enabled; - - const isV2GAUser = - !isBeta && - isFeatureEnabledV2( - 'Managed Databases', - !!flags.dbaasV2?.enabled, - account?.capabilities ?? [] - ); - - const isDatabasesGA = - flags.dbaasV2?.enabled && flags.dbaasV2.beta === false; + const isDatabasesV2Beta: boolean = isDatabasesV2Enabled && hasV2BetaFlag; return { isDatabasesEnabled: isDatabasesV1Enabled || isDatabasesV2Enabled, - isDatabasesGAEnabled: isDatabasesV1Enabled && isDatabasesGA, isDatabasesV1Enabled, - isDatabasesV2Beta: isDatabasesV2Enabled && flags.dbaasV2?.beta, isDatabasesV2Enabled, - isV2ExistingBetaUser, - isV2GAUser, - isV2NewBetaUser, + + isDatabasesV2Beta, + isUserExistingBeta: isDatabasesV2Beta && isDatabasesV1Enabled, + isUserNewBeta: isDatabasesV2Beta && !isDatabasesV1Enabled, + + isDatabasesV2GA: + (isDatabasesV1Enabled || isDatabasesV2Enabled) && hasV2GAFlag, + + isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled, + isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta, }; } - const userCouldLoadDatabaseEngines = engines !== undefined; + const hasLegacyTypes: boolean = !!legacyTypes; + const hasDefaultTypes: boolean = !!types && hasV2Flag; return { - isDatabasesEnabled: userCouldLoadDatabaseEngines, + isDatabasesEnabled: hasLegacyTypes || hasDefaultTypes, + isDatabasesV1Enabled: hasLegacyTypes, + isDatabasesV2Enabled: hasDefaultTypes, + + isDatabasesV2Beta: hasDefaultTypes && hasV2BetaFlag, + isUserExistingBeta: hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag, + isUserNewBeta: !hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag, + + isDatabasesV2GA: (hasLegacyTypes || hasDefaultTypes) && hasV2GAFlag, + + isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled, + isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta, }; }; diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts index 4ca5d85c42a..1913ba799f3 100644 --- a/packages/manager/src/queries/databases/databases.ts +++ b/packages/manager/src/queries/databases/databases.ts @@ -187,9 +187,13 @@ export const useDatabaseEnginesQuery = (enabled: boolean = false) => enabled, }); -export const useDatabaseTypesQuery = (filter: Filter = {}) => +export const useDatabaseTypesQuery = ( + filter: Filter = {}, + enabled: boolean = true +) => useQuery({ ...databaseQueries.types._ctx.all(filter), + enabled, }); export const useDatabaseCredentialsQuery = ( From 5e54d46a760d70f5c5739f17fe320a19548bc52e Mon Sep 17 00:00:00 2001 From: smans-akamai Date: Thu, 17 Oct 2024 12:25:49 -0400 Subject: [PATCH 32/64] feat: [UIE-7995] DBaaS Monitor GA (#11105) --- .../pr-11105-added-1729023730319.md | 5 ++ .../src/components/Tabs/TabLinkList.tsx | 2 + .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + .../DatabaseMonitor/DatabaseMonitor.test.tsx | 29 ++++++++++++ .../DatabaseMonitor/DatabaseMonitor.tsx | 18 ++++++++ .../Databases/DatabaseDetail/index.tsx | 46 +++++++++++++++---- 6 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-11105-added-1729023730319.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx diff --git a/packages/manager/.changeset/pr-11105-added-1729023730319.md b/packages/manager/.changeset/pr-11105-added-1729023730319.md new file mode 100644 index 00000000000..38c14c9a4a8 --- /dev/null +++ b/packages/manager/.changeset/pr-11105-added-1729023730319.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +DBaaS GA Monitor tab ([#11105](https://github.com/linode/manager/pull/11105)) diff --git a/packages/manager/src/components/Tabs/TabLinkList.tsx b/packages/manager/src/components/Tabs/TabLinkList.tsx index 5d621068ae1..486f8a088b6 100644 --- a/packages/manager/src/components/Tabs/TabLinkList.tsx +++ b/packages/manager/src/components/Tabs/TabLinkList.tsx @@ -7,6 +7,7 @@ import { TabList } from 'src/components/Tabs/TabList'; export interface Tab { routeName: string; title: string; + chip?: React.JSX.Element | null; } interface TabLinkListProps { @@ -30,6 +31,7 @@ export const TabLinkList = ({ noLink, tabs }: TabLinkListProps) => { {...extraTemporaryProps} > {tab.title} + {tab.chip} ); })} diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 3c58f58ae6d..581371a7740 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -31,6 +31,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, + { flag: 'dbaasV2MonitorMetrics', label: 'Databases V2 Monitor' }, { flag: 'databaseResize', label: 'Database Resize' }, { flag: 'apicliDxToolsAdditions', label: 'APICLI DX Tools Additions' }, { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx new file mode 100644 index 00000000000..ec6c413ebb5 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx @@ -0,0 +1,29 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; +import { databaseFactory } from 'src/factories'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; +import { DatabaseMonitor } from './DatabaseMonitor'; + +const loadingTestId = 'circle-progress'; + +beforeAll(() => mockMatchMedia()); + +describe('database monitor', () => { + const database = databaseFactory.build({ id: 12 }); + it('should render a loading state', async () => { + const { getByTestId } = renderWithTheme( + + ); + // Should render a loading state + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + }); + + it('should render CloudPulseDashboardWithFilters', async () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByTestId('cloudpulse-time-duration')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx new file mode 100644 index 00000000000..5a255785977 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters'; +import { Database } from '@linode/api-v4'; + +interface Props { + database: Database; +} + +export const DatabaseMonitor = ({ database }: Props) => { + const databaseId = database?.id; + const dbaasDashboardId = 1; + return ( + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index 177cfd28b05..c1594063187 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -8,7 +8,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; -import { TabLinkList } from 'src/components/Tabs/TabLinkList'; +import { Tab, TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; @@ -24,6 +24,8 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { Engine } from '@linode/api-v4/lib/databases/types'; import type { APIError } from '@linode/api-v4/lib/types'; +import { BetaChip } from 'src/components/BetaChip/BetaChip'; +import { useIsDatabasesEnabled } from '../utilities'; const DatabaseSummary = React.lazy(() => import('./DatabaseSummary')); const DatabaseBackups = React.lazy( @@ -35,7 +37,11 @@ const DatabaseResize = React.lazy(() => default: DatabaseResize, })) ); - +const DatabaseMonitor = React.lazy(() => + import('./DatabaseMonitor/DatabaseMonitor').then(({ DatabaseMonitor }) => ({ + default: DatabaseMonitor, + })) +); export const DatabaseDetail = () => { const history = useHistory(); const flags = useFlags(); @@ -66,6 +72,11 @@ export const DatabaseDetail = () => { setEditableLabelError, } = useEditableLabelState(); + const { + isDatabasesMonitorEnabled, + isDatabasesMonitorBeta, + } = useIsDatabasesEnabled(); + if (error) { return ( { return null; } - const tabs = [ + const isDefault = database.platform === 'rdbms-default'; + const isMonitorEnabled = isDefault && isDatabasesMonitorEnabled; + + const tabs: Tab[] = [ { routeName: `/databases/${engine}/${id}/summary`, title: 'Summary', @@ -99,8 +113,19 @@ export const DatabaseDetail = () => { }, ]; + const resizeIndex = isMonitorEnabled ? 3 : 2; + const backupsIndex = isMonitorEnabled ? 2 : 1; + + if (isMonitorEnabled) { + tabs.splice(1, 0, { + routeName: `/databases/${engine}/${id}/monitor`, + title: 'Monitor', + chip: isDatabasesMonitorBeta ? : null, + }); + } + if (flags.databaseResize) { - tabs.splice(2, 0, { + tabs.splice(resizeIndex, 0, { routeName: `/databases/${engine}/${id}/resize`, title: 'Resize', }); @@ -187,18 +212,23 @@ export const DatabaseDetail = () => { disabled={isDatabasesGrantReadOnly} /> - + {isMonitorEnabled ? ( + + + + ) : null} + {flags.databaseResize ? ( - + ) : null} - + { - {database.platform === 'rdbms-default' && } + {isDefault && } ); }; From cf8823adbd37cd6f592fda86d2f3ad16859e1ec1 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:40:05 -0500 Subject: [PATCH 33/64] feat: [M3-7841] - Add the capability to search for a Linode by ID using the main search tool. (#11112) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * chore: [M3-8662] - Update Github Actions actions (#11009) * update actions * add changeset --------- Co-authored-by: Banks Nussman * Add capability to search linode by id from main search tool * Added changeset: Add the capability to search for a Linode by ID using the main search tool. * Update packages/manager/.changeset/pr-11112-added-1729086990373.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-11112-added-1729086990373.md | 5 +++ .../e2e/core/linodes/search-linodes.spec.ts | 44 +++++++++++++++++++ .../src/features/Search/refinedSearch.ts | 12 ++++- .../src/features/Search/search.interfaces.ts | 2 +- .../src/store/selectors/getSearchEntities.ts | 2 +- 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-11112-added-1729086990373.md create mode 100644 packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts diff --git a/packages/manager/.changeset/pr-11112-added-1729086990373.md b/packages/manager/.changeset/pr-11112-added-1729086990373.md new file mode 100644 index 00000000000..12a00fcc645 --- /dev/null +++ b/packages/manager/.changeset/pr-11112-added-1729086990373.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Add the capability to search for a Linode by ID using the main search tool ([#11112](https://github.com/linode/manager/pull/11112)) diff --git a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts new file mode 100644 index 00000000000..dcc0b7c133a --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts @@ -0,0 +1,44 @@ +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; +import { authenticate } from 'support/api/authentication'; +import { createTestLinode } from 'support/util/linodes'; +import type { Linode } from '@linode/api-v4'; + +authenticate(); +describe('Search Linodes', () => { + beforeEach(() => { + cleanUp(['linodes']); + cy.tag('method:e2e'); + }); + + /* + * - Confirm that linodes are searchable and filtered in the UI. + */ + it('create a linode and make sure it shows up in the table and is searchable in main search tool', () => { + cy.defer(() => createTestLinode({ booted: true })).then( + (linode: Linode) => { + cy.visitWithLogin('/linodes'); + cy.get(`[data-qa-linode="${linode.label}"]`) + .should('be.visible') + .within(() => { + cy.contains('Running').should('be.visible'); + }); + + // Confirm that linode is listed on the landing page. + cy.findByText(linode.label).should('be.visible'); + + // Use the main search bar to search and filter linode by label + cy.get('[id="main-search"').type(linode.label); + ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + + // Use the main search bar to search and filter linode by id value + cy.get('[id="main-search"').clear().type(`${linode.id}`); + ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + + // Use the main search bar to search and filter linode by id: pattern + cy.get('[id="main-search"').clear().type(`id:${linode.id}`); + ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + } + ); + }); +}); diff --git a/packages/manager/src/features/Search/refinedSearch.ts b/packages/manager/src/features/Search/refinedSearch.ts index 2624d2fce4c..d844566d379 100644 --- a/packages/manager/src/features/Search/refinedSearch.ts +++ b/packages/manager/src/features/Search/refinedSearch.ts @@ -5,7 +5,7 @@ import searchString from 'search-string'; import type { SearchField, SearchableItem } from './search.interfaces'; export const COMPRESSED_IPV6_REGEX = /^([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,7})?::([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,7})?$/; -const DEFAULT_SEARCH_FIELDS = ['label', 'tags', 'ips']; +const DEFAULT_SEARCH_FIELDS = ['label', 'tags', 'ips', 'value']; // ============================================================================= // REFINED SEARCH @@ -166,6 +166,11 @@ export const doesSearchTermMatchItemField = ( const fieldValue = ensureValueIsString(flattenedItem[field]); + // Handle numeric comparison (e.g., for the "value" field to search linode by id) + if (typeof fieldValue === 'number') { + return fieldValue === Number(query); // Ensure exact match for numeric fields + } + if (caseSensitive) { return fieldValue.includes(query); } else { @@ -177,6 +182,7 @@ export const doesSearchTermMatchItemField = ( export const flattenSearchableItem = (item: SearchableItem) => ({ label: item.label, type: item.entityType, + value: item.value, ...item.data, }); @@ -203,7 +209,7 @@ export const getQueryInfo = (parsedQuery: any) => { }; }; -// Our entities have several fields we'd like to search: "tags", "label", "ips". +// Our entities have several fields we'd like to search: "tags", "label", "ips", "value". // A user might submit the query "tag:my-app". In this case, we want to trade // "tag" for "tags", since "tags" is the actual name of the intended property. export const getRealEntityKey = (key: string): SearchField | string => { @@ -211,9 +217,11 @@ export const getRealEntityKey = (key: string): SearchField | string => { const LABEL: SearchField = 'label'; const IPS: SearchField = 'ips'; const TYPE: SearchField = 'type'; + const VALUE: SearchField = 'value'; const substitutions = { group: TAGS, + id: VALUE, ip: IPS, is: TYPE, name: LABEL, diff --git a/packages/manager/src/features/Search/search.interfaces.ts b/packages/manager/src/features/Search/search.interfaces.ts index e8c45d5c334..a5d035f5f55 100644 --- a/packages/manager/src/features/Search/search.interfaces.ts +++ b/packages/manager/src/features/Search/search.interfaces.ts @@ -22,7 +22,7 @@ export type SearchableEntityType = | 'volume'; // These are the properties on our entities we'd like to search -export type SearchField = 'ips' | 'label' | 'tags' | 'type'; +export type SearchField = 'ips' | 'label' | 'tags' | 'type' | 'value'; export interface SearchResultsByEntity { buckets: SearchableItem[]; diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index f7cbef079f3..d7e9bf8d653 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -158,7 +158,7 @@ export const bucketToSearchableItem = ( cluster: bucket.cluster, created: bucket.created, description: readableBytes(bucket.size).formatted, - icon: 'bucket', + icon: 'storage', label: bucket.label, path: `/object-storage/buckets/${bucket.cluster}/${bucket.label}`, }, From 1d622f4c3756a96a5995cd3a757da35f4d7db6a6 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:07:27 -0700 Subject: [PATCH 34/64] fix: [M3-8739] - Fix MSW 2.0 initial mock store and support ticket seeder bugs (#11090) * Fix the bug in initial mock store creation * Fix bug where support ticket seeds aren't removed on uncheck * Added changeset: Fix MSW 2.0 initial mock store and support ticket seeder bugs * Fix conditional logic * Update removeSeeds comment to clarify use --- .../.changeset/pr-11090-tech-stories-1728605016946.md | 5 +++++ packages/manager/src/mocks/mockState.ts | 9 ++++++++- packages/manager/src/mocks/presets/crud/seeds/utils.ts | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md diff --git a/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md b/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md new file mode 100644 index 00000000000..313b634e115 --- /dev/null +++ b/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Fix MSW 2.0 initial mock store and support ticket seeder bugs ([#11090](https://github.com/linode/manager/pull/11090)) diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index 3cbf770964f..d4aa0d3a905 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -44,7 +44,14 @@ export const createInitialMockStore = async (): Promise => { const mockState = await mswDB.getStore('mockState'); if (mockState) { - return mockState; + const mockStateKeys = Object.keys(mockState); + const emptyStoreKeys = Object.keys(emptyStore); + + // Return the existing mockState if it includes all keys from the empty store; + // else, discard the existing mockState because we've introduced new values. + if (emptyStoreKeys.every((key) => mockStateKeys.includes(key))) { + return mockState; + } } return emptyStore; diff --git a/packages/manager/src/mocks/presets/crud/seeds/utils.ts b/packages/manager/src/mocks/presets/crud/seeds/utils.ts index 348c6dd6c23..5b77c2d2781 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/utils.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/utils.ts @@ -5,6 +5,7 @@ import type { MockSeeder, MockState } from 'src/mocks/types'; /** * Removes the seeds from the database. + * This function is called upon unchecking an individual seeder in the MSW. * * @param seederId - The ID of the seeder to remove. * @@ -22,6 +23,9 @@ export const removeSeeds = async (seederId: MockSeeder['id']) => { case 'volumes:crud': await mswDB.deleteAll('volumes', mockState, 'seedState'); break; + case 'support-tickets:crud': + await mswDB.deleteAll('supportTickets', mockState, 'seedState'); + break; default: break; } From 875a0b0adc4422525cd27530ff6347f45071ddea Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:33:21 -0400 Subject: [PATCH 35/64] chore: Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from `.env.example` (#11117) * remove `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from env example * add changeset --------- Co-authored-by: Banks Nussman --- .../.changeset/pr-11117-tech-stories-1729171591044.md | 5 +++++ packages/manager/.env.example | 4 ---- 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md diff --git a/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md b/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md new file mode 100644 index 00000000000..0d65405ab62 --- /dev/null +++ b/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from `.env.example` ([#11117](https://github.com/linode/manager/pull/11117)) diff --git a/packages/manager/.env.example b/packages/manager/.env.example index 1eede53ac4f..e5fbc51ed82 100644 --- a/packages/manager/.env.example +++ b/packages/manager/.env.example @@ -9,8 +9,6 @@ REACT_APP_API_ROOT='https://api.linode.com/v4' # REACT_APP_CLIENT_ID='UPDATE_WITH_YOUR_ID' REACT_APP_APP_ROOT='http://localhost:3000' -REACT_APP_LKE_HIGH_AVAILABILITY_PRICE='60' - ################################## # Optional: ################################## @@ -64,5 +62,3 @@ REACT_APP_LKE_HIGH_AVAILABILITY_PRICE='60' # E2E TESTS REMOVE ALL OF YOUR DATA AND RESOURCES # INCLUDING LINODES,VOLUMES,DOMAINS,NODEBALANCERS #MANAGER_OAUTH='YOUR_OATH_TOKEN' - - From 6ccccdbbd22003e6ec52062665b575d3f6e3f14a Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:43:36 -0400 Subject: [PATCH 36/64] test: [M3-8734] - Reduce Linode rebuild test flakiness (#11119) * Address test flake by waiting for Image data before interacting with autocomplete * Add changeset --------- Co-authored-by: Joe D'Amore --- packages/manager/.changeset/pr-11119-tests-1729169604255.md | 5 +++++ .../manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11119-tests-1729169604255.md diff --git a/packages/manager/.changeset/pr-11119-tests-1729169604255.md b/packages/manager/.changeset/pr-11119-tests-1729169604255.md new file mode 100644 index 00000000000..475ddd0ae43 --- /dev/null +++ b/packages/manager/.changeset/pr-11119-tests-1729169604255.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Reduce flakiness of Linode rebuild test ([#11119](https://github.com/linode/manager/pull/11119)) diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index dd0314af1b0..c4b77519d83 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -324,7 +324,9 @@ describe('rebuild linode', () => { .findByText('Choose an image') .should('be.visible') .click() - .type(`${image}{enter}`); + .type(`${image}`); + + ui.select.findItemByText(image).should('be.visible').click(); assertPasswordComplexity(rootPassword, 'Good'); From 17d088329199101b517ccd3bf27c0f1d32414d19 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Mon, 21 Oct 2024 11:15:08 +0530 Subject: [PATCH 37/64] feat: [M3-8705] - Disable Create Longview Client button with tooltip text on Landing Page for restricted Users. (#11108) * feat: [M3-8705] - Disable Create Longview button with tooltip text Landing Page for restricted users. * Added changeset: Disable Create Lonview Client button with tooltip text on Landing Page for restricted users. * change changeset description Co-authored-by: Purvesh Makode --------- Co-authored-by: Purvesh Makode --- .../.changeset/pr-11108-changed-1729059614823.md | 5 +++++ .../Longview/LongviewLanding/LongviewLanding.tsx | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/manager/.changeset/pr-11108-changed-1729059614823.md diff --git a/packages/manager/.changeset/pr-11108-changed-1729059614823.md b/packages/manager/.changeset/pr-11108-changed-1729059614823.md new file mode 100644 index 00000000000..9339b47b880 --- /dev/null +++ b/packages/manager/.changeset/pr-11108-changed-1729059614823.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Disable Longview 'Add Client' button with tooltip text on landing page for restricted users. ([#11108](https://github.com/linode/manager/pull/11108)) diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx index b14810020cb..138ed3d0cf4 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx @@ -19,6 +19,8 @@ import withLongviewClients from 'src/containers/longview.container'; import { useAPIRequest } from 'src/hooks/useAPIRequest'; import { useAccountSettings } from 'src/queries/account/settings'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { SubscriptionDialog } from './SubscriptionDialog'; @@ -71,6 +73,10 @@ export const LongviewLanding = (props: LongviewLandingProps) => { }, ]; + const isLongviewCreationRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_longview', + }); + const matches = (p: string) => { return Boolean(matchPath(p, { path: props.location.pathname })); }; @@ -134,6 +140,14 @@ export const LongviewLanding = (props: LongviewLandingProps) => { onButtonClick={handleAddClient} removeCrumbX={1} title="Longview" + disabledCreateButton={isLongviewCreationRestricted} + buttonDataAttrs={{ + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Longview Clients', + }), + }} /> Date: Mon, 21 Oct 2024 11:16:59 +0530 Subject: [PATCH 38/64] feat: [M3-8704] - Disable Create Firewalls button with tooltip text on empty state Landing Page for restricted users. (#11093) * feat: [M3-8704] - Disable Create Firewalls button with tooltip text on empty state Landing Page for restricted users * Added changeset: Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users --- .../.changeset/pr-11093-fixed-1728898767762.md | 5 +++++ .../FirewallLanding/FirewallLandingEmptyState.tsx | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 packages/manager/.changeset/pr-11093-fixed-1728898767762.md diff --git a/packages/manager/.changeset/pr-11093-fixed-1728898767762.md b/packages/manager/.changeset/pr-11093-fixed-1728898767762.md new file mode 100644 index 00000000000..eb9f6d41006 --- /dev/null +++ b/packages/manager/.changeset/pr-11093-fixed-1728898767762.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users ([#11093](https://github.com/linode/manager/pull/11093)) diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx index 1a5ccbab7d0..7822e9e81eb 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import FirewallIcon from 'src/assets/icons/entityIcons/firewall.svg'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -18,11 +20,16 @@ interface Props { export const FirewallLandingEmptyState = (props: Props) => { const { openAddFirewallDrawer } = props; + const isFirewallsCreationRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_firewalls', + }); + return ( { sendEvent({ action: 'Click:button', @@ -31,6 +38,11 @@ export const FirewallLandingEmptyState = (props: Props) => { }); openAddFirewallDrawer(); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Firewalls', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} From 3eaf8c034efd0613dc6c619171ba712276dfb388 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:32:45 -0400 Subject: [PATCH 39/64] Revert "test: [M3-7863] - Use `happy-dom` instead of `jsdom` in unit tests (#11085)" (#11128) This reverts commit efa85802c21e63e41dbce3517826090c8486e6dd. Co-authored-by: Banks Nussman --- .../pr-11085-tests-1728657019139.md | 5 - packages/manager/package.json | 2 +- .../src/components/Avatar/Avatar.test.tsx | 6 +- .../src/components/BetaChip/BetaChip.test.tsx | 2 +- .../DescriptionList/DescriptionList.test.tsx | 2 +- .../HighlightedMarkdown.test.tsx.snap | 78 +++---- .../src/components/Notice/Notice.test.tsx | 4 +- .../manager/src/components/Tabs/Tab.test.tsx | 2 +- .../TextTooltip/TextTooltip.test.tsx | 2 +- .../DatabaseCreate/DatabaseCreate.test.tsx | 4 +- .../DatabaseLanding/DatabaseLanding.test.tsx | 36 ++-- .../CreateCluster/HAControlPlane.test.tsx | 6 +- .../Linodes/CloneLanding/Disks.test.tsx | 8 +- .../Linodes/LinodeCreate/VPC/VPC.test.tsx | 27 ++- .../Linodes/LinodeCreate/index.test.tsx | 4 +- .../LinodeIPAddressRow.test.tsx | 48 ++--- .../LinodeSettings/VPCPanel.test.tsx | 10 +- .../NodeBalancerConfigPanel.test.tsx | 9 +- .../NodeBalancerConfigurations.test.tsx | 22 +- .../NodeBalancerActionMenu.test.tsx | 20 +- .../NodeBalancerTableRow.test.tsx | 12 +- .../CreateOAuthClientDrawer.test.tsx | 8 +- .../features/Volumes/VolumeCreate.test.tsx | 2 +- .../src/utilities/omittedProps.test.tsx | 2 +- packages/manager/vite.config.ts | 3 +- .../pr-11085-tests-1728657169966.md | 5 - .../src/components/BetaChip/BetaChip.test.tsx | 2 +- packages/ui/vitest.config.ts | 2 +- yarn.lock | 196 +++++++++++++++--- 29 files changed, 319 insertions(+), 210 deletions(-) delete mode 100644 packages/manager/.changeset/pr-11085-tests-1728657019139.md delete mode 100644 packages/ui/.changeset/pr-11085-tests-1728657169966.md diff --git a/packages/manager/.changeset/pr-11085-tests-1728657019139.md b/packages/manager/.changeset/pr-11085-tests-1728657019139.md deleted file mode 100644 index 882e40644d2..00000000000 --- a/packages/manager/.changeset/pr-11085-tests-1728657019139.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Use `happy-dom` instead of `jsdom` in unit tests ([#11085](https://github.com/linode/manager/pull/11085)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 10248b48034..41241a56de8 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -195,7 +195,7 @@ "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", "glob": "^10.3.1", - "happy-dom": "^15.7.4", + "jsdom": "^24.1.1", "junit2json": "^3.1.4", "lint-staged": "^15.2.9", "mocha-junit-reporter": "^2.2.1", diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx index e8f7ae51d3a..65e4ab1baa0 100644 --- a/packages/manager/src/components/Avatar/Avatar.test.tsx +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -31,7 +31,7 @@ describe('Avatar', () => { const avatarStyles = getComputedStyle(avatar); expect(getByTestId('avatar-letter')).toHaveTextContent('M'); - expect(avatarStyles.backgroundColor).toBe('#0174bc'); // theme.color.primary.dark (#0174bc) + expect(avatarStyles.backgroundColor).toBe('rgb(1, 116, 188)'); // theme.color.primary.dark (#0174bc) }); it('should render a background color from props', () => { @@ -48,8 +48,8 @@ describe('Avatar', () => { const avatarTextStyles = getComputedStyle(avatarText); // Confirm background color contrasts with text color. - expect(avatarStyles.backgroundColor).toBe('#000000'); // black - expect(avatarTextStyles.color).toBe('#fff'); // white + expect(avatarStyles.backgroundColor).toBe('rgb(0, 0, 0)'); // black + expect(avatarTextStyles.color).toBe('rgb(255, 255, 255)'); // white }); it('should render the first letter of username from props', async () => { diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx index 69d7d499fe2..39d28178640 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/manager/src/components/BetaChip/BetaChip.test.tsx @@ -17,7 +17,7 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: #108ad6'); + expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); }); it('triggers an onClick callback', () => { diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx index 477d27088f0..6fc2fd2fe20 100644 --- a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx +++ b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx @@ -32,7 +32,7 @@ describe('Description List', () => { it('has it title bolded', () => { const { getByText } = renderWithTheme(); const title = getByText('Random title'); - expect(title).toHaveStyle('font-family: LatoWebBold, sans-serif'); + expect(title).toHaveStyle('font-family: "LatoWebBold",sans-serif'); }); it('renders a column by default', () => { diff --git a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap index 09a0177c34b..238b90d44c9 100644 --- a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap +++ b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap @@ -4,52 +4,52 @@ exports[`HighlightedMarkdown component > should highlight text consistently 1`]

    -

    - Some markdown -

    - + /> +

    + Some markdown +

    + -
    -      
    +    
    +      
    +        const
    +      
    +       x = 
    +      
             
    -          const
    -        
    -         x = 
    -        
    -          
    -            function
    -          
    -          (
    -          
    -          ) 
    -        
    -        { 
    -        
    -          return
    +          function
             
    -         
    +        (
             
    -          true
    -        
    -        ; }
    +          class="hljs-params"
    +        />
    +        ) 
    +      
    +      { 
    +      
    +        return
    +      
    +       
    +      
    +        true
    +      
    +      ; }
     
    -      
    -    
    -

    +
    + +

    `; diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index 029e9d86d10..e7d536cd907 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -11,8 +11,8 @@ describe('Notice Component', () => { const notice = container.firstChild; expect(notice).toHaveStyle('margin-bottom: 24px'); - expect(notice).toHaveStyle('margin-left: 0px'); - expect(notice).toHaveStyle('margin-top: 0px'); + expect(notice).toHaveStyle('margin-left: 0'); + expect(notice).toHaveStyle('margin-top: 0'); }); it('renders with text', () => { diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index 4dc53cd77da..6463053b864 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -20,7 +20,7 @@ describe('Tab Component', () => { expect(tabElement).toHaveStyle(` display: inline-flex; - color: #0174bc; + color: rgb(0, 156, 222); `); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index ba592caabed..301c5787ca5 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -56,7 +56,7 @@ describe('TextTooltip', () => { const displayText = getByText(props.displayText); - expect(displayText).toHaveStyle('color: #0174bc'); + expect(displayText).toHaveStyle('color: rgb(0, 156, 222)'); expect(displayText).toHaveStyle('font-size: 18px'); }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index eee0966bf78..40b0bf1c6ce 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -24,9 +24,7 @@ describe('Database Create', () => { const { getAllByTestId, getAllByText } = renderWithTheme( ); - await waitForElementToBeRemoved(getAllByTestId(loadingTestId), { - timeout: 10_000, - }); + await waitForElementToBeRemoved(getAllByTestId(loadingTestId)); getAllByText('Cluster Label'); getAllByText('Database Engine'); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index d4c3f11d44d..a7230c71e9f 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import { fireEvent } from '@testing-library/react'; import { waitForElementToBeRemoved } from '@testing-library/react'; import { DateTime } from 'luxon'; @@ -71,7 +71,6 @@ describe('Database Table Row', () => { describe('Database Table', () => { it('should render database landing table with items', async () => { - const database = databaseInstanceFactory.build({ status: 'active' }); const mockAccount = accountFactory.build({ capabilities: [managedDBBetaCapability], }); @@ -82,25 +81,32 @@ describe('Database Table', () => { ); server.use( http.get(databaseInstancesEndpoint, () => { - return HttpResponse.json(makeResourcePage([database])); + const databases = databaseInstanceFactory.buildList(1, { + status: 'active', + }); + return HttpResponse.json(makeResourcePage(databases)); }) ); - const { getByText } = renderWithTheme(); + const { getAllByText, getByTestId, queryAllByText } = renderWithTheme( + + ); - // wait for API data to load - await waitFor(() => expect(getByText(database.label)).toBeVisible(), { - timeout: 10_000, - }); - expect(getByText('Active')).toBeVisible(); + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); // Static text and table column headers - expect(getByText('Cluster Label')).toBeVisible(); - expect(getByText('Status')).toBeVisible(); - expect(getByText('Configuration')).toBeVisible(); - expect(getByText('Engine')).toBeVisible(); - expect(getByText('Region')).toBeVisible(); - expect(getByText('Created')).toBeVisible(); + getAllByText('Cluster Label'); + getAllByText('Status'); + getAllByText('Configuration'); + getAllByText('Engine'); + getAllByText('Region'); + getAllByText('Created'); + + // Check to see if the mocked API data rendered in the table + queryAllByText('Active'); }); it('should render database landing with empty state', async () => { diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx index 94968d34b79..b8f995c02f1 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx @@ -1,4 +1,4 @@ -import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; @@ -42,11 +42,11 @@ describe('HAControlPlane', () => { await findByText(/\$60\.00/); }); - it('should call the handleChange function on change', async () => { + it('should call the handleChange function on change', () => { const { getByTestId } = renderWithTheme(); const haRadioButton = getByTestId('ha-radio-button-yes'); - await userEvent.click(haRadioButton); + fireEvent.click(haRadioButton); expect(props.setHighAvailability).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx b/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx index 396cc774603..dc55a90af9e 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx +++ b/packages/manager/src/features/Linodes/CloneLanding/Disks.test.tsx @@ -33,7 +33,7 @@ describe('Disks', () => { const { getByTestId } = render(wrapWithTheme()); disks.forEach((eachDisk) => { const checkbox = getByTestId(`checkbox-${eachDisk.id}`).parentNode; - fireEvent.click(checkbox!); + fireEvent.click(checkbox as any); expect(mockHandleSelect).toHaveBeenCalledWith(eachDisk.id); }); }); @@ -47,10 +47,10 @@ describe('Disks', () => { }); it('checks the disk if the associated config is selected', () => { - const { getByRole } = render( + const { getByTestId } = render( wrapWithTheme() ); - const checkbox = getByRole('checkbox', { name: '512 MB Swap Image' }); - expect(checkbox).toBeChecked(); + const checkbox: any = getByTestId('checkbox-19040624').firstElementChild; + expect(checkbox).toHaveAttribute('checked'); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx index a500440cfb3..eb193d24815 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx @@ -69,7 +69,7 @@ describe('VPC', () => { it('renders VPC IPv4, NAT checkboxes, and IP Ranges inputs when a subnet is selected', async () => { const { - getByRole, + getByLabelText, getByText, } = renderWithThemeAndHookFormContext({ component: , @@ -82,15 +82,13 @@ describe('VPC', () => { }); expect( - getByRole('checkbox', { - name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', - }) + getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) ).toBeInTheDocument(); expect( - getByRole('checkbox', { - name: 'Assign a public IPv4 address for this Linode', - }) + getByLabelText('Assign a public IPv4 address for this Linode') ).toBeInTheDocument(); expect(getByText('Assign additional IPv4 ranges')).toBeInTheDocument(); @@ -98,7 +96,7 @@ describe('VPC', () => { it('should check the VPC IPv4 if a "ipv4.vpc" is null/undefined', async () => { const { - getByRole, + getByLabelText, } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { @@ -114,16 +112,15 @@ describe('VPC', () => { }); expect( - getByRole('checkbox', { - name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', - }) + getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) ).toBeChecked(); }); it('should uncheck the VPC IPv4 if a "ipv4.vpc" is a string value and show the VPC IP TextField', async () => { const { getByLabelText, - getByRole, } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { @@ -135,9 +132,9 @@ describe('VPC', () => { }); expect( - getByRole('checkbox', { - name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', - }) + getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) ).not.toBeChecked(); expect(getByLabelText('VPC IPv4 (required)')).toBeVisible(); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx index dbde52b4812..7e53195eacc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx @@ -23,10 +23,10 @@ describe('Linode Create', () => { }); it('Should not render the region select when creating from a backup', () => { - const { queryByLabelText } = renderWithTheme(, { + const { queryByText } = renderWithTheme(, { MemoryRouter: { initialEntries: ['/linodes/create?type=Backups'] }, }); - expect(queryByLabelText('Region')).toBeNull(); + expect(queryByText('Region')).toBeNull(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx index 6e82eb2be09..8eebfcc782b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx @@ -1,4 +1,4 @@ -import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { LinodeConfigInterfaceFactoryWithVPC } from 'src/factories/linodeConfigInterfaceFactory'; @@ -29,8 +29,8 @@ const handlers: IPAddressRowHandlers = { }; describe('LinodeIPAddressRow', () => { - it('should render a Linode IP Address row', async () => { - const { getAllByText, getByLabelText } = renderWithTheme( + it('should render a Linode IP Address row', () => { + const { getAllByText } = renderWithTheme( wrapWithTableBody( { ) ); - // open the action menu - await userEvent.click( - getByLabelText('Action menu for IP Address [object Object]') - ); - getAllByText(ipDisplay.address); getAllByText(ipDisplay.type); getAllByText(ipDisplay.gateway); @@ -77,7 +72,7 @@ describe('LinodeIPAddressRow', () => { }); it('should disable the row if disabled is true and display a tooltip', async () => { - const { getAllByLabelText, getByLabelText, getByTestId } = renderWithTheme( + const { findByRole, getByTestId } = renderWithTheme( wrapWithTableBody( { ) ); - // open the action menu - await userEvent.click( - getByLabelText('Action menu for IP Address [object Object]') - ); - - const deleteBtn = getByTestId('Delete'); + const deleteBtn = getByTestId('action-menu-item-delete'); expect(deleteBtn).toHaveAttribute('aria-disabled', 'true'); + fireEvent.mouseEnter(deleteBtn); + const publicIpsUnassignedTooltip = await findByRole('tooltip'); + expect(publicIpsUnassignedTooltip).toContainHTML( + PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT + ); - const editRDNSBtn = getByTestId('Edit RDNS'); + const editRDNSBtn = getByTestId('action-menu-item-edit-rdns'); expect(editRDNSBtn).toHaveAttribute('aria-disabled', 'true'); - expect(getAllByLabelText(PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT)).toHaveLength(2); + fireEvent.mouseEnter(editRDNSBtn); + const publicIpsUnassignedTooltip2 = await findByRole('tooltip'); + expect(publicIpsUnassignedTooltip2).toContainHTML( + PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT + ); }); - it('should not disable the row if disabled is false', async () => { - const { getByLabelText, getByTestId } = renderWithTheme( + it('should not disable the row if disabled is false', () => { + const { getAllByRole } = renderWithTheme( wrapWithTableBody( { ) ); - // open the action menu - await userEvent.click( - getByLabelText('Action menu for IP Address [object Object]') - ); + const buttons = getAllByRole('button'); - expect(getByTestId('Delete')).toBeEnabled(); + const deleteBtn = buttons[1]; + expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true'); - expect(getByTestId('Edit RDNS')).toBeEnabled(); + const editRDNSBtn = buttons[3]; + expect(editRDNSBtn).not.toHaveAttribute('aria-disabled', 'true'); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx index dda6abc4339..2255e3115ec 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx @@ -212,9 +212,9 @@ describe('VPCPanel', () => { await waitFor(() => { expect( - wrapper.getByRole('checkbox', { - name: 'Auto-assign a VPC IPv4 address for this Linode in the VPC', - }) + wrapper.getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) ).not.toBeChecked(); // Using regex here to account for the "(required)" indicator. expect(wrapper.getByLabelText(/^VPC IPv4.*/)).toHaveValue('10.0.4.3'); @@ -244,9 +244,7 @@ describe('VPCPanel', () => { await waitFor(() => { expect( - wrapper.getByRole('checkbox', { - name: 'Assign a public IPv4 address for this Linode', - }) + wrapper.getByLabelText('Assign a public IPv4 address for this Linode') ).toBeChecked(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx index 064e76f616b..fa06be32953 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx @@ -85,7 +85,6 @@ const proxyProtocol = 'Proxy Protocol'; describe('NodeBalancerConfigPanel', () => { it('renders the NodeBalancerConfigPanel', () => { const { - getAllByLabelText, getByLabelText, getByText, queryByLabelText, @@ -102,13 +101,7 @@ describe('NodeBalancerConfigPanel', () => { expect(getByLabelText('Label')).toBeVisible(); expect(getByLabelText('IP Address')).toBeVisible(); expect(getByLabelText('Weight')).toBeVisible(); - - const portTextFields = getAllByLabelText('Port'); - expect(portTextFields).toHaveLength(2); // There is a port field for the config and a port field for the one node - for (const field of portTextFields) { - expect(field).toBeVisible(); - } - + expect(getByLabelText('Port')).toBeVisible(); expect(getByText('Listen on this port.')).toBeVisible(); expect(getByText('Active Health Checks')).toBeVisible(); expect( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx index d4480664a67..d82c1156bf9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx @@ -44,15 +44,13 @@ describe('NodeBalancerConfigurations', () => { }) ); - const { - getAllByLabelText, - getByLabelText, - getByTestId, - getByText, - } = renderWithTheme(, { - MemoryRouter: memoryRouter, - routePath, - }); + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + , + { + MemoryRouter: memoryRouter, + routePath, + } + ); expect(getByTestId(loadingTestId)).toBeInTheDocument(); @@ -67,11 +65,7 @@ describe('NodeBalancerConfigurations', () => { expect(getByLabelText('Label')).toBeInTheDocument(); expect(getByLabelText('IP Address')).toBeInTheDocument(); expect(getByLabelText('Weight')).toBeInTheDocument(); - const portTextFields = getAllByLabelText('Port'); - expect(portTextFields).toHaveLength(2); // There is a port field for the config and a port field for the one node - for (const field of portTextFields) { - expect(field).toBeInTheDocument(); - } + expect(getByLabelText('Port')).toBeInTheDocument(); expect(getByText('Listen on this port.')).toBeInTheDocument(); expect(getByText('Active Health Checks')).toBeInTheDocument(); expect( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx index 61407c83bf6..e950688d3d4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -12,14 +12,13 @@ const props = { }; describe('NodeBalancerActionMenu', () => { - it('renders the NodeBalancerActionMenu', async () => { - const { getByLabelText, getByText } = renderWithTheme( - - ); + afterEach(() => { + vi.resetAllMocks(); + }); - // Open the Action Menu - await userEvent.click( - getByLabelText(`Action menu for NodeBalancer ${props.nodeBalancerId}`) + it('renders the NodeBalancerActionMenu', () => { + const { getByText } = renderWithTheme( + ); expect(getByText('Configurations')).toBeVisible(); @@ -28,15 +27,10 @@ describe('NodeBalancerActionMenu', () => { }); it('triggers the action to delete the NodeBalancer', async () => { - const { getByLabelText, getByText } = renderWithTheme( + const { getByText } = renderWithTheme( ); - // Open the Action Menu - await userEvent.click( - getByLabelText(`Action menu for NodeBalancer ${props.nodeBalancerId}`) - ); - const deleteButton = getByText('Delete'); await userEvent.click(deleteButton); expect(props.toggleDialog).toHaveBeenCalled(); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index 32d60ff80d6..21b88f7e9a6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -20,19 +20,11 @@ describe('NodeBalancerTableRow', () => { vi.resetAllMocks(); }); - it('renders the NodeBalancer table row', async () => { - const { getByLabelText, getByText } = renderWithTheme( - - ); + it('renders the NodeBalancer table row', () => { + const { getByText } = renderWithTheme(); expect(getByText('nodebalancer-id-1')).toBeVisible(); expect(getByText('0.0.0.0')).toBeVisible(); - - // Open the Action Menu - await userEvent.click( - getByLabelText(`Action menu for NodeBalancer ${props.id}`) - ); - expect(getByText('Configurations')).toBeVisible(); expect(getByText('Settings')).toBeVisible(); expect(getByText('Delete')).toBeVisible(); diff --git a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx index 1697d16479f..59d5955191f 100644 --- a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.test.tsx @@ -27,11 +27,11 @@ describe('Create API Token Drawer', () => { getByText('Cancel'); }); it('Should show client side validation errors', async () => { - const { getByRole, getByText } = renderWithTheme( + const { getByText } = renderWithTheme( ); - const submit = getByRole('button', { name: 'Create' }); + const submit = getByText('Create'); await userEvent.click(submit); @@ -47,7 +47,7 @@ describe('Create API Token Drawer', () => { }) ); - const { getAllByTestId, getByRole } = renderWithTheme( + const { getAllByTestId, getByText } = renderWithTheme( ); @@ -56,7 +56,7 @@ describe('Create API Token Drawer', () => { const labelField = textFields[0]; const callbackUrlField = textFields[1]; - const submit = getByRole('button', { name: 'Create' }); + const submit = getByText('Create'); await userEvent.type(labelField, 'my-oauth-client'); await userEvent.type(callbackUrlField, 'http://localhost:3000'); diff --git a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx index 57352b996da..0e611ed9fb7 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx @@ -55,6 +55,6 @@ describe('VolumeCreate', () => { flags: { blockStorageEncryption: true }, }); - await findByText(encryptVolumeSectionHeader, {}, { timeout: 5_000 }); + await findByText(encryptVolumeSectionHeader); }); }); diff --git a/packages/manager/src/utilities/omittedProps.test.tsx b/packages/manager/src/utilities/omittedProps.test.tsx index b8921875e9e..56a046e1220 100644 --- a/packages/manager/src/utilities/omittedProps.test.tsx +++ b/packages/manager/src/utilities/omittedProps.test.tsx @@ -35,7 +35,7 @@ describe('omittedProps utility', () => { expect(component).not.toHaveAttribute('extraProp'); expect(component).not.toHaveAttribute('anotherProp'); - expect(component).toHaveStyle('color: red'); + expect(component).toHaveStyle('color: rgb(255, 0, 0)'); }); }); diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index 296323066bc..afab3254c56 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -37,7 +37,8 @@ export default defineConfig({ 'src/**/*.utils.{js,jsx,ts,tsx}', ], }, - environment: 'happy-dom', + pool: 'forks', + environment: 'jsdom', globals: true, setupFiles: './src/testSetup.ts', }, diff --git a/packages/ui/.changeset/pr-11085-tests-1728657169966.md b/packages/ui/.changeset/pr-11085-tests-1728657169966.md deleted file mode 100644 index 107ae6b952a..00000000000 --- a/packages/ui/.changeset/pr-11085-tests-1728657169966.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/ui": Tests ---- - -Use `happy-dom` instead of `jsdom` in unit tests ([#11085](https://github.com/linode/manager/pull/11085)) diff --git a/packages/ui/src/components/BetaChip/BetaChip.test.tsx b/packages/ui/src/components/BetaChip/BetaChip.test.tsx index 4f922765477..c4da709edd5 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.test.tsx @@ -18,7 +18,7 @@ describe('BetaChip', () => { const { getByTestId } = render(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: #1976d2'); + expect(betaChip).toHaveStyle('background-color: rgb(25, 118, 210)'); }); it('triggers an onClick callback', () => { diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index c4ce34c1442..95754d431b5 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - environment: 'happy-dom', + environment: 'jsdom', setupFiles: './testSetup.ts', }, }); diff --git a/yarn.lock b/yarn.lock index 7fef5d8b381..39b3c3fa612 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2922,6 +2922,13 @@ acorn@^8.12.0, acorn@^8.12.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -4008,6 +4015,13 @@ css.escape@^1.5.1: resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== +cssstyle@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.1.0.tgz#161faee382af1bafadb6d3867a92a19bcb4aea70" + integrity sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA== + dependencies: + rrweb-cssom "^0.7.1" + csstype@^2.5.7: version "2.6.21" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e" @@ -4172,6 +4186,14 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +data-urls@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" + integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== + dependencies: + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + data-view-buffer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" @@ -4211,6 +4233,13 @@ debug@2.6.9: dependencies: ms "2.0.0" +debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@~4.3.6: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + debug@^3.1.0: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -4218,18 +4247,16 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@~4.3.6: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - decimal.js-light@^2.4.1: version "2.5.1" resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + decode-named-character-reference@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" @@ -4456,7 +4483,7 @@ enquirer@^2.3.5, enquirer@^2.3.6: ansi-colors "^4.1.1" strip-ansi "^6.0.1" -entities@^4.4.0, entities@^4.5.0: +entities@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -5788,15 +5815,6 @@ graphql@^16.8.1: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== -happy-dom@^15.7.4: - version "15.7.4" - resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-15.7.4.tgz#05aade59c1d307336001b7004c76dfc6a829f220" - integrity sha512-r1vadDYGMtsHAAsqhDuk4IpPvr6N8MGKy5ntBo7tSdim+pWDxus2PNqOcOt8LuDZ4t3KJHE+gCuzupcx/GKnyQ== - dependencies: - entities "^4.5.0" - webidl-conversions "^7.0.0" - whatwg-mimetype "^3.0.0" - has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -5918,6 +5936,13 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" +html-encoding-sniffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" + integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== + dependencies: + whatwg-encoding "^3.1.1" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -5947,6 +5972,14 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + http-signature@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.4.0.tgz#dee5a9ba2bf49416abc544abd6d967f6a94c8c3f" @@ -5956,6 +5989,14 @@ http-signature@~1.4.0: jsprim "^2.0.2" sshpk "^1.18.0" +https-proxy-agent@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -5983,6 +6024,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -6307,6 +6355,11 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -6519,6 +6572,33 @@ jsdoc-type-pratt-parser@^4.0.0: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" integrity sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg== +jsdom@^24.1.1: + version "24.1.3" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.1.3.tgz#88e4a07cb9dd21067514a619e9f17b090a394a9f" + integrity sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ== + dependencies: + cssstyle "^4.0.1" + data-urls "^5.0.0" + decimal.js "^10.4.3" + form-data "^4.0.0" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.5" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.12" + parse5 "^7.1.2" + rrweb-cssom "^0.7.1" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.4" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + ws "^8.18.0" + xml-name-validator "^5.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -7664,6 +7744,11 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" +nwsapi@^2.2.12: + version "2.2.12" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.12.tgz#fb6af5c0ec35b27b4581eb3bbad34ec9e5c696f8" + integrity sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w== + object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -7870,6 +7955,13 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -8142,7 +8234,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -8769,6 +8861,11 @@ rollup@^4.19.0, rollup@^4.20.0: "@rollup/rollup-win32-x64-msvc" "4.22.4" fsevents "~2.3.2" +rrweb-cssom@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b" + integrity sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg== + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -8819,7 +8916,7 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -8829,6 +8926,13 @@ sax@>=0.6.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scheduler@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" @@ -9380,6 +9484,11 @@ symbol-observable@^1.0.4: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + table@^5.2.3: version "5.4.6" resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" @@ -9562,6 +9671,13 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" +tr46@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" + integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== + dependencies: + punycode "^2.3.1" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -10065,6 +10181,13 @@ vitest@^2.1.1: vite-node "2.1.1" why-is-node-running "^2.3.0" +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" @@ -10092,15 +10215,30 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + whatwg-fetch@>=0.10.0: version "3.6.20" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== -whatwg-mimetype@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" - integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + +whatwg-url@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6" + integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== + dependencies: + tr46 "^5.0.0" + webidl-conversions "^7.0.0" whatwg-url@^5.0.0: version "5.0.0" @@ -10244,11 +10382,16 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@^8.2.3: +ws@^8.18.0, ws@^8.2.3: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + xml2js@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" @@ -10267,6 +10410,11 @@ xmlbuilder@~11.0.0: resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" From 8770d5b29125242d47c8d099c11b95367a580b57 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 21 Oct 2024 14:46:51 -0400 Subject: [PATCH 40/64] fix: Dark Mode style regressions (#11123) * use `deepmerge` to create our dark theme * merge with MUI's `deepmerge` before calling `createTheme` --------- Co-authored-by: Banks Nussman --- packages/manager/package.json | 1 + packages/ui/src/foundations/themes/index.ts | 3 ++- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/manager/package.json b/packages/manager/package.json index 41241a56de8..21b07e4e746 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -25,6 +25,7 @@ "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^5.14.7", "@mui/material": "^5.14.7", + "@mui/utils": "^5.14.7", "@mui/x-date-pickers": "^7.12.0", "@paypal/react-paypal-js": "^7.8.3", "@reach/tabs": "^0.10.5", diff --git a/packages/ui/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts index a874f0c0bba..c94d55ac8ae 100644 --- a/packages/ui/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -1,4 +1,5 @@ import { createTheme } from '@mui/material/styles'; +import { deepmerge } from '@mui/utils'; // Themes & Brands import { darkTheme } from './dark'; @@ -107,4 +108,4 @@ declare module '@mui/material/styles/createTheme' { export const inputMaxWidth = _inputMaxWidth; export const light = createTheme(lightTheme); -export const dark = createTheme(lightTheme, darkTheme); +export const dark = createTheme(deepmerge(lightTheme, darkTheme)); diff --git a/yarn.lock b/yarn.lock index 39b3c3fa612..4219a830d0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1165,7 +1165,7 @@ resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.17.tgz#329826062d4079de5ea2b97007575cebbba1fdbc" integrity sha512-oyumoJgB6jDV8JFzRqjBo2daUuHpzDjoO/e3IrRhhHo/FxJlaVhET6mcNrKHUq2E+R+q3ql0qAtvQ4rfWHhAeQ== -"@mui/utils@^5.16.6": +"@mui/utils@^5.14.7", "@mui/utils@^5.16.6": version "5.16.6" resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.16.6.tgz#905875bbc58d3dcc24531c3314a6807aba22a711" integrity sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA== @@ -10426,9 +10426,9 @@ yallist@^3.0.2: integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== yaml@^1.10.0, yaml@^1.7.2, yaml@^2.3.0, yaml@~2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.1.tgz#c9772aacf62cb7494a95b0c4f1fb065b563db130" - integrity sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q== + version "2.6.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.0.tgz#14059ad9d0b1680d0f04d3a60fe00f3a857303c3" + integrity sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ== yargs-parser@^21.1.1: version "21.1.1" From d0e1012dae0e182077d0b2a75e4a481d9137b261 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:41:58 -0400 Subject: [PATCH 41/64] docs: Change `pageSize` to `page_size` in `api-v4` documentation (#11129) * change `pageSize` to `page_size` * add changeset --------- Co-authored-by: Banks Nussman --- packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md | 5 +++++ packages/api-v4/README.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md diff --git a/packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md b/packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md new file mode 100644 index 00000000000..4a2d6de69a4 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Fixed +--- + +Incorrect documentation on how to set a page size ([#11129](https://github.com/linode/manager/pull/11129)) diff --git a/packages/api-v4/README.md b/packages/api-v4/README.md index 4c3daea5d41..28c200284c2 100644 --- a/packages/api-v4/README.md +++ b/packages/api-v4/README.md @@ -141,7 +141,7 @@ pagination and filter parameters to the API: ```js // Return page 2 of Linodes, with a page size of 100: -getLinodes({ page: 2, pageSize: 100 }); +getLinodes({ page: 2, page_size: 100 }); // Return all public Linode Images: getImages({}, { is_public: true }); From 26d7ea345f53b6a853643352811e9377506bd126 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 21 Oct 2024 17:00:12 -0400 Subject: [PATCH 42/64] =?UTF-8?q?refactor:=20[M3-8746]=20=E2=80=93=20Move?= =?UTF-8?q?=20`inputMaxWidth`=20into=20`Theme`=20(#11116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/src/features/Linodes/LinodeCreate/Security.tsx | 7 +++++-- .../manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx | 5 +++-- packages/ui/.changeset/pr-11116-changed-1729114321677.md | 5 +++++ packages/ui/src/foundations/themes/index.ts | 5 +++-- packages/ui/src/foundations/themes/light.ts | 3 ++- 5 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 packages/ui/.changeset/pr-11116-changed-1729114321677.md diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx index 36c86b2c3dc..dcb02f65c7b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx @@ -1,4 +1,3 @@ -import { inputMaxWidth } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -56,7 +55,11 @@ export const Security = () => { Security } + fallback={ + ({ height: '89px', maxWidth: theme.inputMaxWidth })} + /> + } > ( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx index 483cb67c548..9c81cdb492f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx @@ -1,4 +1,3 @@ -import { inputMaxWidth } from '@linode/ui'; import React, { useState } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -133,7 +132,9 @@ export const VPC = () => { }} textFieldProps={{ sx: (theme) => ({ - [theme.breakpoints.up('sm')]: { minWidth: inputMaxWidth }, + [theme.breakpoints.up('sm')]: { + minWidth: theme.inputMaxWidth, + }, }), tooltipText: REGION_CAVEAT_HELPER_TEXT, }} diff --git a/packages/ui/.changeset/pr-11116-changed-1729114321677.md b/packages/ui/.changeset/pr-11116-changed-1729114321677.md new file mode 100644 index 00000000000..adc6876ac5a --- /dev/null +++ b/packages/ui/.changeset/pr-11116-changed-1729114321677.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Changed +--- + +Moved `inputMaxWidth` into `Theme` ([#11116](https://github.com/linode/manager/pull/11116)) diff --git a/packages/ui/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts index c94d55ac8ae..4a507f9e6b1 100644 --- a/packages/ui/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -3,7 +3,7 @@ import { deepmerge } from '@mui/utils'; // Themes & Brands import { darkTheme } from './dark'; -import { lightTheme, inputMaxWidth as _inputMaxWidth } from './light'; +import { lightTheme } from './light'; import type { ChartTypes, @@ -77,6 +77,7 @@ declare module '@mui/material/styles/createTheme' { color: Colors; font: Fonts; graphs: any; + inputMaxWidth: number; inputStyles: any; interactionTokens: InteractionTypes; name: ThemeName; @@ -97,6 +98,7 @@ declare module '@mui/material/styles/createTheme' { color?: DarkModeColors | LightModeColors; font?: Fonts; graphs?: any; + inputMaxWidth?: number; inputStyles?: any; interactionTokens?: InteractionTypes; name: ThemeName; @@ -106,6 +108,5 @@ declare module '@mui/material/styles/createTheme' { } } -export const inputMaxWidth = _inputMaxWidth; export const light = createTheme(lightTheme); export const dark = createTheme(deepmerge(lightTheme, darkTheme)); diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index b42e5ade10d..c7f36351839 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -15,7 +15,7 @@ import { latoWeb } from '../fonts'; import type { ThemeOptions } from '@mui/material/styles'; -export const inputMaxWidth = 416; +const inputMaxWidth = 416; export const bg = { app: Color.Neutrals[5], @@ -1526,6 +1526,7 @@ export const lightTheme: ThemeOptions = { }, yellow: `rgba(255, 220, 125, ${graphTransparency})`, }, + inputMaxWidth, inputStyles: { default: { backgroundColor: Select.Default.Background, From 7dc8c8be0eb55224bda907f52d569ed5ef1b9cc4 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 21 Oct 2024 17:15:42 -0400 Subject: [PATCH 43/64] fix: [M3-8763] - fix flaky `DatabaseBackups.test.tsx` in coverage job (#11130) * attempt to fix flaky which I dont see locally * making sure we can debug current branch in the CI * Added changeset: Flaky DatabaseBackups.test.tsx in coverage job * hopefully a better test * clean up comments * Update GHA comment --- .github/workflows/coverage.yml | 4 +- .../pr-11130-fixed-1729536728596.md | 5 +++ .../DatabaseBackups/DatabaseBackups.test.tsx | 39 ++++++++++++++----- 3 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-11130-fixed-1729536728596.md diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 146bea26c61..4da3dbe498b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -47,7 +47,9 @@ jobs: path: ref_code_coverage.txt current_branch: - if: github.event.pull_request.draft == false + # We want to make sure we only run on open PRs (not drafts), but also should run even if the base branch coverage job fails. + # If the base branch coverage job fails to create a report, the current branch coverage job will fail as well, but this may help us debug the CI on the current branch. + if: ${{ always() && github.event.pull_request.draft == false }} runs-on: ubuntu-latest needs: base_branch diff --git a/packages/manager/.changeset/pr-11130-fixed-1729536728596.md b/packages/manager/.changeset/pr-11130-fixed-1729536728596.md new file mode 100644 index 00000000000..7dbedc2c63b --- /dev/null +++ b/packages/manager/.changeset/pr-11130-fixed-1729536728596.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Flaky DatabaseBackups.test.tsx in coverage job ([#11130](https://github.com/linode/manager/pull/11130)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx index 0abbd2a4cad..b482223dfc7 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/react'; import * as React from 'react'; import { @@ -6,7 +7,7 @@ import { profileFactory, } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { formatDate } from 'src/utilities/formatDate'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -19,7 +20,6 @@ describe('Database Backups', () => { }); const backups = databaseBackupFactory.buildList(7); - // Mock the Database because the Backups Details page requires it to be loaded server.use( http.get('*/profile', () => { return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); @@ -32,15 +32,34 @@ describe('Database Backups', () => { }) ); - const { findByText } = renderWithTheme(); + const { findAllByText, getByText, queryByText } = renderWithTheme( + + ); + + // Wait for loading to disappear + await waitFor(() => + expect(queryByText(/loading/i)).not.toBeInTheDocument() + ); + + await waitFor( + async () => { + const renderedBackups = await findAllByText((content) => { + return /\d{4}-\d{2}-\d{2}/.test(content); + }); + expect(renderedBackups).toHaveLength(backups.length); + }, + { timeout: 5000 } + ); - for (const backup of backups) { - // Check to see if all 7 backups are rendered - expect( - // eslint-disable-next-line no-await-in-loop - await findByText(formatDate(backup.created, { timezone: 'utc' })) - ).toBeInTheDocument(); - } + await waitFor( + () => { + backups.forEach((backup) => { + const formattedDate = formatDate(backup.created, { timezone: 'utc' }); + expect(getByText(formattedDate)).toBeInTheDocument(); + }); + }, + { timeout: 5000 } + ); }); it('should render an empty state if there are no backups', async () => { From dec6b5081ed21963c86e9e88195ce7feae706ca4 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Mon, 21 Oct 2024 14:16:29 -0700 Subject: [PATCH 44/64] change: [M3-8509] - Add Pendo documentation to our development guide (#11122) * Add Pendo documentation to our development guide * Add changeset * Add bullet point about data-testid use --- docs/tooling/analytics.md | 35 ++++++++++++++++--- .../pr-11122-added-1729183156879.md | 5 +++ packages/manager/src/hooks/usePendo.ts | 2 +- 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-11122-added-1729183156879.md diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md index 7ba441792d9..a0ab6b9e474 100644 --- a/docs/tooling/analytics.md +++ b/docs/tooling/analytics.md @@ -1,12 +1,15 @@ -# Adobe Analytics +# Analytics + +## Adobe Analytics Cloud Manager uses Adobe Analytics to capture page view and custom event statistics. To view analytics, Cloud Manager developers must follow internal processes to request access to Adobe Analytics dashboards. -## Writing a Custom Event +### Writing a Custom Event Custom events live (mostly) in `src/utilities/analytics/customEventAnalytics.ts`. Try to write and export custom events in this file if possible, and import them in the component(s) where they are used. A custom event will take this shape: + ```tsx // Component.tsx {file(s) where the event is called, for quick reference} // OtherComponent.tsx @@ -33,7 +36,7 @@ Examples - `sendMarketplaceSearchEvent` fires when selecting a category from the dropdown (`label` is predefined) and clicking the search field (a generic `label` is used). - `sendBucketCreateEvent` sends the region of the bucket, but does not send the bucket label. -## Writing Form Events +### Writing Form Events Form events differ from custom events because they track user's journey through a flow and, optionally, branching flows. Form events live in `src/utilities/analytics/formEventAnalytics.ts`. Try to write and export custom events in this file if possible, and import them in the component(s) where they are used. @@ -53,10 +56,34 @@ These are the form events we use: See the `LinodeCreateForm` form events as an example. -## Locally Testing Page Views & Custom Events and/or Troubleshooting +### Locally Testing Page Views & Custom Events and/or Troubleshooting Adobe Analytics 1. Set the `REACT_APP_ADOBE_ANALYTICS_URL` environment variable in `.env`. 2. Use the browser tools Network tab, filter requests by "adobe", and check that successful network requests have been made to load the launch script and its extensions. 3. In the browser console, type `_satellite.setDebug(true)`. 4. Refresh the page. You should see Adobe debug log output in the console. Each page view change or custom event that fires should be visible in the logs. 5. When viewing dashboards in Adobe Analytics, it may take ~1 hour for analytics data to update. Once this happens, locally fired events will be visible in the dev dashboard. + +## Pendo + +Cloud Manager uses [Pendo](https://www.pendo.io/pendo-for-your-customers/) to capture analytics, guide users, and improve the user experience. To view Pendo dashboards, Cloud Manager developers must follow internal processes to request access. + +### Set Up and Initialization + +Pendo is configured in [`usePendo.js`](https://github.com/linode/manager/blob/develop/packages/manager/src/hooks/usePendo.ts). This custom hook allows us to initialize the Pendo analytics script when the [App](https://github.com/linode/manager/blob/develop/packages/manager/src/App.tsx#L56) is mounted. + +Important notes: + +- Pendo is only loaded 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). +- We are hashing account and visitor IDs in a way that is consistent with Akamai's standards. +- 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 is currently not using any client-side (cookies or local) storage. +- 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 "pendo", and check that successful network requests have been made to load Pendo scripts. (Also visible in 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 a hashed `accountId` and hashed `visitorId`. Each page view change or custom event that fires should be visible as a request in the Network tab. diff --git a/packages/manager/.changeset/pr-11122-added-1729183156879.md b/packages/manager/.changeset/pr-11122-added-1729183156879.md new file mode 100644 index 00000000000..307c7091080 --- /dev/null +++ b/packages/manager/.changeset/pr-11122-added-1729183156879.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Pendo documentation to our development guide ([#11122](https://github.com/linode/manager/pull/11122)) diff --git a/packages/manager/src/hooks/usePendo.ts b/packages/manager/src/hooks/usePendo.ts index 0c109a4a3c9..898b5dc8567 100644 --- a/packages/manager/src/hooks/usePendo.ts +++ b/packages/manager/src/hooks/usePendo.ts @@ -30,7 +30,7 @@ const hashUniquePendoId = (id: string | undefined) => { }; /** - * Initializes our Pendo analytics script on mount. + * Initializes our Pendo analytics script on mount if a valid `PENDO_API_KEY` exists. */ export const usePendo = () => { const { data: account } = useAccount(); From 7735a378a72e3955191b664de8fb16e825949bfb Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:12:10 -0400 Subject: [PATCH 45/64] upcoming: [M3-8751& M3-8610] - Image Service Gen 2 final GA tweaks (#11115) * initial work to the table * initial work to the table * save progress * small changes and unit tests * be consistant with flags * add changeset * use new copy for tooltip * put icon before label * Revert "put icon before label" This reverts commit fa4f886c2796db0d291f614dedbd8d9f69cbd464. * put icon before label * remove assertion that is no longer needed for GA changes --------- Co-authored-by: Banks Nussman --- ...r-11115-upcoming-features-1729115799261.md | 5 + .../core/images/manage-image-regions.spec.ts | 3 - .../components/ImageSelect/ImageOption.tsx | 2 +- .../ImageSelectv2/ImageOptionv2.test.tsx | 2 +- .../ImageSelectv2/ImageOptionv2.tsx | 2 +- .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + .../ImagesCreate/CreateImageTab.test.tsx | 34 ++++++ .../Images/ImagesCreate/CreateImageTab.tsx | 37 +++++-- .../Images/ImagesLanding/ImageRow.test.tsx | 100 +++++++++++++----- .../Images/ImagesLanding/ImageRow.tsx | 46 ++++++-- .../Images/ImagesLanding/ImagesLanding.tsx | 16 +-- 12 files changed, 192 insertions(+), 57 deletions(-) create mode 100644 packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md diff --git a/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md b/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md new file mode 100644 index 00000000000..e06f14e10c4 --- /dev/null +++ b/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Image Service Gen 2 final GA tweaks ([#11115](https://github.com/linode/manager/pull/11115)) diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 9ec97afd8fb..fd2c8cd8787 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -47,9 +47,6 @@ describe('Manage Image Replicas', () => { // Verify total size is rendered cy.findByText(`0.1 GB`).should('be.visible'); // 100 / 1024 = 0.09765 - // Verify capabilities are rendered - cy.findByText('Distributed').should('be.visible'); - // Verify the number of regions is rendered and click it cy.findByText(`${image.regions.length} Regions`) .should('be.visible') diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 362382fe9cd..e6b8ecef3d6 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -81,7 +81,7 @@ export const ImageOption = (props: ImageOptionProps) => { )} {flags.metadata && data.isCloudInitCompatible && ( - + )} diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index c5e0726ddc4..4c6ddfaab35 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -33,7 +33,7 @@ describe('ImageOptionv2', () => { ); expect( - getByLabelText('This image is compatible with cloud-init.') + getByLabelText('This image supports our Metadata service via cloud-init.') ).toBeVisible(); }); it('renders a distributed icon if image has the "distributed-sites" capability', () => { diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index f34e5da413f..a2f6a2638b7 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -46,7 +46,7 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { )} {flags.metadata && image.capabilities.includes('cloud-init') && ( - +

    diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 581371a7740..317ad7b4081 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -25,6 +25,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'gecko2', label: 'Gecko' }, { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, + { flag: 'imageServiceGen2Ga', label: 'Image Service Gen2 GA' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index a157f0c3dc2..2af7a792b13 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -107,6 +107,7 @@ export interface Flags { gecko2: GeckoFeatureFlag; gpuv2: gpuV2; imageServiceGen2: boolean; + imageServiceGen2Ga: boolean; ipv6Sharing: boolean; linodeDiskEncryption: boolean; mainContentBanner: MainContentBanner; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx index aac0cc5349c..70d6c4adbc7 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -163,6 +163,40 @@ describe('CreateImageTab', () => { ); }); + it('should render a notice if the user selects a Linode in a region that does not support image storage and Image Service Gen 2 GA is enabled', async () => { + const region = regionFactory.build({ capabilities: [] }); + const linode = linodeFactory.build({ region: region.id }); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme(, { + flags: { imageServiceGen2: true, imageServiceGen2Ga: true }, + }); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + await findByText( + 'This Linode’s region doesn’t support local image storage.', + { exact: false } + ); + }); + it('should render an encryption notice if disk encryption is enabled and the Linode is not in a distributed compute region', async () => { const region = regionFactory.build({ site_type: 'core' }); const linode = linodeFactory.build({ region: region.id }); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 0c565f74d7a..b2a07c59764 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -14,7 +14,6 @@ import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/uti import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; @@ -140,11 +139,20 @@ export const CreateImageTab = () => { const isRawDisk = selectedDisk?.filesystem === 'raw'; - const { data: regionsData } = useRegionsQuery(); + const { data: regions } = useRegionsQuery(); - const linodeIsInDistributedRegion = getIsDistributedRegion( - regionsData ?? [], - selectedLinode?.region ?? '' + const selectedLinodeRegion = regions?.find( + (r) => r.id === selectedLinode?.region + ); + + const linodeIsInDistributedRegion = + selectedLinodeRegion?.site_type === 'distributed'; + + /** + * The 'Object Storage' capability indicates a region can store images + */ + const linodeRegionSupportsImageStorage = selectedLinodeRegion?.capabilities.includes( + 'Object Storage' ); /* @@ -220,7 +228,24 @@ export const CreateImageTab = () => { required value={selectedLinodeId} /> - {linodeIsInDistributedRegion && ( + {selectedLinode && + !linodeRegionSupportsImageStorage && + flags.imageServiceGen2 && + flags.imageServiceGen2Ga && ( + + This Linode’s region doesn’t support local image storage. This + image will be stored in the core compute region that’s{' '} + + geographically closest + + . After it’s stored, you can replicate it to other{' '} + + core compute regions + + . + + )} + {linodeIsInDistributedRegion && !flags.imageServiceGen2Ga && ( This Linode is in a distributed compute region. These regions can't store images. The image is stored in the core compute diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index 1860593c336..e507170eb39 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import * as React from 'react'; +import React from 'react'; import { imageFactory } from 'src/factories'; import { @@ -15,16 +15,6 @@ import type { Handlers } from './ImagesActionMenu'; beforeAll(() => mockMatchMedia()); describe('Image Table Row', () => { - const image = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-sites'], - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - size: 300, - total_size: 600, - }); - const handlers: Handlers = { onCancelFailed: vi.fn(), onDelete: vi.fn(), @@ -35,36 +25,94 @@ describe('Image Table Row', () => { onRetry: vi.fn(), }; - it('should render an image row', async () => { - const { getAllByText, getByLabelText, getByText } = renderWithTheme( + it('should render an image row with Image Service Gen2 enabled', async () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-sites'], + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + size: 300, + total_size: 600, + }); + + const { getByLabelText, getByText } = renderWithTheme( wrapWithTableBody( ) ); // Check to see if the row rendered some data - + expect(getByText(image.label)).toBeVisible(); + expect(getByText(image.id)).toBeVisible(); + expect(getByText('Ready')).toBeVisible(); + expect(getByText('Cloud-init, Distributed')).toBeVisible(); expect(getByText('2 Regions')).toBeVisible(); - expect(getByText('0.29 GB')).toBeVisible(); // 300 / 1024 = 0.292 - expect(getByText('0.59 GB')).toBeVisible(); // 600 / 1024 = 0.585 - - getByText(image.label); - getAllByText('Ready'); - getAllByText('Cloud-init, Distributed'); - getAllByText(image.id); + expect(getByText('0.29 GB')).toBeVisible(); // Size is converted from MB to GB - 300 / 1024 = 0.292 + expect(getByText('0.59 GB')).toBeVisible(); // Size is converted from MB to GB - 600 / 1024 = 0.585 // Open action menu const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); await userEvent.click(actionMenu); - getByText('Edit'); - getByText('Manage Replicas'); - getByText('Deploy to New Linode'); - getByText('Rebuild an Existing Linode'); - getByText('Delete'); + expect(getByText('Edit')).toBeVisible(); + expect(getByText('Manage Replicas')).toBeVisible(); + expect(getByText('Deploy to New Linode')).toBeVisible(); + expect(getByText('Rebuild an Existing Linode')).toBeVisible(); + expect(getByText('Delete')).toBeVisible(); + }); + + it('should show a cloud-init icon with a tooltip when Image Service Gen 2 GA is enabled and the image supports cloud-init', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init'], + regions: [{ region: 'us-east', status: 'available' }], + }); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + , + { flags: { imageServiceGen2: true, imageServiceGen2Ga: true } } + ) + ); + + expect( + getByLabelText('This image supports our Metadata service via cloud-init.') + ).toBeVisible(); + }); + + it('does not show the compatibility column when Image Service Gen2 GA is enabled', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-sites'], + }); + + const { queryByText } = renderWithTheme( + wrapWithTableBody( + , + { flags: { imageServiceGen2: true, imageServiceGen2Ga: true } } + ) + ); + + expect(queryByText('Cloud-init, Distributed')).not.toBeInTheDocument(); + }); + + it('should show N/A if multiRegionsEnabled is true, but the Image does not have any regions', () => { + const image = imageFactory.build({ regions: [] }); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + , + { flags: { imageServiceGen2: true } } + ) + ); + + expect(getByText('N/A')).toBeVisible(); }); it('calls handlers when performing actions', async () => { + const image = imageFactory.build({ + regions: [{ region: 'us-east', status: 'available' }], + }); + const { getByLabelText, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 73cb2527106..c986d0cdbc8 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -1,10 +1,14 @@ -import * as React from 'react'; +import React from 'react'; +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; import { Hidden } from 'src/components/Hidden'; import { LinkButton } from 'src/components/LinkButton'; +import { Stack } from 'src/components/Stack'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useProfile } from 'src/queries/profile/profile'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; @@ -44,6 +48,7 @@ export const ImageRow = (props: Props) => { } = image; const { data: profile } = useProfile(); + const flags = useFlags(); const isFailed = status === 'pending_upload' && event?.status === 'failed'; @@ -92,23 +97,42 @@ export const ImageRow = (props: Props) => { return ( - {label} + + {capabilities.includes('cloud-init') && + flags.imageServiceGen2 && + flags.imageServiceGen2Ga ? ( + + +
    + +
    +
    + {label} +
    + ) : ( + label + )} +
    {getStatusForImage(status)} {multiRegionsEnabled && ( - <> - - + + + {regions.length > 0 ? ( handlers.onManageRegions?.(image)}> {pluralize('Region', 'Regions', regions.length)} - - - - {compatibilitiesList} - - + ) : ( + 'N/A' + )} + + + )} + {multiRegionsEnabled && !flags.imageServiceGen2Ga && ( + + {compatibilitiesList} + )} {getSizeForImage(size, status, event?.status)} diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 8311ea04c83..1a52b32b1c4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -450,14 +450,14 @@ export const ImagesLanding = () => { Status {multiRegionsEnabled && ( - <> - - Replicated in - - - Compatibility - - + + Replicated in + + )} + {multiRegionsEnabled && !flags.imageServiceGen2Ga && ( + + Compatibility + )} Date: Tue, 22 Oct 2024 11:34:49 -0400 Subject: [PATCH 46/64] feat: [UIE-8074] - DBaaS GA Summary tab (#11091) --- ...r-11091-upcoming-features-1728668394698.md | 5 + .../DatabaseBackups/DatabaseBackups.tsx | 8 +- .../DatabaseSummary/DatabaseSummary.test.tsx | 215 ++++++++++ .../DatabaseSummary/DatabaseSummary.tsx | 50 ++- .../DatabaseSummaryAccessControls.tsx | 18 - ...tabaseSummaryClusterConfiguration.style.ts | 51 +++ ...tabaseSummaryClusterConfiguration.test.tsx | 201 ++++++++++ .../DatabaseSummaryClusterConfiguration.tsx | 131 +++---- .../DatabaseSummaryConnectionDetails.style.ts | 99 +++++ .../DatabaseSummaryConnectionDetails.test.tsx | 129 ++++++ .../DatabaseSummaryConnectionDetails.tsx | 329 ++++------------ ...abaseSummaryClusterConfigurationLegacy.tsx | 146 +++++++ ...DatabaseSummaryConnectionDetailsLegacy.tsx | 370 ++++++++++++++++++ .../src/features/Databases/utilities.ts | 39 +- 14 files changed, 1422 insertions(+), 369 deletions(-) create mode 100644 packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx delete mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryAccessControls.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx diff --git a/packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md b/packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md new file mode 100644 index 00000000000..142cb841d0b --- /dev/null +++ b/packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS GA summary tab enhancements ([#11091](https://github.com/linode/manager/pull/11091)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index c150abd0bfa..a29b00fa088 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -136,7 +136,7 @@ export const DatabaseBackups = (props: Props) => { Summary Databases are automatically backed-up with full daily backups for the - past 10 days, and binary logs recorded continuously. Full backups are + past 14 days, and binary logs recorded continuously. Full backups are version-specific binary backups, which when combined with binary logs allow for consistent recovery to a specific point in time (PITR). @@ -146,13 +146,13 @@ export const DatabaseBackups = (props: Props) => { {isDatabasesV2GA ? ( The newest full backup plus incremental is selected by default. Or, - select any date and time within the last 10 days you want to create + select any date and time within the last 14 days you want to create a fork from. ) : ( - Select a date and time within the last 10 days you want to create a - forkfrom. + Select a date and time within the last 14 days you want to create a + fork from. )}
    diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx new file mode 100644 index 00000000000..b8c05182ff1 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx @@ -0,0 +1,215 @@ +import { waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { vi } from 'vitest'; + +import { databaseFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import * as utils from '../../utilities'; +import { DatabaseSummary } from './DatabaseSummary'; + +import type { Database } from '@linode/api-v4'; + +const CLUSTER_CONFIGURATION = 'Cluster Configuration'; +const THREE_NODE = 'Primary +2 replicas'; +const TWO_NODE = 'Primary +1 replicas'; +const VERSION = 'Version'; + +const CONNECTION_DETAILS = 'Connection Details'; +const PRIVATE_NETWORK_HOST = 'Private Network Host'; +const PRIVATE_NETWORK_HOST_LABEL = 'private network host'; +const READONLY_HOST_LABEL = 'read-only host'; +const GA_READONLY_HOST_LABEL = 'Read-only Host'; + +const ACCESS_CONTROLS = 'Access Controls'; + +const DEFAULT_PLATFORM = 'rdbms-default'; +const DEFAULT_PRIMARY = 'db-mysql-default-primary.net'; +const DEFAULT_STANDBY = 'db-mysql-default-standby.net'; + +const LEGACY_PLATFORM = 'rdbms-legacy'; +const LEGACY_PRIMARY = 'db-mysql-legacy-primary.net'; +const LEGACY_SECONDARY = 'db-mysql-legacy-secondary.net'; + +const spy = vi.spyOn(utils, 'useIsDatabasesEnabled'); +spy.mockReturnValue({ + isDatabasesEnabled: true, + isDatabasesV1Enabled: true, + isDatabasesV2Beta: false, + isDatabasesV2Enabled: true, + isDatabasesV2GA: true, + isUserExistingBeta: false, + isUserNewBeta: false, +}); + +describe('Database Summary', () => { + it('should render V2GA view default db', async () => { + const database = databaseFactory.build({ + cluster_size: 2, + hosts: { + primary: DEFAULT_PRIMARY, + standby: DEFAULT_STANDBY, + }, + platform: DEFAULT_PLATFORM, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + await waitFor(() => { + expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); + expect(queryAllByText(TWO_NODE)).toHaveLength(1); + expect(queryAllByText(VERSION)).toHaveLength(0); + + expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); + expect(queryAllByText(PRIVATE_NETWORK_HOST)).toHaveLength(0); + expect(queryAllByText(GA_READONLY_HOST_LABEL)).toHaveLength(1); + expect(queryAllByText(DEFAULT_STANDBY)).toHaveLength(1); + + expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(0); + }); + }); + + it('should render V2GA view legacy db', async () => { + const database = databaseFactory.build({ + cluster_size: 3, + hosts: { + primary: LEGACY_PRIMARY, + secondary: LEGACY_SECONDARY, + }, + platform: LEGACY_PLATFORM, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + await waitFor(() => { + expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); + expect(queryAllByText(THREE_NODE)).toHaveLength(1); + expect(queryAllByText(VERSION)).toHaveLength(0); + + expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); + expect(queryAllByText(PRIVATE_NETWORK_HOST)).toHaveLength(1); + expect(queryAllByText(GA_READONLY_HOST_LABEL)).toHaveLength(0); + expect(queryAllByText(LEGACY_SECONDARY)).toHaveLength(1); + + expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(0); + }); + }); + + it('should render Beta view default db', async () => { + spy.mockReturnValue({ + isDatabasesEnabled: true, + isDatabasesV1Enabled: true, + isDatabasesV2Beta: true, + isDatabasesV2Enabled: true, + isDatabasesV2GA: false, + isUserExistingBeta: true, + isUserNewBeta: false, + }); + const database = databaseFactory.build({ + cluster_size: 2, + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: DEFAULT_STANDBY, + }, + platform: DEFAULT_PLATFORM, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + await waitFor(() => { + expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); + expect(queryAllByText(TWO_NODE)).toHaveLength(1); + expect(queryAllByText(VERSION)).toHaveLength(1); + + expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); + expect(queryAllByText(PRIVATE_NETWORK_HOST_LABEL)).toHaveLength(0); + expect(queryAllByText(READONLY_HOST_LABEL)).toHaveLength(1); + expect(queryAllByText(/db-mysql-default-standby.net/)).toHaveLength(1); + + expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(1); + }); + }); + + it('should render Beta view legacy db', async () => { + spy.mockReturnValue({ + isDatabasesEnabled: true, + isDatabasesV1Enabled: true, + isDatabasesV2Beta: true, + isDatabasesV2Enabled: true, + isDatabasesV2GA: false, + isUserExistingBeta: true, + isUserNewBeta: false, + }); + const database = databaseFactory.build({ + cluster_size: 3, + hosts: { + primary: LEGACY_PRIMARY, + secondary: LEGACY_SECONDARY, + standby: undefined, + }, + platform: LEGACY_PLATFORM, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + await waitFor(() => { + expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); + expect(queryAllByText(THREE_NODE)).toHaveLength(1); + expect(queryAllByText(VERSION)).toHaveLength(1); + + expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); + expect(queryAllByText(PRIVATE_NETWORK_HOST_LABEL)).toHaveLength(1); + expect(queryAllByText(READONLY_HOST_LABEL)).toHaveLength(0); + expect(queryAllByText(/db-mysql-legacy-secondary.net/)).toHaveLength(1); + + expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(1); + }); + }); + + it('should render V1 view legacy db', async () => { + spy.mockReturnValue({ + isDatabasesEnabled: true, + isDatabasesV1Enabled: true, + isDatabasesV2Beta: false, + isDatabasesV2Enabled: false, + isDatabasesV2GA: false, + isUserExistingBeta: false, + isUserNewBeta: false, + }); + const database = databaseFactory.build({ + cluster_size: 3, + hosts: { + primary: LEGACY_PRIMARY, + secondary: LEGACY_SECONDARY, + standby: undefined, + }, + platform: LEGACY_PLATFORM, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + await waitFor(() => { + expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); + expect(queryAllByText(THREE_NODE)).toHaveLength(1); + expect(queryAllByText(VERSION)).toHaveLength(1); + + expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); + expect(queryAllByText(PRIVATE_NETWORK_HOST_LABEL)).toHaveLength(1); + expect(queryAllByText(READONLY_HOST_LABEL)).toHaveLength(0); + expect(queryAllByText(/db-mysql-legacy-secondary.net/)).toHaveLength(1); + + expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(1); + }); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index 37e50ebbdc2..f93c7f8fc0d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -5,10 +5,12 @@ import { Divider } from 'src/components/Divider'; import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; - -import AccessControls from '../AccessControls'; -import ClusterConfiguration from './DatabaseSummaryClusterConfiguration'; -import ConnectionDetails from './DatabaseSummaryConnectionDetails'; +import AccessControls from 'src/features/Databases/DatabaseDetail/AccessControls'; +import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; +import ConnectionDetails from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails'; +import ClusterConfigurationLegacy from 'src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy'; +import ConnectionDetailsLegacy from 'src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy'; +import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import type { Database } from '@linode/api-v4/lib/databases/types'; @@ -19,6 +21,7 @@ interface Props { export const DatabaseSummary: React.FC = (props) => { const { database, disabled } = props; + const { isDatabasesV2GA } = useIsDatabasesEnabled(); const description = ( <> @@ -40,19 +43,38 @@ export const DatabaseSummary: React.FC = (props) => { return ( - - + + {isDatabasesV2GA ? ( + + ) : ( + // Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 + // TODO (UIE-8214) remove POST GA + + )} - - + + {isDatabasesV2GA ? ( + + ) : ( + // Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 + // TODO (UIE-8214) remove POST GA + + )} - - + {!isDatabasesV2GA && ( + // Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 + // AccessControls accessible through dropdown menu on landing page table and on settings tab + // TODO (UIE-8214) remove POST GA + <> + + + + )} ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryAccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryAccessControls.tsx deleted file mode 100644 index c4eb0c625b6..00000000000 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryAccessControls.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -// import useDatabases from 'src/hooks/useDatabases'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface Props { - // databaseID: number; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const DatabaseSummaryAccessControls: React.FC = (props) => { - // const databases = useDatabases(); - // const { databaseID } = props; - // const thisDatabase = databases.databases.itemsById[databaseID]; - - return <> Access Controls ; -}; - -export default DatabaseSummaryAccessControls; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts new file mode 100644 index 00000000000..a187dac8e51 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts @@ -0,0 +1,51 @@ +import { styled } from '@mui/material/styles'; +import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; + +import { Typography } from 'src/components/Typography'; + +export const StyledGridContainer = styled(Grid2, { + label: 'StyledGridContainer', +})(({ theme }) => ({ + '&>*:nth-of-type(even)': { + boxShadow: `inset 0px -1px 0px 0 ${ + theme.palette.mode === 'dark' + ? theme.color.white + : theme.palette.grey[200] + }`, + }, + '&>*:nth-of-type(odd)': { + boxShadow: `inset 0px -1px 0px 0 ${theme.color.white}`, + marginBottom: '1px', + }, + boxShadow: `inset 0 -1px 0 0 ${ + theme.palette.mode === 'dark' ? theme.color.white : theme.palette.grey[200] + }, inset 0 1px 0 0 ${ + theme.palette.mode === 'dark' ? theme.color.white : theme.palette.grey[200] + }, inset -1px 0 0 ${ + theme.palette.mode === 'dark' ? theme.color.white : theme.palette.grey[200] + }`, +})); + +export const StyledLabelTypography = styled(Typography, { + label: 'StyledLabelTypography', +})(({ theme }) => ({ + background: + theme.palette.mode === 'dark' + ? theme.bg.tableHeader + : theme.palette.grey[200], + color: theme.palette.mode === 'dark' ? theme.color.grey6 : 'inherit', + fontFamily: theme.font.bold, + height: '100%', + padding: `${theme.spacing(0.5)} 15px`, +})); + +export const StyledValueGrid = styled(Grid2, { + label: 'StyledValueGrid', +})(({ theme }) => ({ + alignItems: 'center', + color: theme.palette.mode === 'dark' ? theme.color.grey8 : theme.color.black, + display: 'flex', + padding: `0 ${theme.spacing()}`, +})); + +// theme.spacing() 8 diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx new file mode 100644 index 00000000000..94231d3d897 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx @@ -0,0 +1,201 @@ +import { waitFor } from '@testing-library/react'; +import React from 'react'; + +import { databaseFactory, databaseTypeFactory } from 'src/factories/databases'; +import { regionFactory } from 'src/factories/regions'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseSummaryClusterConfiguration } from './DatabaseSummaryClusterConfiguration'; + +import type { Database, DatabaseStatus } from '@linode/api-v4/lib/databases'; + +const STATUS_VALUE = 'Active'; +const PLAN_VALUE = 'New DBaaS - Dedicated 8 GB'; +const NODES_VALUE = 'Primary +1 replicas'; +const REGION_ID = 'us-east'; +const REGION_LABEL = 'Newark, NJ'; + +const DEFAULT_PLATFORM = 'rdbms-default'; +const TYPE = 'g6-dedicated-4'; + +const queryMocks = vi.hoisted(() => ({ + useDatabaseTypesQuery: vi.fn().mockReturnValue({}), + useRegionsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/regions/regions', () => ({ + useRegionsQuery: queryMocks.useRegionsQuery, +})); + +vi.mock('src/queries/databases/databases', () => ({ + useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, +})); + +describe('DatabaseSummaryClusterConfiguration', () => { + it('should display correctly for default db', async () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: regionFactory.buildList(1, { + country: 'us', + id: REGION_ID, + label: REGION_LABEL, + status: 'ok', + }), + }); + + queryMocks.useDatabaseTypesQuery.mockReturnValue({ + data: databaseTypeFactory.buildList(1, { + class: 'dedicated', + disk: 163840, + id: TYPE, + label: PLAN_VALUE, + memory: 8192, + vcpus: 4, + }), + }); + + const database = databaseFactory.build({ + cluster_size: 2, + engine: 'postgresql', + platform: DEFAULT_PLATFORM, + region: REGION_ID, + status: STATUS_VALUE.toLowerCase() as DatabaseStatus, + total_disk_size_gb: 130, + type: TYPE, + used_disk_size_gb: 6, + version: '16.4', + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenCalledWith({ + platform: DEFAULT_PLATFORM, + }); + + await waitFor(() => { + expect(queryAllByText('Status')).toHaveLength(1); + expect(queryAllByText(STATUS_VALUE)).toHaveLength(1); + + expect(queryAllByText('Plan')).toHaveLength(1); + expect(queryAllByText(PLAN_VALUE)).toHaveLength(1); + + expect(queryAllByText('Nodes')).toHaveLength(1); + expect(queryAllByText(NODES_VALUE)).toHaveLength(1); + + expect(queryAllByText('CPUs')).toHaveLength(1); + expect(queryAllByText(4)).toHaveLength(1); + + expect(queryAllByText('Engine')).toHaveLength(1); + expect(queryAllByText('PostgreSQL v16.4')).toHaveLength(1); + + expect(queryAllByText('Region')).toHaveLength(1); + expect(queryAllByText(REGION_LABEL)).toHaveLength(1); + + expect(queryAllByText('RAM')).toHaveLength(1); + expect(queryAllByText('8 GB')).toHaveLength(1); + + expect(queryAllByText('Total Disk Size')).toHaveLength(1); + expect(queryAllByText('130 GB')).toHaveLength(1); + }); + }); + + it('should display correctly for legacy db', async () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: regionFactory.buildList(1, { + country: 'us', + id: 'us-southeast', + label: 'Atlanta, GA, USA', + status: 'ok', + }), + }); + + queryMocks.useDatabaseTypesQuery.mockReturnValue({ + data: databaseTypeFactory.buildList(1, { + class: 'nanode', + disk: 25600, + id: 'g6-nanode-1', + label: 'DBaaS - Nanode 1GB', + memory: 1024, + vcpus: 1, + }), + }); + + const database = databaseFactory.build({ + cluster_size: 1, + engine: 'mysql', + platform: 'rdbms-legacy', + region: 'us-southeast', + replication_type: 'none', + status: 'provisioning', + total_disk_size_gb: 15, + type: 'g6-nanode-1', + used_disk_size_gb: 2, + version: '8.0.30', + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenCalledWith({ + platform: 'rdbms-legacy', + }); + + await waitFor(() => { + expect(queryAllByText('Status')).toHaveLength(1); + expect(queryAllByText('Provisioning')).toHaveLength(1); + + expect(queryAllByText('Plan')).toHaveLength(1); + expect(queryAllByText('Nanode 1 GB')).toHaveLength(1); + + expect(queryAllByText('Nodes')).toHaveLength(1); + expect(queryAllByText('Primary')).toHaveLength(1); + + expect(queryAllByText('CPUs')).toHaveLength(1); + expect(queryAllByText(1)).toHaveLength(1); + + expect(queryAllByText('Engine')).toHaveLength(1); + expect(queryAllByText('MySQL v8.0.30')).toHaveLength(1); + + expect(queryAllByText('Region')).toHaveLength(1); + expect(queryAllByText('Atlanta, GA, USA')).toHaveLength(1); + + expect(queryAllByText('RAM')).toHaveLength(1); + expect(queryAllByText('1 GB')).toHaveLength(1); + + expect(queryAllByText('Total Disk Size')).toHaveLength(1); + expect(queryAllByText('15 GB')).toHaveLength(1); + }); + }); + + it('should return null when there is no matching type', async () => { + queryMocks.useDatabaseTypesQuery.mockReturnValue({ + data: databaseTypeFactory.buildList(1, { + class: 'standard', + disk: 81920, + id: 'g6-standard-2', + label: 'DBaaS - Standard 4GB', + memory: 4096, + vcpus: 2, + }), + }); + + const database = databaseFactory.build({ + platform: 'rdbms-legacy', + type: 'g6-nanode-1', + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryMocks.useDatabaseTypesQuery).toHaveBeenCalledWith({ + platform: 'rdbms-legacy', + }); + + await waitFor(() => { + expect(queryAllByText('Cluster Configuration')).toHaveLength(0); + }); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx index 721633578d9..f619078acab 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx @@ -1,57 +1,41 @@ +import Grid from '@mui/material/Unstable_Grid2/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Box } from 'src/components/Box'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; +import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; +import { + StyledGridContainer, + StyledLabelTypography, + StyledValueGrid, +} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; +import { databaseEngineMap } from 'src/features/Databases/DatabaseLanding/DatabaseRow'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { convertMegabytesTo } from 'src/utilities/unitConversions'; -import { databaseEngineMap } from '../../DatabaseLanding/DatabaseRow'; -import { DatabaseStatusDisplay } from '../DatabaseStatusDisplay'; - import type { Region } from '@linode/api-v4'; import type { Database, - DatabaseInstance, DatabaseType, } from '@linode/api-v4/lib/databases/types'; import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()((theme: Theme) => ({ - configs: { - fontSize: '0.875rem', - lineHeight: '22px', - }, header: { marginBottom: theme.spacing(2), }, - label: { - fontFamily: theme.font.bold, - lineHeight: '22px', - width: theme.spacing(13), - }, - status: { - alignItems: 'center', - display: 'flex', - textTransform: 'capitalize', - }, })); interface Props { database: Database; } -export const getDatabaseVersionNumber = ( - version: DatabaseInstance['version'] -) => version.split('/')[1]; - export const DatabaseSummaryClusterConfiguration = (props: Props) => { const { classes } = useStyles(); - const { database } = props; const { data: types } = useDatabaseTypesQuery({ @@ -88,60 +72,69 @@ export const DatabaseSummaryClusterConfiguration = (props: Props) => { Cluster Configuration -
    - - Status -
    - -
    -
    - - Version - {databaseEngineMap[database.engine]} v{database.version} - - - Nodes + + + Status + + + + + + Plan + + + {formatStorageUnits(type.label)} + + + Nodes + + {configuration} - - - Region + + + CPUs + + + {type.vcpus} + + + Engine + + + {databaseEngineMap[database.engine]} v{database.version} + + + Region + + {region?.label ?? database.region} - - - Plan - {formatStorageUnits(type.label)} - - - RAM + + + RAM + + {type.memory / 1024} GB - - - CPUs - {type.vcpus} - - {database.total_disk_size_gb ? ( - <> - - Total Disk Size + + + + {database.total_disk_size_gb ? 'Total Disk Size' : 'Storage'} + + + + {database.total_disk_size_gb ? ( + <> {database.total_disk_size_gb} GB - - - Used - {database.used_disk_size_gb} GB - - - ) : ( - - Storage - {convertMegabytesTo(type.disk, true)} - - )} -
    + + ) : ( + convertMegabytesTo(type.disk, true) + )} + + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts new file mode 100644 index 00000000000..325501935ca --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts @@ -0,0 +1,99 @@ +import { makeStyles } from 'tss-react/mui'; + +import type { Theme } from '@mui/material/styles'; + +export const useStyles = makeStyles()((theme: Theme) => ({ + actionBtnsCtn: { + display: 'flex', + justifyContent: 'flex-end', + marginTop: '10px', + padding: `${theme.spacing(1)} 0`, + }, + caCertBtn: { + '& svg': { + marginRight: theme.spacing(), + }, + '&:hover': { + backgroundColor: 'transparent', + opacity: 0.7, + }, + '&[disabled]': { + '& g': { + stroke: '#cdd0d5', + }, + '&:hover': { + backgroundColor: 'inherit', + textDecoration: 'none', + }, + // Override disabled background color defined for dark mode + backgroundColor: 'transparent', + color: '#cdd0d5', + cursor: 'default', + }, + color: theme.palette.primary.main, + fontFamily: theme.font.bold, + fontSize: '0.875rem', + lineHeight: '1.125rem', + marginLeft: theme.spacing(), + minHeight: 'auto', + minWidth: 'auto', + padding: 0, + }, + connectionDetailsCtn: { + '& p': { + lineHeight: '1.5rem', + }, + '& span': { + fontFamily: theme.font.bold, + }, + background: theme.interactionTokens.Background.Secondary, + border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, + padding: `${theme.spacing(1)} 15px`, + }, + copyToolTip: { + '& svg': { + color: theme.palette.primary.main, + height: `${theme.spacing(2)} !important`, + width: `${theme.spacing(2)} !important`, + }, + marginRight: 12, + }, + error: { + color: theme.color.red, + marginLeft: theme.spacing(2), + }, + header: { + marginBottom: theme.spacing(2), + }, + inlineCopyToolTip: { + '& svg': { + height: theme.spacing(2), + width: theme.spacing(2), + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'inline-flex', + marginLeft: theme.spacing(0.5), + }, + progressCtn: { + '& circle': { + stroke: theme.palette.primary.main, + }, + alignSelf: 'flex-end', + marginBottom: 2, + marginLeft: 22, + }, + provisioningText: { + fontFamily: theme.font.normal, + fontStyle: 'italic', + }, + showBtn: { + color: theme.palette.primary.main, + fontSize: '0.875rem', + marginLeft: theme.spacing(), + minHeight: 'auto', + minWidth: 'auto', + padding: 0, + }, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx new file mode 100644 index 00000000000..7b0e11c7009 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx @@ -0,0 +1,129 @@ +import { waitFor } from '@testing-library/react'; +import React from 'react'; + +import { databaseFactory } from 'src/factories/databases'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import DatabaseSummaryConnectionDetails from './DatabaseSummaryConnectionDetails'; + +import type { Database } from '@linode/api-v4/lib/databases'; + +const AKMADMIN = 'akmadmin'; +const POSTGRESQL = 'postgresql'; +const DEFAULT_PRIMARY = 'db-mysql-default-primary.net'; +const DEFAULT_STANDBY = 'db-mysql-default-standby.net'; + +const MYSQL = 'mysql'; +const LINROOT = 'linroot'; +const LEGACY_PRIMARY = 'db-mysql-legacy-primary.net'; +const LEGACY_SECONDARY = 'db-mysql-legacy-secondary.net'; + +const queryMocks = vi.hoisted(() => ({ + useDatabaseCredentialsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/databases/databases', () => ({ + useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, +})); + +describe('DatabaseSummaryConnectionDetails', () => { + it('should display correctly for default db', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: { + password: 'abc123', + username: AKMADMIN, + }, + }); + + const database = databaseFactory.build({ + engine: POSTGRESQL, + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: DEFAULT_STANDBY, + }, + id: 99, + platform: 'rdbms-default', + port: 22496, + ssl_connection: true, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryMocks.useDatabaseCredentialsQuery).toHaveBeenCalledWith( + POSTGRESQL, + 99 + ); + + await waitFor(() => { + expect(queryAllByText('Username')).toHaveLength(1); + expect(queryAllByText(AKMADMIN)).toHaveLength(1); + + expect(queryAllByText('Password')).toHaveLength(1); + + expect(queryAllByText('Host')).toHaveLength(1); + expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1); + + expect(queryAllByText('Read-only Host')).toHaveLength(1); + expect(queryAllByText(DEFAULT_STANDBY)).toHaveLength(1); + + expect(queryAllByText('Port')).toHaveLength(1); + expect(queryAllByText('22496')).toHaveLength(1); + + expect(queryAllByText('SSL')).toHaveLength(1); + expect(queryAllByText('ENABLED')).toHaveLength(1); + }); + }); + + it('should display correctly for legacy db', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: { + password: 'abc123', + username: LINROOT, + }, + }); + + const database = databaseFactory.build({ + engine: MYSQL, + hosts: { + primary: LEGACY_PRIMARY, + secondary: LEGACY_SECONDARY, + standby: undefined, + }, + id: 22, + platform: 'rdbms-legacy', + port: 3306, + ssl_connection: true, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryMocks.useDatabaseCredentialsQuery).toHaveBeenCalledWith( + MYSQL, + 22 + ); + + await waitFor(() => { + expect(queryAllByText('Username')).toHaveLength(1); + expect(queryAllByText(LINROOT)).toHaveLength(1); + + expect(queryAllByText('Password')).toHaveLength(1); + + expect(queryAllByText('Host')).toHaveLength(1); + expect(queryAllByText(LEGACY_PRIMARY)).toHaveLength(1); + + expect(queryAllByText('Private Network Host')).toHaveLength(1); + expect(queryAllByText(LEGACY_SECONDARY)).toHaveLength(1); + + expect(queryAllByText('Port')).toHaveLength(1); + expect(queryAllByText('3306')).toHaveLength(1); + + expect(queryAllByText('SSL')).toHaveLength(1); + expect(queryAllByText('ENABLED')).toHaveLength(1); + }); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index 17b54795979..48b122b70ef 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,13 +1,9 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; -import { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; -import { useTheme } from '@mui/material'; -import { Theme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2/Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import DownloadIcon from 'src/assets/icons/lke-download.svg'; -import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; @@ -18,100 +14,14 @@ import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; import { downloadFile } from 'src/utilities/downloadFile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -const useStyles = makeStyles()((theme: Theme) => ({ - actionBtnsCtn: { - display: 'flex', - justifyContent: 'flex-end', - padding: `${theme.spacing(1)} 0`, - }, - caCertBtn: { - '& svg': { - marginRight: theme.spacing(), - }, - '&:hover': { - backgroundColor: 'transparent', - opacity: 0.7, - }, - '&[disabled]': { - '& g': { - stroke: '#cdd0d5', - }, - '&:hover': { - backgroundColor: 'inherit', - textDecoration: 'none', - }, - // Override disabled background color defined for dark mode - backgroundColor: 'transparent', - color: '#cdd0d5', - cursor: 'default', - }, - color: theme.palette.primary.main, - fontFamily: theme.font.bold, - fontSize: '0.875rem', - lineHeight: '1.125rem', - marginLeft: theme.spacing(), - minHeight: 'auto', - minWidth: 'auto', - padding: 0, - }, - connectionDetailsCtn: { - '& p': { - lineHeight: '1.5rem', - }, - '& span': { - fontFamily: theme.font.bold, - }, - background: theme.interactionTokens.Background.Secondary, - border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, - padding: '8px 15px', - }, - copyToolTip: { - '& svg': { - color: theme.palette.primary.main, - height: `16px !important`, - width: `16px !important`, - }, - marginRight: 12, - }, - error: { - color: theme.color.red, - marginLeft: theme.spacing(2), - }, - header: { - marginBottom: theme.spacing(2), - }, - inlineCopyToolTip: { - '& svg': { - height: `16px`, - width: `16px`, - }, - '&:hover': { - backgroundColor: 'transparent', - }, - display: 'inline-flex', - marginLeft: 4, - }, - progressCtn: { - '& circle': { - stroke: theme.palette.primary.main, - }, - alignSelf: 'flex-end', - marginBottom: 2, - marginLeft: 22, - }, - provisioningText: { - fontFamily: theme.font.normal, - fontStyle: 'italic', - }, - showBtn: { - color: theme.palette.primary.main, - fontSize: '0.875rem', - marginLeft: theme.spacing(), - minHeight: 'auto', - minWidth: 'auto', - padding: 0, - }, -})); +import { + StyledGridContainer, + StyledLabelTypography, + StyledValueGrid, +} from './DatabaseSummaryClusterConfiguration.style'; +import { useStyles } from './DatabaseSummaryConnectionDetails.style'; + +import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; interface Props { database: Database; @@ -125,14 +35,11 @@ const sxTooltipIcon = { const privateHostCopy = 'A private network host and a private IP can only be used to access a Database Cluster from Linodes in the same data center and will not incur transfer costs.'; -const mongoHostHelperCopy = - 'This is a public hostname. Coming soon: connect to your MongoDB clusters using private IPs'; - export const DatabaseSummaryConnectionDetails = (props: Props) => { const { database } = props; const { classes } = useStyles(); - const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); + const isLegacy = database.platform !== 'rdbms-default'; const [showCredentials, setShowPassword] = React.useState(false); const [isCACertDownloading, setIsCACertDownloading] = React.useState( @@ -160,9 +67,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { setShowPassword((showCredentials) => !showCredentials); }; - const isMongoReplicaSet = - database.engine === 'mongodb' && database.cluster_size > 1; - React.useEffect(() => { if (showCredentials && !credentials) { getDatabaseCredentials(); @@ -198,7 +102,31 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { const disableShowBtn = ['failed', 'provisioning'].includes(database.status); const disableDownloadCACertificateBtn = database.status === 'provisioning'; - const readOnlyHost = database?.hosts?.standby || database?.hosts?.secondary; + const readOnlyHostValue = + database?.hosts?.standby ?? database?.hosts?.secondary ?? ''; + + const readOnlyHost = () => { + const defaultValue = isLegacy ? '-' : 'not available'; + const value = readOnlyHostValue ?? defaultValue; + return ( + <> + {value} + {value && ( + + )} + {isLegacy && ( + + )} + + ); + }; const credentialsBtn = (handleClick: () => void, btnText: string) => { return ( @@ -223,7 +151,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { Download CA Certificate - {disableDownloadCACertificateBtn ? ( + {disableDownloadCACertificateBtn && ( { text="Your Database Cluster is currently provisioning." /> - ) : null} + )} ); @@ -240,14 +168,18 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { Connection Details - - - username = {username} - - - - password = {password} - + + + Username + + + {username} + + + Password + + + {password} {showCredentials && credentialsLoading ? (
    @@ -265,7 +197,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { showCredentials && credentials ? 'Hide' : 'Show' ) )} - {disableShowBtn ? ( + {disableShowBtn && ( { status="help" sxTooltipIcon={sxTooltipIcon} /> - ) : null} - {showCredentials && credentials ? ( + )} + {showCredentials && credentials && ( - ) : null} - - - {!isMongoReplicaSet ? ( - - {database.hosts?.primary ? ( - <> - - host ={' '} - - {database.hosts?.primary} - {' '} - - - {database.engine === 'mongodb' ? ( - - ) : null} - - ) : ( - - host ={' '} - - Your hostname will appear here once it is available. - - - )} - - ) : ( - <> - - hosts ={' '} - {!database.peers || database.peers.length === 0 ? ( - - Your hostnames will appear here once they are available. - - ) : null} - - {database.peers && database.peers.length > 0 - ? database.peers.map((hostname, i) => ( - - - - {hostname} - - - - {/* Display the helper text on the first hostname */} - {i === 0 ? ( - - ) : null} - - )) - : null} - )} - - {readOnlyHost ? ( - - - {database.platform === 'rdbms-default' ? ( - read-only host - ) : ( - private network host - )} - = {readOnlyHost} - - - - - ) : null} - - port = {database.port} - - {isMongoReplicaSet ? ( - database.replica_set ? ( - - - replica set ={' '} - - {database.replica_set} - - + + + Host + + + {database.hosts?.primary ? ( + <> + {database.hosts?.primary} - + ) : ( - replica set ={' '} - Your replica set will appear here once it is available. + Your hostname will appear here once it is available. - ) - ) : null} - - ssl = {database.ssl_connection ? 'ENABLED' : 'DISABLED'} - - + )} + + + + {isLegacy ? 'Private Network Host' : 'Read-only Host'} + + + + {readOnlyHost()} + + + Port + + + {database.port} + + + SSL + + + {database.ssl_connection ? 'ENABLED' : 'DISABLED'} + +
    {database.ssl_connection ? caCertificateJSX : null}
    diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx new file mode 100644 index 00000000000..5bc03953c51 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { Box } from 'src/components/Box'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; +import { databaseEngineMap } from 'src/features/Databases/DatabaseLanding/DatabaseRow'; +import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; +import { useInProgressEvents } from 'src/queries/events/events'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; +import { convertMegabytesTo } from 'src/utilities/unitConversions'; + +import type { Region } from '@linode/api-v4'; +import type { + Database, + DatabaseType, +} from '@linode/api-v4/lib/databases/types'; +import type { Theme } from '@mui/material/styles'; + +const useStyles = makeStyles()((theme: Theme) => ({ + configs: { + fontSize: '0.875rem', + lineHeight: '22px', + }, + header: { + marginBottom: theme.spacing(2), + }, + label: { + fontFamily: theme.font.bold, + lineHeight: '22px', + width: theme.spacing(13), + }, + status: { + alignItems: 'center', + display: 'flex', + textTransform: 'capitalize', + }, +})); + +interface Props { + database: Database; +} + +/** + * Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 + * TODO (UIE-8214) remove POST GA + */ +export const DatabaseSummaryClusterConfigurationLegacy = (props: Props) => { + const { classes } = useStyles(); + const { database } = props; + + const { data: types } = useDatabaseTypesQuery({ + platform: database.platform, + }); + + const type = types?.find((type: DatabaseType) => type.id === database?.type); + + const { data: regions } = useRegionsQuery(); + + const region = regions?.find((r: Region) => r.id === database.region); + + const { data: events } = useInProgressEvents(); + + if (!database || !type) { + return null; + } + + const configuration = + database.cluster_size === 1 + ? 'Primary' + : `Primary +${database.cluster_size - 1} replicas`; + + const sxTooltipIcon = { + marginLeft: '4px', + padding: '0px', + }; + + const STORAGE_COPY = + 'The total disk size is smaller than the selected plan capacity due to overhead from the OS.'; + + return ( + <> + + Cluster Configuration + +
    + + Status +
    + +
    +
    + + Version + {databaseEngineMap[database.engine]} v{database.version} + + + Nodes + {configuration} + + + Region + {region?.label ?? database.region} + + + Plan + {formatStorageUnits(type.label)} + + + RAM + {type.memory / 1024} GB + + + CPUs + {type.vcpus} + + {database.total_disk_size_gb ? ( + <> + + Total Disk Size + {database.total_disk_size_gb} GB + + + + Used + {database.used_disk_size_gb} GB + + + ) : ( + + Storage + {convertMegabytesTo(type.disk, true)} + + )} +
    + + ); +}; + +export default DatabaseSummaryClusterConfigurationLegacy; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx new file mode 100644 index 00000000000..8fbdb195452 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx @@ -0,0 +1,370 @@ +import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; +import { useTheme } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import DownloadIcon from 'src/assets/icons/lke-download.svg'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { DB_ROOT_USERNAME } from 'src/constants'; +import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; +import { downloadFile } from 'src/utilities/downloadFile'; +import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; + +import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; +import type { Theme } from '@mui/material/styles'; + +const useStyles = makeStyles()((theme: Theme) => ({ + actionBtnsCtn: { + display: 'flex', + justifyContent: 'flex-end', + marginTop: '10px', + padding: `${theme.spacing(1)} 0`, + }, + caCertBtn: { + '& svg': { + marginRight: theme.spacing(), + }, + '&:hover': { + backgroundColor: 'transparent', + opacity: 0.7, + }, + '&[disabled]': { + '& g': { + stroke: '#cdd0d5', + }, + '&:hover': { + backgroundColor: 'inherit', + textDecoration: 'none', + }, + // Override disabled background color defined for dark mode + backgroundColor: 'transparent', + color: '#cdd0d5', + cursor: 'default', + }, + color: theme.palette.primary.main, + fontFamily: theme.font.bold, + fontSize: '0.875rem', + lineHeight: '1.125rem', + marginLeft: theme.spacing(), + minHeight: 'auto', + minWidth: 'auto', + padding: 0, + }, + connectionDetailsCtn: { + '& p': { + lineHeight: '1.5rem', + }, + '& span': { + fontFamily: theme.font.bold, + }, + background: theme.bg.bgAccessRowTransparentGradient, + border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, + padding: '8px 15px', + }, + copyToolTip: { + '& svg': { + color: theme.palette.primary.main, + height: `16px !important`, + width: `16px !important`, + }, + marginRight: 12, + }, + error: { + color: theme.color.red, + marginLeft: theme.spacing(2), + }, + header: { + marginBottom: theme.spacing(2), + }, + inlineCopyToolTip: { + '& svg': { + height: `16px`, + width: `16px`, + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'inline-flex', + marginLeft: 4, + }, + progressCtn: { + '& circle': { + stroke: theme.palette.primary.main, + }, + alignSelf: 'flex-end', + marginBottom: 2, + marginLeft: 22, + }, + provisioningText: { + fontFamily: theme.font.normal, + fontStyle: 'italic', + }, + showBtn: { + color: theme.palette.primary.main, + fontSize: '0.875rem', + marginLeft: theme.spacing(), + minHeight: 'auto', + minWidth: 'auto', + padding: 0, + }, +})); + +interface Props { + database: Database; +} + +const sxTooltipIcon = { + marginLeft: '4px', + padding: '0px', +}; + +const privateHostCopy = + 'A private network host and a private IP can only be used to access a Database Cluster from Linodes in the same data center and will not incur transfer costs.'; + +const mongoHostHelperCopy = + 'This is a public hostname. Coming soon: connect to your MongoDB clusters using private IPs'; + +/** + * Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 + * TODO (UIE-8214) remove POST GA + */ +export const DatabaseSummaryConnectionDetailsLegacy = (props: Props) => { + const { database } = props; + const { classes } = useStyles(); + const theme = useTheme(); + const { enqueueSnackbar } = useSnackbar(); + + const [showCredentials, setShowPassword] = React.useState(false); + const [isCACertDownloading, setIsCACertDownloading] = React.useState( + false + ); + + const { + data: credentials, + error: credentialsError, + isLoading: credentialsLoading, + refetch: getDatabaseCredentials, + } = useDatabaseCredentialsQuery(database.engine, database.id); + + const username = + database.platform === 'rdbms-default' + ? 'akmadmin' + : database.engine === 'postgresql' + ? 'linpostgres' + : DB_ROOT_USERNAME; + + const password = + showCredentials && credentials ? credentials?.password : '••••••••••'; + + const handleShowPasswordClick = () => { + setShowPassword((showCredentials) => !showCredentials); + }; + + React.useEffect(() => { + if (showCredentials && !credentials) { + getDatabaseCredentials(); + } + }, [credentials, getDatabaseCredentials, showCredentials]); + + const handleDownloadCACertificate = () => { + setIsCACertDownloading(true); + getSSLFields(database.engine, database.id) + .then((response: SSLFields) => { + // Convert to utf-8 from base64 + try { + const decodedFile = window.atob(response.ca_certificate); + downloadFile(`${database.label}-ca-certificate.crt`, decodedFile); + setIsCACertDownloading(false); + } catch (e) { + enqueueSnackbar('Error parsing your CA Certificate file', { + variant: 'error', + }); + setIsCACertDownloading(false); + return; + } + }) + .catch((errorResponse: any) => { + const error = getErrorStringOrDefault( + errorResponse, + 'Unable to download your CA Certificate' + ); + setIsCACertDownloading(false); + enqueueSnackbar(error, { variant: 'error' }); + }); + }; + + const disableShowBtn = ['failed', 'provisioning'].includes(database.status); + const disableDownloadCACertificateBtn = database.status === 'provisioning'; + const readOnlyHost = database?.hosts?.standby || database?.hosts?.secondary; + + const credentialsBtn = (handleClick: () => void, btnText: string) => { + return ( + + ); + }; + + const caCertificateJSX = ( + <> + + {disableDownloadCACertificateBtn && ( + + + + )} + + ); + + return ( + <> + + Connection Details + + + + username = {username} + + + + password = {password} + + {showCredentials && credentialsLoading ? ( +
    + +
    + ) : credentialsError ? ( + <> + + Error retrieving credentials. + + {credentialsBtn(() => getDatabaseCredentials(), 'Retry')} + + ) : ( + credentialsBtn( + handleShowPasswordClick, + showCredentials && credentials ? 'Hide' : 'Show' + ) + )} + {disableShowBtn && ( + + )} + {showCredentials && credentials && ( + + )} +
    + + + hosts ={' '} + {(!database.peers || database.peers.length === 0) && ( + + Your hostnames will appear here once they are available. + + )} + + {database.peers && + database.peers.length > 0 && + database.peers.map((hostname, i) => ( + + + + {hostname} + + + + {/* Display the helper text on the first hostname */} + {i === 0 && ( + + )} + + ))} + + {readOnlyHost && ( + + + {database.platform === 'rdbms-default' ? ( + read-only host + ) : ( + private network host + )} + = {readOnlyHost} + + + {database.platform === 'rdbms-legacy' && ( + + )} + + )} + + port = {database.port} + + + ssl = {database.ssl_connection ? 'ENABLED' : 'DISABLED'} + +
    +
    + {database.ssl_connection ? caCertificateJSX : null} +
    + + ); +}; + +export default DatabaseSummaryConnectionDetailsLegacy; diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index ce4fe9bc149..2d3179964c0 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -1,17 +1,22 @@ -import type { DatabaseInstance } from '@linode/api-v4'; -import { DatabaseFork } from '@linode/api-v4'; import { DateTime } from 'luxon'; + import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account/account'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; + import { databaseEngineMap } from './DatabaseLanding/DatabaseRow'; +import type { DatabaseInstance } from '@linode/api-v4'; +import type { DatabaseFork } from '@linode/api-v4'; + export interface IsDatabasesEnabled { isDatabasesEnabled: boolean; + isDatabasesMonitorBeta?: boolean; + isDatabasesMonitorEnabled?: boolean; isDatabasesV1Enabled: boolean; - isDatabasesV2Enabled: boolean; isDatabasesV2Beta: boolean; + isDatabasesV2Enabled: boolean; isDatabasesV2GA: boolean; /** * Temporary variable to be removed post GA release @@ -21,8 +26,6 @@ export interface IsDatabasesEnabled { * Temporary variable to be removed post GA release */ isUserNewBeta: boolean; - isDatabasesMonitorEnabled: boolean; - isDatabasesMonitorBeta: boolean; } /** @@ -75,18 +78,16 @@ export const useIsDatabasesEnabled = (): IsDatabasesEnabled => { return { isDatabasesEnabled: isDatabasesV1Enabled || isDatabasesV2Enabled, + isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta, + isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled, isDatabasesV1Enabled, - isDatabasesV2Enabled, - isDatabasesV2Beta, - isUserExistingBeta: isDatabasesV2Beta && isDatabasesV1Enabled, - isUserNewBeta: isDatabasesV2Beta && !isDatabasesV1Enabled, - + isDatabasesV2Enabled, isDatabasesV2GA: (isDatabasesV1Enabled || isDatabasesV2Enabled) && hasV2GAFlag, - isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled, - isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta, + isUserExistingBeta: isDatabasesV2Beta && isDatabasesV1Enabled, + isUserNewBeta: isDatabasesV2Beta && !isDatabasesV1Enabled, }; } @@ -95,17 +96,14 @@ export const useIsDatabasesEnabled = (): IsDatabasesEnabled => { return { isDatabasesEnabled: hasLegacyTypes || hasDefaultTypes, + isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta, + isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled, isDatabasesV1Enabled: hasLegacyTypes, - isDatabasesV2Enabled: hasDefaultTypes, - isDatabasesV2Beta: hasDefaultTypes && hasV2BetaFlag, + isDatabasesV2Enabled: hasDefaultTypes, + isDatabasesV2GA: (hasLegacyTypes || hasDefaultTypes) && hasV2GAFlag, isUserExistingBeta: hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag, isUserNewBeta: !hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag, - - isDatabasesV2GA: (hasLegacyTypes || hasDefaultTypes) && hasV2GAFlag, - - isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled, - isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta, }; }; @@ -169,8 +167,7 @@ export const toSelectedDateTime = ( const isoTime = DateTime.now() .set({ hour: time, minute: 0 }) ?.toISOTime({ includeOffset: false }); - const selectedDateTime = DateTime.fromISO(`${isoDate}T${isoTime}`); - return selectedDateTime; + return DateTime.fromISO(`${isoDate}T${isoTime}`); }; /** From e9b001dc668072b5c90febc16a4969bf68004947 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Tue, 22 Oct 2024 22:36:30 +0530 Subject: [PATCH 47/64] upcoming: [DI-20928] - Introduce label for all global filters (#11118) * upcoming: [DI-20928] - Filter titles initial changes * upcoming: [DI-20928] - ESLint issue fix * upcoming: [DI-20928] - Name to label alias change * upcoming: [DI-20928] - Add a margin below filter button to look neat while showing filter * upcoming: [DI-20928] - Fix UT issues * upcoming: [DI-20928] - Remove unused import * upcoming: [DI-20928] - PR comments * upcoming: [DI-20928] - PR comments * upcoming: [DI-20928] - Title name updates * upcoming: [DI-20928] - Title name for dashboard and time range selection too * upcoming: [DI-20928] - Title name for dashboard and time range selection too * upcoming: [DI-20928] - Test fixes * upcoming: [DI-20928] - CSS changes * upcoming: [DI-20928] - Code refactoring and UT additions * upcoming: [DI-20928] - Code refactoring and edits * upcoming: [DI-20928] - Destructure properties from objects and use * upcoming: [DI-20928] - Resource to Resources * label changes * upcoming: [DI-20928] - Popper placement changes * upcoming: [DI-20928] - PR comments * upcoming: [DI-20928] - ESLint issue fix * upcoming: [DI-20928] - Added comments * upcoming: [DI-20928] - remove unwanted changes in package.json * upcoming: [DI-20928] - use theme and add changeset * upcoming: [DI-20928] - more simplification * upcoming: [DI-20928] - more space for filters max height * upcoming: [DI-20928] - remove comment * upcoming: [DI-20928] - destructure props * upcoming: [DI-20928] - DB to Databases * upcoming: [DI-20928] - DB to Databases for engine * upcoming: [DI-20928] - Minor PR comments * upcoming: [DI-20928] - Minor PR comments * upcoming: [DI-20928] - Minor PR comments * upcoming: [DI-20928] - Delete unused imports --------- Co-authored-by: vmangalr Co-authored-by: agorthi --- ...r-11118-upcoming-features-1729486113842.md | 5 + .../dbaas-widgets-verification.spec.ts | 10 +- .../linode-widget-verification.spec.ts | 6 +- .../cypress/support/util/cloudpulse.ts | 9 +- .../Autocomplete/Autocomplete.styles.tsx | 8 +- .../components/RegionSelect/RegionSelect.tsx | 2 + .../Overview/GlobalFilters.test.tsx | 2 +- .../CloudPulse/Overview/GlobalFilters.tsx | 3 +- .../CloudPulse/Utils/FilterBuilder.test.ts | 106 ++++++++++++------ .../CloudPulse/Utils/FilterBuilder.ts | 11 +- .../features/CloudPulse/Utils/FilterConfig.ts | 14 +-- .../CloudPulseComponentRenderer.test.tsx | 2 +- .../shared/CloudPulseCustomSelect.test.tsx | 8 ++ .../shared/CloudPulseCustomSelect.tsx | 12 +- .../CloudPulseDashboardFilterBuilder.test.tsx | 2 +- .../CloudPulseDashboardFilterBuilder.tsx | 11 +- .../shared/CloudPulseDashboardSelect.tsx | 6 +- .../shared/CloudPulseRegionSelect.test.tsx | 5 +- .../shared/CloudPulseRegionSelect.tsx | 8 +- .../shared/CloudPulseResourcesSelect.test.tsx | 13 ++- .../shared/CloudPulseResourcesSelect.tsx | 8 +- .../shared/CloudPulseTimeRangeSelect.tsx | 8 +- 22 files changed, 171 insertions(+), 88 deletions(-) create mode 100644 packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md diff --git a/packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md b/packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md new file mode 100644 index 00000000000..dba2806a662 --- /dev/null +++ b/packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add title / label for all global filters in ACLP ([#11118](https://github.com/linode/manager/pull/11118)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index b5b479ced6d..d1f1181337d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -167,21 +167,21 @@ describe('Integration Tests for DBaaS Dashboard ', () => { // Selecting a dashboard from the autocomplete input. ui.autocomplete - .findByLabel('Select a Dashboard') + .findByLabel('Dashboard') .should('be.visible') .type(`${dashboardName}{enter}`) .should('be.visible'); // Select a time duration from the autocomplete input. ui.autocomplete - .findByLabel('Select a Time Duration') + .findByLabel('Time Range') .should('be.visible') .type(`${timeDurationToSelect}{enter}`) .should('be.visible'); //Select a Engine from the autocomplete input. ui.autocomplete - .findByLabel('Select an Engine') + .findByLabel('Database Engine') .should('be.visible') .type(`${engine}{enter}`) .should('be.visible'); @@ -191,7 +191,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { // Select a resource from the autocomplete input. ui.autocomplete - .findByLabel('Select a Resource') + .findByLabel('Database Clusters') .should('be.visible') .type(`${clusterName}{enter}`) .click(); @@ -199,7 +199,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { //Select a Node from the autocomplete input. ui.autocomplete - .findByLabel('Select a Node Type') + .findByLabel('Node Type') .should('be.visible') .type(`${nodeType}{enter}`); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index dd95d49c7f2..11106e45858 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -139,14 +139,14 @@ describe('Integration Tests for Linode Dashboard ', () => { // Selecting a dashboard from the autocomplete input. ui.autocomplete - .findByLabel('Select a Dashboard') + .findByLabel('Dashboard') .should('be.visible') .type(`${dashboardName}{enter}`) .should('be.visible'); // Select a time duration from the autocomplete input. ui.autocomplete - .findByLabel('Select a Time Duration') + .findByLabel('Time Range') .should('be.visible') .type(`${timeDurationToSelect}{enter}`) .should('be.visible'); @@ -156,7 +156,7 @@ describe('Integration Tests for Linode Dashboard ', () => { // Select a resource from the autocomplete input. ui.autocomplete - .findByLabel('Select a Resource') + .findByLabel('Resources') .should('be.visible') .type(`${resource}{enter}`) .click(); diff --git a/packages/manager/cypress/support/util/cloudpulse.ts b/packages/manager/cypress/support/util/cloudpulse.ts index 78c36f4b8d4..a760bc84f39 100644 --- a/packages/manager/cypress/support/util/cloudpulse.ts +++ b/packages/manager/cypress/support/util/cloudpulse.ts @@ -1,13 +1,10 @@ // Function to generate random values based on the number of points import type { CloudPulseMetricsResponseData } from '@linode/api-v4'; +import type { Labels } from 'src/features/CloudPulse/shared/CloudPulseTimeRangeSelect'; + export const generateRandomMetricsData = ( - time: - | 'Last 7 Days' - | 'Last 12 Hours' - | 'Last 24 Hours' - | 'Last 30 Days' - | 'Last 30 Minutes', + time: Labels, granularityData: '1 day' | '1 hr' | '5 min' | 'Auto' ): CloudPulseMetricsResponseData => { const currentTime = Math.floor(Date.now() / 1000); diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx index 9f31b39af99..5e4c9989e51 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx @@ -44,7 +44,7 @@ export const SelectedIcon = styled(DoneIcon, { })); export const CustomPopper = (props: PopperProps) => { - const { style, ...rest } = props; + const { placement, style, ...rest } = props; const updatedStyle = { ...style, @@ -58,9 +58,13 @@ export const CustomPopper = (props: PopperProps) => { return ( ); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 5d67de51d21..1a107f1aa3f 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -51,6 +51,7 @@ export const RegionSelect = < helperText, ignoreAccountAvailability, label, + noMarginTop, onChange, placeholder, regionFilter, @@ -172,6 +173,7 @@ export const RegionSelect = < label={label ?? 'Region'} loading={accountAvailabilityLoading} loadingText="Loading regions..." + noMarginTop={noMarginTop} noOptionsText="No results" onChange={onChange} options={regionOptions} diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx index bdb9bc34884..286c95377e1 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx @@ -35,7 +35,7 @@ describe('Global filters component test', () => { expect(timeRangeSelect).toBeInTheDocument(); expect( - screen.getByRole('combobox', { name: 'Select a Time Duration' }) + screen.getByRole('combobox', { name: 'Time Range' }) ).toHaveAttribute('value', 'Last 30 Minutes'); }); }); diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 320e8e1280d..5da22919834 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -107,13 +107,14 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { defaultValue={preferences?.timeDuration} handleStatsChange={handleTimeRangeChange} hideLabel - label="Select Time Range" + label="Time Range" savePreferences /> { expect(regionConfig).toBeDefined(); if (regionConfig) { - const result = getRegionProperties( + const { + handleRegionChange, + label, + selectedDashboard, + } = getRegionProperties( { config: regionConfig, dashboard: mockDashboard, @@ -39,20 +43,26 @@ it('test getRegionProperties method', () => { }, vi.fn() ); - expect(result['handleRegionChange']).toBeDefined(); - expect(result['selectedDashboard']).toEqual(mockDashboard); + const { name } = regionConfig.configuration; + expect(handleRegionChange).toBeDefined(); + expect(selectedDashboard).toEqual(mockDashboard); + expect(label).toEqual(name); } }); it('test getTimeDuratonProperties method', () => { const timeDurationConfig = linodeConfig?.filters.find( - (filterObj) => filterObj.name === 'Time Duration' + ({ name }) => name === 'Time Range' ); expect(timeDurationConfig).toBeDefined(); if (timeDurationConfig) { - const result = getTimeDurationProperties( + const { + handleStatsChange, + label, + savePreferences, + } = getTimeDurationProperties( { config: timeDurationConfig, dashboard: mockDashboard, @@ -60,8 +70,10 @@ it('test getTimeDuratonProperties method', () => { }, vi.fn() ); - expect(result['handleStatsChange']).toBeDefined(); - expect(result['savePreferences']).toEqual(true); + const { name } = timeDurationConfig.configuration; + expect(handleStatsChange).toBeDefined(); + expect(savePreferences).toEqual(true); + expect(label).toEqual(name); } }); @@ -73,7 +85,13 @@ it('test getResourceSelectionProperties method', () => { expect(resourceSelectionConfig).toBeDefined(); if (resourceSelectionConfig) { - const result = getResourcesProperties( + const { + disabled, + handleResourcesSelection, + label, + savePreferences, + xFilter, + } = getResourcesProperties( { config: resourceSelectionConfig, dashboard: mockDashboard, @@ -82,12 +100,12 @@ it('test getResourceSelectionProperties method', () => { }, vi.fn() ); - expect(result['handleResourcesSelection']).toBeDefined(); - expect(result['savePreferences']).toEqual(false); - expect(result['disabled']).toEqual(false); - expect(JSON.stringify(result['xFilter'])).toEqual( - '{"+and":[{"region":"us-east"}]}' - ); + const { name } = resourceSelectionConfig.configuration; + expect(handleResourcesSelection).toBeDefined(); + expect(savePreferences).toEqual(false); + expect(disabled).toEqual(false); + expect(JSON.stringify(xFilter)).toEqual('{"+and":[{"region":"us-east"}]}'); + expect(label).toEqual(name); } }); @@ -99,7 +117,13 @@ it('test getResourceSelectionProperties method with disabled true', () => { expect(resourceSelectionConfig).toBeDefined(); if (resourceSelectionConfig) { - const result = getResourcesProperties( + const { + disabled, + handleResourcesSelection, + label, + savePreferences, + xFilter, + } = getResourcesProperties( { config: resourceSelectionConfig, dashboard: mockDashboard, @@ -108,10 +132,12 @@ it('test getResourceSelectionProperties method with disabled true', () => { }, vi.fn() ); - expect(result['handleResourcesSelection']).toBeDefined(); - expect(result['savePreferences']).toEqual(false); - expect(result['disabled']).toEqual(true); - expect(JSON.stringify(result['xFilter'])).toEqual('{"+and":[]}'); + const { name } = resourceSelectionConfig.configuration; + expect(handleResourcesSelection).toBeDefined(); + expect(savePreferences).toEqual(false); + expect(disabled).toEqual(true); + expect(JSON.stringify(xFilter)).toEqual('{"+and":[]}'); + expect(label).toEqual(name); } }); @@ -201,7 +227,14 @@ it('test getCustomSelectProperties method', () => { expect(customSelectEngineConfig).toBeDefined(); if (customSelectEngineConfig) { - let result = getCustomSelectProperties( + const { + clearDependentSelections, + disabled, + isMultiSelect, + label, + options, + savePreferences, + } = getCustomSelectProperties( { config: customSelectEngineConfig, dashboard: { ...mockDashboard, service_type: 'dbaas' }, @@ -210,13 +243,14 @@ it('test getCustomSelectProperties method', () => { vi.fn() ); - expect(result.options).toBeDefined(); - expect(result.options?.length).toEqual(2); - expect(result.savePreferences).toEqual(false); - expect(result.isMultiSelect).toEqual(false); - expect(result.disabled).toEqual(false); - expect(result.clearDependentSelections).toBeDefined(); - expect(result.clearDependentSelections?.includes(RESOURCES)).toBe(true); + expect(options).toBeDefined(); + expect(options?.length).toEqual(2); + expect(savePreferences).toEqual(false); + expect(isMultiSelect).toEqual(false); + expect(label).toEqual(customSelectEngineConfig.configuration.name); + expect(disabled).toEqual(false); + expect(clearDependentSelections).toBeDefined(); + expect(clearDependentSelections?.includes(RESOURCES)).toBe(true); customSelectEngineConfig.configuration.type = CloudPulseSelectTypes.dynamic; customSelectEngineConfig.configuration.apiV4QueryKey = @@ -224,7 +258,12 @@ it('test getCustomSelectProperties method', () => { customSelectEngineConfig.configuration.isMultiSelect = true; customSelectEngineConfig.configuration.options = undefined; - result = getCustomSelectProperties( + const { + apiV4QueryKey, + isMultiSelect: isMultiSelectApi, + savePreferences: savePreferencesApi, + type, + } = getCustomSelectProperties( { config: customSelectEngineConfig, dashboard: mockDashboard, @@ -233,10 +272,13 @@ it('test getCustomSelectProperties method', () => { vi.fn() ); - expect(result.apiV4QueryKey).toEqual(databaseQueries.engines); - expect(result.type).toEqual(CloudPulseSelectTypes.dynamic); - expect(result.savePreferences).toEqual(false); - expect(result.isMultiSelect).toEqual(true); + const { name } = customSelectEngineConfig.configuration; + + expect(apiV4QueryKey).toEqual(databaseQueries.engines); + expect(type).toEqual(CloudPulseSelectTypes.dynamic); + expect(savePreferencesApi).toEqual(false); + expect(isMultiSelectApi).toEqual(true); + expect(label).toEqual(name); } }); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index dc9ec47fe01..e058b2e3d03 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -56,11 +56,12 @@ export const getRegionProperties = ( props: CloudPulseFilterProperties, handleRegionChange: (region: string | undefined, savePref?: boolean) => void ): CloudPulseRegionSelectProps => { - const { placeholder } = props.config.configuration; + const { name: label, placeholder } = props.config.configuration; const { dashboard, isServiceAnalyticsIntegration, preferences } = props; return { defaultValue: preferences?.[REGION], handleRegionChange, + label, placeholder, savePreferences: !isServiceAnalyticsIntegration, selectedDashboard: dashboard, @@ -84,7 +85,7 @@ export const getResourcesProperties = ( savePref?: boolean ) => void ): CloudPulseResourcesSelectProps => { - const { filterKey, placeholder } = props.config.configuration; + const { filterKey, name: label, placeholder } = props.config.configuration; const { config, dashboard, @@ -100,6 +101,7 @@ export const getResourcesProperties = ( dashboard ), handleResourcesSelection: handleResourceChange, + label, placeholder, resourceType: dashboard.service_type, savePreferences: !isServiceAnalyticsIntegration, @@ -129,6 +131,7 @@ export const getCustomSelectProperties = ( filterType, isMultiSelect, maxSelections, + name: label, options, placeholder, } = props.config.configuration; @@ -156,6 +159,7 @@ export const getCustomSelectProperties = ( filterType, handleSelectionChange: handleCustomSelectChange, isMultiSelect, + label, maxSelections, options, placeholder, @@ -183,13 +187,14 @@ export const getTimeDurationProperties = ( savePref?: boolean ) => void ): CloudPulseTimeRangeSelectProps => { - const { placeholder } = props.config.configuration; + const { name: label, placeholder } = props.config.configuration; const { isServiceAnalyticsIntegration, preferences } = props; const timeDuration = preferences?.timeDuration; return { defaultValue: timeDuration, handleStatsChange: handleTimeRangeChange, + label, placeholder, savePreferences: !isServiceAnalyticsIntegration, }; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 9034ab46ca8..55c96678749 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -2,7 +2,7 @@ import { CloudPulseSelectTypes } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; -const TIME_DURATION = 'Time Duration'; +const TIME_DURATION = 'Time Range'; export const LINODE_CONFIG: Readonly = { filters: [ @@ -26,9 +26,9 @@ export const LINODE_CONFIG: Readonly = { isFilterable: true, isMetricsFilter: true, isMultiSelect: true, - name: 'Resource', + name: 'Resources', neededInServicePage: false, - placeholder: 'Select a Resource', + placeholder: 'Select Resources', priority: 2, }, name: 'Resources', @@ -60,7 +60,7 @@ export const DBAAS_CONFIG: Readonly = { isFilterable: false, // isFilterable -- this determines whethere you need to pass it metrics api isMetricsFilter: false, // if it is false, it will go as a part of filter params, else global filter isMultiSelect: false, - name: 'DB Engine', + name: 'Database Engine', neededInServicePage: false, options: [ { @@ -72,7 +72,7 @@ export const DBAAS_CONFIG: Readonly = { label: 'PostgreSQL', }, ], - placeholder: 'Select an Engine', + placeholder: 'Select a Database Engine', priority: 2, type: CloudPulseSelectTypes.static, }, @@ -98,9 +98,9 @@ export const DBAAS_CONFIG: Readonly = { isFilterable: true, isMetricsFilter: true, isMultiSelect: true, - name: 'Resource', + name: 'Database Clusters', neededInServicePage: false, - placeholder: 'Select a DB Cluster', + placeholder: 'Select Database Clusters', priority: 3, }, name: 'Resources', diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx index 1b907419a46..dd74c70a1ef 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx @@ -81,6 +81,6 @@ describe('ComponentRenderer component tests', () => { })} ); - expect(getByPlaceholderText('Select a Resource')).toBeDefined(); + expect(getByPlaceholderText('Select Resources')).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx index 78ae7c77efe..59f92338cf9 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx @@ -56,12 +56,14 @@ describe('CloudPulseCustomSelect component tests', () => { filterKey="testfilter" filterType="number" handleSelectionChange={vi.fn()} + label="Test" options={mockOptions} placeholder={testFilter} type={CloudPulseSelectTypes.static} /> ); expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); + expect(screen.getByLabelText('Test')).toBeInTheDocument(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); fireEvent.click(screen.getByText('Test1')); @@ -76,12 +78,14 @@ describe('CloudPulseCustomSelect component tests', () => { filterType="number" handleSelectionChange={vi.fn()} isMultiSelect={true} + label="CustomTest" options={[...mockOptions]} placeholder={testFilter} type={CloudPulseSelectTypes.static} /> ); expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); + expect(screen.getByLabelText('CustomTest')).toBeInTheDocument(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 @@ -105,11 +109,13 @@ describe('CloudPulseCustomSelect component tests', () => { filterKey="testfilter" filterType="number" handleSelectionChange={selectionChnage} + label="Test" placeholder={testFilter} type={CloudPulseSelectTypes.dynamic} /> ); expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); + expect(screen.getByLabelText('Test')).toBeInTheDocument(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); fireEvent.click(screen.getByText('Test1')); @@ -131,11 +137,13 @@ describe('CloudPulseCustomSelect component tests', () => { filterType="number" handleSelectionChange={selectionChnage} isMultiSelect={true} + label="Test" placeholder={testFilter} type={CloudPulseSelectTypes.dynamic} /> ); expect(screen.queryByPlaceholderText(testFilter)).toBeNull(); + expect(screen.getByLabelText('Test')).toBeInTheDocument(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 4d25327a3ea..95c413c4adc 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -81,6 +81,8 @@ export interface CloudPulseCustomSelectProps { */ isMultiSelect?: boolean; + label: string; + /** * The maximum selections that the user can make incase of multiselect */ @@ -126,6 +128,7 @@ export const CloudPulseCustomSelect = React.memo( filterKey, handleSelectionChange, isMultiSelect, + label, maxSelections, options, placeholder, @@ -227,15 +230,18 @@ export const CloudPulseCustomSelect = React.memo( ? '' : placeholder || 'Select a Value' } - textFieldProps={{ - hideLabel: true, + slotProps={{ + popper: { + placement: 'bottom', + }, }} autoHighlight disabled={isAutoCompleteDisabled} errorText={staticErrorText} isOptionEqualToValue={(option, value) => option.label === value.label} - label={placeholder || 'Select a Value'} + label={label || 'Select a Value'} multiple={isMultiSelect} + noMarginTop onChange={handleChange} value={selectedResource ?? (isMultiSelect ? [] : null)} /> diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index 006dc05afb6..ec2c10bacc5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -32,7 +32,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { /> ); - expect(getByPlaceholderText('Select an Engine')).toBeDefined(); + expect(getByPlaceholderText('Select a Database Engine')).toBeDefined(); expect(getByPlaceholderText('Select a Region')).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 73bb56016ad..4444f942a57 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -292,10 +292,11 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } sx={{ justifyContent: 'start', - m: 0, + m: theme.spacing(0), + marginBottom: theme.spacing(showFilter ? 1 : 0), minHeight: 'auto', minWidth: 'auto', - p: 0, + p: theme.spacing(0), svg: { color: theme.color.grey4, }, @@ -306,13 +307,13 @@ export const CloudPulseDashboardFilterBuilder = React.memo( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index 81526099c4d..19828bc4328 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -102,9 +102,6 @@ export const CloudPulseDashboardSelect = React.memo( {params.children} )} - textFieldProps={{ - hideLabel: true, - }} autoHighlight clearOnBlur data-testid="cloudpulse-dashboard-select" @@ -113,8 +110,9 @@ export const CloudPulseDashboardSelect = React.memo( fullWidth groupBy={(option: Dashboard) => option.service_type} isOptionEqualToValue={(option, value) => option.id === value.id} - label="Select a Dashboard" + label="Dashboard" loading={dashboardsLoading || serviceTypesLoading} + noMarginTop options={getSortedDashboardsList(dashboardsList ?? [])} placeholder={placeHolder} value={selectedDashboard ?? null} // Undefined is not allowed for uncontrolled component diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index fc49edf0f0a..3b571880994 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -10,6 +10,7 @@ import type { Region } from '@linode/api-v4'; const props: CloudPulseRegionSelectProps = { handleRegionChange: vi.fn(), + label: 'Region', selectedDashboard: undefined, }; @@ -19,9 +20,11 @@ describe('CloudPulseRegionSelect', () => { } as ReturnType); it('should render a Region Select component', () => { - const { getByTestId } = renderWithTheme( + const { getByLabelText, getByTestId } = renderWithTheme( ); + const { label } = props; + expect(getByLabelText(label)).toBeInTheDocument(); expect(getByTestId('region-select')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 7f775d3eb87..6a6b7bc388c 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -8,6 +8,7 @@ import type { Dashboard, FilterValue } from '@linode/api-v4'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; handleRegionChange: (region: string | undefined, savePref?: boolean) => void; + label: string; placeholder?: string; savePreferences?: boolean; selectedDashboard: Dashboard | undefined; @@ -20,6 +21,7 @@ export const CloudPulseRegionSelect = React.memo( const { defaultValue, handleRegionChange, + label, placeholder, savePreferences, selectedDashboard, @@ -44,15 +46,13 @@ export const CloudPulseRegionSelect = React.memo( setSelectedRegion(region?.id); handleRegionChange(region?.id, savePreferences); }} - textFieldProps={{ - hideLabel: true, - }} currentCapability={undefined} data-testid="region-select" disableClearable={false} disabled={!selectedDashboard || !regions} fullWidth - label="Select a Region" + label={label || 'Region'} + noMarginTop placeholder={placeholder ?? 'Select a Region'} regions={regions ? regions : []} value={selectedRegion} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 019d038369b..23aaecb35ac 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -33,12 +33,14 @@ describe('CloudPulseResourcesSelect component tests', () => { const { getByPlaceholderText, getByTestId } = renderWithTheme( ); expect(getByTestId('resource-select')).toBeInTheDocument(); - expect(getByPlaceholderText('Select a Resource')).toBeInTheDocument(); + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); + expect(getByPlaceholderText('Select Resources')).toBeInTheDocument(); }), it('should render resources happy path', () => { queryMocks.useResourcesQuery.mockReturnValue({ @@ -50,11 +52,13 @@ describe('CloudPulseResourcesSelect component tests', () => { renderWithTheme( ); fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); expect( screen.getByRole('option', { name: 'linode-3', @@ -77,12 +81,14 @@ describe('CloudPulseResourcesSelect component tests', () => { renderWithTheme( ); fireEvent.click(screen.getByRole('button', { name: 'Open' })); fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); expect( screen.getByRole('option', { name: 'linode-5', @@ -105,6 +111,7 @@ describe('CloudPulseResourcesSelect component tests', () => { renderWithTheme( @@ -112,6 +119,7 @@ describe('CloudPulseResourcesSelect component tests', () => { fireEvent.click(screen.getByRole('button', { name: 'Open' })); fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); expect( screen.getByRole('option', { name: 'linode-7', @@ -134,6 +142,7 @@ describe('CloudPulseResourcesSelect component tests', () => { renderWithTheme( @@ -141,6 +150,7 @@ describe('CloudPulseResourcesSelect component tests', () => { fireEvent.click(screen.getByRole('button', { name: 'Open' })); fireEvent.click(screen.getByRole('option', { name: 'linode-9' })); fireEvent.click(screen.getByRole('option', { name: 'linode-10' })); + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); expect( screen.getByRole('option', { @@ -175,6 +185,7 @@ describe('CloudPulseResourcesSelect component tests', () => { void; + label: string; placeholder?: string; region?: string; resourceType: string | undefined; @@ -34,6 +35,7 @@ export const CloudPulseResourcesSelect = React.memo( defaultValue, disabled, handleResourcesSelection, + label, placeholder, region, resourceType, @@ -103,7 +105,7 @@ export const CloudPulseResourcesSelect = React.memo( isAutocompleteOpen.current = true; }} placeholder={ - selectedResources?.length ? '' : placeholder || 'Select a Resource' + selectedResources?.length ? '' : placeholder || 'Select Resources' } textFieldProps={{ InputProps: { @@ -115,16 +117,16 @@ export const CloudPulseResourcesSelect = React.memo( }, }, }, - hideLabel: true, }} autoHighlight clearOnBlur data-testid="resource-select" disabled={disabled || isLoading} isOptionEqualToValue={(option, value) => option.id === value.id} - label="Select a Resource" + label={label || 'Resources'} limitTags={2} multiple + noMarginTop options={getResourcesList} value={selectedResources ?? []} /> diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index 6a37d417c1a..c0e74b2d2b6 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -36,7 +36,7 @@ export type Labels = export const CloudPulseTimeRangeSelect = React.memo( (props: CloudPulseTimeRangeSelectProps) => { - const { defaultValue, handleStatsChange, savePreferences } = props; + const { defaultValue, handleStatsChange, label, savePreferences } = props; const options = generateSelectOptions(); const getDefaultValue = React.useCallback((): Item => { if (!savePreferences) { @@ -80,15 +80,13 @@ export const CloudPulseTimeRangeSelect = React.memo( onChange={(e, value: Item) => { handleChange(value); }} - textFieldProps={{ - hideLabel: true, - }} autoHighlight data-testid="cloudpulse-time-duration" disableClearable fullWidth isOptionEqualToValue={(option, value) => option.value === value.value} - label="Select a Time Duration" + label={label || 'Time Range'} + noMarginTop options={options} value={selectedTimeRange} /> From 00365469193fd99b064698f3a75ec445ce46d886 Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:07:56 +0530 Subject: [PATCH 48/64] feat: [DI-21463] - Added capability to transform area chart into line chart (#11136) * feat: [DI-21463] - Added capability to transform area chart into line chart * feat: [DI-21463] - Added changeset * feat: [DI-21463] - Updated jsdocs --- .../pr-11136-added-1729581444483.md | 5 ++ .../src/components/AreaChart/AreaChart.tsx | 70 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11136-added-1729581444483.md diff --git a/packages/manager/.changeset/pr-11136-added-1729581444483.md b/packages/manager/.changeset/pr-11136-added-1729581444483.md new file mode 100644 index 00000000000..5855583b3dd --- /dev/null +++ b/packages/manager/.changeset/pr-11136-added-1729581444483.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Add `hideFill` & `fillOpacity` properties to `AreaChart` component ([#11136](https://github.com/linode/manager/pull/11136)) diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index 29ed0c35d23..9416c620701 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -9,7 +9,6 @@ import { Legend, ResponsiveContainer, Tooltip, - TooltipProps, XAxis, YAxis, } from 'recharts'; @@ -17,7 +16,6 @@ import { import { AccessibleAreaChart } from 'src/components/AreaChart/AccessibleAreaChart'; import { Box } from 'src/components/Box'; import MetricsDisplay from 'src/components/LineGraph/MetricsDisplay'; -import { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; import { Paper } from 'src/components/Paper'; import { StyledBottomLegend } from 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel'; @@ -27,25 +25,90 @@ import { tooltipValueFormatter, } from './utils'; +import type { TooltipProps } from 'recharts'; +import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; + interface AreaProps { + /** + * color for the area + */ color: string; + + /** + * datakey for the area + */ dataKey: string; } interface XAxisProps { + /** + * format for the x-axis timestamp + * ex: 'hh' to convert timestamp into hour + */ tickFormat: string; + + /** + * represents the pixer gap between two x-axis ticks + */ tickGap: number; } interface AreaChartProps { + /** + * list of areas to be displayed + */ areas: AreaProps[]; + + /** + * arialabel for the graph + */ ariaLabel: string; + + /** + * data to be displayed on the graph + */ data: any; + + /** + * + */ + fillOpacity?: number; + + /** + * maximum height of the chart container + */ height: number; + + /** + * list of legends rows to be displayed + */ legendRows?: Omit; + + /** + * true to display legends rows else false to hide + * @default false + */ showLegend?: boolean; + + /** + * timezone for the timestamp of graph data + */ timezone: string; + + /** + * unit to be displayed with data + */ unit: string; + + /** + * make chart appear as a line or area chart + * @default area + */ + variant?: 'area' | 'line'; + + /** + * x-axis properties + */ xAxis: XAxisProps; } @@ -54,11 +117,13 @@ export const AreaChart = (props: AreaChartProps) => { areas, ariaLabel, data, + fillOpacity, height, legendRows, showLegend, timezone, unit, + variant, xAxis, } = props; @@ -190,6 +255,7 @@ export const AreaChart = (props: AreaChartProps) => { Date: Tue, 22 Oct 2024 13:45:43 -0400 Subject: [PATCH 49/64] test: [M3-8072] - Cloud changes for ad-hoc test pipeline (#11088) * Delete 'region-1' Docker Compose service, deprecate 'e2e', 'e2e_heimdall', and 'component' services, and add 'cypress_local', 'cypress_remote', and 'cypress_component' * Set yarn as entrypoint for test runner in Dockerfile * Allow Cypress Slack notification title to be customized * Allow extra information to be displayed in Slack/GitHub notifications * Only show up to six failures in a Slack notification * Allow JUnit summary when there are no JUnit files in given path * Move LaunchDarkly URL matchers to constants file * Allow feature flags to be overridden via CY_TEST_FEATURE_FLAGS environment variable * Add changesets --- docker-compose.yml | 63 ++++++++++++++----- docs/development-guide/08-testing.md | 11 ++-- .../pr-11088-tech-stories-1729535071709.md | 5 ++ .../pr-11088-tests-1729535093463.md | 5 ++ .../pr-11088-tests-1729535165205.md | 5 ++ .../pr-11088-tests-1729535197632.md | 5 ++ packages/manager/Dockerfile | 1 - packages/manager/cypress.config.ts | 2 + .../support/constants/feature-flags.ts | 11 ++++ packages/manager/cypress/support/e2e.ts | 2 + .../support/intercepts/feature-flags.ts | 12 ++-- .../support/plugins/feature-flag-override.ts | 39 ++++++++++++ .../setup/mock-feature-flags-request.ts | 37 +++++++++++ .../formatters/github-formatter.ts | 3 + .../formatters/slack-formatter.ts | 39 +++++++++--- scripts/junit-summary/index.ts | 10 +-- scripts/junit-summary/metadata/metadata.ts | 6 ++ 17 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md create mode 100644 packages/manager/.changeset/pr-11088-tests-1729535093463.md create mode 100644 packages/manager/.changeset/pr-11088-tests-1729535165205.md create mode 100644 packages/manager/.changeset/pr-11088-tests-1729535197632.md create mode 100644 packages/manager/cypress/support/constants/feature-flags.ts create mode 100644 packages/manager/cypress/support/plugins/feature-flag-override.ts create mode 100644 packages/manager/cypress/support/setup/mock-feature-flags-request.ts diff --git a/docker-compose.yml b/docker-compose.yml index 494f64db0de..36bbe54ef43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ x-e2e-env: # Cloud Manager-specific test configuration. CY_TEST_SUITE: ${CY_TEST_SUITE} CY_TEST_REGION: ${CY_TEST_REGION} + CY_TEST_FEATURE_FLAGS: ${CY_TEST_FEATURE_FLAGS} CY_TEST_TAGS: ${CY_TEST_TAGS} CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} @@ -80,14 +81,9 @@ x-e2e-runners: context: . dockerfile: ./packages/manager/Dockerfile target: e2e - depends_on: - web: - condition: service_healthy env_file: ./packages/manager/.env volumes: *default-volumes - # TODO Stop using entrypoint, use CMD instead. - # (Or just make `yarn` the entrypoint, but either way stop forcing `cy:e2e`). - entrypoint: ['yarn', 'cy:e2e'] + entrypoint: 'yarn' services: # Serves a local instance of Cloud Manager for Cypress to use for its tests. @@ -110,6 +106,48 @@ services: timeout: 10s retries: 10 + # Cypress test runner service to run tests against a remotely-served Cloud instance. + # + # This is useful when testing against a standard Cloud Manager environment, + # like Production at cloud.linode.com, but can also be used to run tests against + # pre-Prod environments, PR preview links, and more. + cypress_remote: + <<: *default-runner + environment: + <<: *default-env + MANAGER_OAUTH: ${MANAGER_OAUTH} + + # Cypress test runner service to run tests against a locally-served Cloud instance. + # + # This is useful when testing against a customized or in-development build of + # Cloud Manager. + cypress_local: + <<: *default-runner + environment: + <<: *default-env + MANAGER_OAUTH: ${MANAGER_OAUTH} + depends_on: + web: + condition: service_healthy + + # Cypress component test runner service. + # + # Unlike other Cloud Manager Cypress tests, these tests can be run without + # requiring a Cloud Manager environment. + cypress_component: + <<: *default-runner + environment: + CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} + CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} + + + # --> ! DEPRECATION NOTICE ! <-- + # The services below this line are deprecated, and will be deleted soon. + # Don't build any pipelines or write any scripts that depend on these. + # Instead, opt to use `cypress_local` in places where you would've used `e2e`, + # use `cypress_remote` in places where you would've used `e2e_heimdall`, and + # use `cypress_component` in places where you would've used `component`. + # Generic end-to-end test runner for Cloud's primary testing pipeline. # Configured to run against a local Cloud instance. e2e: @@ -117,6 +155,7 @@ services: environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} + entrypoint: ['yarn', 'cy:e2e'] # Component test runner. # Does not require any Cloud Manager environment to run. @@ -136,14 +175,4 @@ services: environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} - - region-1: - build: - context: . - dockerfile: ./packages/manager/Dockerfile - target: e2e - env_file: ./packages/manager/.env - volumes: *default-volumes - environment: - <<: *default-env - MANAGER_OAUTH: ${MANAGER_OAUTH_1} + entrypoint: ['yarn', 'cy:e2e'] diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 5635774c867..9ce93c1169b 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -191,12 +191,13 @@ Environment variables related to the general operation of the Cloud Manager Cypr | `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core`, `region`, or `synthetic`. | `region` | Unset; defaults to `core` suite | | `CY_TEST_TAGS` | Query identifying tests that should run by specifying allowed and disallowed tags. | `method:e2e` | Unset; all tests run by default | -###### Regions -These environment variables are used by Cloud Manager's UI tests to override region selection behavior. This can be useful for testing Cloud Manager functionality against a specific region. +###### Overriding Behavior +These environment variables can be used to override some behaviors of Cloud Manager's UI tests. This can be useful when testing Cloud Manager for nonstandard or work-in-progress functionality. -| Environment Variable | Description | Example | Default | -|----------------------|-------------------------------------------------|-----------|---------------------------------------| -| `CY_TEST_REGION` | ID of region to test (as used by Linode APIv4). | `us-east` | Unset; regions are selected at random | +| Environment Variable | Description | Example | Default | +|-------------------------|-------------------------------------------------|-----------|--------------------------------------------| +| `CY_TEST_REGION` | ID of region to test (as used by Linode APIv4). | `us-east` | Unset; regions are selected at random | +| `CY_TEST_FEATURE_FLAGS` | JSON string containing feature flag data | `{}` | Unset; feature flag data is not overridden | ###### Run Splitting These environment variables facilitate splitting the Cypress run between multiple runners without the use of any third party services. This can be useful for improving Cypress test performance in some circumstances. For additional performance gains, an optional test weights file can be specified using `CY_TEST_SPLIT_RUN_WEIGHTS` (see `CY_TEST_GENWEIGHTS` to generate test weights). diff --git a/packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md b/packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md new file mode 100644 index 00000000000..037e8d3821f --- /dev/null +++ b/packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace 'e2e', 'e2e_heimdall', and 'component' Docker Compose services with 'cypress_local', 'cypress_remote', and 'cypress_component' ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11088-tests-1729535093463.md b/packages/manager/.changeset/pr-11088-tests-1729535093463.md new file mode 100644 index 00000000000..2dc798af453 --- /dev/null +++ b/packages/manager/.changeset/pr-11088-tests-1729535093463.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Allow overriding feature flags via CY_TEST_FEATURE_FLAGS environment variable ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11088-tests-1729535165205.md b/packages/manager/.changeset/pr-11088-tests-1729535165205.md new file mode 100644 index 00000000000..a895a08ccd5 --- /dev/null +++ b/packages/manager/.changeset/pr-11088-tests-1729535165205.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Allow pipeline Slack notifications to be customized ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11088-tests-1729535197632.md b/packages/manager/.changeset/pr-11088-tests-1729535197632.md new file mode 100644 index 00000000000..24c079ce5dc --- /dev/null +++ b/packages/manager/.changeset/pr-11088-tests-1729535197632.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Show PR title in Slack CI notifications ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 2cfa22aca4e..466edaf64ea 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -47,4 +47,3 @@ ENV CI=1 ENV NO_COLOR=1 ENV HOME=/home/node/ ENV CYPRESS_CACHE_FOLDER=/home/node/.cache/Cypress -ENTRYPOINT yarn cy:ci diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index 09322be48ba..b51562d9f38 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -17,6 +17,7 @@ import { enableJunitReport } from './cypress/support/plugins/junit-report'; import { generateTestWeights } from './cypress/support/plugins/generate-weights'; import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; import cypressViteConfig from './cypress/vite.config'; +import { featureFlagOverrides } from './cypress/support/plugins/feature-flag-override'; /** * Exports a Cypress configuration object. @@ -91,6 +92,7 @@ export default defineConfig({ fetchAccount, fetchLinodeRegions, regionOverrideCheck, + featureFlagOverrides, logTestTagInfo, splitCypressRun, enableJunitReport(), diff --git a/packages/manager/cypress/support/constants/feature-flags.ts b/packages/manager/cypress/support/constants/feature-flags.ts new file mode 100644 index 00000000000..08e996ca15d --- /dev/null +++ b/packages/manager/cypress/support/constants/feature-flags.ts @@ -0,0 +1,11 @@ +/** + * @file Constants related to Cypress's handling of LaunchDarkly feature flags. + */ + +// LaunchDarkly URL pattern for feature flag retrieval. +export const launchDarklyUrlPattern = + 'https://app.launchdarkly.com/sdk/evalx/*/contexts/*'; + +// LaunchDarkly URL pattern for feature flag / event streaming. +export const launchDarklyClientstreamPattern = + 'https://clientstream.launchdarkly.com/eval/*/*'; diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index 2e21f7ca60b..5996d3d71aa 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -60,11 +60,13 @@ chai.use(function (chai, utils) { // Test setup. import { deleteInternalHeader } from './setup/delete-internal-header'; +import { mockFeatureFlagRequests } from './setup/mock-feature-flags-request'; import { mockFeatureFlagClientstream } from './setup/feature-flag-clientstream'; import { mockAccountRequest } from './setup/mock-account-request'; import { trackApiRequests } from './setup/request-tracking'; trackApiRequests(); mockAccountRequest(); +mockFeatureFlagRequests(); mockFeatureFlagClientstream(); deleteInternalHeader(); diff --git a/packages/manager/cypress/support/intercepts/feature-flags.ts b/packages/manager/cypress/support/intercepts/feature-flags.ts index 9bf393efb58..d77ab55f531 100644 --- a/packages/manager/cypress/support/intercepts/feature-flags.ts +++ b/packages/manager/cypress/support/intercepts/feature-flags.ts @@ -3,17 +3,13 @@ */ import { getResponseDataFromMockData } from 'support/util/feature-flags'; +import { + launchDarklyUrlPattern, + launchDarklyClientstreamPattern, +} from 'support/constants/feature-flags'; import type { FeatureFlagMockData } from 'support/util/feature-flags'; -// LaunchDarkly URL pattern for feature flag retrieval. -const launchDarklyUrlPattern = - 'https://app.launchdarkly.com/sdk/evalx/*/contexts/*'; - -// LaunchDarkly URL pattern for feature flag / event streaming. -const launchDarklyClientstreamPattern = - 'https://clientstream.launchdarkly.com/eval/*/*'; - /** * Intercepts GET request to feature flag clientstream URL and mocks the response. * diff --git a/packages/manager/cypress/support/plugins/feature-flag-override.ts b/packages/manager/cypress/support/plugins/feature-flag-override.ts new file mode 100644 index 00000000000..b8694cc9ff9 --- /dev/null +++ b/packages/manager/cypress/support/plugins/feature-flag-override.ts @@ -0,0 +1,39 @@ +import type { CypressPlugin } from './plugin'; + +/** + * Handles setup related to Launch Darkly feature flag overrides. + * + * Checks if the user has passed overrides via the `CY_TEST_FEATURE_FLAGS` env, + * and validates its value if so by attempting to parse it as JSON. If that + * succeeds, the parsed override object is exposed to Cypress via the + * `featureFlagOverrides` config. + */ +export const featureFlagOverrides: CypressPlugin = (_on, config) => { + const featureFlagOverridesJson = config.env?.['CY_TEST_FEATURE_FLAGS']; + + let featureFlagOverrides = undefined; + if (featureFlagOverridesJson) { + const notice = + 'Feature flag overrides are enabled with the following JSON payload:'; + const jsonWarning = + 'Be aware that malformed or invalid feature flag data can trigger crashes and other unexpected behavior.'; + + console.info(`${notice}\n\n${featureFlagOverridesJson}\n\n${jsonWarning}`); + + try { + featureFlagOverrides = JSON.parse(featureFlagOverridesJson); + } catch (e) { + throw new Error( + `Unable to parse feature flag JSON:\n\n${featureFlagOverridesJson}\n\nPlease double check your 'CY_TEST_FEATURE_FLAGS' value and try again.` + ); + } + } + + return { + ...config, + env: { + ...config.env, + featureFlagOverrides, + }, + }; +}; diff --git a/packages/manager/cypress/support/setup/mock-feature-flags-request.ts b/packages/manager/cypress/support/setup/mock-feature-flags-request.ts new file mode 100644 index 00000000000..2f45f33f0cf --- /dev/null +++ b/packages/manager/cypress/support/setup/mock-feature-flags-request.ts @@ -0,0 +1,37 @@ +/** + * @file Intercepts and mocks Launch Darkly feature flag requests with override data if specified. + */ + +import { launchDarklyUrlPattern } from 'support/constants/feature-flags'; + +/** + * If feature flag overrides have been specified, intercept every LaunchDarkly + * feature flag request and modify the response to contain the override data. + * + * This override happens before other intercepts and mocks (e.g. via `mockGetFeatureFlags` + * and `mockAppendFeatureFlags`), so mocks set up by those functions will take + * priority in the event that both modify the same feature flag value. + */ +export const mockFeatureFlagRequests = () => { + const featureFlagOverrides = Cypress.env('featureFlagOverrides'); + + if (featureFlagOverrides) { + beforeEach(() => { + cy.intercept( + { + middleware: true, + url: launchDarklyUrlPattern, + }, + (req) => { + req.on('before:response', (res) => { + const overriddenFeatureFlagData = { + ...res.body, + ...featureFlagOverrides, + }; + res.body = overriddenFeatureFlagData; + }); + } + ); + }); + } +}; diff --git a/scripts/junit-summary/formatters/github-formatter.ts b/scripts/junit-summary/formatters/github-formatter.ts index c1161a61cf0..d039db83bed 100644 --- a/scripts/junit-summary/formatters/github-formatter.ts +++ b/scripts/junit-summary/formatters/github-formatter.ts @@ -39,6 +39,8 @@ export const githubFormatter: Formatter = ( const breakdown = `:x: ${runInfo.failing} Failing | :green_heart: ${runInfo.passing} Passing | :arrow_right_hook: ${runInfo.skipped} Skipped | :clock1: ${secondsToTimeString(runInfo.time)}\n\n`; + const extra = metadata.extra ? `${metadata.extra}\n\n` : null; + const failedTestSummary = (() => { const heading = `### Details`; const failedTestHeader = `
    Test
    `; @@ -82,6 +84,7 @@ export const githubFormatter: Formatter = ( headline, '', breakdown, + extra, runInfo.failing > 0 ? failedTestSummary : null, runInfo.failing > 0 ? rerunNote : null, ] diff --git a/scripts/junit-summary/formatters/slack-formatter.ts b/scripts/junit-summary/formatters/slack-formatter.ts index 4168cd4a9f5..65b2b3dda1f 100644 --- a/scripts/junit-summary/formatters/slack-formatter.ts +++ b/scripts/junit-summary/formatters/slack-formatter.ts @@ -8,6 +8,14 @@ import { secondsToTimeString } from '../util'; import * as path from 'path'; import { cypressRunCommand } from '../util/cypress'; +/** + * The maximum number of failures that will be listed in the Slack notification. + * + * The Slack notification has a maximum character limit, so we must truncate + * the failure results to reduce the risk of hitting that limit. + */ +const FAILURE_SUMMARY_LIMIT = 6; + /** * Outputs test result summary formatted as a Slack message. * @@ -23,37 +31,47 @@ export const slackFormatter: Formatter = ( _junitData: TestSuites[] ) => { const indicator = runInfo.failing ? ':x-mark:' : ':check-mark:'; - const headline = (metadata.runId && metadata.runUrl) - ? `*Cypress test results for run <${metadata.runUrl}|#${metadata.runId}>*\n` - : `*Cypress test results*\n`; + const headline = metadata.pipelineTitle + ? `*${metadata.pipelineTitle}*\n` + : '*Cypress test results*\n'; + + const prInfo = (metadata.changeId && metadata.changeUrl && metadata.changeTitle) + ? `:pull-request: ${metadata.changeTitle} (<${metadata.changeUrl}|#${metadata.changeId}>)\n` + : null; const breakdown = `:small_red_triangle: ${runInfo.failing} Failing | :thumbs_up_green: ${runInfo.passing} Passing | :small_blue_diamond: ${runInfo.skipped} Skipped\n\n`; // Show a human-readable summary of what was tested and whether it succeeded. const summary = (() => { - const info = !runInfo.failing + const statusInfo = !runInfo.failing ? `> ${indicator} ${runInfo.passing} passing ${pluralize(runInfo.passing, 'test', 'tests')}` : `> ${indicator} ${runInfo.failing} failed ${pluralize(runInfo.failing, 'test', 'tests')}`; - const prInfo = (metadata.changeId && metadata.changeUrl) - ? ` on PR <${metadata.changeUrl}|#${metadata.changeId}>${metadata.changeTitle ? ` - _${metadata.changeTitle}_` : ''}` + const buildInfo = (metadata.runId && metadata.runUrl) + ? ` on run <${metadata.runUrl}|#${metadata.runId}>` : ''; const runLength = `(${secondsToTimeString(runInfo.time)})`; const endingPunctuation = !runInfo.failing ? '.' : ':'; - return `${info}${prInfo} ${runLength}${endingPunctuation}` + return `${statusInfo}${buildInfo} ${runLength}${endingPunctuation}` })(); // Display a list of failed tests and collection of actions when applicable. const failedTestSummary = (() => { const failedTestLines = results .filter((result: TestResult) => result.failing) + .slice(0, FAILURE_SUMMARY_LIMIT) .map((result: TestResult) => { const specFile = path.basename(result.testFilename); return `• \`${specFile}\` — _${result.groupName}_ » _${result.testName}_`; }); + const remainingFailures = runInfo.failing - FAILURE_SUMMARY_LIMIT; + const truncationNote = (runInfo.failing > FAILURE_SUMMARY_LIMIT) + ? `and ${remainingFailures} more ${pluralize(remainingFailures, 'failure', 'failures')}...\n` + : null; + // When applicable, display actions that can be taken by the user. const failedTestActions = [ metadata.resultsUrl ? `<${metadata.resultsUrl}|View results>` : '', @@ -66,6 +84,7 @@ export const slackFormatter: Formatter = ( return [ '', ...failedTestLines, + truncationNote, '', failedTestActions ? failedTestActions : null, ] @@ -86,6 +105,8 @@ export const slackFormatter: Formatter = ( return `${rerunTip}\n${cypressCommand}`; })(); + const extra = metadata.extra ? `${metadata.extra}\n` : null; + // Display test run details (author, PR number, run number, etc.) when applicable. const footer = (() => { const authorIdentifier = (metadata.authorSlack ? `@${metadata.authorSlack}` : null) @@ -104,6 +125,7 @@ export const slackFormatter: Formatter = ( return [ headline, + prInfo, breakdown, summary, @@ -115,6 +137,9 @@ export const slackFormatter: Formatter = ( runInfo.failing > 0 ? `${failedTestSummary}\n` : null, runInfo.failing > 0 ? `${rerunNote}\n` : null, + // If extra information has been supplied, display it above the footer. + extra, + // Show run details footer. `:cypress: ${footer}`, ].filter((item) => item !== null).join('\n'); diff --git a/scripts/junit-summary/index.ts b/scripts/junit-summary/index.ts index acc02fb1677..a2d9cbd2d23 100644 --- a/scripts/junit-summary/index.ts +++ b/scripts/junit-summary/index.ts @@ -24,6 +24,7 @@ program .version('0.1.0') .arguments('') .option('-f, --format ', 'JUnit summary output format', 'json') + .option('--meta:title ', 'Pipeline title') .option('--meta:author-name ', 'Author name') .option('--meta:author-slack ', 'Author Slack name') .option('--meta:author-github ', 'Author GitHub name') @@ -36,6 +37,7 @@ program .option('--meta:artifacts-url ', 'CI artifacts URL') .option('--meta:results-url ', 'CI results URL') .option('--meta:rerun-url ', 'CI rerun URL') + .option('--meta:extra ', 'Extra information to display in output') .action((junitPath) => { return main(junitPath); }); @@ -49,6 +51,8 @@ const main = async (junitPath: string) => { const reportPath = path.resolve(junitPath); const summaryFormat = program.opts().format; const metadata: Metadata = { + pipelineTitle: program.opts()['meta:title'], + authorName: program.opts()['meta:authorName'], authorSlack: program.opts()['meta:authorSlack'], authorGitHub: program.opts()['meta:auhtorGithub'], @@ -65,6 +69,8 @@ const main = async (junitPath: string) => { artifactsUrl: program.opts()['meta:artifactsUrl'], resultsUrl: program.opts()['meta:resultsUrl'], rerunUrl: program.opts()['meta:rerunUrl'], + + extra: program.opts()['meta:extra'], }; // Create an array of absolute file paths to JUnit XML report files. @@ -76,10 +82,6 @@ const main = async (junitPath: string) => { return path.resolve(reportPath, dirItem); }); - if (reportFiles.length < 1) { - throw new Error(`No JUnit report files found in '${reportPath}'.`) - } - // Read JUnit report file contents. const loadReportFileContents = reportFiles.map((reportFile: string) => { return fs.readFile(reportFile, 'utf8'); diff --git a/scripts/junit-summary/metadata/metadata.ts b/scripts/junit-summary/metadata/metadata.ts index 12d234aa361..35cde6fd38b 100644 --- a/scripts/junit-summary/metadata/metadata.ts +++ b/scripts/junit-summary/metadata/metadata.ts @@ -3,6 +3,9 @@ */ export interface Metadata { + /** Job or pipeline title. */ + pipelineTitle?: string; + /** Code author name. */ authorName?: string; @@ -38,4 +41,7 @@ export interface Metadata { /** CI rerun trigger URL. */ rerunUrl?: string; + + /** Arbitrary extra information that can be added to output. */ + extra?: string; } From b64a39d6979b16d36643d255922f5fb6289337b7 Mon Sep 17 00:00:00 2001 From: corya-akamai <136115382+corya-akamai@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:59:00 -0400 Subject: [PATCH 50/64] feat: [UIE-8196] - DBaaS create encourage users to add IP allow_list (#11124) --- ...r-11124-upcoming-features-1729198459249.md | 5 + .../MultipleIPInput/MultipleIPInput.test.tsx | 40 ++++- .../MultipleIPInput/MultipleIPInput.tsx | 5 + .../DatabaseCreate/DatabaseCreate.tsx | 49 +----- .../DatabaseCreateAccessControls.test.tsx | 107 +++++++++++++ .../DatabaseCreateAccessControls.tsx | 144 ++++++++++++++++++ 6 files changed, 308 insertions(+), 42 deletions(-) create mode 100644 packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md create mode 100644 packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx diff --git a/packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md b/packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md new file mode 100644 index 00000000000..1435345c4bb --- /dev/null +++ b/packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS encourage setting access controls during create ([#11124](https://github.com/linode/manager/pull/11124)) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.test.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.test.tsx index 16c22a2f991..06c123078b4 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.test.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.test.tsx @@ -2,7 +2,6 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; - import { MultipleIPInput } from './MultipleIPInput'; const baseProps = { @@ -52,4 +51,43 @@ describe('MultipleIPInput', () => { { address: 'ip3' }, ]); }); + + it('should enable all actions by default', async () => { + const props = { + ...baseProps, + ips: [{ address: 'ip1' }, { address: 'ip2' }], + }; + const { getByTestId, getByLabelText, getByText } = renderWithTheme( + + ); + const ip0 = getByLabelText('domain-transfer-ip-0'); + const ip1 = getByLabelText('domain-transfer-ip-1'); + const closeButton = getByTestId('delete-ip-1').closest('button'); + const addButton = getByText('Add an IP').closest('button'); + + expect(ip0).toBeEnabled(); + expect(ip1).toBeEnabled(); + expect(closeButton).toBeEnabled(); + expect(addButton).toBeEnabled(); + }); + + it('should disable all actions', async () => { + const props = { + ...baseProps, + disabled: true, + ips: [{ address: 'ip1' }, { address: 'ip2' }], + }; + const { getByTestId, getByLabelText, getByText } = renderWithTheme( + + ); + const ip0 = getByLabelText('domain-transfer-ip-0'); + const ip1 = getByLabelText('domain-transfer-ip-1'); + const closeButton = getByTestId('delete-ip-1').closest('button'); + const addButton = getByText('Add an IP').closest('button'); + + expect(ip0).toBeDisabled(); + expect(ip1).toBeDisabled(); + expect(closeButton).toBeDisabled(); + expect(addButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 6d241a3b2df..1d60931b8a1 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -60,6 +60,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ interface Props { buttonText?: string; className?: string; + disabled?: boolean; error?: string; forDatabaseAccessControls?: boolean; forVPCIPv4Ranges?: boolean; @@ -78,6 +79,7 @@ export const MultipleIPInput = React.memo((props: Props) => { const { buttonText, className, + disabled, error, forDatabaseAccessControls, forVPCIPv4Ranges, @@ -137,6 +139,7 @@ export const MultipleIPInput = React.memo((props: Props) => { buttonType="secondary" className={classes.addIP} compactX + disabled={disabled} onClick={addNewInput} > {buttonText ?? 'Add an IP'} @@ -184,6 +187,7 @@ export const MultipleIPInput = React.memo((props: Props) => { ) => @@ -206,6 +210,7 @@ export const MultipleIPInput = React.memo((props: Props) => { {(idx > 0 || forDatabaseAccessControls || forVPCIPv4Ranges) && ( - ); + const addIPButton = + forVPCIPv4Ranges || isLinkStyled ? ( + + {buttonText} + + ) : ( + + ); return (
    diff --git a/packages/manager/src/components/MultipleIPInput/MultipleNonExtendedIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleNonExtendedIPInput.tsx new file mode 100644 index 00000000000..3a709de3c0c --- /dev/null +++ b/packages/manager/src/components/MultipleIPInput/MultipleNonExtendedIPInput.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { MultipleIPInput } from './MultipleIPInput'; + +import type { MultipeIPInputProps } from './MultipleIPInput'; +import type { FieldError, Merge } from 'react-hook-form'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +interface Props extends Omit { + ipErrors?: Merge; + nonExtendedIPs: string[]; + onNonExtendedIPChange: (ips: string[]) => void; +} + +/** + * Quick wrapper for MultipleIPInput so that we do not have to directly use the type ExtendedIP (which has its own error field) + * + * I wanted to avoid touching MultipleIPInput too much, since a lot of other flows use that component. This component was + * made with 'react-hook-form' in mind, taking in 'react-hook-form' errors and mapping them to the given (non + * extended) IPs. We might eventually try to completely remove the ExtendedIP type - see + * https://github.com/linode/manager/pull/10968#discussion_r1800089369 for context + */ +export const MultipleNonExtendedIPInput = (props: Props) => { + const { ipErrors, nonExtendedIPs, onNonExtendedIPChange, ...rest } = props; + + const extendedIPs: ExtendedIP[] = + nonExtendedIPs.map((ip, idx) => { + return { + address: ip, + error: ipErrors ? ipErrors[idx]?.message : '', + }; + }) ?? []; + + return ( + { + const _ips = ips.map((ip) => { + return ip.address; + }); + onNonExtendedIPChange(_ips); + }} + ips={extendedIPs} + /> + ); +}; diff --git a/packages/manager/src/factories/dashboards.ts b/packages/manager/src/factories/dashboards.ts index 6183d04101c..9506c349794 100644 --- a/packages/manager/src/factories/dashboards.ts +++ b/packages/manager/src/factories/dashboards.ts @@ -51,8 +51,8 @@ export const widgetFactory = Factory.Sync.makeFactory({ y_label: Factory.each((i) => `y_label_${i}`), }); -export const dashboardMetricFactory = - Factory.Sync.makeFactory({ +export const dashboardMetricFactory = Factory.Sync.makeFactory( + { available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [], label: Factory.each((i) => `widget_label_${i}`), @@ -62,10 +62,11 @@ export const dashboardMetricFactory = (i) => scrape_interval[i % scrape_interval.length] ), unit: 'defaultUnit', - }); + } +); -export const cloudPulseMetricsResponseDataFactory = - Factory.Sync.makeFactory({ +export const cloudPulseMetricsResponseDataFactory = Factory.Sync.makeFactory( + { result: [ { metric: {}, @@ -73,14 +74,16 @@ export const cloudPulseMetricsResponseDataFactory = }, ], result_type: 'matrix', - }); + } +); -export const cloudPulseMetricsResponseFactory = - Factory.Sync.makeFactory({ +export const cloudPulseMetricsResponseFactory = Factory.Sync.makeFactory( + { data: cloudPulseMetricsResponseDataFactory.build(), isPartial: false, stats: { series_fetched: 2, }, status: 'success', - }); + } +); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx new file mode 100644 index 00000000000..8939e656d0c --- /dev/null +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx @@ -0,0 +1,103 @@ +import { FormLabel } from '@mui/material'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { ErrorMessage } from 'src/components/ErrorMessage'; +import { FormControl } from 'src/components/FormControl'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { Notice } from 'src/components/Notice/Notice'; +import { Toggle } from 'src/components/Toggle/Toggle'; +import { Typography } from 'src/components/Typography'; +import { validateIPs } from 'src/utilities/ipUtils'; + +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +export interface ControlPlaneACLProps { + enableControlPlaneACL: boolean; + errorText: string | undefined; + handleIPv4Change: (ips: ExtendedIP[]) => void; + handleIPv6Change: (ips: ExtendedIP[]) => void; + ipV4Addr: ExtendedIP[]; + ipV6Addr: ExtendedIP[]; + setControlPlaneACL: (enabled: boolean) => void; +} + +export const ControlPlaneACLPane = (props: ControlPlaneACLProps) => { + const { + enableControlPlaneACL, + errorText, + handleIPv4Change, + handleIPv6Change, + ipV4Addr, + ipV6Addr, + setControlPlaneACL, + } = props; + + return ( + <> + + + Control Plane ACL + + {errorText && ( + + {' '} + + )} + + Enable an access control list (ACL) on your LKE cluster to restrict + access to your cluster’s control plane. When enabled, only the IP + addresses and ranges specified by you can connect to the control + plane. + + setControlPlaneACL(!enableControlPlaneACL)} + /> + } + label="Enable Control Plane ACL" + /> + + {enableControlPlaneACL && ( + + { + const validatedIPs = validateIPs(_ips, { + allowEmptyAddress: true, + errorMessage: 'Must be a valid IPv4 address.', + }); + handleIPv4Change(validatedIPs); + }} + buttonText="Add IPv4 Address" + ips={ipV4Addr} + isLinkStyled + onChange={handleIPv4Change} + placeholder="0.0.0.0/0" + title="IPv4 Addresses or CIDRs" + /> + + { + const validatedIPs = validateIPs(_ips, { + allowEmptyAddress: true, + errorMessage: 'Must be a valid IPv6 address.', + }); + handleIPv6Change(validatedIPs); + }} + buttonText="Add IPv6 Address" + ips={ipV6Addr} + isLinkStyled + onChange={handleIPv6Change} + placeholder="::/0" + title="IPv6 Addresses or CIDRs" + /> + + + )} + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 4df1d4319b3..21a39a48c74 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -1,8 +1,3 @@ -import { - type CreateKubeClusterPayload, - type CreateNodePoolData, - type KubeNodePoolResponse, -} from '@linode/api-v4/lib/kubernetes'; import { Divider } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { createLazyRoute } from '@tanstack/react-router'; @@ -24,6 +19,7 @@ import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperT import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { + getKubeControlPlaneACL, getKubeHighAvailability, getLatestVersion, useGetAPLAvailability, @@ -44,6 +40,7 @@ import { useAllTypes } from 'src/queries/types'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; +import { stringToExtendedIP } from 'src/utilities/ipUtils'; import { plansNoticesUtils } from 'src/utilities/planNotices'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; @@ -52,6 +49,7 @@ import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import KubeCheckoutBar from '../KubeCheckoutBar'; import { ApplicationPlatform } from './ApplicationPlatform'; +import { ControlPlaneACLPane } from './ControlPlaneACLPane'; import { StyledDocsLinkContainer, StyledFieldWithDocsStack, @@ -60,7 +58,13 @@ import { import { HAControlPlane } from './HAControlPlane'; import { NodePoolPanel } from './NodePoolPanel'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, +} from '@linode/api-v4/lib/kubernetes'; import type { APIError } from '@linode/api-v4/lib/types'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; export const CreateCluster = () => { const { classes } = useStyles(); @@ -76,6 +80,7 @@ export const CreateCluster = () => { const formContainerRef = React.useRef(null); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const [highAvailability, setHighAvailability] = React.useState(); + const [controlPlaneACL, setControlPlaneACL] = React.useState(true); const [apl_enabled, setApl_enabled] = React.useState(false); const { data, error: regionsError } = useRegionsQuery(); @@ -84,6 +89,13 @@ export const CreateCluster = () => { const { data: account } = useAccount(); const showAPL = useGetAPLAvailability(); const { showHighAvailability } = getKubeHighAvailability(account); + const { showControlPlaneACL } = getKubeControlPlaneACL(account); + const [ipV4Addr, setIPv4Addr] = React.useState([ + stringToExtendedIP(''), + ]); + const [ipV6Addr, setIPv6Addr] = React.useState([ + stringToExtendedIP(''), + ]); const { data: kubernetesHighAvailabilityTypesData, @@ -128,44 +140,85 @@ export const CreateCluster = () => { } }, [versionData]); -const createCluster = () => { - const { push } = history; - setErrors(undefined); - setSubmitting(true); + const createCluster = () => { + if (ipV4Addr.some((ip) => ip.error) || ipV6Addr.some((ip) => ip.error)) { + scrollErrorIntoViewV2(formContainerRef); + return; + } - const node_pools = nodePools.map(pick(['type', 'count'])) as CreateNodePoolData[]; + const { push } = history; + setErrors(undefined); + setSubmitting(true); - let payload: CreateKubeClusterPayload = { - control_plane: { high_availability: highAvailability ?? false }, - k8s_version: version, - label, - node_pools, - region: selectedRegionId, - }; + const node_pools = nodePools.map( + pick(['type', 'count']) + ) as CreateNodePoolData[]; - if (showAPL) { - payload = { ...payload, apl_enabled }; - } + const _ipv4 = ipV4Addr + .map((ip) => { + return ip.address; + }) + .filter((ip) => ip !== ''); - const createClusterFn = showAPL ? createKubernetesClusterBeta : createKubernetesCluster; - - createClusterFn(payload) - .then((cluster) => { - push(`/kubernetes/clusters/${cluster.id}`); - if (hasAgreed) { - updateAccountAgreements({ - eu_model: true, - privacy_policy: true, - }).catch(reportAgreementSigningError); - } - }) - .catch((err) => { - setErrors(getAPIErrorOrDefault(err, 'Error creating your cluster')); - setSubmitting(false); - scrollErrorIntoViewV2(formContainerRef); - }); -}; + const _ipv6 = ipV6Addr + .map((ip) => { + return ip.address; + }) + .filter((ip) => ip !== ''); + + const addressIPv4Payload = { + ...(_ipv4.length > 0 && { ipv4: _ipv4 }), + }; + + const addressIPv6Payload = { + ...(_ipv6.length > 0 && { ipv6: _ipv6 }), + }; + let payload: CreateKubeClusterPayload = { + control_plane: { + acl: { + enabled: controlPlaneACL, + 'revision-id': '', + ...(controlPlaneACL && // only send the IPs if we are enabling IPACL + (_ipv4.length > 0 || _ipv6.length > 0) && { + addresses: { + ...addressIPv4Payload, + ...addressIPv6Payload, + }, + }), + }, + high_availability: highAvailability ?? false, + }, + k8s_version: version, + label, + node_pools, + region: selectedRegionId, + }; + + if (showAPL) { + payload = { ...payload, apl_enabled }; + } + + const createClusterFn = showAPL + ? createKubernetesClusterBeta + : createKubernetesCluster; + + createClusterFn(payload) + .then((cluster) => { + push(`/kubernetes/clusters/${cluster.id}`); + if (hasAgreed) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }).catch(reportAgreementSigningError); + } + }) + .catch((err) => { + setErrors(getAPIErrorOrDefault(err, 'Error creating your cluster')); + setSubmitting(false); + scrollErrorIntoViewV2(formContainerRef); + }); + }; const toggleHasAgreed = () => setAgreed((prevHasAgreed) => !prevHasAgreed); @@ -196,7 +249,14 @@ const createCluster = () => { }); const errorMap = getErrorMap( - ['region', 'node_pools', 'label', 'k8s_version', 'versionLoad'], + [ + 'region', + 'node_pools', + 'label', + 'k8s_version', + 'versionLoad', + 'control_plane', + ], errors ); @@ -292,8 +352,8 @@ const createCluster = () => { )} - - {showHighAvailability ? ( + + {showHighAvailability && ( { setHighAvailability={setHighAvailability} /> - ) : null} + )} + {showControlPlaneACL && ( + <> + + { + setIPv4Addr(newIpV4Addr); + }} + handleIPv6Change={(newIpV6Addr: ExtendedIP[]) => { + setIPv6Addr(newIpV6Addr); + }} + enableControlPlaneACL={controlPlaneACL} + errorText={errorMap.control_plane} + ipV4Addr={ipV4Addr} + ipV6Addr={ipV6Addr} + setControlPlaneACL={setControlPlaneACL} + /> + + )} void; + clusterId: number; + clusterLabel: string; + clusterMigrated: boolean; + open: boolean; + showControlPlaneACL: boolean; +} + +export const KubeControlPlaneACLDrawer = (props: Props) => { + const formContainerRef = React.useRef(null); + const { + closeDrawer, + clusterId, + clusterLabel, + clusterMigrated, + open, + showControlPlaneACL, + } = props; + + const { + data: data, + error: isErrorKubernetesACL, + isLoading: isLoadingKubernetesACL, + } = useKubernetesControlPlaneACLQuery(clusterId, showControlPlaneACL); + + const { + mutateAsync: updateKubernetesClusterControlPlaneACL, + } = useKubernetesControlPlaneACLMutation(clusterId); + + const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( + clusterId + ); + + const { + control, + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + setError, + watch, + } = useForm({ + defaultValues: data, + mode: 'onBlur', + resolver: yupResolver(kubernetesControlPlaneACLPayloadSchema), + values: { + acl: { + addresses: { + ipv4: data?.acl?.addresses?.ipv4 ?? [''], + ipv6: data?.acl?.addresses?.ipv6 ?? [''], + }, + enabled: data?.acl?.enabled ?? false, + 'revision-id': data?.acl?.['revision-id'] ?? '', + }, + }, + }); + + const { acl } = watch(); + + const updateCluster = async () => { + // A quick note on the following code: + // + // - A non-IPACL'd cluster (denominated 'traditional') does not have IPACLs natively. + // The customer must then install IPACL (or 'migrate') on this cluster. + // This is done through a call to the updateKubernetesCluster endpoint. + // Only after a migration will the call to the updateKubernetesClusterControlPlaneACL + // endpoint be accepted. + // + // Do note that all new clusters automatically have IPACLs installed (even if the customer + // chooses to disable it during creation). + // + // For this reason, further in this code, we check whether the cluster has migrated or not + // before choosing which endpoint to use. + // + // - The address stanza of the JSON payload is optional. If provided though, that stanza must + // contain either/or/both IPv4 and IPv6. This is why there is additional code to properly + // check whether either exists, and only if they do, do we provide the addresses stanza + // to the payload + // + // - Hopefully this explains the behavior of this code, and why one must be very careful + // before introducing any clever/streamlined code - there's a reason to the mess :) + + const ipv4 = acl.addresses?.ipv4 + ? acl.addresses.ipv4.filter((ip) => ip !== '') + : []; + + const ipv6 = acl.addresses?.ipv6 + ? acl.addresses.ipv6.filter((ip) => ip !== '') + : []; + + const payload: KubernetesControlPlaneACLPayload = { + acl: { + enabled: acl.enabled, + 'revision-id': acl['revision-id'], + ...((ipv4.length > 0 || ipv6.length > 0) && { + addresses: { + ...(ipv4.length > 0 && { ipv4 }), + ...(ipv6.length > 0 && { ipv6 }), + }, + }), + }, + }; + + try { + if (clusterMigrated) { + await updateKubernetesClusterControlPlaneACL(payload); + } else { + await updateKubernetesCluster({ + control_plane: payload, + }); + } + closeDrawer(); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + scrollErrorIntoViewV2(formContainerRef); + } + }; + + return ( + reset()} + open={open} + title={'Control Plane ACL'} + wide + > + +
    + {errors.root?.message && ( + + {errors.root.message} + + )} + + + Control Plane ACL secures network access to your LKE + cluster's control plane. Use this form to enable or disable + the ACL on your LKE cluster, update the list of allowed IP + addresses, and adjust other settings. + + + Activation Status + + Enable or disable the Control Plane ACL. If the ACL is not + enabled, any public IP address can be used to access your control + plane. Once enabled, all network access is denied except for the + IP addresses and CIDR ranges defined on the ACL. + + + ( + + } + label={'Enable Control Plane ACL'} + /> + )} + control={control} + name="acl.enabled" + /> + + + {clusterMigrated && ( + <> + Revision ID + + A unique identifing string for this particular revision to the + ACL, used by clients to track events related to ACL update + requests and enforcement. This defaults to a randomly + generated string but can be edited if you prefer to specify + your own string to use for tracking this change. + + ( + + )} + control={control} + name="acl.revision-id" + /> + + + )} + Addresses + + A list of allowed IPv4 and IPv6 addresses and CIDR ranges. This + cluster's control plane will only be accessible from IP + addresses within this list. + + {errors.acl?.message && ( + + {errors.acl.message} + + )} + + ( + + )} + control={control} + name="acl.addresses.ipv4" + /> + + ( + + )} + control={control} + name="acl.addresses.ipv6" + /> + + + + {!clusterMigrated && ( + + + Control Plane ACL has not yet been installed on this cluster. + During installation, it may take up to 15 minutes for the + access control list to be fully enforced. + + + )} + + +
    +
    + ); +}; + +const StyledTypography = styled(Typography, { label: 'StyledTypography' })({ + width: '90%', +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx new file mode 100644 index 00000000000..7093d838879 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx @@ -0,0 +1,186 @@ +import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { TagCell } from 'src/components/TagCell/TagCell'; +import { + StyledBox, + StyledLabelBox, + StyledListItem, + sxLastListItem, + sxListItemFirstChild, +} from 'src/features/Linodes/LinodeEntityDetail.styles'; +import { useKubernetesClusterMutation } from 'src/queries/kubernetes'; +import { useProfile } from 'src/queries/profile/profile'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { formatDate } from 'src/utilities/formatDate'; +import { pluralize } from 'src/utilities/pluralize'; + +import type { KubernetesControlPlaneACLPayload } from '@linode/api-v4'; + +interface FooterProps { + aclData: KubernetesControlPlaneACLPayload | undefined; + clusterCreated: string; + clusterId: number; + clusterLabel: string; + clusterTags: string[]; + clusterUpdated: string; + isClusterReadOnly: boolean; + isLoadingKubernetesACL: boolean; + setControlPlaneACLDrawerOpen: React.Dispatch>; + showControlPlaneACL: boolean; +} + +export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { + const theme = useTheme(); + + const { data: profile } = useProfile(); + const { + aclData, + clusterCreated, + clusterId, + clusterLabel, + clusterTags, + clusterUpdated, + isClusterReadOnly, + isLoadingKubernetesACL, + setControlPlaneACLDrawerOpen, + showControlPlaneACL, + } = props; + + const enabledACL = aclData?.acl.enabled ?? false; + const totalIPv4 = aclData?.acl.addresses?.ipv4?.length ?? 0; + const totalIPv6 = aclData?.acl.addresses?.ipv6?.length ?? 0; + const totalNumberIPs = totalIPv4 + totalIPv6; + + const buttonCopyACL = enabledACL + ? `Enabled (${pluralize('IP Address', 'IP Addresses', totalNumberIPs)})` + : 'Enable'; + + const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( + clusterId + ); + + const { enqueueSnackbar } = useSnackbar(); + + const handleUpdateTags = React.useCallback( + (newTags: string[]) => { + return updateKubernetesCluster({ + tags: newTags, + }).catch((e) => + enqueueSnackbar( + getAPIErrorOrDefault(e, 'Error updating tags')[0].reason, + { + variant: 'error', + } + ) + ); + }, + [updateKubernetesCluster, enqueueSnackbar] + ); + + return ( + + + + + Cluster ID:{' '} + {clusterId} + + {showControlPlaneACL && ( + + + Control Plane ACL:{' '} + {' '} + {isLoadingKubernetesACL ? ( + + + + ) : ( + setControlPlaneACLDrawerOpen(true)} + > + {buttonCopyACL} + + )} + + )} + + + + Created:{' '} + {formatDate(clusterCreated, { + timezone: profile?.timezone, + })} + + + Updated:{' '} + {formatDate(clusterUpdated, { + timezone: profile?.timezone, + })} + + + + + + + + ); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx new file mode 100644 index 00000000000..3dc8e3799e9 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx @@ -0,0 +1,17 @@ +// This component was built asuming an unmodified MUI
    Failing Tests
    SpecTest
    +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; + +export const StyledActionRowGrid = styled(Grid, { + label: 'StyledActionRowGrid', +})({ + '& button': { + alignItems: 'flex-start', + }, + alignItems: 'flex-end', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + padding: '8px 0px', +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index a3579c98c27..a4cca466c65 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -3,7 +3,6 @@ import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; @@ -13,12 +12,13 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { Stack } from 'src/components/Stack'; -import { TagCell } from 'src/components/TagCell/TagCell'; import { Typography } from 'src/components/Typography'; import { KubeClusterSpecs } from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; +import { getKubeControlPlaneACL } from 'src/features/Kubernetes/kubeUtils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useAccount } from 'src/queries/account/account'; import { - useKubernetesClusterMutation, + useKubernetesControlPlaneACLQuery, useKubernetesDashboardQuery, useResetKubeConfigMutation, } from 'src/queries/kubernetes'; @@ -27,72 +27,11 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { DeleteKubernetesClusterDialog } from './DeleteKubernetesClusterDialog'; import { KubeConfigDisplay } from './KubeConfigDisplay'; import { KubeConfigDrawer } from './KubeConfigDrawer'; +import { KubeControlPlaneACLDrawer } from './KubeControlPaneACLDrawer'; +import { KubeEntityDetailFooter } from './KubeEntityDetailFooter'; +import { StyledActionRowGrid } from './KubeSummaryPanel.styles'; import type { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - actionRow: { - '& button': { - alignItems: 'flex-start', - }, - alignItems: 'flex-end', - alignSelf: 'stretch', - display: 'flex', - flexDirection: 'row', - justifyContent: 'flex-end', - padding: '8px 0px', - }, - dashboard: { - '& svg': { - height: 14, - marginLeft: 4, - }, - alignItems: 'center', - display: 'flex', - }, - deleteClusterBtn: { - [theme.breakpoints.up('md')]: { - paddingRight: '8px', - }, - }, - tags: { - // Tags Panel wrapper - '& > div:last-child': { - marginBottom: 0, - marginTop: 2, - width: '100%', - }, - '&.MuiGrid-item': { - paddingBottom: 0, - }, - alignItems: 'flex-end', - alignSelf: 'stretch', - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-end', - [theme.breakpoints.down('lg')]: { - width: '100%', - }, - [theme.breakpoints.up('lg')]: { - '& .MuiChip-root': { - marginLeft: 4, - marginRight: 0, - }, - // Add a Tag button - '& > div:first-of-type': { - justifyContent: 'flex-end', - marginTop: theme.spacing(4), - }, - // Tags Panel wrapper - '& > div:last-child': { - display: 'flex', - justifyContent: 'flex-end', - }, - }, - width: '100%', - }, -})); interface Props { cluster: KubernetesCluster; @@ -101,18 +40,20 @@ interface Props { export const KubeSummaryPanel = React.memo((props: Props) => { const { cluster } = props; - const { classes } = useStyles(); + const { data: account } = useAccount(); + const { showControlPlaneACL } = getKubeControlPlaneACL(account); + const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); const [drawerOpen, setDrawerOpen] = React.useState(false); + const [ + isControlPlaneACLDrawerOpen, + setControlPlaneACLDrawerOpen, + ] = React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); - const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( - cluster.id - ); - const { data: dashboard, error: dashboardError, @@ -130,6 +71,12 @@ export const KubeSummaryPanel = React.memo((props: Props) => { id: cluster.id, }); + const { + data: aclData, + error: isErrorKubernetesACL, + isLoading: isLoadingKubernetesACL, + } = useKubernetesControlPlaneACLQuery(cluster.id, !!showControlPlaneACL); + const [ resetKubeConfigDialogOpen, setResetKubeConfigDialogOpen, @@ -148,12 +95,6 @@ export const KubeSummaryPanel = React.memo((props: Props) => { setDrawerOpen(true); }; - const handleUpdateTags = (newTags: string[]) => { - return updateKubernetesCluster({ - tags: newTags, - }); - }; - const sxSpacing = { paddingLeft: theme.spacing(3), paddingRight: theme.spacing(1), @@ -191,7 +132,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { lg={5} xs={12} > - + {cluster.control_plane.high_availability && ( { variant="outlined" /> )} - - - - + } + footer={ + + } header={ { onClick={() => { window.open(dashboard?.url, '_blank'); }} - className={classes.dashboard} + sx={{ + '& svg': { + height: '14px', + marginLeft: '4px', + }, + alignItems: 'center', + display: 'flex', + }} disabled={Boolean(dashboardError) || !dashboard} > Kubernetes Dashboard setIsDeleteDialogOpen(true)} > Delete Cluster @@ -253,6 +210,14 @@ export const KubeSummaryPanel = React.memo((props: Props) => { clusterLabel={cluster.label} open={drawerOpen} /> + setControlPlaneACLDrawerOpen(false)} + clusterId={cluster.id} + clusterLabel={cluster.label} + clusterMigrated={!isErrorKubernetesACL} + open={isControlPlaneACLDrawerOpen} + showControlPlaneACL={!!showControlPlaneACL} + /> { const id = Number(clusterID); const location = useLocation(); const showAPL = useGetAPLAvailability(); - const kubernetesClusterBetaQuery = useKubernetesClusterBetaQuery(id); - const kubernetesClusterQuery = useKubernetesClusterQuery(id); - const { data: cluster, error, isLoading } = showAPL - ? kubernetesClusterBetaQuery - : kubernetesClusterQuery; + const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); const { data: regionsData } = useRegionsQuery(); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index f18717fc86b..0dafba32367 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -123,6 +123,24 @@ export const useGetAPLAvailability = (): boolean => { return Boolean(flags.apl); }; +export const getKubeControlPlaneACL = ( + account: Account | undefined, + cluster?: KubernetesCluster | null +) => { + const showControlPlaneACL = account?.capabilities.includes( + 'LKE Network Access Control List (IP ACL)' + ); + + const isClusterControlPlaneACLd = Boolean( + showControlPlaneACL && cluster?.control_plane.acl + ); + + return { + isClusterControlPlaneACLd, + showControlPlaneACL, + }; +}; + /** * Retrieves the latest version from an array of version objects. * diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index 4c7ec07a99e..47f29f03e5b 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -1,8 +1,7 @@ // This component was built asuming an unmodified MUI
    +import { styled } from '@mui/material/styles'; import Table from '@mui/material/Table'; import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; -import { Theme } from '@mui/material/styles'; import { Link } from 'react-router-dom'; import { Box } from 'src/components/Box'; @@ -11,6 +10,8 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + // --------------------------------------------------------------------- // Header Styles // --------------------------------------------------------------------- diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 3a8b98ece3b..cf8e1322ff9 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -7,6 +7,7 @@ import { getKubeConfig, getKubernetesCluster, getKubernetesClusterBeta, + getKubernetesClusterControlPlaneACL, getKubernetesClusterDashboard, getKubernetesClusterEndpoints, getKubernetesClusters, @@ -18,6 +19,7 @@ import { recycleNode, resetKubeConfig, updateKubernetesCluster, + updateKubernetesClusterControlPlaneACL, updateNodePool, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; @@ -28,6 +30,7 @@ import { useQueryClient, } from '@tanstack/react-query'; +import { useGetAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; @@ -38,6 +41,7 @@ import type { CreateNodePoolData, KubeNodePoolResponse, KubernetesCluster, + KubernetesControlPlaneACLPayload, KubernetesDashboardResponse, KubernetesEndpointResponse, KubernetesVersion, @@ -54,8 +58,8 @@ import type { export const kubernetesQueries = createQueryKeys('kubernetes', { cluster: (id: number) => ({ contextQueries: { - beta: { - queryFn: () => getKubernetesClusterBeta(id), + acl: { + queryFn: () => getKubernetesClusterControlPlaneACL(id), queryKey: [id], }, dashboard: { @@ -105,17 +109,13 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }); export const useKubernetesClusterQuery = (id: number) => { - return useQuery(kubernetesQueries.cluster(id)); -}; - -/** - * duplicated function of useKubernetesClusterQuery - * necessary to call BETA_API_ROOT in a seperate function based on feature flag - */ -export const useKubernetesClusterBetaQuery = (id: number) => { - return useQuery( - kubernetesQueries.cluster(id)._ctx.beta - ); + const showAPL = useGetAPLAvailability(); + return useQuery({ + ...kubernetesQueries.cluster(id), + queryFn: showAPL + ? () => getKubernetesClusterBeta(id) // necessary to call BETA_API_ROOT in a seperate function based on feature flag + : () => getKubernetesCluster(id), + }); }; export const useKubernetesClustersQuery = ( @@ -139,6 +139,9 @@ export const useKubernetesClusterMutation = (id: number) => { queryClient.invalidateQueries({ queryKey: kubernetesQueries.lists.queryKey, }); + queryClient.invalidateQueries({ + queryKey: kubernetesQueries.cluster(id)._ctx.acl.queryKey, + }); queryClient.setQueryData(kubernetesQueries.cluster(id).queryKey, data); }, } @@ -349,6 +352,34 @@ export const useAllKubernetesClustersQuery = (enabled = false) => { }); }; +export const useKubernetesControlPlaneACLQuery = ( + clusterId: number, + enabled: boolean = true +) => { + return useQuery({ + enabled, + retry: 1, + ...kubernetesQueries.cluster(clusterId)._ctx.acl, + }); +}; + +export const useKubernetesControlPlaneACLMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation< + KubernetesControlPlaneACLPayload, + APIError[], + Partial + >({ + mutationFn: (data) => updateKubernetesClusterControlPlaneACL(id, data), + onSuccess(data) { + queryClient.setQueryData( + kubernetesQueries.cluster(id)._ctx.acl.queryKey, + data + ); + }, + }); +}; + const getAllNodePoolsForCluster = (clusterId: number) => getAll((params, filters) => getNodePools(clusterId, params, filters) diff --git a/packages/validation/.changeset/pr-10968-added-1729020457987.md b/packages/validation/.changeset/pr-10968-added-1729020457987.md new file mode 100644 index 00000000000..dc0af59b2ff --- /dev/null +++ b/packages/validation/.changeset/pr-10968-added-1729020457987.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Validation schema for LKE ACL payload ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/validation/src/firewalls.schema.ts b/packages/validation/src/firewalls.schema.ts index e1a2eeb6e4f..d00e1eb6451 100644 --- a/packages/validation/src/firewalls.schema.ts +++ b/packages/validation/src/firewalls.schema.ts @@ -7,7 +7,8 @@ export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 address or range.'; export const validateIP = (ipAddress?: string | null): boolean => { - if (!ipAddress) { + // ''is falsy, so we must specify that it is OK + if (ipAddress !== '' && !ipAddress) { return false; } // We accept plain IPs as well as ranges (i.e. CIDR notation). Ipaddr.js has separate parsing diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index 4dd7fbfd60d..8910936d005 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -1,3 +1,4 @@ +import { validateIP } from './firewalls.schema'; import { array, number, object, string, boolean } from 'yup'; export const nodePoolSchema = object().shape({ @@ -58,3 +59,28 @@ export const createKubeClusterSchema = object().shape({ .of(nodePoolSchema) .min(1, 'Please add at least one node pool.'), }); + +export const ipv4Address = string().test({ + name: 'validateIP', + message: 'Must be a valid IPv4 address.', + test: validateIP, +}); + +export const ipv6Address = string().test({ + name: 'validateIP', + message: 'Must be a valid IPv6 address.', + test: validateIP, +}); + +const controlPlaneACLOptionsSchema = object().shape({ + enabled: boolean(), + 'revision-id': string(), + addresses: object().shape({ + ipv4: array().of(ipv4Address).nullable(true), + ipv6: array().of(ipv6Address).nullable(true), + }), +}); + +export const kubernetesControlPlaneACLPayloadSchema = object().shape({ + acl: controlPlaneACLOptionsSchema, +}); From ac079beca3a935455869172ee14ff6d0c010a841 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:38:27 -0400 Subject: [PATCH 52/64] refactor: [M3-8765] - Remove unused Marketplace feature flags (#11133) * remove unused flags * rename `oneClickAppsv2.ts` to `oneClickApps.ts` * add chageset and fix import --------- Co-authored-by: Banks Nussman --- .../.changeset/pr-11133-tech-stories-1729550913039.md | 5 +++++ .../cypress/e2e/core/oneClickApps/one-click-apps.spec.ts | 2 +- packages/manager/src/featureFlags.ts | 8 ++------ .../LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx | 5 +++-- .../LinodeCreate/Tabs/Marketplace/utilities.test.ts | 2 +- .../Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts | 2 +- .../StackScripts/UserDefinedFields/UserDefinedFields.tsx | 2 +- .../OneClickApps/{oneClickAppsv2.ts => oneClickApps.ts} | 0 8 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md rename packages/manager/src/features/OneClickApps/{oneClickAppsv2.ts => oneClickApps.ts} (100%) diff --git a/packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md b/packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md new file mode 100644 index 00000000000..b3706fac8a6 --- /dev/null +++ b/packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove unused Marketplace feature flags ([#11133](https://github.com/linode/manager/pull/11133)) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index ad8f3a6e28a..bec1b2c8bf0 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -8,7 +8,7 @@ import { mockCreateLinode } from 'support/intercepts/linodes'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { stackScriptFactory } from 'src/factories/stackscripts'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import { getMarketplaceAppLabel } from 'src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities'; import type { StackScript } from '@linode/api-v4'; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 2af7a792b13..0aad47f89c7 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,4 +1,4 @@ -import type { Doc, OCA } from './features/OneClickApps/types'; +import type { OCA } from './features/OneClickApps/types'; import type { TPAProvider } from '@linode/api-v4/lib/profile'; import type { NoticeVariant } from 'src/components/Notice/Notice'; @@ -74,8 +74,6 @@ interface gpuV2 { planDivider: boolean; } -type OneClickApp = Record; - interface DesignUpdatesBannerFlag extends BaseFeatureFlag { key: string; link: string; @@ -115,8 +113,6 @@ export interface Flags { metadata: boolean; objMultiCluster: boolean; objectStorageGen2: BaseFeatureFlag; - oneClickApps: OneClickApp; - oneClickAppsDocsOverride: Record; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; @@ -137,7 +133,7 @@ interface MarketplaceAppOverride { /** * Define app details that should be overwritten * - * If you are adding an app that is not already defined in "oneClickAppsv2.ts", + * If you are adding an app that is not already defined in "oneClickApps.ts", * you *must* include all required OCA properties or Cloud Manager could crash. * * Pass `null` to hide the marketplace app diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx index ed272f47f61..4d8b899c71d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSelect.test.tsx @@ -58,8 +58,9 @@ describe('Marketplace', () => { component: , }); - await waitFor(() => { - expect(getByPlaceholderText('Select category')).not.toBeDisabled(); + await waitFor( + () => { + expect(getByPlaceholderText('Select category')).not.toBeDisabled(); }, { timeout: 5_000 } ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.test.ts index 2ec5b3eecce..be11126e1de 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.test.ts @@ -1,7 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { stackScriptFactory } from 'src/factories'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { wrapWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts index 34319797d94..6546e5706ed 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts @@ -1,6 +1,6 @@ import { decode } from 'he'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import { useFlags } from 'src/hooks/useFlags'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx index cf9d422099e..8316b8c8e75 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx @@ -9,7 +9,7 @@ import { Paper } from 'src/components/Paper'; import { ShowMoreExpansion } from 'src/components/ShowMoreExpansion'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import { useStackScriptQuery } from 'src/queries/stackscripts'; import { getMarketplaceAppLabel } from '../../Marketplace/utilities'; diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts similarity index 100% rename from packages/manager/src/features/OneClickApps/oneClickAppsv2.ts rename to packages/manager/src/features/OneClickApps/oneClickApps.ts From 43000641db32a33daffaf47993f0f8938776cfe6 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Wed, 23 Oct 2024 03:18:52 +0530 Subject: [PATCH 53/64] refactor: [M3-8501] - AccessSelect Optimization: Use React Hook Form (#10952) * refactor: [M3-8501] - AccessSelect Optimization: Use React Hook Form * Added changeset: Optimize AccessSelect component: Use React Hook Form & React Query * refactor: [M3-8501] - Updated tests * change: [M3-8501] - Tiny bug fixes * change: [M3-8501] - Styling changes * refactor: [M3-8501] - Replacing useEffect with useMemo --- .../pr-10952-tech-stories-1726576261944.md | 5 + .../BucketDetail/AccessSelect.test.tsx | 58 ++-- .../BucketDetail/AccessSelect.tsx | 291 ++++++++++-------- .../BucketDetail/BucketAccess.tsx | 19 +- .../BucketDetail/ObjectDetailsDrawer.tsx | 21 +- .../BucketLanding/BucketDetailsDrawer.tsx | 34 +- .../src/queries/object-storage/queries.ts | 76 +++++ 7 files changed, 287 insertions(+), 217 deletions(-) create mode 100644 packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md diff --git a/packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md b/packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md new file mode 100644 index 00000000000..ad951701cb3 --- /dev/null +++ b/packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Optimize AccessSelect component: Use React Hook Form & React Query ([#10952](https://github.com/linode/manager/pull/10952)) diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx index bd7b58082c9..a02a5ea5b1f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AccessSelect } from './AccessSelect'; @@ -11,31 +12,27 @@ import type { ObjectStorageEndpointTypes } from '@linode/api-v4'; const CORS_ENABLED_TEXT = 'CORS Enabled'; const AUTHENTICATED_READ_TEXT = 'Authenticated Read'; +const BUCKET_ACCESS_URL = '*object-storage/buckets/*/*/access'; +const OBJECT_ACCESS_URL = '*object-storage/buckets/*/*/object-acl'; vi.mock('src/components/EnhancedSelect/Select'); -const mockGetAccess = vi.fn().mockResolvedValue({ - acl: 'private', - cors_enabled: true, -}); -const mockUpdateAccess = vi.fn().mockResolvedValue({}); - const defaultProps: Props = { + clusterOrRegion: 'in-maa', endpointType: 'E1', - getAccess: mockGetAccess, name: 'my-object-name', - updateAccess: mockUpdateAccess, variant: 'bucket', }; describe('AccessSelect', () => { const renderComponent = (props: Partial = {}) => - renderWithTheme(); + renderWithTheme(, { + flags: { objectStorageGen2: { enabled: true } }, + }); beforeEach(() => { vi.clearAllMocks(); }); - it.each([ ['bucket', 'E0', true], ['bucket', 'E1', true], @@ -48,13 +45,21 @@ describe('AccessSelect', () => { ])( 'shows correct UI for %s variant and %s endpoint type', async (variant, endpointType, shouldShowCORS) => { + server.use( + http.get(BUCKET_ACCESS_URL, () => { + return HttpResponse.json({ acl: 'private', cors_enabled: true }); + }), + http.get(OBJECT_ACCESS_URL, () => { + return HttpResponse.json({ acl: 'private' }); + }) + ); + renderComponent({ endpointType: endpointType as ObjectStorageEndpointTypes, variant: variant as 'bucket' | 'object', }); const aclSelect = screen.getByRole('combobox'); - await waitFor(() => { expect(aclSelect).toBeEnabled(); expect(aclSelect).toHaveValue('Private'); @@ -69,7 +74,6 @@ describe('AccessSelect', () => { 'aria-selected', 'true' ); - if (shouldShowCORS) { await waitFor(() => { expect(screen.getByLabelText(CORS_ENABLED_TEXT)).toBeInTheDocument(); @@ -92,13 +96,25 @@ describe('AccessSelect', () => { it('updates the access and CORS settings and submits the appropriate values', async () => { renderComponent(); + server.use( + http.get(BUCKET_ACCESS_URL, () => { + return HttpResponse.json({ acl: 'private', cors_enabled: true }); + }), + http.put(BUCKET_ACCESS_URL, () => { + return HttpResponse.json({}); + }) + ); + const aclSelect = screen.getByRole('combobox'); const saveButton = screen.getByText('Save').closest('button')!; - await waitFor(() => { - expect(aclSelect).toBeEnabled(); - expect(aclSelect).toHaveValue('Private'); - }); + await waitFor( + () => { + expect(aclSelect).toBeEnabled(); + expect(aclSelect).toHaveValue('Private'); + }, + { interval: 100, timeout: 5000 } + ); // Wait for CORS toggle to appear and be checked const corsToggle = await screen.findByRole('checkbox', { @@ -131,12 +147,8 @@ describe('AccessSelect', () => { }); await userEvent.click(saveButton); - expect(mockUpdateAccess).toHaveBeenCalledWith('authenticated-read', false); - - await userEvent.click(corsToggle); - await waitFor(() => expect(corsToggle).toBeChecked()); - - await userEvent.click(saveButton); - expect(mockUpdateAccess).toHaveBeenCalledWith('authenticated-read', true); + await waitFor(() => + screen.findByText('Bucket access updated successfully.') + ); }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx index a1093415801..362a8b21f74 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx @@ -1,9 +1,8 @@ -import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Button } from 'src/components/Button/Button'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; @@ -11,6 +10,12 @@ import { Notice } from 'src/components/Notice/Notice'; import { Toggle } from 'src/components/Toggle/Toggle'; import { Typography } from 'src/components/Typography'; import { useOpenClose } from 'src/hooks/useOpenClose'; +import { + useBucketAccess, + useObjectAccess, + useUpdateBucketAccessMutation, + useUpdateObjectAccessMutation, +} from 'src/queries/object-storage/queries'; import { capitalize } from 'src/utilities/capitalize'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; @@ -22,14 +27,15 @@ import type { ObjectStorageBucketAccess, ObjectStorageEndpointTypes, ObjectStorageObjectACL, + UpdateObjectStorageBucketAccessPayload, } from '@linode/api-v4/lib/object-storage'; import type { Theme } from '@mui/material/styles'; export interface Props { + bucketName?: string; + clusterOrRegion: string; endpointType?: ObjectStorageEndpointTypes; - getAccess: () => Promise; name: string; - updateAccess: (acl: ACLType, cors_enabled?: boolean) => Promise<{}>; variant: 'bucket' | 'object'; } @@ -40,21 +46,8 @@ function isUpdateObjectStorageBucketAccessPayload( } export const AccessSelect = React.memo((props: Props) => { - const { endpointType, getAccess, name, updateAccess, variant } = props; - // Access data for this Object (from the API). - const [aclData, setACLData] = React.useState(null); - const [corsData, setCORSData] = React.useState(true); - const [accessLoading, setAccessLoading] = React.useState(false); - const [accessError, setAccessError] = React.useState(''); - // The ACL Option currently selected in the component. - const [selectedACL, setSelectedACL] = React.useState(null); - // The CORS Option currently selected in the component. - const [selectedCORSOption, setSelectedCORSOption] = React.useState(true); // TODO: OBJGen2 - We need to handle this in upcoming PR - // State for submitting access options. - const [updateAccessLoading, setUpdateAccessLoading] = React.useState(false); - const [updateAccessError, setUpdateAccessError] = React.useState(''); - const [updateAccessSuccess, setUpdateAccessSuccess] = React.useState(false); - // State for dealing with the confirmation modal when selecting read/write. + const { bucketName, clusterOrRegion, endpointType, name, variant } = props; + const { close: closeDialog, isOpen, open: openDialog } = useOpenClose(); const label = capitalize(variant); const isCorsAvailable = @@ -62,59 +55,63 @@ export const AccessSelect = React.memo((props: Props) => { endpointType !== 'E2' && endpointType !== 'E3'; - React.useEffect(() => { - setUpdateAccessError(''); - setAccessError(''); - setUpdateAccessSuccess(false); - setAccessLoading(true); - getAccess() - .then((response) => { - setAccessLoading(false); - const { acl } = response; - // Don't show "public-read-write" for Objects here; use "custom" instead - // since "public-read-write" Objects are basically the same as "public-read". - const _acl = - variant === 'object' && acl === 'public-read-write' ? 'custom' : acl; - setACLData(_acl); - setSelectedACL(_acl); - if (isUpdateObjectStorageBucketAccessPayload(response)) { - const { cors_enabled } = response; - if (typeof cors_enabled === 'boolean') { - setCORSData(cors_enabled); - setSelectedCORSOption(cors_enabled); - } - } - }) - .catch((err) => { - setAccessLoading(false); - setAccessError(getErrorStringOrDefault(err)); - }); - }, [getAccess, variant]); + const { + data: bucketAccessData, + error: bucketAccessError, + isFetching: bucketAccessIsFetching, + } = useBucketAccess(clusterOrRegion, name, variant === 'bucket'); - const handleSubmit = () => { - // TS safety check. - if (!name || !selectedACL) { - return; + const { + data: objectAccessData, + error: objectAccessError, + isFetching: objectAccessIsFetching, + } = useObjectAccess( + bucketName || '', + clusterOrRegion, + { name }, + variant === 'object' + ); + + const { + error: updateBucketAccessError, + isSuccess: updateBucketAccessSuccess, + mutateAsync: updateBucketAccess, + } = useUpdateBucketAccessMutation(clusterOrRegion, name); + + const { + error: updateObjectAccessError, + isSuccess: updateObjectAccessSuccess, + mutateAsync: updateObjectAccess, + } = useUpdateObjectAccessMutation(clusterOrRegion, bucketName || '', name); + + const formValues = React.useMemo(() => { + const data = variant === 'object' ? objectAccessData : bucketAccessData; + + if (data) { + const { acl } = data; + // Don't show "public-read-write" for Objects here; use "custom" instead + // since "public-read-write" Objects are basically the same as "public-read". + const _acl = + variant === 'object' && acl === 'public-read-write' ? 'custom' : acl; + const cors_enabled = isUpdateObjectStorageBucketAccessPayload(data) + ? data.cors_enabled ?? false + : true; + return { acl: _acl as ACLType, cors_enabled }; } + return { acl: 'private' as ACLType, cors_enabled: true }; + }, [bucketAccessData, objectAccessData, , variant]); - setUpdateAccessSuccess(false); - setUpdateAccessLoading(true); - setUpdateAccessError(''); - setAccessError(''); - closeDialog(); + const { + control, + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + watch, + } = useForm>({ + defaultValues: formValues, + values: formValues, + }); - updateAccess(selectedACL, selectedCORSOption) - .then(() => { - setUpdateAccessSuccess(true); - setACLData(selectedACL); - setCORSData(selectedCORSOption); - setUpdateAccessLoading(false); - }) - .catch((err) => { - setUpdateAccessLoading(false); - setUpdateAccessError(getErrorStringOrDefault(err)); - }); - }; + const selectedACL = watch('acl'); const aclOptions = variant === 'bucket' ? bucketACLOptions : objectACLOptions; @@ -127,78 +124,119 @@ export const AccessSelect = React.memo((props: Props) => { // select "public-read-write" as an Object ACL, which is just equivalent to // "public-read", so we don't present it as an option. const _options = - aclData === 'custom' + selectedACL === 'custom' ? [{ label: 'Custom', value: 'custom' }, ...aclOptions] : aclOptions; - const aclLabel = _options.find( - (thisOption) => thisOption.value === selectedACL - )?.label; - + const aclLabel = _options.find((option) => option.value === selectedACL) + ?.label; const aclCopy = selectedACL ? copy[variant][selectedACL] : null; - const errorText = accessError || updateAccessError; + const errorText = + getErrorStringOrDefault(bucketAccessError || '') || + getErrorStringOrDefault(objectAccessError || '') || + getErrorStringOrDefault(updateBucketAccessError || '') || + getErrorStringOrDefault(updateObjectAccessError || '') || + errors.acl?.message; - const CORSLabel = accessLoading - ? 'Loading access...' - : selectedCORSOption - ? 'CORS Enabled' - : 'CORS Disabled'; + const onSubmit = handleSubmit(async (data) => { + closeDialog(); + if (errorText) { + return; + } - const selectedOption = - _options.find((thisOption) => thisOption.value === selectedACL) ?? - _options.find((thisOption) => thisOption.value === 'private'); + if (variant === 'bucket') { + // Don't send the ACL with the payload if it's "custom", since it's + // not valid (though it's a valid return type). + const payload = + data.acl === 'custom' ? { cors_enabled: data.cors_enabled } : data; + await updateBucketAccess(payload); + } else { + await updateObjectAccess(data.acl); + } + }); return ( - <> - {updateAccessSuccess ? ( +
    + {(updateBucketAccessSuccess || updateObjectAccessSuccess) && ( - ) : null} + )} - {errorText ? : null} + {errorText && ( + + )} - { - if (selected) { - setUpdateAccessSuccess(false); - setUpdateAccessError(''); - setSelectedACL(selected.value as ACLType); - } - }} - data-testid="acl-select" - disableClearable - disabled={Boolean(accessError) || accessLoading} - label="Access Control List (ACL)" - loading={accessLoading} - options={!accessLoading ? _options : []} - placeholder={accessLoading ? 'Loading access...' : 'Select an ACL...'} - value={!accessLoading ? selectedOption : undefined} + ( + { + if (selected) { + field.onChange(selected.value); + } + }} + placeholder={ + bucketAccessIsFetching || objectAccessIsFetching + ? 'Loading access...' + : 'Select an ACL...' + } + data-testid="acl-select" + disableClearable + disabled={bucketAccessIsFetching || objectAccessIsFetching} + label="Access Control List (ACL)" + loading={bucketAccessIsFetching || objectAccessIsFetching} + options={_options} + value={_options.find((option) => option.value === field.value)} + /> + )} + control={control} + name="acl" + rules={{ required: 'ACL is required' }} />
    - {aclLabel && aclCopy ? ( + {aclLabel && aclCopy && ( {aclLabel}: {aclCopy} - ) : null} + )}
    - {isCorsAvailable ? ( - setSelectedCORSOption((prev) => !prev)} + {isCorsAvailable && ( + ( + + } + label={ + bucketAccessIsFetching || objectAccessIsFetching + ? 'Loading access...' + : field.value + ? 'CORS Enabled' + : 'CORS Disabled' + } + style={{ display: 'block', marginTop: 16 }} /> - } - label={CORSLabel} - style={{ display: 'block', marginTop: 16 }} + )} + control={control} + name="cors_enabled" /> - ) : null} + )} {isCorsAvailable ? ( @@ -209,8 +247,7 @@ export const AccessSelect = React.memo((props: Props) => { . - ) : ( - // TODO: OBJGen2 - We need to handle link in upcoming PR + ) : endpointType && variant === 'bucket' ? ( ({ @@ -225,19 +262,19 @@ export const AccessSelect = React.memo((props: Props) => { . - )} + ) : null} { - // This isn't really a sane option: open a dialog for confirmation. if (selectedACL === 'public-read-write') { openDialog(); } else { - handleSubmit(); + onSubmit(); } }, sx: (theme: Theme) => ({ @@ -255,7 +292,7 @@ export const AccessSelect = React.memo((props: Props) => { label: 'Cancel', onClick: closeDialog, }} - primaryButtonProps={{ label: 'Confirm', onClick: handleSubmit }} + primaryButtonProps={{ label: 'Confirm', onClick: onSubmit }} style={{ padding: 0 }} /> )} @@ -267,12 +304,6 @@ export const AccessSelect = React.memo((props: Props) => { Everyone will be able to list, create, overwrite, and delete Objects in this Bucket. This is not recommended. - + ); }); - -export const StyledSubmitButton = styled(Button, { - label: 'StyledFileUploadsContainer', -})(({ theme }) => ({ - marginTop: theme.spacing(3), -})); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx index 8305b1d5015..096209ce702 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx @@ -1,7 +1,3 @@ -import { - getBucketAccess, - updateBucketAccess, -} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -10,10 +6,7 @@ import { Typography } from 'src/components/Typography'; import { AccessSelect } from './AccessSelect'; -import type { - ACLType, - ObjectStorageEndpointTypes, -} from '@linode/api-v4/lib/object-storage'; +import type { ObjectStorageEndpointTypes } from '@linode/api-v4/lib/object-storage'; export const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', @@ -34,16 +27,8 @@ export const BucketAccess = React.memo((props: Props) => { Bucket Access { - // Don't send the ACL with the payload if it's "custom", since it's - // not valid (though it's a valid return type). - const payload = - acl === 'custom' ? { cors_enabled } : { acl, cors_enabled }; - - return updateBucketAccess(clusterId, bucketName, payload); - }} + clusterOrRegion={clusterId} endpointType={endpointType} - getAccess={() => getBucketAccess(clusterId, bucketName)} name={bucketName} variant="bucket" /> diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx index 52fc380cfcc..dbb4e111a0c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx @@ -1,7 +1,3 @@ -import { - getObjectACL, - updateObjectACL, -} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -17,10 +13,7 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from './AccessSelect'; -import type { - ACLType, - ObjectStorageEndpointTypes, -} from '@linode/api-v4/lib/object-storage'; +import type { ObjectStorageEndpointTypes } from '@linode/api-v4/lib/object-storage'; export interface ObjectDetailsDrawerProps { bucketName: string; @@ -93,16 +86,8 @@ export const ObjectDetailsDrawer = React.memo( <> - getObjectACL({ - bucket: bucketName, - clusterId, - params: { name }, - }) - } - updateAccess={(acl: ACLType) => - updateObjectACL(clusterId, bucketName, name, acl) - } + bucketName={bucketName} + clusterOrRegion={clusterId} endpointType={endpointType} name={name} variant="object" diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index b4ebf39c075..7743e840963 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -1,7 +1,3 @@ -import { - getBucketAccess, - updateBucketAccess, -} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -24,10 +20,7 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from '../BucketDetail/AccessSelect'; import { BucketRateLimitTable } from './BucketRateLimitTable'; -import type { - ACLType, - ObjectStorageBucket, -} from '@linode/api-v4/lib/object-storage'; +import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; export interface BucketDetailsDrawerProps { onClose: () => void; @@ -159,28 +152,11 @@ export const BucketDetailsDrawer = React.memo( )} {cluster && label && ( - getBucketAccess( - isObjMultiClusterEnabled && currentRegion - ? currentRegion.id - : cluster, - label - ) + clusterOrRegion={ + isObjMultiClusterEnabled && currentRegion + ? currentRegion.id + : cluster } - updateAccess={(acl: ACLType, cors_enabled: boolean) => { - // Don't send the ACL with the payload if it's "custom", since it's - // not valid (though it's a valid return type). - const payload = - acl === 'custom' ? { cors_enabled } : { acl, cors_enabled }; - - return updateBucketAccess( - isObjMultiClusterEnabled && currentRegion - ? currentRegion.id - : cluster, - label, - payload - ); - }} endpointType={endpoint_type} name={label} variant="bucket" diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 09ca1d0f534..4b552df609e 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -4,10 +4,14 @@ import { deleteBucket, deleteBucketWithRegion, deleteSSLCert, + getBucketAccess, + getObjectACL, getObjectList, getObjectStorageKeys, getObjectURL, getSSLCert, + updateBucketAccess, + updateObjectACL, uploadSSLCert, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; @@ -40,20 +44,24 @@ import { prefixToQueryKey } from './utilities'; import type { BucketsResponse, BucketsResponseType } from './requests'; import type { + ACLType, APIError, CreateObjectStorageBucketPayload, CreateObjectStorageBucketSSLPayload, CreateObjectStorageObjectURLPayload, ObjectStorageBucket, + ObjectStorageBucketAccess, ObjectStorageBucketSSL, ObjectStorageCluster, ObjectStorageEndpoint, ObjectStorageKey, + ObjectStorageObjectACL, ObjectStorageObjectList, ObjectStorageObjectURL, Params, PriceType, ResourcePage, + UpdateObjectStorageBucketAccessPayload, } from '@linode/api-v4'; export const objectStorageQueries = createQueryKeys('object-storage', { @@ -63,6 +71,10 @@ export const objectStorageQueries = createQueryKeys('object-storage', { }), bucket: (clusterOrRegion: string, bucketName: string) => ({ contextQueries: { + access: { + queryFn: () => getBucketAccess(clusterOrRegion, bucketName), + queryKey: null, + }, objects: { // This is a placeholder queryFn and QueryKey. View the `useObjectBucketObjectsInfiniteQuery` implementation for details. queryFn: null, @@ -179,6 +191,70 @@ export const useObjectStorageAccessKeys = (params: Params) => placeholderData: keepPreviousData, }); +export const useBucketAccess = ( + clusterOrRegion: string, + bucket: string, + queryEnabled: boolean +) => + useQuery({ + ...objectStorageQueries.bucket(clusterOrRegion, bucket)._ctx.access, + enabled: queryEnabled, + }); + +export const useObjectAccess = ( + bucket: string, + clusterId: string, + params: { name: string }, + queryEnabled: boolean +) => + useQuery({ + enabled: queryEnabled, + queryFn: () => getObjectACL({ bucket, clusterId, params }), + queryKey: [bucket, clusterId, params.name], + }); + +export const useUpdateBucketAccessMutation = ( + clusterOrRegion: string, + bucket: string +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], UpdateObjectStorageBucketAccessPayload>({ + mutationFn: (data) => updateBucketAccess(clusterOrRegion, bucket, data), + onSuccess: (_, variables) => { + queryClient.setQueryData( + objectStorageQueries.bucket(clusterOrRegion, bucket)._ctx.access + .queryKey, + (oldData) => ({ + acl: variables?.acl ?? 'private', + acl_xml: oldData?.acl_xml ?? '', + cors_enabled: variables?.cors_enabled ?? null, + cors_xml: oldData?.cors_xml ?? null, + }) + ); + }, + }); +}; + +export const useUpdateObjectAccessMutation = ( + clusterId: string, + bucketName: string, + name: string +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], ACLType>({ + mutationFn: (data) => updateObjectACL(clusterId, bucketName, name, data), + onSuccess: (_, acl) => { + queryClient.setQueryData( + [bucketName, clusterId, name], + (oldData) => ({ + acl, + acl_xml: oldData?.acl_xml ?? null, + }) + ); + }, + }); +}; + export const useCreateBucketMutation = () => { const queryClient = useQueryClient(); return useMutation< From 7b93edc14edfcfa3b426eea858c97dc9c7ba4c8e Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Wed, 23 Oct 2024 17:54:40 +0530 Subject: [PATCH 54/64] upcoming: [M3-8755, M3-6700] - Add global colorTokens to theme and replace one-off hardcoded white colors (#11120) * Add global colorTokens to theme and replace one-off hardcoded white colors * Added changeset: Add global colorTokens to theme and replace one-off hardcoded white colors * replace remaining colors * Remove optional chaining for this token --- ...pr-11120-upcoming-features-1729173753438.md | 5 +++++ packages/manager/src/components/Accordion.tsx | 2 +- .../CopyableTextField/CopyableTextField.tsx | 2 +- .../src/components/Placeholder/Placeholder.tsx | 4 ++-- .../components/PrimaryNav/PrimaryNav.styles.ts | 2 +- .../RegionSelect/RegionSelect.styles.ts | 6 +++--- .../manager/src/components/Tag/Tag.styles.ts | 4 +++- .../manager/src/components/TagCell/TagCell.tsx | 2 +- packages/manager/src/dev-tools/Preferences.tsx | 4 +++- .../PaymentDrawer/GooglePayButton.tsx | 18 ++++++++++-------- .../features/CancelLanding/CancelLanding.tsx | 2 +- .../Tabs/Marketplace/AppDetailDrawer.tsx | 2 +- .../TwoFactor/QRCodeForm.tsx | 2 +- .../VPCs/VPCDetail/VPCDetail.styles.ts | 2 +- .../PlansPanel/PlanSelection.styles.ts | 2 +- packages/ui/src/foundations/themes/index.ts | 3 +++ packages/ui/src/foundations/themes/light.ts | 1 + 17 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md diff --git a/packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md b/packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md new file mode 100644 index 00000000000..41910c4409c --- /dev/null +++ b/packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add global colorTokens to theme and replace one-off hardcoded white colors ([#11120](https://github.com/linode/manager/pull/11120)) diff --git a/packages/manager/src/components/Accordion.tsx b/packages/manager/src/components/Accordion.tsx index cdfa7a2e7fc..e3c7133630e 100644 --- a/packages/manager/src/components/Accordion.tsx +++ b/packages/manager/src/components/Accordion.tsx @@ -23,7 +23,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ alignItems: 'center', backgroundColor: '#2575d0', borderRadius: '50%', - color: '#fff', + color: theme.colorTokens.Neutrals.White, display: 'flex', fontFamily: theme.font.bold, fontSize: '0.875rem', diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index 1f2da7ffcd7..6dea1154ba6 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -65,7 +65,7 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ color: theme.name === 'light' ? `${theme.palette.text.primary} !important` - : '#fff !important', + : `${theme.colorTokens.Neutrals.White} !important`, opacity: theme.name === 'dark' ? 0.5 : 0.8, }, '&& .MuiInput-root': { diff --git a/packages/manager/src/components/Placeholder/Placeholder.tsx b/packages/manager/src/components/Placeholder/Placeholder.tsx index 003c2984029..bdfff2a7e72 100644 --- a/packages/manager/src/components/Placeholder/Placeholder.tsx +++ b/packages/manager/src/components/Placeholder/Placeholder.tsx @@ -96,14 +96,14 @@ export const Placeholder = (props: PlaceholderProps) => { fill: theme.palette.primary.main, }, '& .circle': { - fill: theme.name === 'light' ? '#fff' : '#000', + fill: theme.name === 'light' ? theme.colorTokens.Neutrals.White : '#000', }, '& .insidePath path': { opacity: 0, stroke: theme.palette.primary.main, }, '& .outerCircle': { - fill: theme.name === 'light' ? '#fff' : '#000', + fill: theme.name === 'light' ? theme.colorTokens.Neutrals.White : '#000', stroke: theme.bg.offWhite, }, height: '160px', diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index 03912e910e5..2455532add4 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -29,7 +29,7 @@ const useStyles = makeStyles()( opacity: 0, }, alignItems: 'center', - color: '#fff', + color: theme.colorTokens.Neutrals.White, display: 'flex', fontFamily: 'LatoWebBold', fontSize: '0.875rem', diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index 8ed59451213..50369c7f418 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -106,7 +106,7 @@ export const SelectedIcon = styled(DoneIcon, { width: 17, })); -export const StyledChip = styled(Chip)(() => ({ +export const StyledChip = styled(Chip)(({ theme }) => ({ '& .MuiChip-deleteIcon': { '& svg': { borderRadius: '50%', @@ -115,10 +115,10 @@ export const StyledChip = styled(Chip)(() => ({ }, '& .MuiChip-deleteIcon.MuiSvgIcon-root': { '&:hover': { - backgroundColor: '#fff', + backgroundColor: theme.colorTokens.Neutrals.White, color: '#3683dc', }, backgroundColor: '#3683dc', - color: '#fff', + color: theme.colorTokens.Neutrals.White, }, })); diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index 74ab54e1dd9..582b8f8dde1 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -91,7 +91,9 @@ export const StyledDeleteButton = styled(StyledLinkButton, { backgroundColor: theme.color.buttonPrimaryHover, }, borderBottomRightRadius: 3, - borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, + borderLeft: `1px solid ${ + theme.name === 'light' ? theme.colorTokens.Neutrals.White : '#2e3238' + }`, borderRadius: 0, borderTopRightRadius: 3, height: 30, diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 24c56689188..49b037bf6d4 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -235,7 +235,7 @@ const StyledTag = styled(Tag, { const StyledIconButton = styled(IconButton)(({ theme }) => ({ '&:hover': { backgroundColor: theme.palette.primary.main, - color: '#ffff', + color: theme.colorTokens.Neutrals.White, }, backgroundColor: theme.color.tagButtonBg, borderRadius: 0, diff --git a/packages/manager/src/dev-tools/Preferences.tsx b/packages/manager/src/dev-tools/Preferences.tsx index de25a3a0216..9fdd92e2928 100644 --- a/packages/manager/src/dev-tools/Preferences.tsx +++ b/packages/manager/src/dev-tools/Preferences.tsx @@ -1,13 +1,15 @@ import LinkIcon from '@mui/icons-material/Link'; +import { useTheme } from '@mui/material'; import * as React from 'react'; export const Preferences = () => { + const theme = useTheme(); return ( <>

    Preferences

    Open preference Modal ({ button: { '& svg': { - color: theme.name === 'light' ? '#fff' : '#616161', + color: + theme.name === 'light' ? theme.colorTokens.Neutrals.White : '#616161', height: 16, }, '&:hover': { @@ -31,7 +32,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ transition: 'none', }, alignItems: 'center', - backgroundColor: theme.name === 'light' ? '#000' : '#fff', + backgroundColor: + theme.name === 'light' ? '#000' : theme.colorTokens.Neutrals.White, border: 0, borderRadius: 4, cursor: 'pointer', diff --git a/packages/manager/src/features/CancelLanding/CancelLanding.tsx b/packages/manager/src/features/CancelLanding/CancelLanding.tsx index 74b96cc01ca..9056ddbde6a 100644 --- a/packages/manager/src/features/CancelLanding/CancelLanding.tsx +++ b/packages/manager/src/features/CancelLanding/CancelLanding.tsx @@ -16,7 +16,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ root: { '& button': { backgroundColor: '#00b159', - color: '#fff', + color: theme.colorTokens.Neutrals.White, marginTop: theme.spacing(8), }, '& h1': { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx index ddbfc43bae3..10fa1e88aed 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx @@ -16,7 +16,7 @@ import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()((theme: Theme) => ({ appName: { - color: '#fff !important', + color: `${theme.colorTokens.Neutrals.White} !important`, fontFamily: theme.font.bold, fontSize: '2.2rem', lineHeight: '2.5rem', diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/QRCodeForm.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/QRCodeForm.tsx index 79d664307c6..e2d79add7c6 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/QRCodeForm.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/QRCodeForm.tsx @@ -42,7 +42,7 @@ const StyledInstructions = styled(Typography, { const StyledQRCodeContainer = styled('div', { label: 'StyledQRCodeContainer', })(({ theme }) => ({ - border: `5px solid #fff`, + border: `5px solid ${theme.colorTokens.Neutrals.White}`, display: 'inline-block', margin: `${theme.spacing(2)} 0`, })); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts index 7dc1846cbb2..8a9e7640dd2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts @@ -9,7 +9,7 @@ export const StyledActionButton = styled(Button, { })(({ theme }) => ({ '&:hover': { backgroundColor: theme.color.blue, - color: '#fff', + color: theme.colorTokens.Neutrals.White, }, color: theme.textColors.linkActiveLight, fontFamily: theme.font.normal, diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelection.styles.ts b/packages/manager/src/features/components/PlansPanel/PlanSelection.styles.ts index ee20e52388a..0a1970408ea 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelection.styles.ts +++ b/packages/manager/src/features/components/PlansPanel/PlanSelection.styles.ts @@ -6,7 +6,7 @@ import { TableCell } from 'src/components/TableCell'; export const StyledChip = styled(Chip, { label: 'StyledChip' })( ({ theme }) => ({ backgroundColor: theme.color.green, - color: '#fff', + color: theme.colorTokens.Neutrals.White, marginLeft: theme.spacing(), position: 'relative', textTransform: 'uppercase', diff --git a/packages/ui/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts index 4a507f9e6b1..757677dd594 100644 --- a/packages/ui/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -7,6 +7,7 @@ import { lightTheme } from './light'; import type { ChartTypes, + ColorTypes, InteractionTypes as InteractionTypesLight, } from '@linode/design-language-system'; import type { InteractionTypes as InteractionTypesDark } from '@linode/design-language-system/themes/dark'; @@ -74,6 +75,7 @@ declare module '@mui/material/styles/createTheme' { bg: BgColors; borderColors: BorderColors; chartTokens: ChartTypes; + colorTokens: ColorTypes; // Global token: theme agnostic color: Colors; font: Fonts; graphs: any; @@ -95,6 +97,7 @@ declare module '@mui/material/styles/createTheme' { bg?: DarkModeBgColors | LightModeBgColors; borderColors?: DarkModeBorderColors | LightModeBorderColors; chartTokens?: ChartTypes; + colorTokens?: ColorTypes; // Global token: theme agnostic color?: DarkModeColors | LightModeColors; font?: Fonts; graphs?: any; diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index c7f36351839..24e658064c6 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -240,6 +240,7 @@ export const lightTheme: ThemeOptions = { borderColors, breakpoints, chartTokens: Chart, + colorTokens: Color, color, components: { MuiAccordion: { From 21455854781dd6ac3ff452cfb43c9dcc915ac651 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 23 Oct 2024 09:14:29 -0400 Subject: [PATCH 55/64] fix: [M3-8696] - Autocomplete renderOption prop console warning (#11140) * remove spreaded key prop console warning * remove the text selection background * Added changeset: Autocomplete renderOption prop console warning * feedback @hkhalil-akamai --- .../pr-11140-fixed-1729625688555.md | 5 ++ .../PlacementGroupsSelect.tsx | 6 ++- .../RegionSelect/RegionMultiSelect.tsx | 11 +++-- .../components/RegionSelect/RegionSelect.tsx | 20 +++++--- .../manager/src/components/TagCell/AddTag.tsx | 12 +++-- .../DatabaseBackups/DatabaseBackups.tsx | 13 +++-- .../LinodeSettings/KernelSelect.tsx | 3 +- .../ServiceTargets/LinodeOrIPSelect.tsx | 3 +- .../NodeBalancers/ConfigNodeIPSelect.tsx | 49 ++++++++++--------- .../NodeBalancers/NodeBalancerSelect.tsx | 3 +- .../PlacementGroupTypeSelect.tsx | 5 +- packages/ui/src/foundations/themes/dark.ts | 5 ++ packages/ui/src/foundations/themes/light.ts | 2 +- 13 files changed, 87 insertions(+), 50 deletions(-) create mode 100644 packages/manager/.changeset/pr-11140-fixed-1729625688555.md diff --git a/packages/manager/.changeset/pr-11140-fixed-1729625688555.md b/packages/manager/.changeset/pr-11140-fixed-1729625688555.md new file mode 100644 index 00000000000..4cfa2208a40 --- /dev/null +++ b/packages/manager/.changeset/pr-11140-fixed-1729625688555.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Autocomplete renderOption prop console warning ([#11140](https://github.com/linode/manager/pull/11140)) diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx index 01291148c28..6118c431f1f 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx @@ -103,12 +103,14 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => { handlePlacementGroupChange(selectedOption ?? null); }} renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 3239fe19231..0bdf1469232 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -106,17 +106,22 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { onChange(selectedOptions?.map((region) => region.id) ?? []) } renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; if (!option.site_type) { // Render options like "Select All / Deselect All" - return {option.label}; + return ( + + {option.label} + + ); } // Render regular options return ( diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 1a107f1aa3f..49650ddee1d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -136,14 +136,18 @@ export const RegionSelect = < getOptionLabel={(region) => isGeckoLAEnabled ? region.label : `${region.label} (${region.id})` } - renderOption={(props, region) => ( - - )} + renderOption={(props, region) => { + const { key, ...rest } = props; + + return ( + + ); + }} sx={(theme) => ({ [theme.breakpoints.up('md')]: { width: '416px', diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index 266f8b10816..d6e0b58364f 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -79,9 +79,15 @@ export const AddTag = (props: AddTagProps) => { handleAddTag(typeof value == 'string' ? value : value.label); } }} - renderOption={(props, option) => ( -
  • {option.displayLabel ?? option.label}
  • - )} + renderOption={(props, option) => { + const { key, ...rest } = props; + + return ( +
  • + {option.displayLabel ?? option.label} +
  • + ); + }} disableClearable forcePopupIcon label={'Create or Select a Tag'} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index a29b00fa088..7fe48d86f24 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -214,11 +214,14 @@ export const DatabaseBackups = (props: Props) => { onChange={(_, newTime) => setSelectedTime(newTime)} options={TIME_OPTIONS} placeholder="Choose a time" - renderOption={(props, option) => ( -
  • - {option.label} -
  • - )} + renderOption={(props, option) => { + const { key, ...rest } = props; + return ( +
  • + {option.label} +
  • + ); + }} textFieldProps={{ dataAttrs: { 'data-qa-time-select': true, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx index 7f677c34731..9759b199486 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx @@ -33,8 +33,9 @@ export const KernelSelect = React.memo((props: KernelSelectProps) => { return ( { + const { key, ...rest } = props; return ( -
  • +
  • {kernel.label}
  • ); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx index df3a31e1765..756cf4be0b6 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx @@ -95,13 +95,14 @@ export const LinodeOrIPSelect = (props: Props) => { } }} renderOption={(props, option, state) => { + const { key, ...rest } = props; const region = regions?.find((r) => r.id === option.region)?.label ?? option.region; const isCustomIp = option === customIpPlaceholder; return ( -
  • +
  • {isCustomIp ? 'Custom IP' : option.label} diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 70061b5e2b8..5b086692862 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -62,29 +62,32 @@ export const ConfigNodeIPSelect = React.memo((props: Props) => { return ( ( -
  • - - - theme.font.bold} - > - {option.label} - - {option.linode.label} - - {selected && } - -
  • - )} + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( +
  • + + + theme.font.bold} + > + {option.label} + + {option.linode.label} + + {selected && } + +
  • + ); + }} disabled={disabled} errorText={errorText ?? error?.[0].reason} id={inputId} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index ff22a80eccd..ee3b53166b4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -132,8 +132,9 @@ export const NodeBalancerSelect = ( renderOption={ renderOption ? (props, option, { selected }) => { + const { key, ...rest } = props; return ( -
  • +
  • {renderOption(option, selected)}
  • ); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupTypeSelect.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupTypeSelect.tsx index 58b3f23cb64..b6bc9535536 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupTypeSelect.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupTypeSelect.tsx @@ -28,6 +28,7 @@ export const PlacementGroupTypeSelect = (props: Props) => { setFieldValue('placement_group_type', value?.value ?? ''); }} renderOption={(props, option) => { + const { key, ...rest } = props; const isDisabledMenuItem = option.value === 'affinity:local'; return ( @@ -49,10 +50,10 @@ export const PlacementGroupTypeSelect = (props: Props) => { enterDelay={200} enterNextDelay={200} enterTouchDelay={200} - key={option.value} + key={key} > Date: Wed, 23 Oct 2024 11:23:01 -0400 Subject: [PATCH 56/64] fix: [M3-8773] - Duplicate punctuation on `image_upload` event message (#11148) * remove extra `.` * add changeset --------- Co-authored-by: Banks Nussman --- packages/manager/.changeset/pr-11148-fixed-1729687116230.md | 5 +++++ packages/manager/src/features/Events/factories/image.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11148-fixed-1729687116230.md diff --git a/packages/manager/.changeset/pr-11148-fixed-1729687116230.md b/packages/manager/.changeset/pr-11148-fixed-1729687116230.md new file mode 100644 index 00000000000..0df33342c35 --- /dev/null +++ b/packages/manager/.changeset/pr-11148-fixed-1729687116230.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Duplicate punctuation on `image_upload` event message ([#11148](https://github.com/linode/manager/pull/11148)) diff --git a/packages/manager/src/features/Events/factories/image.tsx b/packages/manager/src/features/Events/factories/image.tsx index 0cc07e47362..bb64e7a66b3 100644 --- a/packages/manager/src/features/Events/factories/image.tsx +++ b/packages/manager/src/features/Events/factories/image.tsx @@ -52,7 +52,7 @@ export const image: PartialEventMap<'image'> = { <> Image could not{' '} be uploaded:{' '} - . + ); }, From 722e16b558655a581f321a978bd3b4d744496d92 Mon Sep 17 00:00:00 2001 From: Dennis van Kekem <38350840+dennisvankekem@users.noreply.github.com> Date: Wed, 23 Oct 2024 19:14:32 +0200 Subject: [PATCH 57/64] fix: [APL-272] - Make `useAPLAvailability` check for beta enrollment (#11110) * added beta check in apl availability flag * feat: test for apl availability * feat: apl test with mocking of usequery * fix: typo * fix: removed unnecessary apl request * Added changeset: LKE Cluster IP APL integration * update changeset * Changeset typo * clean up react query layer * remove unnessesary invalidation * clean up based on feedback * last bit of clean up --------- Co-authored-by: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Co-authored-by: Alban Bailly Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman --- packages/api-v4/src/account/types.ts | 4 +++ .../pr-11110-added-1729516190960.md | 5 ++++ .../CreateCluster/CreateCluster.tsx | 4 +-- .../KubernetesClusterDetail.tsx | 4 +-- .../src/features/Kubernetes/kubeUtils.test.ts | 25 +++++++++++++++++++ .../src/features/Kubernetes/kubeUtils.ts | 13 +++++++--- packages/manager/src/queries/account/betas.ts | 7 ++++++ packages/manager/src/queries/kubernetes.ts | 4 +-- 8 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-11110-added-1729516190960.md diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 26362dcfe92..8a0af4e587c 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -604,6 +604,10 @@ export interface AccountBeta { id: string; ended?: string; description?: string; + /** + * The datetime the account enrolled into the beta + * @example 2024-10-23T14:22:29 + */ enrolled: string; } diff --git a/packages/manager/.changeset/pr-11110-added-1729516190960.md b/packages/manager/.changeset/pr-11110-added-1729516190960.md new file mode 100644 index 00000000000..e26b83f9de0 --- /dev/null +++ b/packages/manager/.changeset/pr-11110-added-1729516190960.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Support for Application platform for Linode Kubernetes (APL)([#11110](https://github.com/linode/manager/pull/11110)) diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 21a39a48c74..0304c6b6ae4 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -22,7 +22,7 @@ import { getKubeControlPlaneACL, getKubeHighAvailability, getLatestVersion, - useGetAPLAvailability, + useAPLAvailability, } from 'src/features/Kubernetes/kubeUtils'; import { useAccount } from 'src/queries/account/account'; import { @@ -87,7 +87,7 @@ export const CreateCluster = () => { const regionsData = data ?? []; const history = useHistory(); const { data: account } = useAccount(); - const showAPL = useGetAPLAvailability(); + const showAPL = useAPLAvailability(); const { showHighAvailability } = getKubeHighAvailability(account); const { showControlPlaneACL } = getKubeControlPlaneACL(account); const [ipV4Addr, setIPv4Addr] = React.useState([ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index cc19610b779..2bd5f3bea1b 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -8,7 +8,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { getKubeHighAvailability } from 'src/features/Kubernetes/kubeUtils'; -import { useGetAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; +import { useAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; import { useAccount } from 'src/queries/account/account'; import { useKubernetesClusterMutation, @@ -28,7 +28,7 @@ export const KubernetesClusterDetail = () => { const { clusterID } = useParams<{ clusterID: string }>(); const id = Number(clusterID); const location = useLocation(); - const showAPL = useGetAPLAvailability(); + const showAPL = useAPLAvailability(); const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); const { data: regionsData } = useRegionsQuery(); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 35619fcce5f..b94816e182c 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -1,13 +1,19 @@ +import { renderHook, waitFor } from '@testing-library/react'; + import { + accountBetaFactory, kubeLinodeFactory, linodeTypeFactory, nodePoolFactory, } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { extendType } from 'src/utilities/extendType'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; import { getLatestVersion, getTotalClusterMemoryCPUAndStorage, + useAPLAvailability, } from './kubeUtils'; describe('helper functions', () => { @@ -67,6 +73,25 @@ describe('helper functions', () => { }); }); }); + describe('APL availability', () => { + it('should return true if the apl flag is true and beta is active', async () => { + const accountBeta = accountBetaFactory.build({ + enrolled: '2023-01-15T00:00:00Z', + id: 'apl', + }); + server.use( + http.get('*/account/betas/apl', () => { + return HttpResponse.json(accountBeta); + }) + ); + const { result } = renderHook(() => useAPLAvailability(), { + wrapper: (ui) => wrapWithTheme(ui, { flags: { apl: true } }), + }); + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + }); describe('getLatestVersion', () => { it('should return the correct latest version from a list of versions', () => { const versions = [ diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index 0dafba32367..81f3526978b 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,4 +1,6 @@ import { useFlags } from 'src/hooks/useFlags'; +import { useAccountBetaQuery } from 'src/queries/account/betas'; +import { getBetaStatus } from 'src/utilities/betaUtils'; import { sortByVersion } from 'src/utilities/sort-by'; import type { Account } from '@linode/api-v4/lib/account'; @@ -113,14 +115,19 @@ export const getKubeHighAvailability = ( }; }; -export const useGetAPLAvailability = (): boolean => { +export const useAPLAvailability = () => { const flags = useFlags(); - if (!flags) { + // Only fetch the account beta if the APL flag is enabled + const { data: beta } = useAccountBetaQuery('apl', Boolean(flags.apl)); + + if (!beta) { return false; } - return Boolean(flags.apl); + const betaStatus = getBetaStatus(beta); + + return betaStatus === 'active'; }; export const getKubeControlPlaneACL = ( diff --git a/packages/manager/src/queries/account/betas.ts b/packages/manager/src/queries/account/betas.ts index fb6bca48f0f..6852a3e5ec3 100644 --- a/packages/manager/src/queries/account/betas.ts +++ b/packages/manager/src/queries/account/betas.ts @@ -43,3 +43,10 @@ export const useCreateAccountBetaMutation = () => { }, }); }; + +export const useAccountBetaQuery = (id: string, enabled: boolean = false) => { + return useQuery({ + enabled, + ...accountQueries.betas._ctx.beta(id), + }); +}; diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index cf8e1322ff9..5afe50eaf04 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -30,7 +30,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { useGetAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; +import { useAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; @@ -109,7 +109,7 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }); export const useKubernetesClusterQuery = (id: number) => { - const showAPL = useGetAPLAvailability(); + const showAPL = useAPLAvailability(); return useQuery({ ...kubernetesQueries.cluster(id), queryFn: showAPL From 941f30fe85c863d81873d75d29606bd060e93997 Mon Sep 17 00:00:00 2001 From: plisiecki1 <79330486+plisiecki1@users.noreply.github.com> Date: Wed, 23 Oct 2024 12:01:06 -0700 Subject: [PATCH 58/64] feat: [M3-8722] - Improve weblish retry behavior (#11067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 - automatically reconnect weblish sessions that have disconnected - … unless it fails rapidly (more than 3 times in a 1 minute window) - … in which case show an error screen with as much information as we can provide - … where the error screen includes a button to try again. - focus the terminal element when starting (which is generally helpful but also prevents _losing_ focus after a reconnect) Previously, a failure would often just result in a spinning circle forever. ## Changes 🔄 List any change relevant to the reviewer. - added option “action button” feature to ErrorState - added some helpers to `Lish.tsx` which may also be useful for similar enhancements to graphical lish (glish) in the future. - changed `socket` to be mutable and nullable so it can be updated when reconnecting. `origSocket` local variable is used to ensure that “stale” events are properly ignored. - moved error parsing/handling to the `close` handler, which also eliminates the potential for console output that too closely matches an "expired" error being interpreted as an error and breaking the session immediately. ## How to test 🧪 ### Prerequisites Running linode(s). ### Reproduction steps Launch a lish session for a machine, type `^a d kill `. The session will exit and show a spinning circle. ### Verification steps - Launch a lish session for a machine, type `^a d kill `. The session will reconnect. - Repeat, it should reconnect again. - Repeat, it should reconnect again. - Repeat, it should show an error message (if all three attempts are withing 60s). - Click the "Retry Connection" button. It should reconnect. - Ensure that the reconnected session actually works, i.e., that interaction with the terminal is successful. --------- Co-authored-by: Hana Xu --- .../pr-11067-added-1729705480335.md | 5 + .../src/components/ErrorState/ErrorState.tsx | 31 ++++- packages/manager/src/features/Lish/Lish.tsx | 71 +++++++++- .../manager/src/features/Lish/Weblish.tsx | 128 +++++++++++++----- 4 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 packages/manager/.changeset/pr-11067-added-1729705480335.md diff --git a/packages/manager/.changeset/pr-11067-added-1729705480335.md b/packages/manager/.changeset/pr-11067-added-1729705480335.md new file mode 100644 index 00000000000..94962cf5d04 --- /dev/null +++ b/packages/manager/.changeset/pr-11067-added-1729705480335.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Improve weblish retry behavior ([#11067](https://github.com/linode/manager/pull/11067)) diff --git a/packages/manager/src/components/ErrorState/ErrorState.tsx b/packages/manager/src/components/ErrorState/ErrorState.tsx index 97c9b02ef4b..702a551a132 100644 --- a/packages/manager/src/components/ErrorState/ErrorState.tsx +++ b/packages/manager/src/components/ErrorState/ErrorState.tsx @@ -1,11 +1,18 @@ import ErrorOutline from '@mui/icons-material/ErrorOutline'; +import { styled, useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { SxProps, Theme, styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; +import { Button } from 'src/components/Button/Button'; import { Typography } from 'src/components/Typography'; -import { SvgIconProps } from '../SvgIcon'; +import type { SvgIconProps } from '../SvgIcon'; +import type { SxProps, Theme } from '@mui/material/styles'; + +export interface ActionButtonProps { + onClick: () => void; + text: string; +} export interface ErrorStateProps { /** @@ -16,14 +23,14 @@ export interface ErrorStateProps { * CSS properties to apply to the custom icon. */ CustomIconStyles?: React.CSSProperties; + actionButtonProps?: ActionButtonProps; + /** * Reduces the padding on the root element. */ compact?: boolean; - /** - * The error text to display. - */ errorText: JSX.Element | string; + /** * Styles applied to the error text */ @@ -31,7 +38,7 @@ export interface ErrorStateProps { } export const ErrorState = (props: ErrorStateProps) => { - const { CustomIcon, compact, typographySx } = props; + const { CustomIcon, actionButtonProps, compact, typographySx } = props; const theme = useTheme(); const sxIcon = { @@ -72,6 +79,18 @@ export const ErrorState = (props: ErrorStateProps) => { ) : (
    {props.errorText}
    )} + {actionButtonProps ? ( +
    + +
    + ) : null} ); diff --git a/packages/manager/src/features/Lish/Lish.tsx b/packages/manager/src/features/Lish/Lish.tsx index 7bfbe47b4f4..1cbb251f1de 100644 --- a/packages/manager/src/features/Lish/Lish.tsx +++ b/packages/manager/src/features/Lish/Lish.tsx @@ -23,10 +23,76 @@ import type { Tab } from 'src/components/Tabs/TabLinkList'; const AUTH_POLLING_INTERVAL = 2000; +export interface RetryLimiterInterface { + reset: () => void; + retryAllowed: () => boolean; +} + +export const RetryLimiter = ( + maxTries: number, + perTimeWindowMs: number +): RetryLimiterInterface => { + let retryTimes: number[] = []; + + return { + reset: (): void => { + retryTimes = []; + }, + retryAllowed: (): boolean => { + const now = Date.now(); + retryTimes.push(now); + const cutOffTime = now - perTimeWindowMs; + while (retryTimes.length && retryTimes[0] < cutOffTime) { + retryTimes.shift(); + } + return retryTimes.length < maxTries; + }, + }; +}; + +export interface LishErrorInterface { + formatted: string; + grn: string; + isExpired: boolean; + reason: string; +} + +export const ParsePotentialLishErrorString = ( + s: null | string +): LishErrorInterface | null => { + if (!s) { + return null; + } + + let parsed = null; + try { + parsed = JSON.parse(s); + } catch { + return null; + } + + const grn = typeof parsed?.grn === 'string' ? parsed?.grn : ''; + const grnFormatted = grn ? ` (${grn})` : ''; + + { + const reason = parsed?.reason; + if (parsed?.type === 'error' && typeof reason === 'string') { + const formattedPrefix = reason.indexOf(' ') >= 0 ? '' : 'Error code: '; + return { + formatted: formattedPrefix + reason + grnFormatted, + grn, + isExpired: reason.toLowerCase() === 'your session has expired.', + reason, + }; + } + } + return null; +}; + const Lish = () => { const history = useHistory(); - const { isLoading: isMakingInitalRequests } = useInitialRequests(); + const { isLoading: isMakingInitialRequests } = useInitialRequests(); const { linodeId, type } = useParams<{ linodeId: string; type: string }>(); const id = Number(linodeId); @@ -44,7 +110,8 @@ const Lish = () => { refetch, } = useLinodeLishQuery(id); - const isLoading = isLinodeLoading || isTokenLoading || isMakingInitalRequests; + const isLoading = + isLinodeLoading || isTokenLoading || isMakingInitialRequests; React.useEffect(() => { const interval = setInterval(checkAuthentication, AUTH_POLLING_INTERVAL); diff --git a/packages/manager/src/features/Lish/Weblish.tsx b/packages/manager/src/features/Lish/Weblish.tsx index 54a376bbb0f..3b7754871b1 100644 --- a/packages/manager/src/features/Lish/Weblish.tsx +++ b/packages/manager/src/features/Lish/Weblish.tsx @@ -4,9 +4,17 @@ import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { + ParsePotentialLishErrorString, + RetryLimiter, +} from 'src/features/Lish/Lish'; -import type { Linode } from '@linode/api-v4/lib/linodes'; import type { LinodeLishData } from '@linode/api-v4/lib/linodes'; +import type { Linode } from '@linode/api-v4/lib/linodes'; +import type { + LishErrorInterface, + RetryLimiterInterface, +} from 'src/features/Lish/Lish'; interface Props extends Pick { linode: Linode; @@ -16,15 +24,19 @@ interface Props extends Pick { interface State { error: string; renderingLish: boolean; + setFocus: boolean; } export class Weblish extends React.Component { + lastMessage: string = ''; mounted: boolean = false; - socket: WebSocket; + retryLimiter: RetryLimiterInterface = RetryLimiter(3, 60000); + socket: WebSocket | null; state: State = { error: '', renderingLish: true, + setFocus: false, }; terminal: Terminal; @@ -35,7 +47,7 @@ export class Weblish extends React.Component { componentDidUpdate(prevProps: Props) { /* - * If we have a new token, refresh the webosocket connection + * If we have a new token, refresh the websocket connection * and console with the new token */ if ( @@ -43,8 +55,9 @@ export class Weblish extends React.Component { JSON.stringify(this.props.ws_protocols) !== JSON.stringify(prevProps.ws_protocols) ) { - this.socket.close(); - this.terminal.dispose(); + this.socket?.close(); + this.terminal?.dispose(); + this.setState({ renderingLish: false }); this.connect(); } } @@ -56,13 +69,54 @@ export class Weblish extends React.Component { connect() { const { weblish_url, ws_protocols } = this.props; - this.socket = new WebSocket(weblish_url, ws_protocols); + /* When this.socket != origSocket, the socket from this connect() + * call has been closed and possibly replaced by a new socket. */ + const origSocket = new WebSocket(weblish_url, ws_protocols); + this.socket = origSocket; + + this.lastMessage = ''; + this.setState({ error: '' }); this.socket.addEventListener('open', () => { if (!this.mounted) { return; } - this.setState({ renderingLish: true }, () => this.renderTerminal()); + this.setState({ renderingLish: true }, () => + this.renderTerminal(origSocket) + ); + }); + + this.socket.addEventListener('close', (evt) => { + /* If this event is not for the currently active socket, just + * ignore it. */ + if (this.socket !== origSocket) { + return; + } + this.socket = null; + this.terminal?.dispose(); + this.setState({ renderingLish: false }); + /* If the control has been unmounted, the cleanup above is + * sufficient. */ + if (!this.mounted) { + return; + } + + const parsed: LishErrorInterface | null = + ParsePotentialLishErrorString(evt?.reason) || + ParsePotentialLishErrorString(this.lastMessage); + + if (!this.retryLimiter.retryAllowed()) { + this.setState({ + error: parsed?.formatted || 'Unexpected WebSocket close', + }); + return; + } + if (parsed?.isExpired) { + const { refreshToken } = this.props; + refreshToken(); + return; + } + this.connect(); }); } @@ -70,8 +124,16 @@ export class Weblish extends React.Component { const { error } = this.state; if (error) { + const actionButtonProps = { + onClick: () => { + this.retryLimiter.reset(); + this.props.refreshToken(); + }, + text: 'Retry Connection', + }; return ( ({ color: theme.palette.common.white })} /> @@ -102,10 +164,21 @@ export class Weblish extends React.Component { ); } - renderTerminal() { - const { linode, refreshToken } = this.props; + renderTerminal(origSocket: WebSocket) { + const { linode } = this.props; const { group, label } = linode; + const socket: WebSocket | null = this.socket; + if (socket === null || socket !== origSocket) { + return; + } + + /* The socket might have already started to fail by the time we + * get here. Leave handling for the close handler. */ + if (socket.readyState !== socket.OPEN) { + return; + } + this.terminal = new Terminal({ cols: 80, cursorBlink: true, @@ -114,33 +187,25 @@ export class Weblish extends React.Component { screenReaderMode: true, }); - this.terminal.onData((data: string) => this.socket.send(data)); + this.setState({ setFocus: true }, () => this.terminal.focus()); + + this.terminal.onData((data: string) => socket.send(data)); const terminalDiv = document.getElementById('terminal'); this.terminal.open(terminalDiv as HTMLElement); this.terminal.writeln('\x1b[32mLinode Lish Console\x1b[m'); - this.socket.addEventListener('message', (evt) => { - let data; - + socket.addEventListener('message', (evt) => { /* * data is either going to be command line strings * or it's going to look like {type: 'error', reason: 'thing failed'} - * the latter can be JSON parsed and the other cannot + * + * The actual handling of errors will be done in the 'close' + * handler. Allow the error to be rendered in the terminal in + * case it is actually valid session content that is not + * then followed by a 'close' message. */ - try { - data = JSON.parse(evt.data); - } catch { - data = evt.data; - } - - if ( - data?.type === 'error' && - data?.reason?.toLowerCase() === 'your session has expired.' - ) { - refreshToken(); - return; - } + this.lastMessage = evt.data; try { this.terminal.write(evt.data); @@ -157,15 +222,6 @@ export class Weblish extends React.Component { } }); - this.socket.addEventListener('close', () => { - this.terminal.dispose(); - if (!this.mounted) { - return; - } - - this.setState({ renderingLish: false }); - }); - const linodeLabel = group ? `${group}/${label}` : label; window.document.title = `${linodeLabel} - Linode Lish Console`; } From 850deb1a191d954b1ce3d20e32806b94b03c4b89 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:02:18 -0400 Subject: [PATCH 59/64] change: [M3-8759] - Hide distributed regions in Linode Create StackScripts (#11139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 StackScripts cannot be supported for distributed regions right now due to ProdSec concerns. This PR hides distributed regions from the Create Linode StackScripts tab ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure your account has the `new-dc-testing`, `new-dc-testing-gecko`, `edge_testing` and `edge_compute` customer tags ### Verification steps (How to verify changes) - Go to `/linodes/create?type=StackScripts` --- packages/api-v4/.changeset/pr-11139-added-1729701623137.md | 5 +++++ packages/api-v4/src/regions/types.ts | 3 ++- .../manager/.changeset/pr-11139-changed-1729698979911.md | 5 +++++ .../src/components/RegionSelect/RegionSelect.utils.tsx | 1 - 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11139-added-1729701623137.md create mode 100644 packages/manager/.changeset/pr-11139-changed-1729698979911.md diff --git a/packages/api-v4/.changeset/pr-11139-added-1729701623137.md b/packages/api-v4/.changeset/pr-11139-added-1729701623137.md new file mode 100644 index 00000000000..5d36ec598f4 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11139-added-1729701623137.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +`StackScripts` to Region capabilities type ([#11139](https://github.com/linode/manager/pull/11139)) diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 4d6b16a6205..e756cc38dca 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -19,7 +19,8 @@ export type Capabilities = | 'Placement Group' | 'Premium Plans' | 'Vlans' - | 'VPCs'; + | 'VPCs' + | 'StackScripts'; export interface DNSResolvers { ipv4: string; // Comma-separated IP addresses diff --git a/packages/manager/.changeset/pr-11139-changed-1729698979911.md b/packages/manager/.changeset/pr-11139-changed-1729698979911.md new file mode 100644 index 00000000000..483a7788882 --- /dev/null +++ b/packages/manager/.changeset/pr-11139-changed-1729698979911.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Hide distributed regions in Linode Create StackScripts ([#11139](https://github.com/linode/manager/pull/11139)) diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 9b497b0cece..b71959a62c5 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -129,7 +129,6 @@ export const isRegionOptionUnavailable = ({ export const isDistributedRegionSupported = (createType: LinodeCreateType) => { const supportedDistributedRegionTypes = [ 'OS', - 'StackScripts', 'Images', undefined, // /linodes/create route ]; From d4ffa1c0ad4282da628c6deec017b736da9be87d Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:03:07 -0400 Subject: [PATCH 60/64] test: [M3-8725] - Add cypress tests for updating ACL in LKE clusters (LKE ACL integration part 2) (#11131) * ip acl e2e tests for updating an lke cluster * lke update acl tests * update tests in accordance with new copies * Added changeset: Added cypress tests for updating ACL in LKE clusters * remove .only, add comment * update validation test * Update packages/manager/cypress/support/intercepts/lke.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/cypress/support/intercepts/lke.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/cypress/support/intercepts/lke.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/cypress/support/intercepts/lke.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * fix numeric typo * fix copy typo * Update packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-11131-tests-1729627189739.md | 5 + .../e2e/core/kubernetes/lke-update.spec.ts | 612 ++++++++++++++++++ .../cypress/support/intercepts/kubernetes.ts | 0 .../manager/cypress/support/intercepts/lke.ts | 103 ++- .../src/factories/kubernetesCluster.ts | 28 +- .../KubeControlPaneACLDrawer.tsx | 4 +- 6 files changed, 746 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-11131-tests-1729627189739.md delete mode 100644 packages/manager/cypress/support/intercepts/kubernetes.ts diff --git a/packages/manager/.changeset/pr-11131-tests-1729627189739.md b/packages/manager/.changeset/pr-11131-tests-1729627189739.md new file mode 100644 index 00000000000..810a70858bb --- /dev/null +++ b/packages/manager/.changeset/pr-11131-tests-1729627189739.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Added cypress tests for updating ACL in LKE clusters ([#11131](https://github.com/linode/manager/pull/11131)) 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 f56f0e93cb8..203362557d5 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1,10 +1,14 @@ import { + accountFactory, kubernetesClusterFactory, nodePoolFactory, kubeLinodeFactory, linodeFactory, + kubernetesControlPlaneACLFactory, + kubernetesControlPlaneACLOptionsFactory, } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; +import { mockGetAccount } from 'support/intercepts/account'; import { latestKubernetesVersion } from 'support/constants/lke'; import { mockGetCluster, @@ -21,6 +25,10 @@ import { mockGetDashboardUrl, mockGetApiEndpoints, mockGetClusters, + mockUpdateControlPlaneACL, + mockGetControlPlaneACL, + mockUpdateControlPlaneACLError, + mockGetControlPlaneACLError, } from 'support/intercepts/lke'; import { mockGetLinodeType, @@ -33,6 +41,7 @@ import { randomIp, randomLabel } from 'support/util/random'; import { getRegionById } from 'support/util/regions'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { randomString } from 'support/util/random'; const mockNodePools = nodePoolFactory.buildList(2); @@ -1341,3 +1350,606 @@ describe('LKE cluster updates', () => { }); }); }); + +describe('LKE ACL updates', () => { + const mockCluster = kubernetesClusterFactory.build(); + const mockRevisionId = randomString(20); + + /** + * - Confirms LKE ACL is only rendered if an account has the corresponding capability + */ + it('does not show ACL without the LKE ACL capability', () => { + mockGetAccount( + accountFactory.build({ + capabilities: [], + }) + ).as('getAccount'); + + mockGetCluster(mockCluster).as('getCluster'); + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getAccount', '@getCluster']); + + cy.contains('Control Plane ACL').should('not.exist'); + }); + + describe('with LKE ACL account capability', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['LKE Network Access Control List (IP ACL)'], + }) + ).as('getAccount'); + }); + + /** + * - Confirms ACL can be enabled from the summary page + * - Confirms revision ID can be updated + * - Confirms both IPv4 and IPv6 can be updated and that summary page and drawer updates as a result + */ + it('can enable ACL on an LKE cluster with ACL pre-installed and edit IPs', () => { + const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ + enabled: false, + addresses: { ipv4: ['10.0.3.0/24'], ipv6: undefined }, + }); + const mockUpdatedACLOptions1 = kubernetesControlPlaneACLOptionsFactory.build( + { + enabled: true, + 'revision-id': mockRevisionId, + addresses: { ipv4: ['10.0.0.0/24'], ipv6: undefined }, + } + ); + const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockACLOptions, + }); + const mockUpdatedControlPlaneACL1 = kubernetesControlPlaneACLFactory.build( + { + acl: mockUpdatedACLOptions1, + } + ); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetControlPlaneACL(mockCluster.id, mockControlPaneACL).as( + 'getControlPlaneACL' + ); + mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL1).as( + 'updateControlPlaneACL' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getAccount', '@getCluster', '@getControlPlaneACL']); + + // confirm summary panel + cy.contains('Control Plane ACL').should('be.visible'); + ui.button + .findByTitle('Enable') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + // Confirm submit button is disabled if form has not been changed + ui.button + .findByTitle('Update') + .should('be.visible') + .should('not.be.enabled'); + + cy.contains( + "Control Plane ACL secures network access to your LKE cluster's control plane. Use this form to enable or disable the ACL on your LKE cluster, update the list of allowed IP addresses, and adjust other settings." + ).should('be.visible'); + + // confirm Activation Status section and toggle on 'Enable' + cy.contains('Activation Status').should('be.visible'); + cy.contains( + 'Enable or disable the Control Plane ACL. If the ACL is not enabled, any public IP address can be used to access your control plane. Once enabled, all network access is denied except for the IP addresses and CIDR ranges defined on the ACL.' + ).should('be.visible'); + cy.findByText('Enable Control Plane ACL'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'false') + .should('be.visible') + .click(); + + // confirm submit button is now enabled + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled'); + + // confirm Revision ID section and edit Revision ID + cy.findAllByText('Revision ID').should('have.length', 2); + cy.contains( + 'A unique identifying string for this particular revision to the ACL, used by clients to track events related to ACL update requests and enforcement. This defaults to a randomly generated string but can be edited if you prefer to specify your own string to use for tracking this change.' + ).should('be.visible'); + cy.findByLabelText('Revision ID').should( + 'have.value', + mockACLOptions['revision-id'] + ); + cy.findByLabelText('Revision ID').clear().type(mockRevisionId); + + // confirm Addresses section + cy.findByText('Addresses').should('be.visible'); + cy.findByText( + "A list of allowed IPv4 and IPv6 addresses and CIDR ranges. This cluster's control plane will only be accessible from IP addresses within this list." + ).should('be.visible'); + cy.findByText('IPv4 Addresses or CIDRs').should('be.visible'); + cy.findByText('Add IPv4 Address') + .should('be.visible') + .should('be.enabled'); + // confirm current IPv4 value and enter new IP + cy.findByDisplayValue('10.0.3.0/24') + .should('be.visible') + .click() + .clear() + .type('10.0.0.0/24'); + cy.findByText('IPv6 Addresses or CIDRs').should('be.visible'); + cy.findByPlaceholderText('::/0').should('be.visible'); + cy.findByText('Add IPv6 Address') + .should('be.visible') + .should('be.enabled'); + + // submit + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateControlPlaneACL']); + + // confirm summary panel updates + cy.contains('Control Plane ACL').should('be.visible'); + cy.findByText('Enable').should('not.exist'); + ui.button + .findByTitle('Enabled (1 IP Address)') + .should('be.visible') + .should('be.enabled') + .click(); + + // update mocks + const mockUpdatedACLOptions2 = kubernetesControlPlaneACLOptionsFactory.build( + { + enabled: true, + 'revision-id': mockRevisionId, + addresses: { + ipv4: ['10.0.0.0/24'], + ipv6: [ + '8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e', + 'f4a2:b849:4a24:d0d9:15f0:704b:f943:718f', + ], + }, + } + ); + const mockUpdatedControlPlaneACL2 = kubernetesControlPlaneACLFactory.build( + { + acl: mockUpdatedACLOptions2, + } + ); + mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL2).as( + 'updateControlPlaneACL' + ); + + // confirm data within drawer is updated and edit IPs again + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + // Confirm submit button is disabled if form has not been changed + ui.button + .findByTitle('Update') + .should('be.visible') + .should('not.be.enabled'); + + // confirm enable toggle was updated + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); + + // confirm Revision ID was updated + cy.findByLabelText('Revision ID').should( + 'have.value', + mockRevisionId + ); + + // update IPv6 addresses + cy.findByDisplayValue('10.0.0.0/24').should('be.visible'); + cy.findByPlaceholderText('::/0') + .should('be.visible') + .click() + .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + cy.findByText('Add IPv6 Address') + .should('be.visible') + .should('be.enabled') + .click(); + cy.get('[id="domain-transfer-ip-1"]') + .should('be.visible') + .click() + .type('f4a2:b849:4a24:d0d9:15f0:704b:f943:718f'); + + // submit + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateControlPlaneACL']); + + // confirm summary panel updates + cy.contains('Control Plane ACL').should('be.visible'); + cy.findByText('Enable').should('not.exist'); + ui.button + .findByTitle('Enabled (3 IP Addresses)') + .should('be.visible') + .should('be.enabled') + .click(); + + // confirm data within drawer is updated again + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + // confirm updated IPv6 addresses display + cy.findByDisplayValue( + '8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e' + ).should('be.visible'); + cy.findByDisplayValue( + 'f4a2:b849:4a24:d0d9:15f0:704b:f943:718f' + ).should('be.visible'); + }); + }); + + /** + * - Confirms ACL can be disabled from the summary page + * - Confirms both IPv4 and IPv6 can be updated and that drawer updates as a result + */ + it('can disable ACL and edit IPs', () => { + const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ + enabled: true, + addresses: { ipv4: undefined, ipv6: undefined }, + }); + const mockUpdatedACLOptions1 = kubernetesControlPlaneACLOptionsFactory.build( + { + enabled: false, + addresses: { + ipv4: ['10.0.0.0/24'], + ipv6: ['8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'], + }, + } + ); + const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockACLOptions, + }); + const mockUpdatedControlPlaneACL1 = kubernetesControlPlaneACLFactory.build( + { + acl: mockUpdatedACLOptions1, + } + ); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetControlPlaneACL(mockCluster.id, mockControlPaneACL).as( + 'getControlPlaneACL' + ); + mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL1).as( + 'updateControlPlaneACL' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getAccount', '@getCluster', '@getControlPlaneACL']); + + // confirm summary panel + cy.contains('Control Plane ACL').should('be.visible'); + ui.button + .findByTitle('Enabled (0 IP Addresses)') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + // Confirm submit button is disabled if form has not been changed + ui.button + .findByTitle('Update') + .should('be.visible') + .should('not.be.enabled'); + + cy.contains( + "Control Plane ACL secures network access to your LKE cluster's control plane. Use this form to enable or disable the ACL on your LKE cluster, update the list of allowed IP addresses, and adjust other settings." + ).should('be.visible'); + + // confirm Activation Status section and toggle off 'Enable' + cy.contains('Activation Status').should('be.visible'); + cy.contains( + 'Enable or disable the Control Plane ACL. If the ACL is not enabled, any public IP address can be used to access your control plane. Once enabled, all network access is denied except for the IP addresses and CIDR ranges defined on the ACL.' + ).should('be.visible'); + cy.findByText('Enable Control Plane ACL'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible') + .click(); + + // confirm submit button is now enabled + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled'); + + // confirm Revision ID section exists + cy.findAllByText('Revision ID').should('have.length', 2); + cy.contains( + 'A unique identifying string for this particular revision to the ACL, used by clients to track events related to ACL update requests and enforcement. This defaults to a randomly generated string but can be edited if you prefer to specify your own string to use for tracking this change.' + ).should('be.visible'); + cy.findByLabelText('Revision ID').should( + 'have.value', + mockACLOptions['revision-id'] + ); + + // confirm Addresses section + cy.findByText('Addresses').should('be.visible'); + cy.findByText( + "A list of allowed IPv4 and IPv6 addresses and CIDR ranges. This cluster's control plane will only be accessible from IP addresses within this list." + ).should('be.visible'); + cy.findByText('IPv4 Addresses or CIDRs').should('be.visible'); + // update IPv4 + cy.findByPlaceholderText('0.0.0.0/0') + .should('be.visible') + .click() + .type('10.0.0.0/24'); + cy.findByText('Add IPv4 Address') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('IPv6 Addresses or CIDRs').should('be.visible'); + // update IPv6 + cy.findByPlaceholderText('::/0') + .should('be.visible') + .click() + .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + cy.findByText('Add IPv6 Address') + .should('be.visible') + .should('be.enabled') + .click(); + + // submit + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateControlPlaneACL']); + + // confirm summary panel updates + cy.contains('Control Plane ACL').should('be.visible'); + cy.findByText('Enabled (O IP Addresses)').should('not.exist'); + ui.button + .findByTitle('Enable') + .should('be.visible') + .should('be.enabled') + .click(); + + // confirm data within drawer is updated + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + // confirm enable toggle was updated + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'false') + .should('be.visible'); + + // confirm updated IP addresses display + cy.findByDisplayValue('10.0.0.0/24').should('be.visible'); + cy.findByDisplayValue( + '8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e' + ).should('be.visible'); + }); + }); + + /** + * - Confirms ACL can be enabled from the summary page when cluster does not have ACL pre-installed + * - Confirms drawer appearance when APL is not pre-installed + * - Confirms that request to correct endpoint is sent + */ + it('can enable ACL on an LKE cluster with ACL not pre-installed and edit IPs', () => { + const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ + enabled: true, + addresses: { ipv4: ['10.0.0.0/24'] }, + }); + const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockACLOptions, + }); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetControlPlaneACLError(mockCluster.id).as('getControlPlaneACLError'); + mockUpdateCluster(mockCluster.id, { + ...mockCluster, + control_plane: mockControlPaneACL, + }).as('updateCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getAccount', + '@getCluster', + '@getControlPlaneACLError', + '@getNodePools', + ]); + + cy.contains('Control Plane ACL').should('be.visible'); + cy.findAllByTestId('circle-progress').should('be.visible'); + + // query retries once if failed + cy.wait('@getControlPlaneACLError'); + + ui.button + .findByTitle('Enable') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetControlPlaneACL(mockCluster.id, mockControlPaneACL).as( + 'getControlPlaneACL' + ); + + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + cy.contains( + "Control Plane ACL secures network access to your LKE cluster's control plane. Use this form to enable or disable the ACL on your LKE cluster, update the list of allowed IP addresses, and adjust other settings." + ).should('be.visible'); + + // Confirm Activation Status section and Enable ACL + cy.contains('Activation Status').should('be.visible'); + cy.contains( + 'Enable or disable the Control Plane ACL. If the ACL is not enabled, any public IP address can be used to access your control plane. Once enabled, all network access is denied except for the IP addresses and CIDR ranges defined on the ACL.' + ).should('be.visible'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'false') + .should('be.visible') + .click(); + + // Confirm revision ID section does not exist + cy.contains('Revision ID').should('not.exist'); + cy.contains( + 'A unique identifying string for this particular revision to the ACL, used by clients to track events related to ACL update requests and enforcement. This defaults to a randomly generated string but can be edited if you prefer to specify your own string to use for tracking this change.' + ).should('not.exist'); + + // Confirm Addresses section and add IP addresses + cy.findByText('Addresses').should('be.visible'); + cy.findByText( + "A list of allowed IPv4 and IPv6 addresses and CIDR ranges. This cluster's control plane will only be accessible from IP addresses within this list." + ).should('be.visible'); + cy.findByText('IPv4 Addresses or CIDRs').should('be.visible'); + cy.findByText('IPv6 Addresses or CIDRs').should('be.visible'); + + cy.findByPlaceholderText('0.0.0.0/0') + .should('be.visible') + .click() + .type('10.0.0.0/24'); + + cy.findByPlaceholderText('::/0') + .should('be.visible') + .click() + .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + + // Confirm installation notice is displayed + cy.contains( + 'Control Plane ACL has not yet been installed on this cluster. During installation, it may take up to 15 minutes for the access control list to be fully enforced.' + ).should('be.visible'); + + // submit + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateCluster', '@getControlPlaneACL']); + + // confirm summary panel updates + cy.contains('Control Plane ACL').should('be.visible'); + cy.findByText('Enabled (2 IP Addresses)').should('be.exist'); + }); + + /** + * - Confirms IP validation error appears when a bad IP is entered + * - Confirms IP validation error disappears when a valid IP is entered + * - Confirms API error appears as expected and doesn't crash the page + */ + it('can handle validation and API errors', () => { + const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ + enabled: true, + addresses: { ipv4: undefined, ipv6: undefined }, + }); + const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockACLOptions, + }); + const mockErrorMessage = 'Control Plane ACL error: failed to update ACL'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetControlPlaneACL(mockCluster.id, mockControlPaneACL).as( + 'getControlPlaneACL' + ); + + mockUpdateControlPlaneACLError(mockCluster.id, mockErrorMessage, 400).as( + 'updateControlPlaneACLError' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getAccount', '@getCluster', '@getControlPlaneACL']); + + // confirm summary panel + cy.contains('Control Plane ACL').should('be.visible'); + ui.button + .findByTitle('Enabled (0 IP Addresses)') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Control Plane ACL') + .should('be.visible') + .within(() => { + // Confirm ACL IP validation works as expected for IPv4 + cy.findByPlaceholderText('0.0.0.0/0') + .should('be.visible') + .click() + .type('invalid ip'); + // click out of textbox and confirm error is visible + cy.contains('Addresses').should('be.visible').click(); + cy.contains('Must be a valid IPv4 address.').should('be.visible'); + // enter valid IP + cy.findByPlaceholderText('0.0.0.0/0') + .should('be.visible') + .click() + .clear() + .type('10.0.0.0/24'); + // Click out of textbox and confirm error is gone + cy.contains('Addresses').should('be.visible').click(); + cy.contains('Must be a valid IPv4 address.').should('not.exist'); + + // Confirm ACL IP validation works as expected for IPv6 + cy.findByPlaceholderText('::/0') + .should('be.visible') + .click() + .type('invalid ip'); + // click out of textbox and confirm error is visible + cy.findByText('Addresses').should('be.visible').click(); + cy.contains('Must be a valid IPv6 address.').should('be.visible'); + // enter valid IP + cy.findByPlaceholderText('::/0') + .should('be.visible') + .click() + .clear() + .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + // Click out of textbox and confirm error is gone + cy.findByText('Addresses').should('be.visible').click(); + cy.contains('Must be a valid IPv6 address.').should('not.exist'); + + // submit + ui.button + .findByTitle('Update') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateControlPlaneACLError']); + cy.contains(mockErrorMessage).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/kubernetes.ts b/packages/manager/cypress/support/intercepts/kubernetes.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index ba868f9c2ed..5f646730a96 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -7,6 +7,7 @@ import { kubernetesDashboardUrlFactory, } from '@src/factories'; import { kubernetesVersions } from 'support/constants/lke'; +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { randomDomainName } from 'support/util/random'; @@ -16,6 +17,7 @@ import type { KubeConfigResponse, KubeNodePoolResponse, KubernetesCluster, + KubernetesControlPlaneACLPayload, KubernetesVersion, } from '@linode/api-v4'; @@ -157,6 +159,25 @@ export const mockCreateCluster = ( ); }; +/** + * Intercepts POST request to create an LKE cluster and mocks an error response. + * + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateClusterError = ( + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('lke/clusters'), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts DELETE request to delete an LKE cluster and mocks the response. * @@ -341,7 +362,7 @@ export const mockGetApiEndpoints = ( /** * Intercepts DELETE request to reset Kubeconfig and mocks the response. * - * @param clusterId - Numberic ID of LKE cluster for which to mock response. + * @param clusterId - Numeric ID of LKE cluster for which to mock response. * * @returns Cypress chainable. */ @@ -354,3 +375,83 @@ export const mockResetKubeconfig = ( makeResponse({}) ); }; + +/** + * Intercepts GET request for a cluster's Control Plane ACL and mocks the response + * + * @param clusterId - Numeric ID of LKE cluster for which to mock response. + * @param controlPlaneACL - control plane ACL data for which to mock response + * + * @returns Cypress chainable + */ +export const mockGetControlPlaneACL = ( + clusterId: number, + controlPlaneACL: KubernetesControlPlaneACLPayload +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/lke/clusters/${clusterId}/control_plane_acl`), + makeResponse(controlPlaneACL) + ); +}; + +/** + * Intercepts GET request for a cluster's Control Plane ACL and mocks an error response + * + * @param clusterId - Numeric ID of LKE cluster for which to mock response. + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable + */ +export const mockGetControlPlaneACLError = ( + clusterId: number, + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/lke/clusters/${clusterId}/control_plane_acl`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + +/** + * Intercepts PUT request for a cluster's Control Plane ACL and mocks the response + * + * @param clusterId - Numeric ID of LKE cluster for which to mock response. + * @param controlPlaneACL - control plane ACL data for which to mock response + * + * @returns Cypress chainable + */ +export const mockUpdateControlPlaneACL = ( + clusterId: number, + controlPlaneACL: KubernetesControlPlaneACLPayload +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`/lke/clusters/${clusterId}/control_plane_acl`), + makeResponse(controlPlaneACL) + ); +}; + +/** + * Intercepts PUT request for a cluster's Control Plane ACL and mocks the response + * + * @param clusterId - Numeric ID of LKE cluster for which to mock response. + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateControlPlaneACLError = ( + clusterId: number, + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`/lke/clusters/${clusterId}/control_plane_acl`), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts index 5bf468c00fe..e7b3bdff2ea 100644 --- a/packages/manager/src/factories/kubernetesCluster.ts +++ b/packages/manager/src/factories/kubernetesCluster.ts @@ -1,13 +1,17 @@ -import { +import { v4 } from 'uuid'; + +import Factory from 'src/factories/factoryProxy'; + +import type { + ControlPlaneACLOptions, KubeNodePoolResponse, KubernetesCluster, + KubernetesControlPlaneACLPayload, KubernetesDashboardResponse, KubernetesEndpointResponse, KubernetesVersion, PoolNodeResponse, } from '@linode/api-v4/lib/kubernetes/types'; -import Factory from 'src/factories/factoryProxy'; -import { v4 } from 'uuid'; export const kubeLinodeFactory = Factory.Sync.makeFactory({ id: Factory.each((id) => `id-${id}`), @@ -73,3 +77,21 @@ export const kubernetesVersionFactory = Factory.Sync.makeFactory( + { + addresses: { + ipv4: ['10.0.0.0/24', '10.0.1.0/24'], + ipv6: ['8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'], + }, + enabled: true, + 'revision-id': '67497a9c5fc8491889a7ef8107493e92', + } +); +export const kubernetesControlPlaneACLFactory = Factory.Sync.makeFactory( + { + acl: { + ...kubernetesControlPlaneACLOptionsFactory.build(), + }, + } +); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx index 72074b2bc94..cc4866b4397 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx @@ -203,8 +203,8 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { <> Revision ID - A unique identifing string for this particular revision to the - ACL, used by clients to track events related to ACL update + A unique identifying string for this particular revision to + the ACL, used by clients to track events related to ACL update requests and enforcement. This defaults to a randomly generated string but can be edited if you prefer to specify your own string to use for tracking this change. From 74d36a65954f2410d15d488fa92060c4cffa7951 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:08:54 -0500 Subject: [PATCH 61/64] refactor: [M3-8766] - Cleanup feature flag - Create Using Command Line (DX Tools Additions) (#11135) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * chore: [M3-8662] - Update Github Actions actions (#11009) * update actions * add changeset --------- Co-authored-by: Banks Nussman * Cleanup feature flag - apicliDxToolsAdditions * Added changeset: Cleanup feature flag - Create Using Command Line (DX Tools Additions) * PR - feedback - @coliu-akamai --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman --- .../pr-11135-tech-stories-1729565746630.md | 5 + .../create-linode-view-code-snippet.spec.ts | 369 ++++++------------ .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 - packages/manager/src/featureFlags.ts | 3 +- .../ApiAwarenessModal/ApiAwarenessModal.tsx | 86 +--- 5 files changed, 138 insertions(+), 326 deletions(-) create mode 100644 packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md diff --git a/packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md b/packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md new file mode 100644 index 00000000000..11d6303ee11 --- /dev/null +++ b/packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Cleanup feature flag - Create Using Command Line (DX Tools Additions) ([#11135](https://github.com/linode/manager/pull/11135)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index 4df65bffb9c..7064f974516 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -7,265 +7,128 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { linodeCreatePage } from 'support/ui/pages'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { chooseRegion } from 'support/util/regions'; -describe('Create Linode', () => { +describe('Create Linode flow to validate code snippet modal', () => { /* * tests for create Linode flow to validate code snippet modal. */ - describe('Create Linode flow with apicliDxToolsAdditions enabled', () => { - // Enable the `apicliDxToolsAdditions` feature flag. - // TODO Delete these mocks once `apicliDxToolsAdditions` feature flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - apicliDxToolsAdditions: true, - testdxtoolabexperiment: 'Create using command line', - }); - }); - it(`view code snippets in create linode flow`, () => { - const linodeLabel = randomLabel(); - const rootPass = randomString(32); - - cy.visitWithLogin('/linodes/create'); - - // Set Linode label, distribution, plan type, password, etc. - linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); - linodeCreatePage.selectRegionById('us-east'); - linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); - linodeCreatePage.setRootPassword(rootPass); - - // View Code Snippets and confirm it's provisioned as expected. - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - ui.tabList - .findTabByTitle('cURL') - .should('be.visible') - .should('be.enabled'); - - ui.tabList.findTabByTitle('Linode CLI').should('be.visible').click(); - - // Validate Integrations - ui.tabList - .findTabByTitle('Integrations') - .should('be.visible') - .click(); - - // Validate Ansible and links - ui.autocomplete.find().click(); - - ui.autocompletePopper - .findByTitle('Ansible') - .should('be.visible') - .click(); - cy.contains( - 'a', - 'Getting Started With Ansible: Basic Installation and Setup' - ).should('be.visible'); - cy.contains('a', 'Linode Cloud Instance Module').should('be.visible'); - cy.contains('a', 'Manage Personal Access Tokens').should( - 'be.visible' - ); - cy.contains('a', 'Best Practices For Ansible').should('be.visible'); - cy.contains( - 'a', - 'Use the Linode Ansible Collection to Deploy a Linode' - ).should('be.visible'); - - // Validate Terraform and links - ui.autocomplete.find().click(); - ui.autocompletePopper - .findByTitle('Terraform') - .should('be.visible') - .click(); - cy.contains('a', `A Beginner's Guide to Terraform`).should( - 'be.visible' - ); - cy.contains('a', 'Install Terraform').should('be.visible'); - cy.contains('a', 'Manage Personal Access Tokens').should( - 'be.visible' - ); - cy.contains('a', 'Use Terraform With Linode Object Storage').should( - 'be.visible' - ); - cy.contains( - 'a', - 'Use Terraform to Provision Infrastructure on Linode' - ).should('be.visible'); - cy.contains( - 'a', - 'Import Existing Infrastructure to Terraform' - ).should('be.visible'); - - // Validate SDKs tab - ui.tabList.findTabByTitle(`SDKs`).should('be.visible').click(); - - ui.autocomplete.find().click(); - - // Validate linodego and links - ui.autocompletePopper - .findByTitle('Go (linodego)') - .should('be.visible') - .click(); - cy.contains('a', 'Go client for Linode REST v4 API').should( - 'be.visible' - ); - cy.contains('a', 'Linodego Documentation').should('be.visible'); - - ui.autocomplete.find().click(); - - // Validate Python API4 and links - ui.autocompletePopper - .findByTitle('Python (linode_api4-python)') - .should('be.visible') - .click(); - - cy.contains( - 'a', - 'Official python library for the Linode APIv4 in python' - ).should('be.visible'); - cy.contains('a', 'linode_api4-python Documentation').should( - 'be.visible' - ); - - ui.button - .findByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); + // TODO Delete these mocks once `testdxtoolabexperiment` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + testdxtoolabexperiment: 'Create using command line', }); }); - - describe('Create Linode flow with apicliDxToolsAdditions disabled', () => { - // Enable the `apicliDxToolsAdditions` feature flag. - // TODO Delete these mocks and test once `apicliDxToolsAdditions` feature flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - apicliDxToolsAdditions: false, - testdxtoolabexperiment: 'Create using command line', + it(`view code snippets in create linode flow`, () => { + const linodeLabel = randomLabel(); + const rootPass = randomString(32); + + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById('us-east'); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(rootPass); + + // View Code Snippets and confirm it's provisioned as expected. + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + ui.tabList + .findTabByTitle('cURL') + .should('be.visible') + .should('be.enabled'); + + ui.tabList.findTabByTitle('Linode CLI').should('be.visible').click(); + + // Validate Integrations + ui.tabList.findTabByTitle('Integrations').should('be.visible').click(); + + // Validate Ansible and links + ui.autocomplete.find().click(); + + ui.autocompletePopper + .findByTitle('Ansible') + .should('be.visible') + .click(); + cy.contains( + 'a', + 'Getting Started With Ansible: Basic Installation and Setup' + ).should('be.visible'); + cy.contains('a', 'Linode Cloud Instance Module').should('be.visible'); + cy.contains('a', 'Manage Personal Access Tokens').should('be.visible'); + cy.contains('a', 'Best Practices For Ansible').should('be.visible'); + cy.contains( + 'a', + 'Use the Linode Ansible Collection to Deploy a Linode' + ).should('be.visible'); + + // Validate Terraform and links + ui.autocomplete.find().click(); + ui.autocompletePopper + .findByTitle('Terraform') + .should('be.visible') + .click(); + cy.contains('a', `A Beginner's Guide to Terraform`).should( + 'be.visible' + ); + cy.contains('a', 'Install Terraform').should('be.visible'); + cy.contains('a', 'Manage Personal Access Tokens').should('be.visible'); + cy.contains('a', 'Use Terraform With Linode Object Storage').should( + 'be.visible' + ); + cy.contains( + 'a', + 'Use Terraform to Provision Infrastructure on Linode' + ).should('be.visible'); + cy.contains('a', 'Import Existing Infrastructure to Terraform').should( + 'be.visible' + ); + + // Validate SDKs tab + ui.tabList.findTabByTitle(`SDKs`).should('be.visible').click(); + + ui.autocomplete.find().click(); + + // Validate linodego and links + ui.autocompletePopper + .findByTitle('Go (linodego)') + .should('be.visible') + .click(); + cy.contains('a', 'Go client for Linode REST v4 API').should( + 'be.visible' + ); + cy.contains('a', 'Linodego Documentation').should('be.visible'); + + ui.autocomplete.find().click(); + + // Validate Python API4 and links + ui.autocompletePopper + .findByTitle('Python (linode_api4-python)') + .should('be.visible') + .click(); + + cy.contains( + 'a', + 'Official python library for the Linode APIv4 in python' + ).should('be.visible'); + cy.contains('a', 'linode_api4-python Documentation').should( + 'be.visible' + ); + + ui.button + .findByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); }); - }); - it(`view code snippets in create linode flow`, () => { - const linodeLabel = randomLabel(); - const rootPass = randomString(32); - - cy.visitWithLogin('/linodes/create'); - - // Set Linode label, distribution, plan type, password, etc. - linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); - linodeCreatePage.selectRegionById('us-east'); - linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); - linodeCreatePage.setRootPassword(rootPass); - - // View Code Snippets and confirm it's provisioned as expected. - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - ui.tabList - .findTabByTitle('cURL') - .should('be.visible') - .should('be.enabled'); - - ui.tabList.findTabByTitle('Linode CLI').should('be.visible').click(); - - // Validate Integrations - ui.tabList.findTabByTitle('Integrations').should('not.exist'); - // Validate Integrations - ui.tabList.findTabByTitle(`SDK's`).should('not.exist'); - - ui.button - .findByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); - it('creates a linode via CLI', () => { - const linodeLabel = randomLabel(); - const linodePass = randomString(32); - const linodeRegion = chooseRegion(); - - cy.visitWithLogin('/linodes/create'); - - ui.regionSelect.find().click(); - ui.autocompletePopper - .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('exist') - .click(); - - cy.get('[id="g6-dedicated-2"]').click(); - - cy.findByLabelText('Linode Label').should( - 'have.value', - `ubuntu-${linodeRegion.id}` - ); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .should('be.enabled') - .clear() - .type(linodeLabel); - - cy.findByLabelText('Root Password') - .should('be.visible') - .should('be.enabled') - .type(linodePass); - - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - // Switch to cURL view if necessary. - cy.findByText('cURL').should('be.visible').click(); - - // Confirm that cURL command has expected details. - [ - `"region": "${linodeRegion.id}"`, - `"type": "g6-dedicated-2"`, - `"label": "${linodeLabel}"`, - `"root_pass": "${linodePass}"`, - ].forEach((line: string) => - cy.findByText(line, { exact: false }).should('be.visible') - ); - - cy.findByText('Linode CLI').should('be.visible').click(); - - [ - `--region ${linodeRegion.id}`, - '--type g6-dedicated-2', - `--label ${linodeLabel}`, - `--root_pass ${linodePass}`, - ].forEach((line: string) => cy.contains(line).should('be.visible')); - - ui.buttonGroup - .findButtonByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); }); }); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 317ad7b4081..c87b9a404f3 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -34,7 +34,6 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'dbaasV2', label: 'Databases V2 Beta' }, { flag: 'dbaasV2MonitorMetrics', label: 'Databases V2 Monitor' }, { flag: 'databaseResize', label: 'Database Resize' }, - { flag: 'apicliDxToolsAdditions', label: 'APICLI DX Tools Additions' }, { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, ]; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 0aad47f89c7..64f07f0ed54 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -90,9 +90,8 @@ export interface Flags { aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; apiMaintenance: APIMaintenance; - apl: boolean; apicliButtonCopy: string; - apicliDxToolsAdditions: boolean; + apl: boolean; blockStorageEncryption: boolean; cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; databaseBeta: boolean; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/ApiAwarenessModal.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/ApiAwarenessModal.tsx index 0b777d4ad61..a12a0bd31af 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/ApiAwarenessModal.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/ApiAwarenessModal.tsx @@ -6,7 +6,6 @@ import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Dialog } from 'src/components/Dialog/Dialog'; import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; @@ -31,20 +30,17 @@ export interface ApiAwarenessModalProps { payLoad: CreateLinodeRequest; } -export const baseTabs = [ - { - component: CurlTabPanel, - title: 'cURL', - type: 'API', - }, +export const tabs = [ { component: LinodeCLIPanel, title: 'Linode CLI', type: 'CLI', }, -]; - -export const additionalTabs = [ + { + component: CurlTabPanel, + title: 'cURL', + type: 'API', + }, { component: IntegrationsTabPanel, title: 'Integrations', @@ -75,13 +71,8 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { const isLinodeCreated = linodeCreationEvent !== undefined; - const isDxAdditionsFeatureEnabled = flags?.apicliDxToolsAdditions; const apicliButtonCopy = flags?.testdxtoolabexperiment; - const tabs = isDxAdditionsFeatureEnabled - ? [baseTabs[1], baseTabs[0], ...additionalTabs] - : baseTabs; - const handleTabChange = (index: number) => { const { title, type } = tabs[index]; @@ -130,23 +121,16 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { title="Create Linode" > - {isDxAdditionsFeatureEnabled ? ( - <> - Create a Linode in the command line, powered by the{' '} - sendApiAwarenessClickEvent('link', 'Linode API')} - to="https://techdocs.akamai.com/linode-api/reference/api/" - > - Linode API - - . Select one of the methods below and paste the corresponding - command into your local terminal. The values for each command have - been populated with the selections made in the Cloud Manager create - form. - - ) : ( - 'Create a Linode in the command line using either cURL or the Linode CLI — both of which are powered by the Linode API. Select one of the methods below and paste the corresponding command into your local terminal. The values for each command have been populated with the selections made in the Cloud Manager create form.' - )} + Create a Linode in the command line, powered by the{' '} + sendApiAwarenessClickEvent('link', 'Linode API')} + to="https://techdocs.akamai.com/linode-api/reference/api/" + > + Linode API + + . Select one of the methods below and paste the corresponding command + into your local terminal. The values for each command have been + populated with the selections made in the Cloud Manager create form. @@ -165,44 +149,6 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { ))} - {!isDxAdditionsFeatureEnabled && ( - - - Deploy and manage your infrastructure with the{' '} - - sendApiAwarenessClickEvent('link', 'Linode Terraform Provider') - } - to="https://www.linode.com/products/linode-terraform-provider/" - > - Linode Terraform Provider - {' '} - and{' '} - - sendApiAwarenessClickEvent('link', 'Ansible Collection') - } - to="https://www.linode.com/products/linode-ansible-collection/" - > - Ansible Collection - - .{' '} - - sendApiAwarenessClickEvent('link', 'View all tools') - } - to="https://techdocs.akamai.com/linode-api/reference/api" - > - View all tools - {' '} - with programmatic access to the Linode platform. - - - )} Date: Wed, 23 Oct 2024 16:24:32 -0400 Subject: [PATCH 62/64] Cloud version v1.131.0, API v4 version v0.129.0, Validation version v0.55.0, and UI version v0.2.0 --- .../pr-10968-added-1727966811522.md | 5 -- .../pr-11129-fixed-1729536266478.md | 5 -- .../pr-11139-added-1729701623137.md | 5 -- packages/api-v4/CHANGELOG.md | 12 +++ packages/api-v4/package.json | 2 +- .../pr-10952-tech-stories-1726576261944.md | 5 -- .../pr-10968-added-1727901904107.md | 5 -- .../pr-10971-tests-1726764686101.md | 5 -- .../pr-11045-tests-1727985809023.md | 5 -- .../pr-11049-tech-stories-1728329839703.md | 5 -- .../pr-11052-changed-1728305995058.md | 5 -- .../pr-11060-tests-1728375821627.md | 5 -- ...r-11062-upcoming-features-1728390800736.md | 5 -- .../pr-11063-added-1728386681970.md | 5 -- .../pr-11067-added-1729705480335.md | 5 -- ...r-11068-upcoming-features-1728916409850.md | 5 -- .../pr-11069-fixed-1728443895478.md | 5 -- .../pr-11070-changed-1728495168369.md | 5 -- .../pr-11074-fixed-1728476792585.md | 5 -- ...r-11078-upcoming-features-1728503718215.md | 5 -- .../pr-11083-fixed-1728564903243.md | 5 -- .../pr-11084-changed-1728577518914.md | 5 -- .../pr-11086-tech-stories-1729012343535.md | 5 -- .../pr-11088-tech-stories-1729535071709.md | 5 -- .../pr-11088-tests-1729535093463.md | 5 -- .../pr-11088-tests-1729535165205.md | 5 -- .../pr-11088-tests-1729535197632.md | 5 -- .../pr-11090-tech-stories-1728605016946.md | 5 -- ...r-11091-upcoming-features-1728668394698.md | 5 -- .../pr-11092-tech-stories-1728680192850.md | 5 -- .../pr-11093-fixed-1728898767762.md | 5 -- .../pr-11094-fixed-1728923124356.md | 5 -- .../pr-11097-fixed-1728926799806.md | 5 -- .../pr-11098-changed-1728938497573.md | 6 -- .../pr-11100-changed-1729010084175.md | 5 -- .../pr-11101-fixed-1729011064940.md | 5 -- .../pr-11103-fixed-1729099711508.md | 5 -- .../pr-11104-tests-1729020207783.md | 5 -- .../pr-11105-added-1729023730319.md | 5 -- .../pr-11106-tests-1729028269027.md | 5 -- .../pr-11107-tests-1729033939339.md | 5 -- .../pr-11108-changed-1729059614823.md | 5 -- .../pr-11110-added-1729516190960.md | 5 -- .../pr-11112-added-1729086990373.md | 5 -- .../pr-11113-tests-1729099850186.md | 5 -- ...r-11115-upcoming-features-1729115799261.md | 5 -- .../pr-11117-tech-stories-1729171591044.md | 5 -- ...r-11118-upcoming-features-1729486113842.md | 5 -- .../pr-11119-tests-1729169604255.md | 5 -- ...r-11120-upcoming-features-1729173753438.md | 5 -- .../pr-11122-added-1729183156879.md | 5 -- ...r-11124-upcoming-features-1729198459249.md | 5 -- .../pr-11130-fixed-1729536728596.md | 5 -- .../pr-11131-tests-1729627189739.md | 5 -- .../pr-11133-tech-stories-1729550913039.md | 5 -- .../pr-11135-tech-stories-1729565746630.md | 5 -- .../pr-11136-added-1729581444483.md | 5 -- .../pr-11139-changed-1729698979911.md | 5 -- .../pr-11140-fixed-1729625688555.md | 5 -- .../pr-11148-fixed-1729687116230.md | 5 -- packages/manager/CHANGELOG.md | 77 +++++++++++++++++++ packages/manager/package.json | 2 +- .../pr-11092-added-1728931799888.md | 5 -- .../pr-11116-changed-1729114321677.md | 5 -- packages/ui/CHANGELOG.md | 11 +++ packages/ui/package.json | 2 +- .../pr-10968-added-1729020457987.md | 5 -- .../pr-11069-added-1728444089255.md | 5 -- .../pr-11069-changed-1728444173048.md | 5 -- packages/validation/CHANGELOG.md | 12 +++ packages/validation/package.json | 2 +- 71 files changed, 116 insertions(+), 320 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-10968-added-1727966811522.md delete mode 100644 packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md delete mode 100644 packages/api-v4/.changeset/pr-11139-added-1729701623137.md delete mode 100644 packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md delete mode 100644 packages/manager/.changeset/pr-10968-added-1727901904107.md delete mode 100644 packages/manager/.changeset/pr-10971-tests-1726764686101.md delete mode 100644 packages/manager/.changeset/pr-11045-tests-1727985809023.md delete mode 100644 packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md delete mode 100644 packages/manager/.changeset/pr-11052-changed-1728305995058.md delete mode 100644 packages/manager/.changeset/pr-11060-tests-1728375821627.md delete mode 100644 packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md delete mode 100644 packages/manager/.changeset/pr-11063-added-1728386681970.md delete mode 100644 packages/manager/.changeset/pr-11067-added-1729705480335.md delete mode 100644 packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md delete mode 100644 packages/manager/.changeset/pr-11069-fixed-1728443895478.md delete mode 100644 packages/manager/.changeset/pr-11070-changed-1728495168369.md delete mode 100644 packages/manager/.changeset/pr-11074-fixed-1728476792585.md delete mode 100644 packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md delete mode 100644 packages/manager/.changeset/pr-11083-fixed-1728564903243.md delete mode 100644 packages/manager/.changeset/pr-11084-changed-1728577518914.md delete mode 100644 packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md delete mode 100644 packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md delete mode 100644 packages/manager/.changeset/pr-11088-tests-1729535093463.md delete mode 100644 packages/manager/.changeset/pr-11088-tests-1729535165205.md delete mode 100644 packages/manager/.changeset/pr-11088-tests-1729535197632.md delete mode 100644 packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md delete mode 100644 packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md delete mode 100644 packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md delete mode 100644 packages/manager/.changeset/pr-11093-fixed-1728898767762.md delete mode 100644 packages/manager/.changeset/pr-11094-fixed-1728923124356.md delete mode 100644 packages/manager/.changeset/pr-11097-fixed-1728926799806.md delete mode 100644 packages/manager/.changeset/pr-11098-changed-1728938497573.md delete mode 100644 packages/manager/.changeset/pr-11100-changed-1729010084175.md delete mode 100644 packages/manager/.changeset/pr-11101-fixed-1729011064940.md delete mode 100644 packages/manager/.changeset/pr-11103-fixed-1729099711508.md delete mode 100644 packages/manager/.changeset/pr-11104-tests-1729020207783.md delete mode 100644 packages/manager/.changeset/pr-11105-added-1729023730319.md delete mode 100644 packages/manager/.changeset/pr-11106-tests-1729028269027.md delete mode 100644 packages/manager/.changeset/pr-11107-tests-1729033939339.md delete mode 100644 packages/manager/.changeset/pr-11108-changed-1729059614823.md delete mode 100644 packages/manager/.changeset/pr-11110-added-1729516190960.md delete mode 100644 packages/manager/.changeset/pr-11112-added-1729086990373.md delete mode 100644 packages/manager/.changeset/pr-11113-tests-1729099850186.md delete mode 100644 packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md delete mode 100644 packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md delete mode 100644 packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md delete mode 100644 packages/manager/.changeset/pr-11119-tests-1729169604255.md delete mode 100644 packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md delete mode 100644 packages/manager/.changeset/pr-11122-added-1729183156879.md delete mode 100644 packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md delete mode 100644 packages/manager/.changeset/pr-11130-fixed-1729536728596.md delete mode 100644 packages/manager/.changeset/pr-11131-tests-1729627189739.md delete mode 100644 packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md delete mode 100644 packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md delete mode 100644 packages/manager/.changeset/pr-11136-added-1729581444483.md delete mode 100644 packages/manager/.changeset/pr-11139-changed-1729698979911.md delete mode 100644 packages/manager/.changeset/pr-11140-fixed-1729625688555.md delete mode 100644 packages/manager/.changeset/pr-11148-fixed-1729687116230.md delete mode 100644 packages/ui/.changeset/pr-11092-added-1728931799888.md delete mode 100644 packages/ui/.changeset/pr-11116-changed-1729114321677.md delete mode 100644 packages/validation/.changeset/pr-10968-added-1729020457987.md delete mode 100644 packages/validation/.changeset/pr-11069-added-1728444089255.md delete mode 100644 packages/validation/.changeset/pr-11069-changed-1728444173048.md diff --git a/packages/api-v4/.changeset/pr-10968-added-1727966811522.md b/packages/api-v4/.changeset/pr-10968-added-1727966811522.md deleted file mode 100644 index df179fea56e..00000000000 --- a/packages/api-v4/.changeset/pr-10968-added-1727966811522.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -ACL related endpoints and types for LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md b/packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md deleted file mode 100644 index 4a2d6de69a4..00000000000 --- a/packages/api-v4/.changeset/pr-11129-fixed-1729536266478.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Fixed ---- - -Incorrect documentation on how to set a page size ([#11129](https://github.com/linode/manager/pull/11129)) diff --git a/packages/api-v4/.changeset/pr-11139-added-1729701623137.md b/packages/api-v4/.changeset/pr-11139-added-1729701623137.md deleted file mode 100644 index 5d36ec598f4..00000000000 --- a/packages/api-v4/.changeset/pr-11139-added-1729701623137.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -`StackScripts` to Region capabilities type ([#11139](https://github.com/linode/manager/pull/11139)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 3bd90ff9e6d..6f2f8dc95bf 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,4 +1,16 @@ +## [2024-10-28] - v0.129.0 + + +### Added: + +- ACL related endpoints and types for LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) +- `StackScripts` to Region capabilities type ([#11139](https://github.com/linode/manager/pull/11139)) + +### Fixed: + +- Incorrect documentation on how to set a page size ([#11129](https://github.com/linode/manager/pull/11129)) + ## [2024-10-14] - v0.128.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 9707b82dc56..d66c2b5743a 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.128.0", + "version": "0.129.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md b/packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md deleted file mode 100644 index ad951701cb3..00000000000 --- a/packages/manager/.changeset/pr-10952-tech-stories-1726576261944.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Optimize AccessSelect component: Use React Hook Form & React Query ([#10952](https://github.com/linode/manager/pull/10952)) diff --git a/packages/manager/.changeset/pr-10968-added-1727901904107.md b/packages/manager/.changeset/pr-10968-added-1727901904107.md deleted file mode 100644 index f4e98f52b9a..00000000000 --- a/packages/manager/.changeset/pr-10968-added-1727901904107.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -IP ACL integration to LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/manager/.changeset/pr-10971-tests-1726764686101.md b/packages/manager/.changeset/pr-10971-tests-1726764686101.md deleted file mode 100644 index b904a51b98f..00000000000 --- a/packages/manager/.changeset/pr-10971-tests-1726764686101.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add assertions for bucket details drawer tests ([#10971](https://github.com/linode/manager/pull/10971)) diff --git a/packages/manager/.changeset/pr-11045-tests-1727985809023.md b/packages/manager/.changeset/pr-11045-tests-1727985809023.md deleted file mode 100644 index 02b9784d7ca..00000000000 --- a/packages/manager/.changeset/pr-11045-tests-1727985809023.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add new test to confirm changes to the Object details drawer for OBJ Gen 2 ([#11045](https://github.com/linode/manager/pull/11045)) diff --git a/packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md b/packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md deleted file mode 100644 index e8ef18d564a..00000000000 --- a/packages/manager/.changeset/pr-11049-tech-stories-1728329839703.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Migrate /betas routes to Tanstack Router ([#11049](https://github.com/linode/manager/pull/11049)) diff --git a/packages/manager/.changeset/pr-11052-changed-1728305995058.md b/packages/manager/.changeset/pr-11052-changed-1728305995058.md deleted file mode 100644 index b799c9bd432..00000000000 --- a/packages/manager/.changeset/pr-11052-changed-1728305995058.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Disable Create VPC button with tooltip text on empty state Landing Page for restricted users ([#11052](https://github.com/linode/manager/pull/11052)) diff --git a/packages/manager/.changeset/pr-11060-tests-1728375821627.md b/packages/manager/.changeset/pr-11060-tests-1728375821627.md deleted file mode 100644 index 3ba2b3c44e6..00000000000 --- a/packages/manager/.changeset/pr-11060-tests-1728375821627.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Cypress test for non-empty Linode landing page with restricted user ([#11060](https://github.com/linode/manager/pull/11060)) diff --git a/packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md b/packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md deleted file mode 100644 index d6f2cbc0ced..00000000000 --- a/packages/manager/.changeset/pr-11062-upcoming-features-1728390800736.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add tooltip for widget level filters and icons, address beta demo feedbacks and CSS changes for CloudPulse ([#11062](https://github.com/linode/manager/pull/11062)) diff --git a/packages/manager/.changeset/pr-11063-added-1728386681970.md b/packages/manager/.changeset/pr-11063-added-1728386681970.md deleted file mode 100644 index 0a633ef71e6..00000000000 --- a/packages/manager/.changeset/pr-11063-added-1728386681970.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Disable Create VPC button with tooltip text on Landing Page for restricted users ([#11063](https://github.com/linode/manager/pull/11063)) diff --git a/packages/manager/.changeset/pr-11067-added-1729705480335.md b/packages/manager/.changeset/pr-11067-added-1729705480335.md deleted file mode 100644 index 94962cf5d04..00000000000 --- a/packages/manager/.changeset/pr-11067-added-1729705480335.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Improve weblish retry behavior ([#11067](https://github.com/linode/manager/pull/11067)) diff --git a/packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md b/packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md deleted file mode 100644 index dbe9182d101..00000000000 --- a/packages/manager/.changeset/pr-11068-upcoming-features-1728916409850.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Retain resource selection while expand or collapse the filter button ([#11068](https://github.com/linode/manager/pull/11068)) diff --git a/packages/manager/.changeset/pr-11069-fixed-1728443895478.md b/packages/manager/.changeset/pr-11069-fixed-1728443895478.md deleted file mode 100644 index 057ac261ea2..00000000000 --- a/packages/manager/.changeset/pr-11069-fixed-1728443895478.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Support Linodes with multiple private IPs in NodeBalancer configurations ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/manager/.changeset/pr-11070-changed-1728495168369.md b/packages/manager/.changeset/pr-11070-changed-1728495168369.md deleted file mode 100644 index eac7baa8f1b..00000000000 --- a/packages/manager/.changeset/pr-11070-changed-1728495168369.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Update Public IP Addresses tooltip and enable LISH console text ([#11070](https://github.com/linode/manager/pull/11070)) diff --git a/packages/manager/.changeset/pr-11074-fixed-1728476792585.md b/packages/manager/.changeset/pr-11074-fixed-1728476792585.md deleted file mode 100644 index 5fd48a4c63f..00000000000 --- a/packages/manager/.changeset/pr-11074-fixed-1728476792585.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -"Support Ticket" button in network tab not working properly ([#11074](https://github.com/linode/manager/pull/11074)) diff --git a/packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md b/packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md deleted file mode 100644 index b78f7edce7a..00000000000 --- a/packages/manager/.changeset/pr-11078-upcoming-features-1728503718215.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Interaction Tokens, Minimally Cleanup Theme Files ([#11078](https://github.com/linode/manager/pull/11078)) diff --git a/packages/manager/.changeset/pr-11083-fixed-1728564903243.md b/packages/manager/.changeset/pr-11083-fixed-1728564903243.md deleted file mode 100644 index f99dd4694f6..00000000000 --- a/packages/manager/.changeset/pr-11083-fixed-1728564903243.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Disable VPC Action buttons when do not have access or have read-only access. ([#11083](https://github.com/linode/manager/pull/11083)) diff --git a/packages/manager/.changeset/pr-11084-changed-1728577518914.md b/packages/manager/.changeset/pr-11084-changed-1728577518914.md deleted file mode 100644 index 9fc53b987bf..00000000000 --- a/packages/manager/.changeset/pr-11084-changed-1728577518914.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Increase Cloud Manager node.js memory allocation (development jobs) ([#11084](https://github.com/linode/manager/pull/11084)) diff --git a/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md b/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md deleted file mode 100644 index 8628e0411de..00000000000 --- a/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update NodeJS naming to Node.js for Marketplace ([#11086](https://github.com/linode/manager/pull/11086)) diff --git a/packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md b/packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md deleted file mode 100644 index 037e8d3821f..00000000000 --- a/packages/manager/.changeset/pr-11088-tech-stories-1729535071709.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Replace 'e2e', 'e2e_heimdall', and 'component' Docker Compose services with 'cypress_local', 'cypress_remote', and 'cypress_component' ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11088-tests-1729535093463.md b/packages/manager/.changeset/pr-11088-tests-1729535093463.md deleted file mode 100644 index 2dc798af453..00000000000 --- a/packages/manager/.changeset/pr-11088-tests-1729535093463.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Allow overriding feature flags via CY_TEST_FEATURE_FLAGS environment variable ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11088-tests-1729535165205.md b/packages/manager/.changeset/pr-11088-tests-1729535165205.md deleted file mode 100644 index a895a08ccd5..00000000000 --- a/packages/manager/.changeset/pr-11088-tests-1729535165205.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Allow pipeline Slack notifications to be customized ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11088-tests-1729535197632.md b/packages/manager/.changeset/pr-11088-tests-1729535197632.md deleted file mode 100644 index 24c079ce5dc..00000000000 --- a/packages/manager/.changeset/pr-11088-tests-1729535197632.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Show PR title in Slack CI notifications ([#11088](https://github.com/linode/manager/pull/11088)) diff --git a/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md b/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md deleted file mode 100644 index 313b634e115..00000000000 --- a/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Fix MSW 2.0 initial mock store and support ticket seeder bugs ([#11090](https://github.com/linode/manager/pull/11090)) diff --git a/packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md b/packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md deleted file mode 100644 index 142cb841d0b..00000000000 --- a/packages/manager/.changeset/pr-11091-upcoming-features-1728668394698.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -DBaaS GA summary tab enhancements ([#11091](https://github.com/linode/manager/pull/11091)) diff --git a/packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md b/packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md deleted file mode 100644 index 6add695d908..00000000000 --- a/packages/manager/.changeset/pr-11092-tech-stories-1728680192850.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Moved `src/foundations` directory from `manager` package to new `ui` package ([#11092](https://github.com/linode/manager/pull/11092)) diff --git a/packages/manager/.changeset/pr-11093-fixed-1728898767762.md b/packages/manager/.changeset/pr-11093-fixed-1728898767762.md deleted file mode 100644 index eb9f6d41006..00000000000 --- a/packages/manager/.changeset/pr-11093-fixed-1728898767762.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users ([#11093](https://github.com/linode/manager/pull/11093)) diff --git a/packages/manager/.changeset/pr-11094-fixed-1728923124356.md b/packages/manager/.changeset/pr-11094-fixed-1728923124356.md deleted file mode 100644 index e08a8954220..00000000000 --- a/packages/manager/.changeset/pr-11094-fixed-1728923124356.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Disable Create Firewall button with tooltip text on Landing Page for restricted users ([#11094](https://github.com/linode/manager/pull/11094)) diff --git a/packages/manager/.changeset/pr-11097-fixed-1728926799806.md b/packages/manager/.changeset/pr-11097-fixed-1728926799806.md deleted file mode 100644 index 93400dceb54..00000000000 --- a/packages/manager/.changeset/pr-11097-fixed-1728926799806.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Invoice heading from 'Invoice' to 'Tax Invoice' for UAE Customers ([#11097](https://github.com/linode/manager/pull/11097)) diff --git a/packages/manager/.changeset/pr-11098-changed-1728938497573.md b/packages/manager/.changeset/pr-11098-changed-1728938497573.md deleted file mode 100644 index f09ce53669f..00000000000 --- a/packages/manager/.changeset/pr-11098-changed-1728938497573.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Revise VPC Not Recommended Configuration Tooltip Text - ([#11098](https://github.com/linode/manager/pull/11098)) diff --git a/packages/manager/.changeset/pr-11100-changed-1729010084175.md b/packages/manager/.changeset/pr-11100-changed-1729010084175.md deleted file mode 100644 index e7ad10fb5e3..00000000000 --- a/packages/manager/.changeset/pr-11100-changed-1729010084175.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Add and use new cloud-init icon ([#11100](https://github.com/linode/manager/pull/11100)) diff --git a/packages/manager/.changeset/pr-11101-fixed-1729011064940.md b/packages/manager/.changeset/pr-11101-fixed-1729011064940.md deleted file mode 100644 index 2536d253581..00000000000 --- a/packages/manager/.changeset/pr-11101-fixed-1729011064940.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Link to expired Markdown cheatsheet domain ([#11101](https://github.com/linode/manager/pull/11101)) diff --git a/packages/manager/.changeset/pr-11103-fixed-1729099711508.md b/packages/manager/.changeset/pr-11103-fixed-1729099711508.md deleted file mode 100644 index fc06956fdba..00000000000 --- a/packages/manager/.changeset/pr-11103-fixed-1729099711508.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Region Multi Select spacing issues ([#11103](https://github.com/linode/manager/pull/11103)) diff --git a/packages/manager/.changeset/pr-11104-tests-1729020207783.md b/packages/manager/.changeset/pr-11104-tests-1729020207783.md deleted file mode 100644 index 4bb1c860164..00000000000 --- a/packages/manager/.changeset/pr-11104-tests-1729020207783.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix `AppSelect.test.tsx` test flake ([#11104](https://github.com/linode/manager/pull/11104)) diff --git a/packages/manager/.changeset/pr-11105-added-1729023730319.md b/packages/manager/.changeset/pr-11105-added-1729023730319.md deleted file mode 100644 index 38c14c9a4a8..00000000000 --- a/packages/manager/.changeset/pr-11105-added-1729023730319.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -DBaaS GA Monitor tab ([#11105](https://github.com/linode/manager/pull/11105)) diff --git a/packages/manager/.changeset/pr-11106-tests-1729028269027.md b/packages/manager/.changeset/pr-11106-tests-1729028269027.md deleted file mode 100644 index 24b0b25aebb..00000000000 --- a/packages/manager/.changeset/pr-11106-tests-1729028269027.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix failing SMTP support ticket test by using mock Linode data ([#11106](https://github.com/linode/manager/pull/11106)) diff --git a/packages/manager/.changeset/pr-11107-tests-1729033939339.md b/packages/manager/.changeset/pr-11107-tests-1729033939339.md deleted file mode 100644 index e3f36bbdef5..00000000000 --- a/packages/manager/.changeset/pr-11107-tests-1729033939339.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Reduce flakiness of Placement Group deletion Cypress tests ([#11107](https://github.com/linode/manager/pull/11107)) diff --git a/packages/manager/.changeset/pr-11108-changed-1729059614823.md b/packages/manager/.changeset/pr-11108-changed-1729059614823.md deleted file mode 100644 index 9339b47b880..00000000000 --- a/packages/manager/.changeset/pr-11108-changed-1729059614823.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Disable Longview 'Add Client' button with tooltip text on landing page for restricted users. ([#11108](https://github.com/linode/manager/pull/11108)) diff --git a/packages/manager/.changeset/pr-11110-added-1729516190960.md b/packages/manager/.changeset/pr-11110-added-1729516190960.md deleted file mode 100644 index e26b83f9de0..00000000000 --- a/packages/manager/.changeset/pr-11110-added-1729516190960.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Support for Application platform for Linode Kubernetes (APL)([#11110](https://github.com/linode/manager/pull/11110)) diff --git a/packages/manager/.changeset/pr-11112-added-1729086990373.md b/packages/manager/.changeset/pr-11112-added-1729086990373.md deleted file mode 100644 index 12a00fcc645..00000000000 --- a/packages/manager/.changeset/pr-11112-added-1729086990373.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Add the capability to search for a Linode by ID using the main search tool ([#11112](https://github.com/linode/manager/pull/11112)) diff --git a/packages/manager/.changeset/pr-11113-tests-1729099850186.md b/packages/manager/.changeset/pr-11113-tests-1729099850186.md deleted file mode 100644 index 93e47dfd08d..00000000000 --- a/packages/manager/.changeset/pr-11113-tests-1729099850186.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Mock APL feature flag to be disabled in LKE update tests ([#11113](https://github.com/linode/manager/pull/11113)) diff --git a/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md b/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md deleted file mode 100644 index e06f14e10c4..00000000000 --- a/packages/manager/.changeset/pr-11115-upcoming-features-1729115799261.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Image Service Gen 2 final GA tweaks ([#11115](https://github.com/linode/manager/pull/11115)) diff --git a/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md b/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md deleted file mode 100644 index 0d65405ab62..00000000000 --- a/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from `.env.example` ([#11117](https://github.com/linode/manager/pull/11117)) diff --git a/packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md b/packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md deleted file mode 100644 index dba2806a662..00000000000 --- a/packages/manager/.changeset/pr-11118-upcoming-features-1729486113842.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add title / label for all global filters in ACLP ([#11118](https://github.com/linode/manager/pull/11118)) diff --git a/packages/manager/.changeset/pr-11119-tests-1729169604255.md b/packages/manager/.changeset/pr-11119-tests-1729169604255.md deleted file mode 100644 index 475ddd0ae43..00000000000 --- a/packages/manager/.changeset/pr-11119-tests-1729169604255.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Reduce flakiness of Linode rebuild test ([#11119](https://github.com/linode/manager/pull/11119)) diff --git a/packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md b/packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md deleted file mode 100644 index 41910c4409c..00000000000 --- a/packages/manager/.changeset/pr-11120-upcoming-features-1729173753438.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add global colorTokens to theme and replace one-off hardcoded white colors ([#11120](https://github.com/linode/manager/pull/11120)) diff --git a/packages/manager/.changeset/pr-11122-added-1729183156879.md b/packages/manager/.changeset/pr-11122-added-1729183156879.md deleted file mode 100644 index 307c7091080..00000000000 --- a/packages/manager/.changeset/pr-11122-added-1729183156879.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Pendo documentation to our development guide ([#11122](https://github.com/linode/manager/pull/11122)) diff --git a/packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md b/packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md deleted file mode 100644 index 1435345c4bb..00000000000 --- a/packages/manager/.changeset/pr-11124-upcoming-features-1729198459249.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -DBaaS encourage setting access controls during create ([#11124](https://github.com/linode/manager/pull/11124)) diff --git a/packages/manager/.changeset/pr-11130-fixed-1729536728596.md b/packages/manager/.changeset/pr-11130-fixed-1729536728596.md deleted file mode 100644 index 7dbedc2c63b..00000000000 --- a/packages/manager/.changeset/pr-11130-fixed-1729536728596.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Flaky DatabaseBackups.test.tsx in coverage job ([#11130](https://github.com/linode/manager/pull/11130)) diff --git a/packages/manager/.changeset/pr-11131-tests-1729627189739.md b/packages/manager/.changeset/pr-11131-tests-1729627189739.md deleted file mode 100644 index 810a70858bb..00000000000 --- a/packages/manager/.changeset/pr-11131-tests-1729627189739.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Added cypress tests for updating ACL in LKE clusters ([#11131](https://github.com/linode/manager/pull/11131)) diff --git a/packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md b/packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md deleted file mode 100644 index b3706fac8a6..00000000000 --- a/packages/manager/.changeset/pr-11133-tech-stories-1729550913039.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Remove unused Marketplace feature flags ([#11133](https://github.com/linode/manager/pull/11133)) diff --git a/packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md b/packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md deleted file mode 100644 index 11d6303ee11..00000000000 --- a/packages/manager/.changeset/pr-11135-tech-stories-1729565746630.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Cleanup feature flag - Create Using Command Line (DX Tools Additions) ([#11135](https://github.com/linode/manager/pull/11135)) diff --git a/packages/manager/.changeset/pr-11136-added-1729581444483.md b/packages/manager/.changeset/pr-11136-added-1729581444483.md deleted file mode 100644 index 5855583b3dd..00000000000 --- a/packages/manager/.changeset/pr-11136-added-1729581444483.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Add `hideFill` & `fillOpacity` properties to `AreaChart` component ([#11136](https://github.com/linode/manager/pull/11136)) diff --git a/packages/manager/.changeset/pr-11139-changed-1729698979911.md b/packages/manager/.changeset/pr-11139-changed-1729698979911.md deleted file mode 100644 index 483a7788882..00000000000 --- a/packages/manager/.changeset/pr-11139-changed-1729698979911.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Hide distributed regions in Linode Create StackScripts ([#11139](https://github.com/linode/manager/pull/11139)) diff --git a/packages/manager/.changeset/pr-11140-fixed-1729625688555.md b/packages/manager/.changeset/pr-11140-fixed-1729625688555.md deleted file mode 100644 index 4cfa2208a40..00000000000 --- a/packages/manager/.changeset/pr-11140-fixed-1729625688555.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Autocomplete renderOption prop console warning ([#11140](https://github.com/linode/manager/pull/11140)) diff --git a/packages/manager/.changeset/pr-11148-fixed-1729687116230.md b/packages/manager/.changeset/pr-11148-fixed-1729687116230.md deleted file mode 100644 index 0df33342c35..00000000000 --- a/packages/manager/.changeset/pr-11148-fixed-1729687116230.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Duplicate punctuation on `image_upload` event message ([#11148](https://github.com/linode/manager/pull/11148)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 086fbe4dd9c..c2767c35c57 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,83 @@ 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/). +## [2024-10-28] - v1.131.0 + + +### Added: + +- IP ACL integration to LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) +- Disable Create VPC button with tooltip text on Landing Page for restricted users ([#11063](https://github.com/linode/manager/pull/11063)) +- Improve weblish retry behavior ([#11067](https://github.com/linode/manager/pull/11067)) +- DBaaS GA Monitor tab ([#11105](https://github.com/linode/manager/pull/11105)) +- Support for Application platform for Linode Kubernetes (APL)([#11110](https://github.com/linode/manager/pull/11110)) +- Add the capability to search for a Linode by ID using the main search tool ([#11112](https://github.com/linode/manager/pull/11112)) +- Pendo documentation to our development guide ([#11122](https://github.com/linode/manager/pull/11122)) +- Add `hideFill` & `fillOpacity` properties to `AreaChart` component ([#11136](https://github.com/linode/manager/pull/11136)) + +### Changed: + +- Disable Create VPC button with tooltip text on empty state Landing Page for restricted users ([#11052](https://github.com/linode/manager/pull/11052)) +- Update Public IP Addresses tooltip and enable LISH console text ([#11070](https://github.com/linode/manager/pull/11070)) +- Increase Cloud Manager node.js memory allocation (development jobs) ([#11084](https://github.com/linode/manager/pull/11084)) +- Invoice heading from 'Invoice' to 'Tax Invoice' for UAE Customers ([#11097](https://github.com/linode/manager/pull/11097)) +- Revise VPC Not Recommended Configuration Tooltip Text + ([#11098](https://github.com/linode/manager/pull/11098)) +- Add and use new cloud-init icon ([#11100](https://github.com/linode/manager/pull/11100)) +- Disable Longview 'Add Client' button with tooltip text on landing page for restricted users. ([#11108](https://github.com/linode/manager/pull/11108)) +- Hide distributed regions in Linode Create StackScripts ([#11139](https://github.com/linode/manager/pull/11139)) + +### Fixed: + +- Support Linodes with multiple private IPs in NodeBalancer configurations ([#11069](https://github.com/linode/manager/pull/11069)) +- "Support Ticket" button in network tab not working properly ([#11074](https://github.com/linode/manager/pull/11074)) +- Disable VPC Action buttons when do not have access or have read-only access. ([#11083](https://github.com/linode/manager/pull/11083)) +- Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users ([#11093](https://github.com/linode/manager/pull/11093)) +- Disable Create Firewall button with tooltip text on Landing Page for restricted users ([#11094](https://github.com/linode/manager/pull/11094)) +- Link to expired Markdown cheatsheet domain ([#11101](https://github.com/linode/manager/pull/11101)) +- Region Multi Select spacing issues ([#11103](https://github.com/linode/manager/pull/11103)) +- Flaky DatabaseBackups.test.tsx in coverage job ([#11130](https://github.com/linode/manager/pull/11130)) +- Autocomplete renderOption prop console warning ([#11140](https://github.com/linode/manager/pull/11140)) +- Duplicate punctuation on `image_upload` event message ([#11148](https://github.com/linode/manager/pull/11148)) + +### Tech Stories: + +- Optimize AccessSelect component: Use React Hook Form & React Query ([#10952](https://github.com/linode/manager/pull/10952)) +- Migrate /betas routes to Tanstack Router ([#11049](https://github.com/linode/manager/pull/11049)) +- Update NodeJS naming to Node.js for Marketplace ([#11086](https://github.com/linode/manager/pull/11086)) +- Replace 'e2e', 'e2e_heimdall', and 'component' Docker Compose services with 'cypress_local', 'cypress_remote', and 'cypress_component' ([#11088](https://github.com/linode/manager/pull/11088)) +- Fix MSW 2.0 initial mock store and support ticket seeder bugs ([#11090](https://github.com/linode/manager/pull/11090)) +- Moved `src/foundations` directory from `manager` package to new `ui` package ([#11092](https://github.com/linode/manager/pull/11092)) +- Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from `.env.example` ([#11117](https://github.com/linode/manager/pull/11117)) +- Remove unused Marketplace feature flags ([#11133](https://github.com/linode/manager/pull/11133)) +- Cleanup feature flag - Create Using Command Line (DX Tools Additions) ([#11135](https://github.com/linode/manager/pull/11135)) + +### Tests: + +- Add assertions for bucket details drawer tests ([#10971](https://github.com/linode/manager/pull/10971)) +- Add new test to confirm changes to the Object details drawer for OBJ Gen 2 ([#11045](https://github.com/linode/manager/pull/11045)) +- Cypress test for non-empty Linode landing page with restricted user ([#11060](https://github.com/linode/manager/pull/11060)) +- Allow overriding feature flags via CY_TEST_FEATURE_FLAGS environment variable ([#11088](https://github.com/linode/manager/pull/11088)) +- Allow pipeline Slack notifications to be customized ([#11088](https://github.com/linode/manager/pull/11088)) +- Show PR title in Slack CI notifications ([#11088](https://github.com/linode/manager/pull/11088)) +- Fix `AppSelect.test.tsx` test flake ([#11104](https://github.com/linode/manager/pull/11104)) +- Fix failing SMTP support ticket test by using mock Linode data ([#11106](https://github.com/linode/manager/pull/11106)) +- Reduce flakiness of Placement Group deletion Cypress tests ([#11107](https://github.com/linode/manager/pull/11107)) +- Mock APL feature flag to be disabled in LKE update tests ([#11113](https://github.com/linode/manager/pull/11113)) +- Reduce flakiness of Linode rebuild test ([#11119](https://github.com/linode/manager/pull/11119)) +- Added cypress tests for updating ACL in LKE clusters ([#11131](https://github.com/linode/manager/pull/11131)) + +### Upcoming Features: + +- Add tooltip for widget level filters and icons, address beta demo feedbacks and CSS changes for CloudPulse ([#11062](https://github.com/linode/manager/pull/11062)) +- Retain resource selection while expand or collapse the filter button ([#11068](https://github.com/linode/manager/pull/11068)) +- Add Interaction Tokens, Minimally Cleanup Theme Files ([#11078](https://github.com/linode/manager/pull/11078)) +- DBaaS GA summary tab enhancements ([#11091](https://github.com/linode/manager/pull/11091)) +- Image Service Gen 2 final GA tweaks ([#11115](https://github.com/linode/manager/pull/11115)) +- Add title / label for all global filters in ACLP ([#11118](https://github.com/linode/manager/pull/11118)) +- Add global colorTokens to theme and replace one-off hardcoded white colors ([#11120](https://github.com/linode/manager/pull/11120)) +- DBaaS encourage setting access controls during create ([#11124](https://github.com/linode/manager/pull/11124)) + ## [2024-10-14] - v1.130.0 ### Added: diff --git a/packages/manager/package.json b/packages/manager/package.json index 21b07e4e746..e82ce6ae8bf 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.130.0", + "version": "1.131.0", "private": true, "type": "module", "bugs": { diff --git a/packages/ui/.changeset/pr-11092-added-1728931799888.md b/packages/ui/.changeset/pr-11092-added-1728931799888.md deleted file mode 100644 index 97433b22de6..00000000000 --- a/packages/ui/.changeset/pr-11092-added-1728931799888.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/ui": Added ---- - -Themes, fonts, and breakpoints previously located in `manager` package ([#11092](https://github.com/linode/manager/pull/11092)) diff --git a/packages/ui/.changeset/pr-11116-changed-1729114321677.md b/packages/ui/.changeset/pr-11116-changed-1729114321677.md deleted file mode 100644 index adc6876ac5a..00000000000 --- a/packages/ui/.changeset/pr-11116-changed-1729114321677.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/ui": Changed ---- - -Moved `inputMaxWidth` into `Theme` ([#11116](https://github.com/linode/manager/pull/11116)) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 955ca93d232..d6e558ae763 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2024-10-28] - v0.2.0 + + +### Added: + +- Themes, fonts, and breakpoints previously located in `manager` package ([#11092](https://github.com/linode/manager/pull/11092)) + +### Changed: + +- Moved `inputMaxWidth` into `Theme` ([#11116](https://github.com/linode/manager/pull/11116)) + ## [2024-10-14] - v0.1.0 ### Added: diff --git a/packages/ui/package.json b/packages/ui/package.json index ca2a3502b90..7a4ba2b1d55 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.1.0", + "version": "0.2.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", diff --git a/packages/validation/.changeset/pr-10968-added-1729020457987.md b/packages/validation/.changeset/pr-10968-added-1729020457987.md deleted file mode 100644 index dc0af59b2ff..00000000000 --- a/packages/validation/.changeset/pr-10968-added-1729020457987.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Added ---- - -Validation schema for LKE ACL payload ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/validation/.changeset/pr-11069-added-1728444089255.md b/packages/validation/.changeset/pr-11069-added-1728444089255.md deleted file mode 100644 index 42512f52050..00000000000 --- a/packages/validation/.changeset/pr-11069-added-1728444089255.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Added ---- - -`PRIVATE_IPv4_REGEX` for determining if an IPv4 address is private ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/validation/.changeset/pr-11069-changed-1728444173048.md b/packages/validation/.changeset/pr-11069-changed-1728444173048.md deleted file mode 100644 index 286ee280e1f..00000000000 --- a/packages/validation/.changeset/pr-11069-changed-1728444173048.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Changed ---- - -Updated `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 4c06c30736f..6355db976eb 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2024-10-28] - v0.55.0 + + +### Added: + +- Validation schema for LKE ACL payload ([#10968](https://github.com/linode/manager/pull/10968)) +- `PRIVATE_IPv4_REGEX` for determining if an IPv4 address is private ([#11069](https://github.com/linode/manager/pull/11069)) + +### Changed: + +- Updated `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069)) + ## [2024-10-14] - v0.54.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index d0c11c63e26..3f3cdc461c5 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.54.0", + "version": "0.55.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From af89a31bc01a41df84237a410caa53191973d411 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 23 Oct 2024 17:27:42 -0400 Subject: [PATCH 63/64] update changelogs after revisions --- packages/manager/CHANGELOG.md | 50 ++++++++++++++++---------------- packages/ui/CHANGELOG.md | 2 +- packages/validation/CHANGELOG.md | 2 +- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index c2767c35c57..abaea6ba630 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -9,37 +9,36 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added: -- IP ACL integration to LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) -- Disable Create VPC button with tooltip text on Landing Page for restricted users ([#11063](https://github.com/linode/manager/pull/11063)) -- Improve weblish retry behavior ([#11067](https://github.com/linode/manager/pull/11067)) -- DBaaS GA Monitor tab ([#11105](https://github.com/linode/manager/pull/11105)) +- Access Control List (ACL) integration to LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) +- Monitor tab to DBaaS details page for GA ([#11105](https://github.com/linode/manager/pull/11105)) - Support for Application platform for Linode Kubernetes (APL)([#11110](https://github.com/linode/manager/pull/11110)) -- Add the capability to search for a Linode by ID using the main search tool ([#11112](https://github.com/linode/manager/pull/11112)) +- Capability to search for a Linode by ID using the main search tool ([#11112](https://github.com/linode/manager/pull/11112)) - Pendo documentation to our development guide ([#11122](https://github.com/linode/manager/pull/11122)) -- Add `hideFill` & `fillOpacity` properties to `AreaChart` component ([#11136](https://github.com/linode/manager/pull/11136)) +- `hideFill` & `fillOpacity` properties to `AreaChart` component ([#11136](https://github.com/linode/manager/pull/11136)) ### Changed: +- Improve weblish retry behavior ([#11067](https://github.com/linode/manager/pull/11067)) +- Disable Create VPC button with tooltip text on Landing Page for restricted users ([#11063](https://github.com/linode/manager/pull/11063)) - Disable Create VPC button with tooltip text on empty state Landing Page for restricted users ([#11052](https://github.com/linode/manager/pull/11052)) +- Disable VPC Action buttons when no access or read-only access. ([#11083](https://github.com/linode/manager/pull/11083)) +- Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users ([#11093](https://github.com/linode/manager/pull/11093)) +- Disable Create Firewall button with tooltip text on Landing Page for restricted users ([#11094](https://github.com/linode/manager/pull/11094)) +- Disable Longview 'Add Client' button with tooltip text on landing page for restricted users. ([#11108](https://github.com/linode/manager/pull/11108)) - Update Public IP Addresses tooltip and enable LISH console text ([#11070](https://github.com/linode/manager/pull/11070)) - Increase Cloud Manager node.js memory allocation (development jobs) ([#11084](https://github.com/linode/manager/pull/11084)) - Invoice heading from 'Invoice' to 'Tax Invoice' for UAE Customers ([#11097](https://github.com/linode/manager/pull/11097)) - Revise VPC Not Recommended Configuration Tooltip Text ([#11098](https://github.com/linode/manager/pull/11098)) -- Add and use new cloud-init icon ([#11100](https://github.com/linode/manager/pull/11100)) -- Disable Longview 'Add Client' button with tooltip text on landing page for restricted users. ([#11108](https://github.com/linode/manager/pull/11108)) +- cloud-init icon ([#11100](https://github.com/linode/manager/pull/11100)) - Hide distributed regions in Linode Create StackScripts ([#11139](https://github.com/linode/manager/pull/11139)) ### Fixed: - Support Linodes with multiple private IPs in NodeBalancer configurations ([#11069](https://github.com/linode/manager/pull/11069)) -- "Support Ticket" button in network tab not working properly ([#11074](https://github.com/linode/manager/pull/11074)) -- Disable VPC Action buttons when do not have access or have read-only access. ([#11083](https://github.com/linode/manager/pull/11083)) -- Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users ([#11093](https://github.com/linode/manager/pull/11093)) -- Disable Create Firewall button with tooltip text on Landing Page for restricted users ([#11094](https://github.com/linode/manager/pull/11094)) +- "Support Ticket" button in Add IP Address drawer not working properly ([#11074](https://github.com/linode/manager/pull/11074)) - Link to expired Markdown cheatsheet domain ([#11101](https://github.com/linode/manager/pull/11101)) -- Region Multi Select spacing issues ([#11103](https://github.com/linode/manager/pull/11103)) -- Flaky DatabaseBackups.test.tsx in coverage job ([#11130](https://github.com/linode/manager/pull/11130)) +- Region MultiSelect spacing issues ([#11103](https://github.com/linode/manager/pull/11103)) - Autocomplete renderOption prop console warning ([#11140](https://github.com/linode/manager/pull/11140)) - Duplicate punctuation on `image_upload` event message ([#11148](https://github.com/linode/manager/pull/11148)) @@ -50,17 +49,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Update NodeJS naming to Node.js for Marketplace ([#11086](https://github.com/linode/manager/pull/11086)) - Replace 'e2e', 'e2e_heimdall', and 'component' Docker Compose services with 'cypress_local', 'cypress_remote', and 'cypress_component' ([#11088](https://github.com/linode/manager/pull/11088)) - Fix MSW 2.0 initial mock store and support ticket seeder bugs ([#11090](https://github.com/linode/manager/pull/11090)) -- Moved `src/foundations` directory from `manager` package to new `ui` package ([#11092](https://github.com/linode/manager/pull/11092)) +- Move `src/foundations` directory from `manager` package to new `ui` package ([#11092](https://github.com/linode/manager/pull/11092)) - Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from `.env.example` ([#11117](https://github.com/linode/manager/pull/11117)) - Remove unused Marketplace feature flags ([#11133](https://github.com/linode/manager/pull/11133)) -- Cleanup feature flag - Create Using Command Line (DX Tools Additions) ([#11135](https://github.com/linode/manager/pull/11135)) +- Clean up Create Using Command Line (DX Tools Additions) feature flag ([#11135](https://github.com/linode/manager/pull/11135)) ### Tests: - Add assertions for bucket details drawer tests ([#10971](https://github.com/linode/manager/pull/10971)) - Add new test to confirm changes to the Object details drawer for OBJ Gen 2 ([#11045](https://github.com/linode/manager/pull/11045)) -- Cypress test for non-empty Linode landing page with restricted user ([#11060](https://github.com/linode/manager/pull/11060)) -- Allow overriding feature flags via CY_TEST_FEATURE_FLAGS environment variable ([#11088](https://github.com/linode/manager/pull/11088)) +- Add Cypress test for non-empty Linode landing page with restricted user ([#11060](https://github.com/linode/manager/pull/11060)) +- Allow overriding feature flags via `CY_TEST_FEATURE_FLAGS` environment variable ([#11088](https://github.com/linode/manager/pull/11088)) +- Fix flaky `DatabaseBackups.test.tsx` in coverage job ([#11130](https://github.com/linode/manager/pull/11130)) - Allow pipeline Slack notifications to be customized ([#11088](https://github.com/linode/manager/pull/11088)) - Show PR title in Slack CI notifications ([#11088](https://github.com/linode/manager/pull/11088)) - Fix `AppSelect.test.tsx` test flake ([#11104](https://github.com/linode/manager/pull/11104)) @@ -68,18 +68,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Reduce flakiness of Placement Group deletion Cypress tests ([#11107](https://github.com/linode/manager/pull/11107)) - Mock APL feature flag to be disabled in LKE update tests ([#11113](https://github.com/linode/manager/pull/11113)) - Reduce flakiness of Linode rebuild test ([#11119](https://github.com/linode/manager/pull/11119)) -- Added cypress tests for updating ACL in LKE clusters ([#11131](https://github.com/linode/manager/pull/11131)) +- Add cypress tests for updating ACL in LKE clusters ([#11131](https://github.com/linode/manager/pull/11131)) ### Upcoming Features: -- Add tooltip for widget level filters and icons, address beta demo feedbacks and CSS changes for CloudPulse ([#11062](https://github.com/linode/manager/pull/11062)) -- Retain resource selection while expand or collapse the filter button ([#11068](https://github.com/linode/manager/pull/11068)) -- Add Interaction Tokens, Minimally Cleanup Theme Files ([#11078](https://github.com/linode/manager/pull/11078)) -- DBaaS GA summary tab enhancements ([#11091](https://github.com/linode/manager/pull/11091)) -- Image Service Gen 2 final GA tweaks ([#11115](https://github.com/linode/manager/pull/11115)) +- Improve CloudPulse Dashboard ([#11062](https://github.com/linode/manager/pull/11062)) +- Retain CloudPulse resource selection while expand or collapse the filter button ([#11068](https://github.com/linode/manager/pull/11068)) +- Add Interaction tokens, minimally clean up theme files ([#11078](https://github.com/linode/manager/pull/11078)) +- Enhance DBaaS GA Summary tab ([#11091](https://github.com/linode/manager/pull/11091)) +- Add Image Service Gen 2 final GA tweaks ([#11115](https://github.com/linode/manager/pull/11115)) - Add title / label for all global filters in ACLP ([#11118](https://github.com/linode/manager/pull/11118)) - Add global colorTokens to theme and replace one-off hardcoded white colors ([#11120](https://github.com/linode/manager/pull/11120)) -- DBaaS encourage setting access controls during create ([#11124](https://github.com/linode/manager/pull/11124)) +- Encourage setting access controls during DBaaS creation ([#11124](https://github.com/linode/manager/pull/11124)) ## [2024-10-14] - v1.130.0 diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index d6e558ae763..2cd676b2c6c 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -7,7 +7,7 @@ ### Changed: -- Moved `inputMaxWidth` into `Theme` ([#11116](https://github.com/linode/manager/pull/11116)) +- Move `inputMaxWidth` into `Theme` ([#11116](https://github.com/linode/manager/pull/11116)) ## [2024-10-14] - v0.1.0 diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 6355db976eb..f5ca2e4dac9 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -8,7 +8,7 @@ ### Changed: -- Updated `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069)) +- Update `nodeBalancerConfigNodeSchema` to allow any private IPv4 rather than just \`192\.168\` IPs ([#11069](https://github.com/linode/manager/pull/11069)) ## [2024-10-14] - v0.54.0 From 568be11fd12d214c5ca9ce729930e7bf405039ba Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Mon, 28 Oct 2024 12:00:15 -0400 Subject: [PATCH 64/64] LKE ACL: update copy, remove placeholder text, change notice position, and update corresponding tests (#11173) --- .../e2e/core/kubernetes/lke-update.spec.ts | 24 ++++++----- .../CreateCluster/ControlPlaneACLPane.tsx | 5 +-- .../KubeControlPaneACLDrawer.tsx | 42 ++++++++++++------- 3 files changed, 40 insertions(+), 31 deletions(-) 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 203362557d5..85de03b7b2d 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1485,7 +1485,9 @@ describe('LKE ACL updates', () => { .clear() .type('10.0.0.0/24'); cy.findByText('IPv6 Addresses or CIDRs').should('be.visible'); - cy.findByPlaceholderText('::/0').should('be.visible'); + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0').should( + 'be.visible' + ); cy.findByText('Add IPv6 Address') .should('be.visible') .should('be.enabled'); @@ -1557,7 +1559,7 @@ describe('LKE ACL updates', () => { // update IPv6 addresses cy.findByDisplayValue('10.0.0.0/24').should('be.visible'); - cy.findByPlaceholderText('::/0') + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); @@ -1565,7 +1567,7 @@ describe('LKE ACL updates', () => { .should('be.visible') .should('be.enabled') .click(); - cy.get('[id="domain-transfer-ip-1"]') + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-1') .should('be.visible') .click() .type('f4a2:b849:4a24:d0d9:15f0:704b:f943:718f'); @@ -1699,7 +1701,7 @@ describe('LKE ACL updates', () => { ).should('be.visible'); cy.findByText('IPv4 Addresses or CIDRs').should('be.visible'); // update IPv4 - cy.findByPlaceholderText('0.0.0.0/0') + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('10.0.0.0/24'); @@ -1709,7 +1711,7 @@ describe('LKE ACL updates', () => { .click(); cy.findByText('IPv6 Addresses or CIDRs').should('be.visible'); // update IPv6 - cy.findByPlaceholderText('::/0') + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); @@ -1835,12 +1837,12 @@ describe('LKE ACL updates', () => { cy.findByText('IPv4 Addresses or CIDRs').should('be.visible'); cy.findByText('IPv6 Addresses or CIDRs').should('be.visible'); - cy.findByPlaceholderText('0.0.0.0/0') + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('10.0.0.0/24'); - cy.findByPlaceholderText('::/0') + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); @@ -1905,7 +1907,7 @@ describe('LKE ACL updates', () => { .should('be.visible') .within(() => { // Confirm ACL IP validation works as expected for IPv4 - cy.findByPlaceholderText('0.0.0.0/0') + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('invalid ip'); @@ -1913,7 +1915,7 @@ describe('LKE ACL updates', () => { cy.contains('Addresses').should('be.visible').click(); cy.contains('Must be a valid IPv4 address.').should('be.visible'); // enter valid IP - cy.findByPlaceholderText('0.0.0.0/0') + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .clear() @@ -1923,7 +1925,7 @@ describe('LKE ACL updates', () => { cy.contains('Must be a valid IPv4 address.').should('not.exist'); // Confirm ACL IP validation works as expected for IPv6 - cy.findByPlaceholderText('::/0') + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .type('invalid ip'); @@ -1931,7 +1933,7 @@ describe('LKE ACL updates', () => { cy.findByText('Addresses').should('be.visible').click(); cy.contains('Must be a valid IPv6 address.').should('be.visible'); // enter valid IP - cy.findByPlaceholderText('::/0') + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') .click() .clear() diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx index 8939e656d0c..07d13a020a6 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx @@ -48,8 +48,7 @@ export const ControlPlaneACLPane = (props: ControlPlaneACLProps) => { Enable an access control list (ACL) on your LKE cluster to restrict access to your cluster’s control plane. When enabled, only the IP - addresses and ranges specified by you can connect to the control - plane. + addresses and ranges you specify can connect to the control plane. { ips={ipV4Addr} isLinkStyled onChange={handleIPv4Change} - placeholder="0.0.0.0/0" title="IPv4 Addresses or CIDRs" /> @@ -92,7 +90,6 @@ export const ControlPlaneACLPane = (props: ControlPlaneACLProps) => { ips={ipV6Addr} isLinkStyled onChange={handleIPv6Change} - placeholder="::/0" title="IPv6 Addresses or CIDRs" /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx index cc4866b4397..49522dc4d39 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx @@ -171,9 +171,23 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { the ACL on your LKE cluster, update the list of allowed IP addresses, and adjust other settings. + {!clusterMigrated && ( + + ({ + fontFamily: theme.font.bold, + fontSize: '15px', + })} + > + Control Plane ACL has not yet been installed on this cluster. + During installation, it may take up to 15 minutes for the + access control list to be fully enforced. + + + )} Activation Status - + Enable or disable the Control Plane ACL. If the ACL is not enabled, any public IP address can be used to access your control plane. Once enabled, all network access is denied except for the @@ -202,7 +216,7 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { {clusterMigrated && ( <> Revision ID - + A unique identifying string for this particular revision to the ACL, used by clients to track events related to ACL update requests and enforcement. This defaults to a randomly @@ -226,7 +240,11 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { )} Addresses - + A list of allowed IPv4 and IPv6 addresses and CIDR ranges. This cluster's control plane will only be accessible from IP addresses within this list. @@ -246,7 +264,6 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { nonExtendedIPs={field.value ?? ['']} onBlur={field.onBlur} onNonExtendedIPChange={field.onChange} - placeholder="0.0.0.0/0" title="IPv4 Addresses or CIDRs" /> )} @@ -263,7 +280,6 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { nonExtendedIPs={field.value ?? ['']} onBlur={field.onBlur} onNonExtendedIPChange={field.onChange} - placeholder="::/0" title="IPv6 Addresses or CIDRs" /> )} @@ -282,15 +298,6 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { }} secondaryButtonProps={{ label: 'Cancel', onClick: closeDrawer }} /> - {!clusterMigrated && ( - - - Control Plane ACL has not yet been installed on this cluster. - During installation, it may take up to 15 minutes for the - access control list to be fully enforced. - - - )} @@ -298,6 +305,9 @@ export const KubeControlPlaneACLDrawer = (props: Props) => { ); }; -const StyledTypography = styled(Typography, { label: 'StyledTypography' })({ +const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})<{ topMargin?: boolean }>(({ theme, ...props }) => ({ + ...(props.topMargin ? { marginTop: theme.spacing(1) } : {}), width: '90%', -}); +}));