From 0822ae6092c3ad627710f5907313fad6b2f768a9 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:16:29 -0400 Subject: [PATCH 01/58] refactor: [M3-7444] - Query Key Factory for Status Page (#10672) Co-authored-by: Jaalah Ramos Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- .../pr-10672-tech-stories-1720709315853.md | 5 + .../src/queries/statusPage/requests.ts | 58 +++++++++++ .../src/queries/statusPage/statusPage.ts | 97 +++++++------------ 3 files changed, 96 insertions(+), 64 deletions(-) create mode 100644 packages/manager/.changeset/pr-10672-tech-stories-1720709315853.md create mode 100644 packages/manager/src/queries/statusPage/requests.ts diff --git a/packages/manager/.changeset/pr-10672-tech-stories-1720709315853.md b/packages/manager/.changeset/pr-10672-tech-stories-1720709315853.md new file mode 100644 index 00000000000..5fa82f7461b --- /dev/null +++ b/packages/manager/.changeset/pr-10672-tech-stories-1720709315853.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Status Page ([#10672](https://github.com/linode/manager/pull/10672)) diff --git a/packages/manager/src/queries/statusPage/requests.ts b/packages/manager/src/queries/statusPage/requests.ts new file mode 100644 index 00000000000..b10d0aa135f --- /dev/null +++ b/packages/manager/src/queries/statusPage/requests.ts @@ -0,0 +1,58 @@ +import { LINODE_STATUS_PAGE_URL } from 'src/constants'; + +import type { IncidentResponse, MaintenanceResponse } from './types'; +import type { APIError } from '@linode/api-v4'; + +/** + * Documentation for the Linode-specific status page API can be found at: + * https://status.linode.com/api/v2/ + */ + +/** + * Helper function to handle errors. + */ +const handleError = (error: APIError, defaultMessage: string) => { + return Promise.reject([{ reason: defaultMessage }]); +}; + +/** + * Return a list of incidents with a status of "unresolved." + */ +export const getIncidents = async (): Promise => { + try { + const response = await fetch( + `${LINODE_STATUS_PAGE_URL}/incidents/unresolved.json` + ); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + return response.json() as Promise; + } catch (error) { + return handleError(error as APIError, 'Error retrieving incidents.'); + } +}; + +/** + * There are several endpoints for maintenance events; this method will return + * a list of the most recent 50 maintenance, inclusive of all statuses. + */ +export const getAllMaintenance = async (): Promise => { + try { + const response = await fetch( + `${LINODE_STATUS_PAGE_URL}/scheduled-maintenances.json` + ); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + return response.json() as Promise; + } catch (error) { + return handleError( + error as APIError, + 'Error retrieving maintenance events.' + ); + } +}; diff --git a/packages/manager/src/queries/statusPage/statusPage.ts b/packages/manager/src/queries/statusPage/statusPage.ts index 9b71a75fa37..aa20fdfda6d 100644 --- a/packages/manager/src/queries/statusPage/statusPage.ts +++ b/packages/manager/src/queries/statusPage/statusPage.ts @@ -1,66 +1,35 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import Axios from 'axios'; -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; - -import { LINODE_STATUS_PAGE_URL } from 'src/constants'; -import { reportException } from 'src/exceptionReporting'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useQuery } from '@tanstack/react-query'; import { queryPresets } from '../base'; -import { IncidentResponse, MaintenanceResponse } from './types'; - -/** - * Documentation for the Linode-specific statuspage API can be found at: - * https://status.linode.com/api/v2/ - */ - -/** - * Return a list of incidents with a status of "unresolved." - */ -const getIncidents = () => { - return Axios.get( - `${LINODE_STATUS_PAGE_URL}/incidents/unresolved.json` - ) - .then((response) => response.data) - .catch((error) => { - // Don't show any errors sent from the statuspage API to users, but report them to Sentry - reportException(error); - return Promise.reject([{ reason: 'Error retrieving incidents.' }]); - }); -}; - -/** - * There are several endpoints for maintenance events; this method will return - * a list of the most recent 50 maintenance, inclusive of all statuses. - */ -const getAllMaintenance = () => { - return Axios.get( - `${LINODE_STATUS_PAGE_URL}/scheduled-maintenances.json` - ) - .then((response) => response.data) - .catch((error) => { - // Don't show any errors sent from the statuspage API to users, but report them to Sentry - reportException(error); - return Promise.reject([ - { reason: 'Error retrieving maintenance events.' }, - ]); - }); -}; - -const incidentKey = 'status-page-incidents'; -const maintenanceKey = 'status-page-maintenance'; - -export const useIncidentQuery = () => { - return useQuery( - [incidentKey], - getIncidents, - queryPresets.shortLived - ); -}; - -export const useMaintenanceQuery = (options?: UseQueryOptions) => { - return useQuery( - [maintenanceKey], - getAllMaintenance, - { ...queryPresets.shortLived, ...(options ?? {}) } - ); -}; +import { getAllMaintenance, getIncidents } from './requests'; + +import type { IncidentResponse, MaintenanceResponse } from './types'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +export const statusPageQueries = createQueryKeys('statusPage', { + incidents: { + queryFn: getIncidents, + queryKey: null, + }, + maintenance: { + queryFn: getAllMaintenance, + queryKey: null, + }, +}); + +export const useIncidentQuery = () => + useQuery({ + ...statusPageQueries.incidents, + ...queryPresets.shortLived, + }); + +export const useMaintenanceQuery = ( + options?: UseQueryOptions +) => + useQuery({ + ...statusPageQueries.maintenance, + ...queryPresets.shortLived, + ...(options ?? {}), + }); From e60faef92e3670909cf793b758f98e32c92d10a1 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:16:57 -0400 Subject: [PATCH 02/58] upcoming: [M3-8296] - APIv4, Validation & Endpoints for OBJGen2 (#10677) Co-authored-by: Jaalah Ramos --- ...r-10677-upcoming-features-1721062200460.md | 5 ++ packages/api-v4/src/object-storage/objects.ts | 19 +++++- packages/api-v4/src/object-storage/types.ts | 34 ++++++++--- ...r-10677-upcoming-features-1721062431452.md | 5 ++ .../core/objectStorage/access-key.e2e.spec.ts | 4 +- .../manager/src/factories/objectStorage.ts | 17 +++++- .../AccessKeyTable/AccessKeyTable.tsx | 4 +- .../AccessKeyTable/AccessKeyTableBody.tsx | 4 +- .../AccessKeyTable/AccessKeyTableRow.tsx | 4 +- .../AccessKeyTable/HostNameTableCell.tsx | 4 +- .../AccessKeyLanding/HostNamesDrawer.tsx | 4 +- .../BucketDetail/AccessSelect.tsx | 36 +++++++---- .../BucketDetail/BucketAccess.tsx | 3 +- .../BucketDetail/ObjectDetailsDrawer.tsx | 3 +- .../BucketLanding/BucketDetailsDrawer.tsx | 5 +- .../BucketLanding/CreateBucketDrawer.tsx | 1 + .../BucketLanding/OMC_CreateBucketDrawer.tsx | 1 + ...r-10677-upcoming-features-1721062312524.md | 5 ++ packages/validation/src/buckets.schema.ts | 60 +++++++++++++------ 19 files changed, 161 insertions(+), 57 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md create mode 100644 packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md create mode 100644 packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md diff --git a/packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md b/packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md new file mode 100644 index 00000000000..50609509e80 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Added new /v4/object-storage/endpoints endpoint ([#10677](https://github.com/linode/manager/pull/10677)) diff --git a/packages/api-v4/src/object-storage/objects.ts b/packages/api-v4/src/object-storage/objects.ts index 5d27fbb563f..fd567d45b9b 100644 --- a/packages/api-v4/src/object-storage/objects.ts +++ b/packages/api-v4/src/object-storage/objects.ts @@ -1,12 +1,21 @@ import { API_ROOT } from '../constants'; -import Request, { setData, setMethod, setURL } from '../request'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; import { ACLType, + ObjectStorageEndpointsResponse, ObjectStorageObjectACL, ObjectStorageObjectURL, ObjectStorageObjectURLOptions, } from './types'; +import type { ResourcePage, RequestOptions } from '../types'; + /** * Gets a URL to upload/download/delete Objects from a Bucket. */ @@ -70,3 +79,11 @@ export const updateObjectACL = ( ), setData({ acl, name }) ); + +export const getObjectStorageEndpoints = ({ filter, params }: RequestOptions) => + Request>( + setMethod('GET'), + setURL(`${API_ROOT}/object-storage/endpoints`), + setParams(params), + setXFilter(filter) + ); diff --git a/packages/api-v4/src/object-storage/types.ts b/packages/api-v4/src/object-storage/types.ts index a537230de08..f8877647df1 100644 --- a/packages/api-v4/src/object-storage/types.ts +++ b/packages/api-v4/src/object-storage/types.ts @@ -1,6 +1,9 @@ -export interface RegionS3EndpointAndID { +export type ObjEndpointTypes = 'E0' | 'E1' | 'E2' | 'E3'; + +export interface ObjAccessKeyRegionsResponse { id: string; s3_endpoint: string; + endpoint_type?: ObjEndpointTypes; } export interface ObjectStorageKey { @@ -9,7 +12,7 @@ export interface ObjectStorageKey { id: number; label: string; limited: boolean; - regions: RegionS3EndpointAndID[]; + regions: ObjAccessKeyRegionsResponse[]; secret_key: string; } @@ -45,6 +48,7 @@ export interface ObjectStorageBucketRequestPayload { cors_enabled?: boolean; label: string; region?: string; + endpoint_type?: ObjEndpointTypes; /* @TODO OBJ Multicluster: 'region' will become required, and the 'cluster' field will be deprecated once the feature is fully rolled out in production as part of the process of cleaning up the 'objMultiCluster' @@ -73,6 +77,8 @@ export interface ObjectStorageBucket { hostname: string; objects: number; size: number; // Size of bucket in bytes + s3_endpoint?: string; + endpoint_type?: ObjEndpointTypes; } export interface ObjectStorageObject { @@ -88,6 +94,12 @@ export interface ObjectStorageObjectURL { url: string; } +export interface ObjectStorageEndpointsResponse { + region: string; + endpoint_type: ObjEndpointTypes; + s3_endpoint: string | null; +} + export type ACLType = | 'private' | 'public-read' @@ -95,9 +107,10 @@ export type ACLType = | 'public-read-write' | 'custom'; +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageObjectACL { - acl: ACLType; - acl_xml: string; + acl: ACLType | null; + acl_xml: string | null; } export interface ObjectStorageObjectURLOptions { @@ -142,8 +155,9 @@ export interface ObjectStorageBucketSSLRequest { private_key: string; } +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageBucketSSLResponse { - ssl: boolean; + ssl: boolean | null; } export interface ObjectStorageBucketAccessRequest { @@ -151,9 +165,15 @@ export interface ObjectStorageBucketAccessRequest { cors_enabled?: boolean; } +export interface ObjBucketAccessPayload { + acl: ACLType; + cors_enabled?: boolean; +} + +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageBucketAccessResponse { acl: ACLType; acl_xml: string; - cors_enabled: boolean; - cors_xml: string; + cors_enabled: boolean | null; + cors_xml: string | null; } diff --git a/packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md b/packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md new file mode 100644 index 00000000000..859c857cfac --- /dev/null +++ b/packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Object Storage Gen2 cors_enabled and type updates ([#10677](https://github.com/linode/manager/pull/10677)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 3806fb0ebb0..7c9b5c8a799 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -2,7 +2,7 @@ * @file End-to-end tests for Object Storage Access Key operations. */ -import { objectStorageBucketFactory } from 'src/factories/objectStorage'; +import { createObjectStorageBucketFactory } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; import { @@ -120,7 +120,7 @@ describe('object storage access key end-to-end tests', () => { it('can create an access key with limited access - e2e', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-east-1'; - const bucketRequest = objectStorageBucketFactory.build({ + const bucketRequest = createObjectStorageBucketFactory.build({ label: bucketLabel, cluster: bucketCluster, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. diff --git a/packages/manager/src/factories/objectStorage.ts b/packages/manager/src/factories/objectStorage.ts index 6d5d7411f34..00ed05f4706 100644 --- a/packages/manager/src/factories/objectStorage.ts +++ b/packages/manager/src/factories/objectStorage.ts @@ -1,10 +1,12 @@ -import { +import Factory from 'src/factories/factoryProxy'; + +import type { ObjectStorageBucket, + ObjectStorageBucketRequestPayload, ObjectStorageCluster, ObjectStorageKey, ObjectStorageObject, } from '@linode/api-v4/lib/object-storage/types'; -import Factory from 'src/factories/factoryProxy'; export const objectStorageBucketFactory = Factory.Sync.makeFactory( { @@ -20,6 +22,17 @@ export const objectStorageBucketFactory = Factory.Sync.makeFactory( + { + acl: 'private', + cluster: 'us-east-1', + cors_enabled: true, + endpoint_type: 'E1', + label: Factory.each((i) => `obj-bucket-${i}`), + region: 'us-east', + } +); + export const objectStorageClusterFactory = Factory.Sync.makeFactory( { domain: Factory.each((id) => `cluster-${id}.linodeobjects.com`), diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx index 3a609d63c13..66a83e726d8 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -1,6 +1,6 @@ import { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; import { APIError } from '@linode/api-v4/lib/types'; import { styled } from '@mui/material/styles'; @@ -41,7 +41,7 @@ export const AccessKeyTable = (props: AccessKeyTableProps) => { const [showHostNamesDrawer, setShowHostNamesDrawers] = useState( false ); - const [hostNames, setHostNames] = useState([]); + const [hostNames, setHostNames] = useState([]); const flags = useFlags(); const { account } = useAccountManagement(); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx index 241f0b43d77..0d0e6d5a48a 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx @@ -1,6 +1,6 @@ import { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; import { APIError } from '@linode/api-v4/lib/types'; import React from 'react'; @@ -19,7 +19,7 @@ type Props = { isRestrictedUser: boolean; openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; - setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; + setHostNames: (hostNames: ObjAccessKeyRegionsResponse[]) => void; setShowHostNamesDrawers: (show: boolean) => void; }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx index 828570409dd..4b1c3e2b460 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx @@ -1,6 +1,6 @@ import { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -20,7 +20,7 @@ import { HostNameTableCell } from './HostNameTableCell'; type Props = { openDrawer: OpenAccessDrawer; openRevokeDialog: (storageKeyData: ObjectStorageKey) => void; - setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; + setHostNames: (hostNames: ObjAccessKeyRegionsResponse[]) => void; setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index 3bfbd4faf08..ae01805cfdc 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -9,11 +9,11 @@ import { getRegionsByRegionId } from 'src/utilities/regions'; import type { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; type Props = { - setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; + setHostNames: (hostNames: ObjAccessKeyRegionsResponse[]) => void; setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx index 1494590163d..28c5ae88dd7 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -1,4 +1,4 @@ -import { RegionS3EndpointAndID } from '@linode/api-v4'; +import { ObjAccessKeyRegionsResponse } from '@linode/api-v4'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -12,7 +12,7 @@ import { CopyAllHostnames } from './CopyAllHostnames'; interface Props { onClose: () => void; open: boolean; - regions: RegionS3EndpointAndID[]; + regions: ObjAccessKeyRegionsResponse[]; } export const HostNamesDrawer = (props: Props) => { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx index 01b34c2d4e3..0cd00cdfc1a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx @@ -1,5 +1,4 @@ -import { ACLType } from '@linode/api-v4/lib/object-storage'; -import { Theme, styled } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -18,18 +17,26 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { bucketACLOptions, objectACLOptions } from '../utilities'; import { copy } from './AccessSelect.data'; -interface AccessPayload { - acl: ACLType; - cors_enabled?: boolean; -} +import type { + ACLType, + ObjBucketAccessPayload, + ObjectStorageObjectACL, +} from '@linode/api-v4/lib/object-storage'; +import type { Theme } from '@mui/material/styles'; export interface Props { - getAccess: () => Promise; + getAccess: () => Promise; name: string; updateAccess: (acl: ACLType, cors_enabled?: boolean) => Promise<{}>; variant: 'bucket' | 'object'; } +function isObjBucketAccessPayload( + payload: ObjBucketAccessPayload | ObjectStorageObjectACL +): payload is ObjBucketAccessPayload { + return 'cors_enabled' in payload; +} + export const AccessSelect = React.memo((props: Props) => { const { getAccess, name, updateAccess, variant } = props; // Access data for this Object (from the API). @@ -40,7 +47,7 @@ export const AccessSelect = React.memo((props: Props) => { // 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); + 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(''); @@ -55,17 +62,22 @@ export const AccessSelect = React.memo((props: Props) => { setUpdateAccessSuccess(false); setAccessLoading(true); getAccess() - .then(({ acl, cors_enabled }) => { + .then((payload) => { setAccessLoading(false); + const { acl } = payload; // 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 (typeof cors_enabled !== 'undefined') { - setCORSData(cors_enabled); - setSelectedCORSOption(cors_enabled); + + if (isObjBucketAccessPayload(payload)) { + const { cors_enabled } = payload; + if (typeof cors_enabled === 'boolean') { + setCORSData(cors_enabled); + setSelectedCORSOption(cors_enabled); + } } }) .catch((err) => { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx index af95b960e6c..dade0370eb2 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx @@ -1,5 +1,4 @@ import { - ACLType, getBucketAccess, updateBucketAccess, } from '@linode/api-v4/lib/object-storage'; @@ -11,6 +10,8 @@ import { Typography } from 'src/components/Typography'; import { AccessSelect } from './AccessSelect'; +import type { ACLType } from '@linode/api-v4/lib/object-storage'; + export const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', })(({ theme }) => ({ diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx index cd9f2f31979..31fe5a798e0 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx @@ -1,5 +1,4 @@ import { - ACLType, getObjectACL, updateObjectACL, } from '@linode/api-v4/lib/object-storage'; @@ -18,6 +17,8 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from './AccessSelect'; +import type { ACLType } from '@linode/api-v4/lib/object-storage'; + export interface ObjectDetailsDrawerProps { bucketName: string; clusterId: string; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 70fb01f55b8..efaeee2688a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -1,6 +1,4 @@ -import { Region } from '@linode/api-v4'; import { - ACLType, getBucketAccess, updateBucketAccess, } from '@linode/api-v4/lib/object-storage'; @@ -24,6 +22,9 @@ import { truncateMiddle } from 'src/utilities/truncate'; import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from '../BucketDetail/AccessSelect'; + +import type { Region } from '@linode/api-v4'; +import type { ACLType } from '@linode/api-v4/lib/object-storage'; export interface BucketDetailsDrawerProps { bucketLabel?: string; bucketRegion?: Region; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 71fb9116ef1..7610a54c6fe 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -112,6 +112,7 @@ export const CreateBucketDrawer = (props: Props) => { const formik = useFormik({ initialValues: { cluster: '', + cors_enabled: true, // For Gen1, CORS is always enabled label: '', }, async onSubmit(values) { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 4340b64e5f6..40fa2049e17 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -95,6 +95,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const formik = useFormik({ initialValues: { + cors_enabled: true, // Gen1 = true, Gen2 = false @TODO: OBJGen2 - Future PR will implement this... label: '', region: '', }, diff --git a/packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md b/packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md new file mode 100644 index 00000000000..31abaf0ee0c --- /dev/null +++ b/packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Updated create bucket schema validation for endpoint_type and cors_enabled ([#10677](https://github.com/linode/manager/pull/10677)) diff --git a/packages/validation/src/buckets.schema.ts b/packages/validation/src/buckets.schema.ts index 90f248ceb4b..b8128239614 100644 --- a/packages/validation/src/buckets.schema.ts +++ b/packages/validation/src/buckets.schema.ts @@ -1,24 +1,46 @@ import { boolean, object, string } from 'yup'; -export const CreateBucketSchema = object().shape( - { - label: string() - .required('Label is required.') - .matches(/^\S*$/, 'Label must not contain spaces.') - .ensure() - .min(3, 'Label must be between 3 and 63 characters.') - .max(63, 'Label must be between 3 and 63 characters.'), - cluster: string().when('region', { - is: (region: string) => !region || region.length === 0, - then: string().required('Cluster is required.'), - }), - region: string().when('cluster', { - is: (cluster: string) => !cluster || cluster.length === 0, - then: string().required('Region is required.'), - }), - }, - [['cluster', 'region']] -); +const ENDPOINT_TYPES = ['E0', 'E1', 'E2', 'E3'] as const; + +export const CreateBucketSchema = object() + .shape( + { + label: string() + .required('Label is required.') + .matches(/^\S*$/, 'Label must not contain spaces.') + .min(3, 'Label must be between 3 and 63 characters.') + .max(63, 'Label must be between 3 and 63 characters.'), + cluster: string().when('region', { + is: (region: string) => !region || region.length === 0, + then: string().required('Cluster is required.'), + }), + region: string().when('cluster', { + is: (cluster: string) => !cluster || cluster.length === 0, + then: string().required('Region is required.'), + }), + endpoint_type: string() + .oneOf([...ENDPOINT_TYPES]) + .notRequired(), + cors_enabled: boolean().notRequired(), + }, + [['cluster', 'region']] + ) + .test('cors-enabled-check', 'Invalid CORS configuration.', function (value) { + const { endpoint_type, cors_enabled } = value; + if ((endpoint_type === 'E0' || endpoint_type === 'E1') && !cors_enabled) { + return this.createError({ + path: 'cors_enabled', + message: 'CORS must be enabled for endpoint type E0 or E1.', + }); + } + if ((endpoint_type === 'E2' || endpoint_type === 'E3') && cors_enabled) { + return this.createError({ + path: 'cors_enabled', + message: 'CORS must be disabled for endpoint type E2 or E3.', + }); + } + return true; + }); export const UploadCertificateSchema = object({ certificate: string().required('Certificate is required.'), From 23d50b6fffe3c2033f771c9960c3d5b4f28535cb Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:39:25 -0400 Subject: [PATCH 03/58] refactor: [M3-8208] - Query Key Factory for Linodes (#10659) * start refactor * save progress * finish refactor * improve linode stats flickering * remove one-off VPC related invalidations * clean up * fix up unit test * fix ip sharing bug * Added changeset: Query Key Factory for Linodes --------- Co-authored-by: Banks Nussman --- .../pr-10659-tech-stories-1720536986552.md | 5 + .../manager/src/features/Backups/utils.ts | 25 +- .../Devices/RemoveDeviceDialog.tsx | 5 +- .../Rules/FirewallRulesLanding.tsx | 10 +- .../FirewallLanding/FirewallDialog.tsx | 4 +- .../Linodes/LinodeEntityDetailHeader.tsx | 23 +- .../SelectLinodePanel/SelectLinodeRow.tsx | 21 +- .../LinodeNetworking/AddIPDrawer.tsx | 7 +- .../LinodeNetworking/EditRangeRDNSDrawer.tsx | 9 +- .../LinodeNetworking/IPSharing.tsx | 2 +- .../LinodeNetworking/IPTransfer.tsx | 11 +- .../LinodeNetworking/LinodeIPAddressRow.tsx | 6 +- .../NetworkTransfer.test.tsx | 125 +++-- .../NetworkTransfer.tsx | 11 +- .../TransferHistory.tsx | 2 +- .../LinodeNetworking/ViewRDNSDrawer.tsx | 5 +- .../LinodeSummary/LinodeSummary.tsx | 36 +- .../VPCs/VPCDetail/SubnetLinodeRow.tsx | 24 +- .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 11 +- .../manager/src/hooks/useUnassignLinode.ts | 25 +- packages/manager/src/queries/firewalls.ts | 12 +- .../manager/src/queries/linodes/actions.ts | 20 +- .../manager/src/queries/linodes/backups.ts | 70 +-- .../manager/src/queries/linodes/configs.ts | 258 ++------- packages/manager/src/queries/linodes/disks.ts | 76 ++- .../manager/src/queries/linodes/events.ts | 103 ++-- .../manager/src/queries/linodes/firewalls.ts | 8 +- .../manager/src/queries/linodes/linodes.ts | 512 +++++++++++------- .../manager/src/queries/linodes/networking.ts | 292 +++++----- .../manager/src/queries/linodes/requests.ts | 28 +- packages/manager/src/queries/linodes/stats.ts | 88 ++- .../src/queries/networking/networking.ts | 121 +++++ .../src/queries/networking/requests.ts | 24 + .../manager/src/queries/placementGroups.ts | 38 +- 34 files changed, 974 insertions(+), 1043 deletions(-) create mode 100644 packages/manager/.changeset/pr-10659-tech-stories-1720536986552.md create mode 100644 packages/manager/src/queries/networking/networking.ts create mode 100644 packages/manager/src/queries/networking/requests.ts diff --git a/packages/manager/.changeset/pr-10659-tech-stories-1720536986552.md b/packages/manager/.changeset/pr-10659-tech-stories-1720536986552.md new file mode 100644 index 00000000000..cc4bfd6806b --- /dev/null +++ b/packages/manager/.changeset/pr-10659-tech-stories-1720536986552.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Linodes ([#10659](https://github.com/linode/manager/pull/10659)) diff --git a/packages/manager/src/features/Backups/utils.ts b/packages/manager/src/features/Backups/utils.ts index c00d8f60895..156a604d1b6 100644 --- a/packages/manager/src/features/Backups/utils.ts +++ b/packages/manager/src/features/Backups/utils.ts @@ -1,7 +1,7 @@ import { enableBackups } from '@linode/api-v4'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { queryKey } from 'src/queries/linodes/linodes'; +import { linodeQueries } from 'src/queries/linodes/linodes'; import { pluralize } from 'src/utilities/pluralize'; import type { APIError, Linode } from '@linode/api-v4'; @@ -30,22 +30,15 @@ export const useEnableBackupsOnLinodesMutation = () => { }, { onSuccess(_, variables) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries(linodeQueries.linodes); for (const linode of variables) { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linode.id, - 'details', - ]); - queryClient.invalidateQueries([ - queryKey, - 'linode', - linode.id, - 'backups', - ]); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linode.id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linode.id)._ctx.backups.queryKey, + }); } }, } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 98b43f25baf..9d199ce7d9b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -6,7 +6,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { linodeQueries } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; import type { FirewallDevice } from '@linode/api-v4'; @@ -57,7 +57,8 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { // Since the linode was removed as a device, invalidate the linode-specific firewall query if (deviceType === 'linode') { queryClient.invalidateQueries({ - queryKey: [linodesQueryKey, deviceType, device.entity.id, 'firewalls'], + queryKey: linodeQueries.linode(device.entity.id)._ctx.firewalls + .queryKey, }); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index d888aa9759d..d998e9809b2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -12,7 +12,7 @@ import { useAllFirewallDevicesQuery, useUpdateFirewallRulesMutation, } from 'src/queries/firewalls'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { linodeQueries } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -206,12 +206,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => { for (const device of devices) { if (device.entity.type === 'linode') { queryClient.invalidateQueries({ - queryKey: [ - linodesQueryKey, - device.entity.type, - device.entity.id, - 'firewalls', - ], + queryKey: linodeQueries.linode(device.entity.id)._ctx.firewalls + .queryKey, }); } if (device.entity.type === 'nodebalancer') { diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index fc36b3089ec..42fb686ac9c 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { linodeQueries } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { capitalize } from 'src/utilities/capitalize'; @@ -69,7 +69,7 @@ export const FirewallDialog = React.memo((props: Props) => { if (entity.type === 'linode') { queryClient.invalidateQueries({ - queryKey: [linodesQueryKey, 'linode', entity.id, 'firewalls'], + queryKey: linodeQueries.linode(entity.id)._ctx.firewalls.queryKey, }); } } diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index 09ceb219071..0aadae894a2 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -1,8 +1,5 @@ -import { Config } from '@linode/api-v4/lib'; -import { LinodeBackups } from '@linode/api-v4/lib/linodes'; import { Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -12,20 +9,20 @@ import { Hidden } from 'src/components/Hidden'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TooltipIcon } from 'src/components/TooltipIcon'; -import { TypographyProps } from 'src/components/Typography'; import { LinodeActionMenu } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu'; import { ProgressDisplay } from 'src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow'; import { lishLaunch } from 'src/features/Lish/lishUtils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { sendLinodeActionMenuItemEvent } from 'src/utilities/analytics/customEventAnalytics'; import { VPC_REBOOT_MESSAGE } from '../VPCs/constants'; import { StyledLink } from './LinodeEntityDetail.styles'; -import { LinodeHandlers } from './LinodesLanding/LinodesLanding'; import { getLinodeIconStatus } from './LinodesLanding/utils'; +import type { LinodeHandlers } from './LinodesLanding/LinodesLanding'; +import type { Config, LinodeBackups } from '@linode/api-v4'; import type { Linode, LinodeType } from '@linode/api-v4/lib/linodes/types'; +import type { TypographyProps } from 'src/components/Typography'; interface LinodeEntityDetailProps { id: number; @@ -67,7 +64,6 @@ export const LinodeEntityDetailHeader = ( props: LinodeEntityDetailHeaderProps ) => { const theme = useTheme(); - const queryClient = useQueryClient(); const { backups, @@ -108,19 +104,6 @@ export const LinodeEntityDetailHeader = ( ) ); - // If the Linode is running, we want to check the active status of its interfaces to determine whether it needs to - // be rebooted or not. So, we need to invalidate the linode configs query to get the most up to date information. - React.useEffect(() => { - if (isRunning) { - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - linodeId, - 'configs', - ]); - } - }, [linodeId, isRunning, queryClient]); - const formattedStatus = isRebootNeeded ? 'REBOOT NEEDED' : linodeStatus.replace('_', ' ').toUpperCase(); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx index f25a337d108..e31ef8d0e1b 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.tsx @@ -1,18 +1,15 @@ import { useTheme } from '@mui/material'; -import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { OrderByProps } from 'src/components/OrderBy'; import { Radio } from 'src/components/Radio/Radio'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; -import { TableCell, TableCellProps } from 'src/components/TableCell'; +import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { getLinodeIconStatus } from 'src/features/Linodes/LinodesLanding/utils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useImageQuery } from 'src/queries/images'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { useTypeQuery } from 'src/queries/types'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; @@ -20,6 +17,8 @@ import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { RegionIndicator } from '../../LinodesLanding/RegionIndicator'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; +import type { OrderByProps } from 'src/components/OrderBy'; +import type { TableCellProps } from 'src/components/TableCell'; interface Props { disabled?: boolean; @@ -31,7 +30,6 @@ interface Props { } export const SelectLinodeRow = (props: Props) => { - const queryClient = useQueryClient(); const { disabled, handlePowerOff, @@ -61,19 +59,6 @@ export const SelectLinodeRow = (props: Props) => { const isDisabled = disabled || isLinodesGrantReadOnly; - // If the Linode's status is running, we want to check if its interfaces associated with this subnet have become active so - // that we can determine if it needs a reboot or not. So, we need to invalidate the linode configs query to get the most up to date information. - React.useEffect(() => { - if (linode && linode.status === 'running') { - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - linode.id, - 'configs', - ]); - } - }, [linode, queryClient]); - const iconStatus = getLinodeIconStatus(linode.status); const isRunning = linode.status == 'running'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index 6d8688f9b04..83806a57416 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -1,4 +1,3 @@ -import { IPv6Prefix } from '@linode/api-v4/lib/networking'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -6,7 +5,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; -import { Item } from 'src/components/EnhancedSelect/Select'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; @@ -17,9 +15,12 @@ import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { useAllocateIPMutation, - useCreateIPv6RangeMutation, useLinodeIPsQuery, } from 'src/queries/linodes/networking'; +import { useCreateIPv6RangeMutation } from 'src/queries/networking/networking'; + +import type { IPv6Prefix } from '@linode/api-v4/lib/networking'; +import type { Item } from 'src/components/EnhancedSelect/Select'; type IPType = 'v4Private' | 'v4Public'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx index eb470919aa1..a94ff66ec1d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx @@ -1,4 +1,3 @@ -import { IPRange } from '@linode/api-v4/lib/networking'; import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; @@ -10,14 +9,14 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { - useAllIPsQuery, - useLinodeIPMutation, -} from 'src/queries/linodes/networking'; +import { useLinodeIPMutation } from 'src/queries/linodes/networking'; +import { useAllIPsQuery } from 'src/queries/networking/networking'; import { getErrorMap } from 'src/utilities/errorUtils'; import { listIPv6InRange } from './LinodeIPAddressRow'; +import type { IPRange } from '@linode/api-v4'; + interface Props { linodeId: number; onClose: () => void; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index 719170446ca..674c5fc885b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -23,10 +23,10 @@ import { useLinodeQuery, } from 'src/queries/linodes/linodes'; import { - useAllDetailedIPv6RangesQuery, useLinodeIPsQuery, useLinodeShareIPMutation, } from 'src/queries/linodes/networking'; +import { useAllDetailedIPv6RangesQuery } from 'src/queries/networking/networking'; import { areArraysEqual } from 'src/utilities/areArraysEqual'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx index 872c66cb5be..d341552d618 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx @@ -1,7 +1,5 @@ -import { IPRange } from '@linode/api-v4/lib/networking'; -import { APIError } from '@linode/api-v4/lib/types'; -import Grid from '@mui/material/Unstable_Grid2'; import { styled, useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { both, compose, @@ -21,7 +19,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; import { Dialog } from 'src/components/Dialog/Dialog'; import { Divider } from 'src/components/Divider'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { usePrevious } from 'src/hooks/usePrevious'; @@ -30,12 +28,15 @@ import { useLinodeQuery, } from 'src/queries/linodes/linodes'; import { - useAllIPv6RangesQuery, useAssignAdressesMutation, useLinodeIPsQuery, } from 'src/queries/linodes/networking'; +import { useAllIPv6RangesQuery } from 'src/queries/networking/networking'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import type { APIError, IPRange } from '@linode/api-v4'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + interface Props { linodeId: number; onClose: () => void; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx index 661ff35ca71..f6005ec0024 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx @@ -9,10 +9,8 @@ import { TableCell } from 'src/components/TableCell'; import { Typography } from 'src/components/Typography'; import { StyledTableRow } from 'src/features/Linodes/LinodeEntityDetail.styles'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { - useAllIPsQuery, - useLinodeIPsQuery, -} from 'src/queries/linodes/networking'; +import { useLinodeIPsQuery } from 'src/queries/linodes/networking'; +import { useAllIPsQuery } from 'src/queries/networking/networking'; import { LinodeNetworkingActionMenu } from './LinodeNetworkingActionMenu'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx index e5024bda6a7..cf29559ff69 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx @@ -4,50 +4,15 @@ import { accountTransferFactory, linodeTransferFactory, regionFactory, - regionWithDynamicPricingFactory, } from 'src/factories'; import { typeFactory } from 'src/factories/types'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NetworkTransfer } from './NetworkTransfer'; import { calculatePercentageWithCeiling } from './utils'; -vi.mock('src/hooks/useAPIRequest', () => ({ - useAPIRequest: vi.fn().mockReturnValue({ - data: linodeTransferFactory.build(), - error: undefined, - isLoading: false, - }), -})); - -vi.mock('src/queries/account/transfer', () => ({ - useAccountNetworkTransfer: vi.fn().mockReturnValue({ - data: accountTransferFactory.build(), - error: undefined, - isLoading: false, - }), -})); - -vi.mock('src/queries/regions/regions', () => { - const mockRegions = [ - ...regionFactory.buildList(5), - regionWithDynamicPricingFactory.build(), - ]; - - return { - useRegionsQuery: vi.fn().mockReturnValue({ - data: mockRegions, - error: undefined, - }), - }; -}); - -vi.mock('src/queries/types', () => ({ - useTypeQuery: vi - .fn() - .mockReturnValue({ data: typeFactory.build(), error: undefined }), -})); - describe('calculatePercentage', () => { it('returns the correct percentage of a value in relation to a target', () => { expect(calculatePercentageWithCeiling(50, 100)).toBe(50); @@ -60,8 +25,25 @@ describe('calculatePercentage', () => { }); describe('renders the component with the right data', () => { - it('renders the component with the right data', () => { - const { getByRole, getByText } = renderWithTheme( + it('renders the component with the right data', async () => { + const type = typeFactory.build(); + + const accountTransfer = accountTransferFactory.build(); + const linodeTransfer = linodeTransferFactory.build(); + + server.use( + http.get('*/v4/linode/types/:id', () => { + return HttpResponse.json(type); + }), + http.get('*/v4/account/transfer', () => { + return HttpResponse.json(accountTransfer); + }), + http.get('*/v4/linode/instances/:id/transfer', () => { + return HttpResponse.json(linodeTransfer); + }) + ); + + const { findByText, getByText } = renderWithTheme( { ); expect(getByText('Monthly Network Transfer')).toBeInTheDocument(); - expect(getByRole('progressbar')).toBeInTheDocument(); - expect(getByText('test-linode (0.01 GB - 1%)')).toBeInTheDocument(); - expect(getByText('Global Pool Used (9000 GB - 36%)')).toBeInTheDocument(); - expect(getByText('Global Pool Remaining (16000 GB)')).toBeInTheDocument(); + + expect(await findByText('test-linode (0.01 GB - 1%)')).toBeInTheDocument(); + expect( + await findByText('Global Pool Used (9000 GB - 36%)') + ).toBeInTheDocument(); + expect( + await findByText('Global Pool Remaining (16000 GB)') + ).toBeInTheDocument(); }); - it('renders the DC specific pricing copy for linodes in eligible regions', () => { - const { getByRole, getByText } = renderWithTheme( + it('renders the DC specific pricing copy for linodes in eligible regions', async () => { + const type = typeFactory.build({ + region_prices: [{ hourly: 1, id: 'br-gru', monthly: 5 }], + }); + const accountTransfer = accountTransferFactory.build({ + region_transfers: [ + { billable: 0, id: 'br-gru', quota: 15000, used: 500 }, + ], + }); + const linodeTransfer = linodeTransferFactory.build({ + region_transfers: [ + { + billable: 0, + id: 'br-gru', + quota: 1500, // GB + used: 90000000000, // Bytes + }, + ], + }); + const region = regionFactory.build({ + id: 'br-gru', + label: 'Sao Paulo, BR', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }), + http.get('*/v4/linode/types/:id', () => { + return HttpResponse.json(type); + }), + http.get('*/v4/account/transfer', () => { + return HttpResponse.json(accountTransfer); + }), + http.get('*/v4/linode/instances/:id/transfer', () => { + return HttpResponse.json(linodeTransfer); + }) + ); + + const { findByText, getByText } = renderWithTheme( { ); expect(getByText('Monthly Network Transfer')).toBeInTheDocument(); - expect(getByRole('progressbar')).toBeInTheDocument(); - expect(getByText('test-linode (83.8 GB - 1%)')).toBeInTheDocument(); - expect(getByText('Transfer Used (500 GB - 4%)')).toBeInTheDocument(); - expect(getByText('Transfer Remaining (14500 GB)')).toBeInTheDocument(); + expect(await findByText('test-linode (83.8 GB - 1%)')).toBeInTheDocument(); + expect( + await findByText('Sao Paulo, BR Transfer Used (500 GB - 4%)') + ).toBeInTheDocument(); + expect( + await findByText('Sao Paulo, BR Transfer Remaining (14500 GB)') + ).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx index 7692cebb30b..8561c60f10d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx @@ -1,10 +1,9 @@ -import { getLinodeTransfer } from '@linode/api-v4/lib/linodes'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Typography } from 'src/components/Typography'; -import { useAPIRequest } from 'src/hooks/useAPIRequest'; import { useAccountNetworkTransfer } from 'src/queries/account/transfer'; +import { useLinodeTransfer } from 'src/queries/linodes/stats'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { @@ -28,11 +27,7 @@ export const NetworkTransfer = React.memo((props: Props) => { const { linodeId, linodeLabel, linodeRegionId, linodeType } = props; const theme = useTheme(); - const linodeTransfer = useAPIRequest( - () => getLinodeTransfer(linodeId), - { billable: 0, quota: 0, region_transfers: [], used: 0 }, - [linodeId] - ); + const linodeTransfer = useLinodeTransfer(linodeId); const regions = useRegionsQuery(); const { data: type } = useTypeQuery(linodeType || '', Boolean(linodeType)); const { @@ -58,7 +53,7 @@ export const NetworkTransfer = React.memo((props: Props) => { const totalUsedInGB = dynamicDCPoolData.used; const accountQuotaInGB = dynamicDCPoolData.quota; const error = Boolean(linodeTransfer.error || accountTransferError); - const loading = linodeTransfer.loading || accountTransferLoading; + const loading = linodeTransfer.isLoading || accountTransferLoading; const isDynamicPricingDC = isLinodeInDynamicPricingDC(linodeRegionId, type); return ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index 8f2a972affa..28d1d68ea43 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -52,7 +52,7 @@ export const TransferHistory = React.memo((props: Props) => { data: stats, error: statsError, isLoading: statsLoading, - } = useLinodeStatsByDate(linodeID, year, month, true, linodeCreated); + } = useLinodeStatsByDate(linodeID, year, month, true); const { data: transfer } = useLinodeTransferByDate( linodeID, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx index 0f62eeedfd9..5bfe5868da0 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx @@ -1,14 +1,15 @@ -import { IPRange } from '@linode/api-v4/lib/networking'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Drawer } from 'src/components/Drawer'; import { Typography } from 'src/components/Typography'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useAllIPsQuery } from 'src/queries/linodes/networking'; +import { useAllIPsQuery } from 'src/queries/networking/networking'; import { listIPv6InRange } from './LinodeIPAddressRow'; +import type { IPRange } from '@linode/api-v4'; + interface Props { linodeId: number; onClose: () => void; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 2a38f1b9474..1c913f40341 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -3,21 +3,14 @@ import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; import { useParams } from 'react-router-dom'; -import { debounce } from 'throttle-debounce'; import PendingIcon from 'src/assets/icons/pending.svg'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { - CPUTimeData, - DiskIOTimeData, - Point, -} from 'src/components/AreaChart/types'; import { Box } from 'src/components/Box'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; -import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { STATS_NOT_READY_API_MESSAGE, STATS_NOT_READY_MESSAGE, @@ -38,6 +31,12 @@ import { NetworkGraphs } from './NetworkGraphs'; import { StatsPanel } from './StatsPanel'; import type { ChartProps } from './NetworkGraphs'; +import type { + CPUTimeData, + DiskIOTimeData, + Point, +} from 'src/components/AreaChart/types'; +import type { Item } from 'src/components/EnhancedSelect/Select'; setUpCharts(); @@ -58,8 +57,6 @@ const LinodeSummary: React.FC = (props) => { const { data: profile } = useProfile(); const timezone = profile?.timezone || DateTime.local().zoneName; - const { height: windowHeight, width: windowWidth } = useWindowDimensions(); - const options = getDateOptions(linodeCreated); const [rangeSelection, setRangeSelection] = React.useState('24'); const [year, month] = rangeSelection.split(' '); @@ -70,14 +67,13 @@ const LinodeSummary: React.FC = (props) => { data: statsData, error: statsError, isLoading: statsLoading, - refetch: refetchLinodeStats, - } = useLinodeStats(id, isLast24Hours, linodeCreated); + } = useLinodeStats(id, isLast24Hours); const { data: statsByDateData, error: statsByDateError, isLoading: statsByDateLoading, - } = useLinodeStatsByDate(id, year, month, !isLast24Hours, linodeCreated); + } = useLinodeStatsByDate(id, year, month, !isLast24Hours); const stats = isLast24Hours ? statsData : statsByDateData; const isLoading = isLast24Hours ? statsLoading : statsByDateLoading; @@ -98,20 +94,6 @@ const LinodeSummary: React.FC = (props) => { setRangeSelection(e.value); }; - /* - We create a debounced function to refetch Linode stats that will run 1.5 seconds after the window is resized. - This makes the graphs adjust sooner than their typical 30-second interval. - */ - const debouncedRefetchLinodeStats = React.useRef( - debounce(1500, false, () => { - refetchLinodeStats(); - }) - ).current; - - React.useEffect(() => { - debouncedRefetchLinodeStats(); - }, [windowWidth, windowHeight, debouncedRefetchLinodeStats]); - /** * This changes the X-Axis tick labels depending on the selected timeframe. * diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 4879592020f..6d51617e316 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -1,7 +1,4 @@ -import { APIError, Firewall, Linode } from '@linode/api-v4'; -import { Config, Interface } from '@linode/api-v4/lib/linodes/types'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; -import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -17,10 +14,7 @@ import { Typography } from 'src/components/Typography'; import { getLinodeIconStatus } from 'src/features/Linodes/LinodesLanding/utils'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; import { useLinodeFirewallsQuery } from 'src/queries/linodes/firewalls'; -import { - queryKey as linodesQueryKey, - useLinodeQuery, -} from 'src/queries/linodes/linodes'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; @@ -41,6 +35,8 @@ import { StyledWarningIcon, } from './SubnetLinodeRow.styles'; +import type { APIError, Firewall, Linode } from '@linode/api-v4'; +import type { Config, Interface } from '@linode/api-v4/lib/linodes/types'; import type { Subnet } from '@linode/api-v4/lib/vpcs/types'; import type { Action } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; @@ -53,7 +49,6 @@ interface Props { } export const SubnetLinodeRow = (props: Props) => { - const queryClient = useQueryClient(); const { handlePowerActionsLinode, handleUnassignLinode, @@ -85,19 +80,6 @@ export const SubnetLinodeRow = (props: Props) => { subnet?.id ?? -1 ); - // If the Linode's status is running, we want to check if its interfaces associated with this subnet have become active so - // that we can determine if it needs a reboot or not. So, we need to invalidate the linode configs query to get the most up to date information. - React.useEffect(() => { - if (linode && linode.status === 'running') { - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - linodeId, - 'configs', - ]); - } - }, [linode, linodeId, queryClient]); - if (linodeLoading || !linode) { return ( diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 99de08eabab..e4a91888e61 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -1,4 +1,3 @@ -import { Subnet } from '@linode/api-v4/lib/vpcs/types'; import { Stack, Typography } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import { useFormik } from 'formik'; @@ -14,11 +13,7 @@ import { RemovableSelectionsListTable } from 'src/components/RemovableSelections import { SUBNET_UNASSIGN_LINODES_WARNING } from 'src/features/VPCs/constants'; import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; -import { - queryKey as linodesQueryKey, - useAllLinodesQuery, -} from 'src/queries/linodes/linodes'; -import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; +import { linodeQueries, useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; @@ -27,6 +22,7 @@ import type { DeleteLinodeConfigInterfacePayload, Interface, Linode, + Subnet, UpdateConfigInterfacePayload, } from '@linode/api-v4'; @@ -115,8 +111,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( const updatedConfigInterfaces = await Promise.all( selectedLinodes.map(async (linode) => { const response = await queryClient.fetchQuery( - [linodesQueryKey, 'linode', linode.id, 'configs'], - () => getAllLinodeConfigs(linode.id) + linodeQueries.linode(linode.id)._ctx.configs ); if (response) { diff --git a/packages/manager/src/hooks/useUnassignLinode.ts b/packages/manager/src/hooks/useUnassignLinode.ts index 515282ae787..bde08746c76 100644 --- a/packages/manager/src/hooks/useUnassignLinode.ts +++ b/packages/manager/src/hooks/useUnassignLinode.ts @@ -2,14 +2,13 @@ import { deleteLinodeConfigInterface } from '@linode/api-v4'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import { configQueryKey, interfaceQueryKey } from 'src/queries/linodes/configs'; -import { queryKey } from 'src/queries/linodes/linodes'; import { vpcQueries } from 'src/queries/vpcs/vpcs'; import type { APIError, DeleteLinodeConfigInterfacePayload, } from '@linode/api-v4'; +import { linodeQueries } from 'src/queries/linodes/linodes'; interface IdsForUnassignLinode extends DeleteLinodeConfigInterfacePayload { vpcId: number; @@ -27,7 +26,6 @@ export const useUnassignLinode = () => { >([]); const invalidateQueries = async ({ - configId, linodeId, vpcId, }: InvalidateSubnetLinodeConfigQueryIds) => { @@ -36,15 +34,7 @@ export const useUnassignLinode = () => { vpcQueries.paginated._def, vpcQueries.vpc(vpcId).queryKey, vpcQueries.vpc(vpcId)._ctx.subnets.queryKey, - [ - queryKey, - 'linode', - linodeId, - configQueryKey, - 'config', - configId, - interfaceQueryKey, - ], + linodeQueries.linode(linodeId)._ctx.configs.queryKey, ]; await Promise.all( queryKeys.map((key) => queryClient.invalidateQueries(key)) @@ -59,17 +49,6 @@ export const useUnassignLinode = () => { }: IdsForUnassignLinode) => { await deleteLinodeConfigInterface(linodeId, configId, interfaceId); invalidateQueries({ configId, linodeId, vpcId }); - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeId, - configQueryKey, - 'config', - configId, - interfaceQueryKey, - 'interface', - interfaceId, - ]); }; return { diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index e1b2b92eea8..66e91d26f56 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -12,7 +12,6 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; import { nodebalancerQueries } from './nodebalancers'; @@ -30,6 +29,7 @@ import type { ResourcePage, } from '@linode/api-v4'; import type { EventHandlerData } from 'src/hooks/useEventHandlers'; +import { linodeQueries } from './linodes/linodes'; const getAllFirewallDevices = ( id: number, @@ -172,12 +172,8 @@ export const useAddFirewallDeviceMutation = (id: number) => { // Refresh the cached result of the linode-specific firewalls query if (firewallDevice.entity.type === 'linode') { queryClient.invalidateQueries({ - queryKey: [ - linodesQueryKey, - 'linode', - firewallDevice.entity.id, - 'firewalls', - ], + queryKey: linodeQueries.linode(firewallDevice.entity.id)._ctx + .firewalls.queryKey, }); } @@ -286,7 +282,7 @@ export const useCreateFirewall = () => { for (const entity of firewall.entities) { if (entity.type === 'linode') { queryClient.invalidateQueries({ - queryKey: [linodesQueryKey, 'linode', entity.id, 'firewalls'], + queryKey: linodeQueries.linode(entity.id)._ctx.firewalls.queryKey, }); } if (entity.type === 'nodebalancer') { diff --git a/packages/manager/src/queries/linodes/actions.ts b/packages/manager/src/queries/linodes/actions.ts index 8d3156f8741..b4fdc590901 100644 --- a/packages/manager/src/queries/linodes/actions.ts +++ b/packages/manager/src/queries/linodes/actions.ts @@ -1,16 +1,22 @@ -import { APIError, startMutation } from '@linode/api-v4'; +import { startMutation } from '@linode/api-v4'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { queryKey } from './linodes'; +import { linodeQueries } from './linodes'; + +import type { APIError } from '@linode/api-v4'; export const useStartLinodeMutationMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => startMutation(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => startMutation(id), onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); }, }); }; diff --git a/packages/manager/src/queries/linodes/backups.ts b/packages/manager/src/queries/linodes/backups.ts index 8885c3c0131..65fde31c39c 100644 --- a/packages/manager/src/queries/linodes/backups.ts +++ b/packages/manager/src/queries/linodes/backups.ts @@ -1,62 +1,63 @@ import { - APIError, - LinodeBackupsResponse, cancelBackups, enableBackups, - getLinodeBackups, restoreBackup, takeSnapshot, } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { queryKey } from './linodes'; +import { linodeQueries } from './linodes'; + +import type { APIError, LinodeBackupsResponse } from '@linode/api-v4'; export const useLinodeBackupsQuery = (id: number, enabled = true) => { - return useQuery( - [queryKey, 'linode', id, 'backups'], - () => getLinodeBackups(id), - { enabled } - ); + return useQuery({ + ...linodeQueries.linode(id)._ctx.backups, + enabled, + }); }; export const useLinodeBackupsEnableMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => enableBackups(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => enableBackups(id), onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); }, }); }; export const useLinodeBackupsCancelMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => cancelBackups(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => cancelBackups(id), onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); }, }); }; export const useLinodeBackupSnapshotMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { label: string }>( - ({ label }) => takeSnapshot(id, label), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'linode', id, 'backups']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - }, - } - ); + return useMutation<{}, APIError[], { label: string }>({ + mutationFn: ({ label }) => takeSnapshot(id, label), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries(linodeQueries.linode(id)._ctx.backups); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); + }, + }); }; export const useLinodeBackupRestoreMutation = () => { @@ -69,7 +70,8 @@ export const useLinodeBackupRestoreMutation = () => { overwrite: boolean; targetLinodeId: number; } - >(({ backupId, linodeId, overwrite, targetLinodeId }) => - restoreBackup(linodeId, backupId, targetLinodeId, overwrite) - ); + >({ + mutationFn: ({ backupId, linodeId, overwrite, targetLinodeId }) => + restoreBackup(linodeId, backupId, targetLinodeId, overwrite), + }); }; diff --git a/packages/manager/src/queries/linodes/configs.ts b/packages/manager/src/queries/linodes/configs.ts index 12e5b05357e..be50cf14515 100644 --- a/packages/manager/src/queries/linodes/configs.ts +++ b/packages/manager/src/queries/linodes/configs.ts @@ -1,73 +1,50 @@ import { - APIError, - Config, - ConfigInterfaceOrderPayload, - Interface, - InterfacePayload, - LinodeConfigCreationData, - UpdateConfigInterfacePayload, - appendConfigInterface, createLinodeConfig, deleteLinodeConfig, - deleteLinodeConfigInterface, - getConfigInterface, - getConfigInterfaces, - updateConfigInterface, updateLinodeConfig, - updateLinodeConfigOrder, } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { queryKey } from './linodes'; -import { getAllLinodeConfigs } from './requests'; +import { linodeQueries } from './linodes'; + +import type { + APIError, + Config, + LinodeConfigCreationData, +} from '@linode/api-v4'; export const useAllLinodeConfigsQuery = (id: number, enabled = true) => { - return useQuery( - [queryKey, 'linode', id, 'configs'], - () => getAllLinodeConfigs(id), - { enabled } - ); + return useQuery({ + ...linodeQueries.linode(id)._ctx.configs, + enabled, + }); }; -export const configQueryKey = 'configs'; -export const interfaceQueryKey = 'interfaces'; - -// Config queries export const useLinodeConfigDeleteMutation = ( linodeId: number, configId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLinodeConfig(linodeId, configId), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeId, - configQueryKey, - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLinodeConfig(linodeId, configId), + onSuccess() { + queryClient.invalidateQueries( + linodeQueries.linode(linodeId)._ctx.configs + ); + }, + }); }; export const useLinodeConfigCreateMutation = (linodeId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLinodeConfig(linodeId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeId, - configQueryKey, - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLinodeConfig(linodeId, data), + onSuccess() { + queryClient.invalidateQueries( + linodeQueries.linode(linodeId)._ctx.configs + ); + }, + }); }; export const useLinodeConfigUpdateMutation = ( @@ -75,177 +52,12 @@ export const useLinodeConfigUpdateMutation = ( configId: number ) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateLinodeConfig(linodeId, configId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeId, - configQueryKey, - ]); - }, - } - ); -}; - -// Config Interface queries -export const useConfigInterfacesQuery = ( - linodeID: number, - configID: number -) => { - return useQuery( - [ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - ], - () => getConfigInterfaces(linodeID, configID), - { keepPreviousData: true } - ); -}; - -export const useConfigInterfaceQuery = ( - linodeID: number, - configID: number, - interfaceID: number -) => { - return useQuery( - [ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - 'interface', - interfaceID, - ], - () => getConfigInterface(linodeID, configID, interfaceID), - { keepPreviousData: true } - ); -}; - -export const useConfigInterfacesOrderMutation = ( - linodeID: number, - configID: number -) => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[], ConfigInterfaceOrderPayload>( - (data) => updateLinodeConfigOrder(linodeID, configID, data), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - interfaceQueryKey, - ]); - }, - } - ); -}; - -export const useAppendConfigInterfaceMutation = ( - linodeID: number, - configID: number -) => { - const queryClient = useQueryClient(); - return useMutation( - (data) => appendConfigInterface(linodeID, configID, data), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - ]); - }, - } - ); -}; - -export const useUpdateConfigInterfaceMutation = ( - linodeID: number, - configID: number, - interfaceID: number -) => { - const queryClient = useQueryClient(); - return useMutation( - (data) => updateConfigInterface(linodeID, configID, interfaceID, data), - { - onSuccess: (InterfaceObj) => { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - ]); - queryClient.setQueryData( - [ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - 'interface', - InterfaceObj.id, - ], - InterfaceObj - ); - }, - } - ); -}; - -export const useDeleteConfigInterfaceMutation = ( - linodeID: number, - configID: number, - interfaceID: number -) => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLinodeConfigInterface(linodeID, configID, interfaceID), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - ]); - queryClient.removeQueries([ - queryKey, - 'linode', - linodeID, - configQueryKey, - 'config', - configID, - interfaceQueryKey, - 'interface', - interfaceID, - ]); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updateLinodeConfig(linodeId, configId, data), + onSuccess() { + queryClient.invalidateQueries( + linodeQueries.linode(linodeId)._ctx.configs + ); + }, + }); }; diff --git a/packages/manager/src/queries/linodes/disks.ts b/packages/manager/src/queries/linodes/disks.ts index 09a5e1bd20a..e2856600cfa 100644 --- a/packages/manager/src/queries/linodes/disks.ts +++ b/packages/manager/src/queries/linodes/disks.ts @@ -1,63 +1,53 @@ import { - APIError, - Disk, - LinodeDiskCreationData, changeLinodeDiskPassword, createLinodeDisk, deleteLinodeDisk, - getLinodeDisks, resizeLinodeDisk, updateLinodeDisk, } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getAll } from 'src/utilities/getAll'; +import { linodeQueries } from './linodes'; -import { queryKey } from './linodes'; +import type { APIError, Disk, LinodeDiskCreationData } from '@linode/api-v4'; export const useAllLinodeDisksQuery = (id: number, enabled = true) => { - return useQuery( - [queryKey, 'linode', id, 'disks', 'all'], - () => getAllLinodeDisks(id), - { enabled } - ); + return useQuery({ + ...linodeQueries.linode(id)._ctx.disks, + enabled, + }); }; -const getAllLinodeDisks = (id: number) => - getAll((params, filter) => getLinodeDisks(id, params, filter))().then( - (data) => data.data - ); - export const useLinodeDiskChangePasswordMutation = ( linodeId: number, diskId: number ) => - useMutation(({ password }) => - changeLinodeDiskPassword(linodeId, diskId, password) - ); + useMutation({ + mutationFn: ({ password }) => + changeLinodeDiskPassword(linodeId, diskId, password), + }); export const useLinodeDeleteDiskMutation = ( linodeId: number, diskId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteLinodeDisk(linodeId, diskId), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLinodeDisk(linodeId, diskId), onSuccess() { - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); + queryClient.invalidateQueries(linodeQueries.linode(linodeId)._ctx.disks); }, }); }; export const useLinodeDiskCreateMutation = (linodeId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLinodeDisk(linodeId, data), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLinodeDisk(linodeId, data), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linode(linodeId)._ctx.disks); + }, + }); }; export const useLinodeDiskUpdateMutation = ( @@ -65,14 +55,12 @@ export const useLinodeDiskUpdateMutation = ( diskId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateLinodeDisk(linodeId, diskId, data), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); - }, - } - ); + return useMutation({ + mutationFn: (data) => updateLinodeDisk(linodeId, diskId, data), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linode(linodeId)._ctx.disks); + }, + }); }; export const useLinodeDiskResizeMutation = ( @@ -80,12 +68,10 @@ export const useLinodeDiskResizeMutation = ( diskId: number ) => { const queryClient = useQueryClient(); - return useMutation( - ({ size }) => resizeLinodeDisk(linodeId, diskId, size), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); - }, - } - ); + return useMutation({ + mutationFn: ({ size }) => resizeLinodeDisk(linodeId, diskId, size), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linode(linodeId)._ctx.disks); + }, + }); }; diff --git a/packages/manager/src/queries/linodes/events.ts b/packages/manager/src/queries/linodes/events.ts index 01c19913bf3..7f44a64ba75 100644 --- a/packages/manager/src/queries/linodes/events.ts +++ b/packages/manager/src/queries/linodes/events.ts @@ -1,7 +1,7 @@ import { accountQueries } from '../account/queries'; import { firewallQueries } from '../firewalls'; import { volumeQueries } from '../volumes/volumes'; -import { queryKey } from './linodes'; +import { linodeQueries } from './linodes'; import type { Event } from '@linode/api-v4'; import type { EventHandlerData } from 'src/hooks/useEventHandlers'; @@ -29,7 +29,7 @@ export const linodeEventsHandler = ({ // Some Linode events are an indication that the reponse from /v4/account/notifications // has changed, so refetch notifications. if (shouldRequestNotifications(event)) { - queryClient.invalidateQueries(accountQueries.notifications.queryKey); + queryClient.invalidateQueries(accountQueries.notifications); } switch (event.action) { @@ -43,66 +43,89 @@ export const linodeEventsHandler = ({ case 'linode_resize_warm_create': case 'linode_reboot': case 'linode_update': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); return; case 'linode_boot': case 'linode_shutdown': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'configs']); // Ensure configs are fresh when Linode is booted up (see https://github.com/linode/manager/pull/9914) - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + // Ensure configs are fresh when Linode is booted up (see https://github.com/linode/manager/pull/9914) + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.configs.queryKey, + }); return; case 'linode_snapshot': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'backups']); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + queryClient.invalidateQueries( + linodeQueries.linode(linodeId)._ctx.backups + ); return; case 'linode_addip': case 'linode_deleteip': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'ips']); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.ips.queryKey, + }); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); return; case 'linode_create': case 'linode_clone': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.disks.queryKey, + }); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); return; case 'linode_rebuild': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'configs']); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'details']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.disks.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.configs.queryKey, + }); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); return; case 'linode_delete': - queryClient.removeQueries([queryKey, 'linode', linodeId]); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + queryClient.removeQueries({ + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); // A Linode made have been on a Firewall's device list, but now that it is deleted, // it will no longer be listed as a device on that firewall. Here, we invalidate outdated firewall data. queryClient.invalidateQueries({ queryKey: firewallQueries._def }); // A Linode may have been attached to a Volume, but deleted. We need to refetch volumes data so that // the Volumes table does not show a Volume attached to a non-existant Linode. - queryClient.invalidateQueries(volumeQueries.lists.queryKey); + queryClient.invalidateQueries({ queryKey: volumeQueries.lists.queryKey }); return; case 'linode_config_create': case 'linode_config_delete': case 'linode_config_update': - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'configs']); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.configs.queryKey, + }); return; } }; @@ -120,7 +143,9 @@ export const diskEventHandler = ({ event, queryClient }: EventHandlerData) => { return; } - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'disks']); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.disks.queryKey, + }); }; /** diff --git a/packages/manager/src/queries/linodes/firewalls.ts b/packages/manager/src/queries/linodes/firewalls.ts index 1e0f60a86cc..e3526b6dfc1 100644 --- a/packages/manager/src/queries/linodes/firewalls.ts +++ b/packages/manager/src/queries/linodes/firewalls.ts @@ -1,12 +1,10 @@ -import { getLinodeFirewalls } from '@linode/api-v4'; import { useQuery } from '@tanstack/react-query'; -import { queryKey } from './linodes'; +import { linodeQueries } from './linodes'; import type { APIError, Firewall, ResourcePage } from '@linode/api-v4'; -export const useLinodeFirewallsQuery = (linodeID: number) => +export const useLinodeFirewallsQuery = (linodeId: number) => useQuery, APIError[]>( - [queryKey, 'linode', linodeID, 'firewalls'], - () => getLinodeFirewalls(linodeID) + linodeQueries.linode(linodeId)._ctx.firewalls ); diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 37176367b4e..e396847ab84 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -1,20 +1,18 @@ import { - Config, - CreateLinodeRequest, - Devices, - Kernel, - Linode, - LinodeCloneData, - LinodeLishData, - MigrateLinodeRequest, - ResizeLinodePayload, changeLinodePassword, cloneLinode, createLinode, deleteLinode, getLinode, + getLinodeBackups, + getLinodeFirewalls, + getLinodeIPs, getLinodeKernel, getLinodeLish, + getLinodeStats, + getLinodeStatsByDate, + getLinodeTransfer, + getLinodeTransferByDate, getLinodes, linodeBoot, linodeReboot, @@ -23,14 +21,8 @@ import { resizeLinode, scheduleOrQueueMigration, updateLinode, -} from '@linode/api-v4/lib/linodes'; -import { - APIError, - DeepPartial, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useMutation, @@ -43,22 +35,120 @@ import { manuallySetVPCConfigInterfacesToActive } from 'src/utilities/configs'; import { accountQueries } from '../account/queries'; import { queryPresets } from '../base'; +import { firewallQueries } from '../firewalls'; import { profileQueries } from '../profile/profile'; import { vlanQueries } from '../vlans'; -import { getAllLinodeKernelsRequest, getAllLinodesRequest } from './requests'; +import { vpcQueries } from '../vpcs/vpcs'; +import { + getAllLinodeConfigs, + getAllLinodeDisks, + getAllLinodeKernelsRequest, + getAllLinodesRequest, +} from './requests'; -export const queryKey = 'linodes'; +import type { + APIError, + Config, + CreateLinodeRequest, + DeepPartial, + Devices, + Filter, + Kernel, + Linode, + LinodeCloneData, + LinodeLishData, + MigrateLinodeRequest, + Params, + ResizeLinodePayload, + ResourcePage, +} from '@linode/api-v4'; + +export const linodeQueries = createQueryKeys('linodes', { + kernel: (id: string) => ({ + queryFn: () => getLinodeKernel(id), + queryKey: [id], + }), + kernels: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllLinodeKernelsRequest(params, filter), + queryKey: [params, filter], + }), + linode: (id: number) => ({ + contextQueries: { + backups: { + queryFn: () => getLinodeBackups(id), + queryKey: null, + }, + configs: { + queryFn: () => getAllLinodeConfigs(id), + queryKey: null, + }, + disks: { + queryFn: () => getAllLinodeDisks(id), + queryKey: null, + }, + firewalls: { + queryFn: () => getLinodeFirewalls(id), + queryKey: null, + }, + ips: { + queryFn: () => getLinodeIPs(id), + queryKey: null, + }, + lish: { + queryFn: () => getLinodeLish(id), + queryKey: null, + }, + stats: { + queryFn: () => getLinodeStats(id), + queryKey: null, + }, + statsByDate: (year: string, month: string) => ({ + queryFn: () => getLinodeStatsByDate(id, year, month), + queryKey: [year, month], + }), + transfer: { + queryFn: () => getLinodeTransfer(id), + queryKey: null, + }, + transferByDate: (year: string, month: string) => ({ + queryFn: () => getLinodeTransferByDate(id, year, month), + queryKey: [year, month], + }), + }, + queryFn: () => getLinode(id), + queryKey: [id], + }), + linodes: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllLinodesRequest(params, filter), + queryKey: [params, filter], + }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLinodes({ page: pageParam, page_size: 25 }, filter), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLinodes(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); export const useLinodesQuery = ( params: Params = {}, filter: Filter = {}, enabled: boolean = true ) => { - return useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getLinodes(params, filter), - { ...queryPresets.longLived, enabled, keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...linodeQueries.linodes._ctx.paginated(params, filter), + ...queryPresets.longLived, + enabled, + keepPreviousData: true, + }); }; export const useAllLinodesQuery = ( @@ -66,50 +156,45 @@ export const useAllLinodesQuery = ( filter: Filter = {}, enabled: boolean = true ) => { - return useQuery( - [queryKey, 'all', params, filter], - () => getAllLinodesRequest(params, filter), - { ...queryPresets.longLived, enabled } - ); + return useQuery({ + ...linodeQueries.linodes._ctx.all(params, filter), + ...queryPresets.longLived, + enabled, + }); }; export const useInfiniteLinodesQuery = (filter: Filter = {}) => - useInfiniteQuery, APIError[]>( - [queryKey, 'infinite', filter], - ({ pageParam }) => getLinodes({ page: pageParam, page_size: 25 }, filter), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + useInfiniteQuery, APIError[]>({ + ...linodeQueries.linodes._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); export const useLinodeQuery = (id: number, enabled = true) => { - return useQuery( - [queryKey, 'linode', id, 'details'], - () => getLinode(id), - { - enabled, - } - ); + return useQuery({ + ...linodeQueries.linode(id), + enabled, + }); }; export const useLinodeUpdateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateLinode(id, data), - { - onSuccess(linode) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.setQueryData([queryKey, 'linode', id, 'details'], linode); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updateLinode(id, data), + onSuccess(linode) { + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + queryClient.setQueryData( + linodeQueries.linode(id).queryKey, + linode + ); + }, + }); }; export const useAllLinodeKernelsQuery = ( @@ -117,44 +202,37 @@ export const useAllLinodeKernelsQuery = ( filter: Filter = {}, enabled = true ) => { - return useQuery( - [queryKey, 'linode', 'kernels', params, filter], - () => getAllLinodeKernelsRequest(params, filter), - { enabled } - ); + return useQuery({ + ...linodeQueries.kernels(params, filter), + enabled, + }); }; export const useLinodeKernelQuery = (kernel: string) => { - return useQuery( - [queryKey, 'linode', 'kernels', 'kernel', kernel], - () => getLinodeKernel(kernel) - ); + return useQuery(linodeQueries.kernel(kernel)); }; export const useLinodeLishQuery = (id: number) => { - return useQuery( - [queryKey, 'linode', id, 'lish'], - () => getLinodeLish(id), - { staleTime: Infinity } - ); + return useQuery({ + ...linodeQueries.linode(id)._ctx.lish, + staleTime: Infinity, + }); }; export const useDeleteLinodeMutation = (id: number) => { const queryClient = useQueryClient(); - const linode = queryClient.getQueryData([ - queryKey, - 'linode', - id, - 'details', - ]); + + const linode = queryClient.getQueryData( + linodeQueries.linode(id).queryKey + ); + const placementGroupId = linode?.placement_group?.id; - return useMutation<{}, APIError[]>(() => deleteLinode(id), { - onSuccess() { - queryClient.removeQueries([queryKey, 'linode', id]); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLinode(id), + async onSuccess() { + queryClient.removeQueries(linodeQueries.linode(id)); + queryClient.invalidateQueries(linodeQueries.linodes); // If the linode is assigned to a placement group, // we need to invalidate the placement group queries @@ -171,22 +249,34 @@ export const useDeleteLinodeMutation = (id: number) => { export const useCreateLinodeMutation = () => { const queryClient = useQueryClient(); - return useMutation(createLinode, { + return useMutation({ + mutationFn: createLinode, onSuccess(linode, variables) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.setQueryData( - [queryKey, 'linode', linode.id, 'details'], + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.setQueryData( + linodeQueries.linode(linode.id).queryKey, linode ); + // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); + queryClient.invalidateQueries(profileQueries.grants); if (variables.interfaces?.some((i) => i.purpose === 'vlan')) { // If a Linode is created with a VLAN, invalidate vlans because // they are derived from Linode configs. - queryClient.invalidateQueries(vlanQueries._def); + queryClient.invalidateQueries({ queryKey: vlanQueries._def }); + } + + const vpcId = variables.interfaces?.find((i) => i.purpose === 'vpc') + ?.vpc_id; + + if (vpcId) { + // If a Linode is created with a VPC, invalidate the related VPC queries. + queryClient.invalidateQueries({ queryKey: vpcQueries.all.queryKey }); + queryClient.invalidateQueries({ queryKey: vpcQueries.paginated._def }); + queryClient.invalidateQueries({ + queryKey: vpcQueries.vpc(vpcId).queryKey, + }); } // If the Linode is assigned to a placement group on creation, @@ -199,6 +289,17 @@ export const useCreateLinodeMutation = () => { queryClient.invalidateQueries(placementGroupQueries.all._def); queryClient.invalidateQueries(placementGroupQueries.paginated._def); } + + // If the Linode is attached to a firewall on creation, invalidate the firewall + // so that the new device is reflected. + if (variables.firewall_id) { + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(variables.firewall_id).queryKey, + }); + } }, }); }; @@ -209,20 +310,17 @@ interface LinodeCloneDataWithId extends LinodeCloneData { export const useCloneLinodeMutation = () => { const queryClient = useQueryClient(); - return useMutation( - ({ sourceLinodeId, ...data }) => cloneLinode(sourceLinodeId, data), - { - onSuccess(linode) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.setQueryData( - [queryKey, 'linode', linode.id, 'details'], - linode - ); - }, - } - ); + return useMutation({ + mutationFn: ({ sourceLinodeId, ...data }) => + cloneLinode(sourceLinodeId, data), + onSuccess(linode) { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.setQueryData( + linodeQueries.linode(linode.id).queryKey, + linode + ); + }, + }); }; export const useBootLinodeMutation = ( @@ -230,32 +328,32 @@ export const useBootLinodeMutation = ( configsToUpdate?: Config[] ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { config_id?: number }>( - ({ config_id }) => linodeBoot(id, config_id), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); - if (configsToUpdate) { - /** - * PR #9893: If booting is successful, we manually set the query config data to have its vpc interfaces as - * active in order to remove the flickering 'Reboot Needed' status issue. This makes sure the Linode's status - * shows up as 'Running' right after being booting. Note that the configs query eventually gets invalidated - * and refetched after the Linode's status changes, ensuring that the actual data will be up to date. - */ - const updatedConfigs: Config[] = manuallySetVPCConfigInterfacesToActive( - configsToUpdate - ); - queryClient.setQueryData( - [queryKey, 'linode', id, 'configs'], - updatedConfigs - ); - } - }, - } - ); + return useMutation<{}, APIError[], { config_id?: number }>({ + mutationFn: ({ config_id }) => linodeBoot(id, config_id), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); + + if (configsToUpdate) { + /** + * PR #9893: If booting is successful, we manually set the query config data to have its vpc interfaces as + * active in order to remove the flickering 'Reboot Needed' status issue. This makes sure the Linode's status + * shows up as 'Running' right after being booting. Note that the configs query eventually gets invalidated + * and refetched after the Linode's status changes, ensuring that the actual data will be up to date. + */ + const updatedConfigs: Config[] = manuallySetVPCConfigInterfacesToActive( + configsToUpdate + ); + queryClient.setQueryData( + linodeQueries.linode(id)._ctx.configs.queryKey, + updatedConfigs + ); + } + }, + }); }; export const useRebootLinodeMutation = ( @@ -263,42 +361,44 @@ export const useRebootLinodeMutation = ( configsToUpdate?: Config[] ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { config_id?: number }>( - ({ config_id }) => linodeReboot(id, config_id), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); - /** - * PR #9893: If rebooting is successful, we manually set the query config data to have its vpc interfaces as - * active in order to remove the flickering 'Reboot Needed' status issue. This makes sure the Linode's status - * shows up as 'Running' right after being rebooting. Note that the configs query eventually gets invalidated - * and refetched after the Linode's status changes, ensuring that the actual data will be up to date. - */ - if (configsToUpdate) { - const updatedConfigs: Config[] = manuallySetVPCConfigInterfacesToActive( - configsToUpdate - ); - queryClient.setQueryData( - [queryKey, 'linode', id, 'configs'], - updatedConfigs - ); - } - }, - } - ); + return useMutation<{}, APIError[], { config_id?: number }>({ + mutationFn: ({ config_id }) => linodeReboot(id, config_id), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); + + /** + * PR #9893: If rebooting is successful, we manually set the query config data to have its vpc interfaces as + * active in order to remove the flickering 'Reboot Needed' status issue. This makes sure the Linode's status + * shows up as 'Running' right after being rebooting. Note that the configs query eventually gets invalidated + * and refetched after the Linode's status changes, ensuring that the actual data will be up to date. + */ + if (configsToUpdate) { + const updatedConfigs: Config[] = manuallySetVPCConfigInterfacesToActive( + configsToUpdate + ); + queryClient.setQueryData( + linodeQueries.linode(id)._ctx.configs.queryKey, + updatedConfigs + ); + } + }, + }); }; export const useShutdownLinodeMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => linodeShutdown(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => linodeShutdown(id), onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); }, }); }; @@ -310,55 +410,53 @@ export const useLinodeChangePasswordMutation = (id: number) => export const useLinodeMigrateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], MigrateLinodeRequest>( - (data) => scheduleOrQueueMigration(id, data), - { - onSuccess(id, data) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); - - if (data.placement_group?.id) { - queryClient.invalidateQueries( - placementGroupQueries.placementGroup(data.placement_group.id) - .queryKey - ); - queryClient.invalidateQueries(placementGroupQueries.all._def); - queryClient.invalidateQueries(placementGroupQueries.paginated._def); - } - }, - } - ); + return useMutation<{}, APIError[], MigrateLinodeRequest>({ + mutationFn: (data) => scheduleOrQueueMigration(id, data), + onSuccess(response, variables) { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); + + if (variables.placement_group?.id) { + queryClient.invalidateQueries( + placementGroupQueries.placementGroup(variables.placement_group.id) + .queryKey + ); + queryClient.invalidateQueries(placementGroupQueries.all._def); + queryClient.invalidateQueries(placementGroupQueries.paginated._def); + } + }, + }); }; export const useLinodeResizeMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], ResizeLinodePayload>( - (data) => resizeLinode(id, data), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); - queryClient.invalidateQueries(accountQueries.notifications.queryKey); - }, - } - ); + return useMutation<{}, APIError[], ResizeLinodePayload>({ + mutationFn: (data) => resizeLinode(id, data), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); + // Refetch notifications to dismiss any migration notifications + queryClient.invalidateQueries(accountQueries.notifications); + }, + }); }; export const useLinodeRescueMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], Devices>( - (data) => rescueLinode(id, data), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'linode', id, 'details']); - }, - } - ); + return useMutation<{}, APIError[], Devices>({ + mutationFn: (data) => rescueLinode(id, data), + onSuccess() { + queryClient.invalidateQueries(linodeQueries.linodes); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(id).queryKey, + }); + }, + }); }; diff --git a/packages/manager/src/queries/linodes/networking.ts b/packages/manager/src/queries/linodes/networking.ts index 0205ad3ad7f..a4ddbf3a721 100644 --- a/packages/manager/src/queries/linodes/networking.ts +++ b/packages/manager/src/queries/linodes/networking.ts @@ -1,45 +1,36 @@ import { + allocateIPAddress, + assignAddresses, + removeIPAddress, + removeIPv6Range, + shareAddresses, + updateIP, +} from '@linode/api-v4'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { networkingQueries } from '../networking/networking'; +import { linodeQueries } from './linodes'; + +import type { APIError, - CreateIPv6RangePayload, - Filter, IPAddress, IPAllocationRequest, IPAssignmentPayload, - IPRange, IPRangeInformation, IPSharingPayload, Linode, LinodeIPsResponse, - Params, - allocateIPAddress, - assignAddresses, - createIPv6Range, - getIPv6RangeInfo, - getLinodeIPs, - removeIPAddress, - removeIPv6Range, - shareAddresses, - updateIP, } from '@linode/api-v4'; -import { - QueryClient, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; - -import { queryKey } from './linodes'; -import { getAllIPv6Ranges, getAllIps } from './requests'; +import type { QueryClient } from '@tanstack/react-query'; export const useLinodeIPsQuery = ( linodeId: number, enabled: boolean = true ) => { - return useQuery( - [queryKey, 'linode', linodeId, 'ips'], - () => getLinodeIPs(linodeId), - { enabled } - ); + return useQuery({ + ...linodeQueries.linode(linodeId)._ctx.ips, + enabled, + }); }; export const useLinodeIPMutation = () => { @@ -48,9 +39,10 @@ export const useLinodeIPMutation = () => { IPAddress, APIError[], { address: string; rdns?: null | string } - >(({ address, rdns }) => updateIP(address, rdns), { + >({ + mutationFn: ({ address, rdns }) => updateIP(address, rdns), onSuccess() { - invalidateAllIPsQueries(queryClient); + invalidateIPsForAllLinodes(queryClient); }, }); }; @@ -60,48 +52,80 @@ export const useLinodeIPDeleteMutation = ( address: string ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => removeIPAddress({ address, linodeID: linodeId }), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeId, - 'details', - ]); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'ips']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'ips']); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => removeIPAddress({ address, linodeID: linodeId }), + onSuccess() { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.ips.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + + queryClient.invalidateQueries({ queryKey: networkingQueries.ips._def }); + }, + }); }; export const useLinodeRemoveRangeMutation = (range: string) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => removeIPv6Range({ range }), { - onSuccess() { - invalidateAllIPsQueries(queryClient); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'ips']); - queryClient.invalidateQueries([queryKey, 'ipv6']); + return useMutation({ + mutationFn: async () => { + const rangeDetails = await queryClient.ensureQueryData( + networkingQueries.ipv6._ctx.range(range) + ); + await removeIPv6Range({ range }); + return rangeDetails; + }, + onSuccess(deletedRange) { + // Update networking queries + queryClient.removeQueries({ + queryKey: networkingQueries.ipv6._ctx.range(range).queryKey, + }); + queryClient.invalidateQueries({ queryKey: networkingQueries.ips._def }); + queryClient.invalidateQueries({ + queryKey: networkingQueries.ipv6._ctx.ranges._def, + }); + + // Update Linode queries + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + + for (const linode of deletedRange.linodes) { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linode).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linode)._ctx.ips.queryKey, + }); + } }, }); }; export const useLinodeShareIPMutation = () => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], IPSharingPayload>(shareAddresses, { - onSuccess() { - invalidateAllIPsQueries(queryClient); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + return useMutation<{}, APIError[], IPSharingPayload>({ + mutationFn: shareAddresses, + onSuccess(response, variables) { + invalidateIPsForAllLinodes(queryClient); + + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(variables.linode_id).queryKey, + }); + + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + + queryClient.invalidateQueries({ queryKey: networkingQueries._def }); }, }); }; @@ -112,128 +136,68 @@ export const useAssignAdressesMutation = ({ currentLinodeId: Linode['id']; }) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], IPAssignmentPayload>(assignAddresses, { + return useMutation<{}, APIError[], IPAssignmentPayload>({ + mutationFn: assignAddresses, onSuccess(_, variables) { for (const { linode_id } of variables.assignments) { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linode_id, - 'details', - ]); - queryClient.invalidateQueries([queryKey, 'linode', linode_id, 'ips']); + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linode_id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linode_id)._ctx.ips.queryKey, + }); } - queryClient.invalidateQueries([ - queryKey, - 'linode', - currentLinodeId, - 'ips', - ]); - queryClient.invalidateQueries([queryKey, 'ipv6']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); + + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(currentLinodeId).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(currentLinodeId)._ctx.ips.queryKey, + }); + + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + + queryClient.invalidateQueries({ queryKey: networkingQueries._def }); }, }); }; export const useAllocateIPMutation = (linodeId: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], IPAllocationRequest>( - (data) => allocateIPAddress(linodeId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - queryKey, - 'linode', - linodeId, - 'details', - ]); - queryClient.invalidateQueries([queryKey, 'linode', linodeId, 'ips']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - queryClient.invalidateQueries([queryKey, 'ips']); - }, - } - ); -}; + return useMutation<{}, APIError[], IPAllocationRequest>({ + mutationFn: (data) => allocateIPAddress(linodeId, data), + onSuccess() { + // Update Linode queries + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linodeId)._ctx.ips.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); -export const useCreateIPv6RangeMutation = () => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[], CreateIPv6RangePayload>(createIPv6Range, { - onSuccess(_, variables) { - queryClient.invalidateQueries([queryKey, 'ips']); - queryClient.invalidateQueries([queryKey, 'ipv6']); - if (variables.linode_id) { - queryClient.invalidateQueries([ - queryKey, - 'linode', - variables.linode_id, - 'details', - ]); - queryClient.invalidateQueries([ - queryKey, - 'linode', - variables.linode_id, - 'ips', - ]); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'infinite']); - } + // Update networking queries + queryClient.invalidateQueries({ + queryKey: networkingQueries.ips._def, + }); }, }); }; -export const useAllIPsQuery = ( - params?: Params, - filter?: Filter, - enabled: boolean = true -) => { - return useQuery( - [queryKey, 'ips', params, filter], - () => getAllIps(params, filter), - { enabled } - ); -}; - -export const useAllIPv6RangesQuery = ( - params?: Params, - filter?: Filter, - enabled: boolean = true -) => { - return useQuery( - [queryKey, 'ipv6', 'ranges', params, filter], - () => getAllIPv6Ranges(params, filter), - { enabled } - ); -}; - -export const useAllDetailedIPv6RangesQuery = ( - params?: Params, - filter?: Filter, - enabled: boolean = true -) => { - const { data: ranges } = useAllIPv6RangesQuery(params, filter, enabled); - return useQuery( - [queryKey, 'ipv6', 'ranges', 'details', params, filter], - async () => { - return await Promise.all( - (ranges ?? []).map((range) => getIPv6RangeInfo(range.range)) - ); - }, - { enabled: ranges !== undefined && enabled } - ); -}; - -const invalidateAllIPsQueries = (queryClient: QueryClient) => { +const invalidateIPsForAllLinodes = (queryClient: QueryClient) => { // Because IPs may be shared between Linodes, we can't simpily invalidate one store. // Here, we look at all of our active query keys, and invalidate any queryKey that contains 'ips'. queryClient.invalidateQueries({ predicate: (query) => { if (Array.isArray(query.queryKey)) { - return query.queryKey[0] === queryKey && query.queryKey[3] === 'ips'; + return query.queryKey[0] === 'linodes' && query.queryKey[3] === 'ips'; } return false; }, diff --git a/packages/manager/src/queries/linodes/requests.ts b/packages/manager/src/queries/linodes/requests.ts index d63aa47b958..b638e2aa690 100644 --- a/packages/manager/src/queries/linodes/requests.ts +++ b/packages/manager/src/queries/linodes/requests.ts @@ -1,7 +1,6 @@ import { - getIPs, - getIPv6Ranges, getLinodeConfigs, + getLinodeDisks, getLinodeFirewalls, getLinodeKernels, getLinodes, @@ -11,10 +10,9 @@ import { getAll } from 'src/utilities/getAll'; import type { Config, + Disk, Filter, Firewall, - IPAddress, - IPRange, Kernel, Linode, Params, @@ -57,21 +55,7 @@ export const getAllLinodeFirewalls = ( ) )().then((data) => data.data); -export const getAllIps = ( - passedParams: Params = {}, - passedFilter: Filter = {} -) => - getAll((params, filter) => - getIPs({ ...params, ...passedParams }, { ...filter, ...passedFilter }) - )().then((data) => data.data); - -export const getAllIPv6Ranges = ( - passedParams: Params = {}, - passedFilter: Filter = {} -) => - getAll((params, filter) => - getIPv6Ranges( - { ...params, ...passedParams }, - { ...filter, ...passedFilter } - ) - )().then((data) => data.data); +export const getAllLinodeDisks = (id: number) => + getAll((params, filter) => getLinodeDisks(id, params, filter))().then( + (data) => data.data + ); diff --git a/packages/manager/src/queries/linodes/stats.ts b/packages/manager/src/queries/linodes/stats.ts index 0884d635ece..8849d10888a 100644 --- a/packages/manager/src/queries/linodes/stats.ts +++ b/packages/manager/src/queries/linodes/stats.ts @@ -1,67 +1,55 @@ -import { +import { useQuery } from '@tanstack/react-query'; + +import { linodeQueries } from './linodes'; + +import type { APIError, NetworkTransfer, + RegionalNetworkUtilization, Stats, - getLinodeStats, - getLinodeStatsByDate, - getLinodeTransferByDate, } from '@linode/api-v4'; -import { DateTime } from 'luxon'; -import { useQuery } from '@tanstack/react-query'; - -import { parseAPIDate } from 'src/utilities/date'; - -import { queryKey } from './linodes'; export const STATS_NOT_READY_API_MESSAGE = 'Stats are unavailable at this time.'; export const STATS_NOT_READY_MESSAGE = 'Stats for this Linode are not available yet'; -const getIsTooEarlyForStats = (linodeCreated?: string) => { - if (!linodeCreated) { - return false; - } - - return parseAPIDate(linodeCreated) > DateTime.local().minus({ minutes: 7 }); +const queryOptions = { + keepPreviousData: true, + refetchInterval: 300_000, // 5 minutes + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: false, + retryOnMount: false, }; -export const useLinodeStats = ( - id: number, - enabled = true, - linodeCreated?: string -) => { - return useQuery( - [queryKey, 'linode', id, 'stats'], - getIsTooEarlyForStats(linodeCreated) - ? () => Promise.reject([{ reason: STATS_NOT_READY_MESSAGE }]) - : () => getLinodeStats(id), - // We need to disable retries because the API will - // error if stats are not ready. If the default retry policy - // is used, a "stats not ready" state can't be shown because the - // query is still trying to request. - { enabled, refetchInterval: 30000, retry: false } - ); +export const useLinodeStats = (id: number, enabled = true) => { + return useQuery({ + ...linodeQueries.linode(id)._ctx.stats, + enabled, + ...queryOptions, + }); }; export const useLinodeStatsByDate = ( id: number, year: string, month: string, - enabled = true, - linodeCreated?: string + enabled = true ) => { - return useQuery( - [queryKey, 'linode', id, 'stats', 'date', year, month], - getIsTooEarlyForStats(linodeCreated) - ? () => Promise.reject([{ reason: STATS_NOT_READY_MESSAGE }]) - : () => getLinodeStatsByDate(id, year, month), - // We need to disable retries because the API will - // error if stats are not ready. If the default retry policy - // is used, a "stats not ready" state can't be shown because the - // query is still trying to request. - { enabled, refetchInterval: 30000, retry: false } - ); + return useQuery({ + ...linodeQueries.linode(id)._ctx.statsByDate(year, month), + enabled, + ...queryOptions, + }); +}; + +export const useLinodeTransfer = (id: number, enabled = true) => { + return useQuery({ + ...linodeQueries.linode(id)._ctx.transfer, + enabled, + ...queryOptions, + }); }; export const useLinodeTransferByDate = ( @@ -70,9 +58,9 @@ export const useLinodeTransferByDate = ( month: string, enabled = true ) => { - return useQuery( - [queryKey, 'linode', id, 'transfer', year, month], - () => getLinodeTransferByDate(id, year, month), - { enabled } - ); + return useQuery({ + ...linodeQueries.linode(id)._ctx.transferByDate(year, month), + enabled, + ...queryOptions, + }); }; diff --git a/packages/manager/src/queries/networking/networking.ts b/packages/manager/src/queries/networking/networking.ts new file mode 100644 index 00000000000..c8e83859de2 --- /dev/null +++ b/packages/manager/src/queries/networking/networking.ts @@ -0,0 +1,121 @@ +import { createIPv6Range, getIPv6RangeInfo } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { + useMutation, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { linodeQueries } from '../linodes/linodes'; +import { getAllIPv6Ranges, getAllIps } from './requests'; + +import type { + APIError, + CreateIPv6RangePayload, + Filter, + IPAddress, + IPRange, + IPRangeInformation, + Params, +} from '@linode/api-v4'; +import { useMemo } from 'react'; + +export const networkingQueries = createQueryKeys('networking', { + ips: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllIps(params, filter), + queryKey: [params, filter], + }), + ipv6: { + contextQueries: { + range: (range: string) => ({ + queryFn: () => getIPv6RangeInfo(range), + queryKey: [range], + }), + ranges: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllIPv6Ranges(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); + +export const useAllIPsQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery({ + ...networkingQueries.ips(params, filter), + enabled, + }); +}; + +export const useAllIPv6RangesQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery({ + ...networkingQueries.ipv6._ctx.ranges(params, filter), + enabled, + }); +}; + +export const useAllDetailedIPv6RangesQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + const { data: ranges } = useAllIPv6RangesQuery(params, filter, enabled); + + const queryResults = useQueries({ + queries: + ranges?.map((range) => networkingQueries.ipv6._ctx.range(range.range)) ?? + [], + }); + + // @todo use React Query's combine once we upgrade to v5 + const data = queryResults.reduce( + (detailedRanges, query) => { + if (query.data) { + detailedRanges.push(query.data); + } + return detailedRanges; + }, + [] + ); + + const stableData = useMemo(() => data, [JSON.stringify(data)]); + + return { data: stableData }; +}; + +export const useCreateIPv6RangeMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], CreateIPv6RangePayload>({ + mutationFn: createIPv6Range, + onSuccess(_, variables) { + // Invalidate networking queries + queryClient.invalidateQueries({ queryKey: networkingQueries.ips._def }); + queryClient.invalidateQueries({ + queryKey: networkingQueries.ipv6.queryKey, + }); + + // Invalidate Linode queries + if (variables.linode_id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(variables.linode_id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(variables.linode_id)._ctx.ips.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linodes.queryKey, + }); + } + }, + }); +}; diff --git a/packages/manager/src/queries/networking/requests.ts b/packages/manager/src/queries/networking/requests.ts new file mode 100644 index 00000000000..eca630f658d --- /dev/null +++ b/packages/manager/src/queries/networking/requests.ts @@ -0,0 +1,24 @@ +import { getIPs, getIPv6Ranges } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +import type { Filter, IPAddress, IPRange, Params } from '@linode/api-v4'; + +export const getAllIps = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getIPs({ ...params, ...passedParams }, { ...filter, ...passedFilter }) + )().then((data) => data.data); + +export const getAllIPv6Ranges = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getIPv6Ranges( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 4f8a4fefd70..37815578335 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -7,24 +7,22 @@ import { unassignLinodesFromPlacementGroup, updatePlacementGroup, } from '@linode/api-v4'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { queryKey as linodeQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; +import { linodeQueries } from './linodes/linodes'; import { profileQueries } from './profile/profile'; import type { + APIError, AssignLinodesToPlacementGroupPayload, CreatePlacementGroupPayload, + Filter, + Params, PlacementGroup, + ResourcePage, UnassignLinodesFromPlacementGroupPayload, UpdatePlacementGroupPayload, } from '@linode/api-v4'; @@ -155,11 +153,14 @@ export const useAssignLinodesToPlacementGroup = (placementGroupId: number) => { placementGroupQueries.placementGroup(placementGroupId).queryKey ); - queryClient.invalidateQueries([ - linodeQueryKey, - 'linode', - variables.linodes[0], - ]); + queryClient.invalidateQueries(linodeQueries.linodes); + + for (const linodeId of variables.linodes) { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + } }, }); }; @@ -182,11 +183,14 @@ export const useUnassignLinodesFromPlacementGroup = ( placementGroupQueries.placementGroup(placementGroupId).queryKey ); - queryClient.invalidateQueries([ - linodeQueryKey, - 'linode', - variables.linodes[0], - ]); + queryClient.invalidateQueries(linodeQueries.linodes); + + for (const linodeId of variables.linodes) { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linodeId).queryKey, + }); + } }, }); }; From ae490718ab901af27d8a1c1b7d952067b882761a Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Thu, 18 Jul 2024 12:41:29 +0530 Subject: [PATCH 04/58] feat: [M3-7683] - Disable Image Action Menu Buttons for Restricted Users (#10682) * feat: [M3-7683] - Disable Image Action Menu Buttons for Restricted Users * Added changeset: Disable Image Action Menu Buttons for Restricted Users --- .../pr-10682-added-1721126168229.md | 5 ++ .../Images/ImagesLanding/ImagesActionMenu.tsx | 69 +++++++++++++++-- .../ImagesLanding/ImagesLanding.test.tsx | 74 +++++++++++++++++++ 3 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-10682-added-1721126168229.md diff --git a/packages/manager/.changeset/pr-10682-added-1721126168229.md b/packages/manager/.changeset/pr-10682-added-1721126168229.md new file mode 100644 index 00000000000..e793ab66437 --- /dev/null +++ b/packages/manager/.changeset/pr-10682-added-1721126168229.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Disable Image Action Menu Buttons for Restricted Users ([#10682](https://github.com/linode/manager/pull/10682)) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index 90bd29494c3..ba6e25be963 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -4,6 +4,10 @@ import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import type { Event, Image, ImageStatus } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useImageAndLinodeGrantCheck } from '../utils'; export interface Handlers { onCancelFailed?: (imageID: string) => void; @@ -40,6 +44,24 @@ export const ImagesActionMenu = (props: Props) => { onRetry, } = handlers; + const isImageReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'image', + id: Number(id.split('/')[1]), + }); + + const isAddLinodeRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_linodes', + }); + + const { + permissionedLinodes: availableLinodes, + } = useImageAndLinodeGrantCheck(); + + const isAvailableLinodesPresent = availableLinodes + ? availableLinodes.length > 0 + : true; + const actions: Action[] = React.useMemo(() => { const isDisabled = status && status !== 'available'; const isAvailable = !isDisabled; @@ -57,41 +79,74 @@ export const ImagesActionMenu = (props: Props) => { ] : [ { - disabled: isDisabled, + disabled: isImageReadOnly || isDisabled, onClick: () => onEdit?.(image), title: 'Edit', - tooltip: isDisabled + tooltip: isImageReadOnly + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Images', + }) + : isDisabled ? 'Image is not yet available for use.' : undefined, }, ...(onManageRegions ? [ { - disabled: isDisabled, + disabled: isImageReadOnly || isDisabled, onClick: () => onManageRegions(image), title: 'Manage Regions', + tooltip: isImageReadOnly + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Images', + }) + : undefined, }, ] : []), { - disabled: isDisabled, + disabled: isAddLinodeRestricted || isDisabled, onClick: () => onDeploy?.(id), title: 'Deploy to New Linode', - tooltip: isDisabled + tooltip: isAddLinodeRestricted + ? getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Linodes', + }) + : isDisabled ? 'Image is not yet available for use.' : undefined, }, { - disabled: isDisabled, + disabled: !isAvailableLinodesPresent || isDisabled, onClick: () => onRestore?.(image), title: 'Rebuild an Existing Linode', - tooltip: isDisabled + tooltip: !isAvailableLinodesPresent + ? getRestrictedResourceText({ + action: 'rebuild', + isSingular: false, + resourceType: 'Linodes', + }) + : isDisabled ? 'Image is not yet available for use.' : undefined, }, { + disabled: isImageReadOnly, onClick: () => onDelete?.(label, id, status), title: isAvailable ? 'Delete' : 'Cancel', + tooltip: isImageReadOnly + ? getRestrictedResourceText({ + action: 'delete', + isSingular: true, + resourceType: 'Images', + }) + : undefined, }, ]; }, [ diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index 4ee4f79359d..59d52947e28 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -289,4 +289,78 @@ describe('Images Landing Table', () => { "You don't have permissions to create Images. Please contact your account administrator to request the necessary permissions." ); }); + + it('disables the action menu buttons if user does not have permissions to edit images', async () => { + const images = imageFactory.buildList(1, { + id: 'private/99999', + label: 'vi-test-image', + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + + server.use( + http.get('*/v4/profile', () => { + const profile = profileFactory.build({ restricted: true }); + return HttpResponse.json(profile); + }), + http.get('*/v4/profile/grants', () => { + const grants = grantsFactory.build({ + global: { + add_linodes: false, + }, + image: [ + { + id: 99999, + label: 'vi-test-image', + permissions: 'read_only', + }, + ], + }); + return HttpResponse.json(grants); + }), + http.get('*/v4/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { + getAllByLabelText, + getByTestId, + findAllByLabelText, + } = renderWithTheme(, { + flags: { imageServiceGen2: true }, + }); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + + await userEvent.click(actionMenu); + + const disabledEditText = await findAllByLabelText( + "You don't have permissions to edit this Image. Please contact your account administrator to request the necessary permissions." + ); + const disabledDeleteText = await findAllByLabelText( + "You don't have permissions to delete this Image. Please contact your account administrator to request the necessary permissions." + ); + const disabledLinodeCreationText = await findAllByLabelText( + "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." + ); + const disabledLinodeRebuildingText = await findAllByLabelText( + "You don't have permissions to rebuild Linodes. Please contact your account administrator to request the necessary permissions." + ); + + expect(disabledEditText.length).toBe(2); + expect(disabledDeleteText.length).toBe(1); + expect(disabledLinodeCreationText.length).toBe(1); + expect(disabledLinodeRebuildingText.length).toBe(1); + }); }); From 21c067af177874db717da73f42253d30596fcc4f Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Thu, 18 Jul 2024 14:35:59 +0530 Subject: [PATCH 05/58] change: [M3-7683] - Keep Create & Upload Image Pages' Error Notification Position Consistent (#10675) * change: [M3-7683] - Keep Create & Upload Image Pages Error Notification Position Consistent * Added changeset: Use `getRestrictedResourceText` utility and move restrictions Notice to top of Image Create and Upload pages * feat: [M3-7683] - Disable Upload Using Comnad Line Button for Restricted users --- .../pr-10675-changed-1721025143054.md | 5 +++++ .../Images/ImagesCreate/CreateImageTab.tsx | 18 ++++++++++----- .../Images/ImagesCreate/ImageUpload.tsx | 22 ++++++++++--------- 3 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 packages/manager/.changeset/pr-10675-changed-1721025143054.md diff --git a/packages/manager/.changeset/pr-10675-changed-1721025143054.md b/packages/manager/.changeset/pr-10675-changed-1721025143054.md new file mode 100644 index 00000000000..ac4438df6f6 --- /dev/null +++ b/packages/manager/.changeset/pr-10675-changed-1721025143054.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use `getRestrictedResourceText` utility and move restrictions Notice to top of Image Create and Upload pages ([#10675](https://github.com/linode/manager/pull/10675)) diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index f28001f5618..da69661f4b2 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -31,6 +31,7 @@ import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useGrants } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import type { CreateImagePayload } from '@linode/api-v4'; import type { LinodeConfigAndDiskQueryParams } from 'src/features/Linodes/types'; @@ -167,13 +168,18 @@ export const CreateImageTab = () => { return (
+ {isImageCreateRestricted && ( + + )} - {isImageCreateRestricted && ( - - )} {formState.errors.root?.message && ( { + {isImageCreateRestricted && ( + + )} Image Details @@ -188,16 +199,6 @@ export const ImageUpload = () => { variant="error" /> )} - {isImageCreateRestricted && ( - - )} ( { From af0aee8684c9bbafe0cf50204b19a4aba0739bb1 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:09:50 -0400 Subject: [PATCH 06/58] test: [M3-8135] - Add Cypress integration test for Help & Support landing page (#10616) * M3-8135 - Add Cypress integration test for Help & Support landing page * Minor change for ticketMap * Added changeset: Add Cypress integration test for Help & Support landing page * Fixed comments * Minor fix --- .../pr-10616-tests-1719431797288.md | 5 + .../support-tickets-landing-page.spec.ts | 287 ++++++++++++++++++ .../cypress/support/constants/tickets.ts | 7 + 3 files changed, 299 insertions(+) create mode 100644 packages/manager/.changeset/pr-10616-tests-1719431797288.md create mode 100644 packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts create mode 100644 packages/manager/cypress/support/constants/tickets.ts diff --git a/packages/manager/.changeset/pr-10616-tests-1719431797288.md b/packages/manager/.changeset/pr-10616-tests-1719431797288.md new file mode 100644 index 00000000000..e15feb2241c --- /dev/null +++ b/packages/manager/.changeset/pr-10616-tests-1719431797288.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress integration test for Support Ticket landing page ([#10616](https://github.com/linode/manager/pull/10616)) diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts new file mode 100644 index 00000000000..2c511d9810f --- /dev/null +++ b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts @@ -0,0 +1,287 @@ +import { interceptGetProfile } from 'support/intercepts/profile'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + randomItem, + randomLabel, + randomNumber, + randomPhrase, +} from 'support/util/random'; +import { + entityFactory, + linodeFactory, + supportTicketFactory, + volumeFactory, + linodeConfigFactory, + LinodeConfigInterfaceFactory, +} from 'src/factories'; +import { + mockGetSupportTicket, + mockGetSupportTickets, + mockGetSupportTicketReplies, +} from 'support/intercepts/support'; +import { SEVERITY_LABEL_MAP } from 'src/features/Support/SupportTickets/constants'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { + mockGetLinodeDetails, + mockGetLinodeVolumes, + mockGetLinodeDisks, +} from 'support/intercepts/linodes'; +import { Config, Disk } from '@linode/api-v4'; + +describe('support tickets landing page', () => { + /* + * - Confirms that "No items to display" is shown when the user has no open support tickets. + */ + it('shows the empty message when there are no tickets.', () => { + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + + interceptGetProfile().as('getProfile'); + + // intercept get ticket request, stub response. + mockGetSupportTickets([]).as('getSupportTickets'); + + cy.visitWithLogin('/support/tickets'); + + cy.wait(['@getProfile', '@getSupportTickets']); + + cy.get('[data-qa-open-tickets-tab]').within(() => { + // Confirm that "Severity" table column is not shown. + cy.findByLabelText('Sort by severity').should('not.exist'); + + // Confirm that other table columns are shown. + cy.findByText('Subject').should('be.visible'); + cy.findByText('Ticket ID').should('be.visible'); + cy.findByText('Regarding').should('be.visible'); + cy.findByText('Date Created').should('be.visible'); + cy.findByText('Last Updated').should('be.visible'); + cy.findByText('Updated By').should('be.visible'); + }); + + // Confirm that no ticket is listed. + cy.findByText('No items to display.').should('be.visible'); + }); + + /* + * - Confirms that support tickets are listed in the table when the user has ones. + */ + it('lists support tickets in the table as expected', () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + const mockAnotherTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'open', + }); + + const mockTickets = [mockTicket, mockAnotherTicket]; + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockGetSupportTickets(mockTickets); + + cy.visitWithLogin('/support/tickets'); + + cy.get('[data-qa-open-tickets-tab]').within(() => { + // Confirm that "Severity" table column is displayed. + cy.findByLabelText('Sort by severity').should('be.visible'); + + // Confirm that other table columns are shown. + cy.findByText('Subject').should('be.visible'); + cy.findByText('Ticket ID').should('be.visible'); + cy.findByText('Regarding').should('be.visible'); + cy.findByText('Date Created').should('be.visible'); + cy.findByText('Last Updated').should('be.visible'); + cy.findByText('Updated By').should('be.visible'); + }); + + mockTickets.forEach((ticket) => { + // Get severity label for numeric severity level. + // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. + const severityLabel = SEVERITY_LABEL_MAP.get(ticket.severity!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${ticket.severity}'. Is this a valid support severity level?` + ); + } + + // Confirm that tickets are listed as expected. + cy.findByText(ticket.summary) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(ticket.id).should('be.visible'); + cy.findByText(severityLabel).should('be.visible'); + }); + }); + }); + + /* + * - Confirms that clicking on the ticket subject navigates to the ticket's page. + */ + it("can navigate to the ticket's page when clicking on the ticket subject", () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + // Get severity label for numeric severity level. + // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. + const severityLabel = SEVERITY_LABEL_MAP.get(mockTicket.severity!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` + ); + } + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockGetSupportTickets([mockTicket]); + mockGetSupportTicket(mockTicket).as('getSupportTicket'); + mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); + + cy.visitWithLogin('/support/tickets'); + + // Confirm that tickets are listed as expected. + cy.findByText(mockTicket.summary).should('be.visible').click(); + + cy.wait(['@getSupportTicket', '@getReplies']); + + cy.url().should('endWith', `/tickets/${mockTicket.id}`); + cy.findByText( + mockTicket.status.substring(0, 1).toUpperCase() + + mockTicket.status.substring(1) + ).should('be.visible'); + cy.findByText(`#${mockTicket.id}: ${mockTicket.summary}`).should( + 'be.visible' + ); + cy.findByText(mockTicket.description).should('be.visible'); + cy.findByText(severityLabel).should('be.visible'); + }); + + /* + * - Confirms that the entity is shown in the table when the support ticket is related to it. + * - Confirms that clicking the entity's label redirects to that entity's page + */ + it("can navigate to the entity's page when clicking the entity's label", () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: `${randomLabel()}-linode`, + }); + const mockVolume = volumeFactory.build(); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + ], + }); + const mockDisks: Disk[] = [ + { + id: 44311273, + status: 'ready', + label: 'Debian 10 Disk', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:30', + filesystem: 'ext4', + size: 81408, + }, + { + id: 44311274, + status: 'ready', + label: '512 MB Swap Image', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:31', + filesystem: 'swap', + size: 512, + }, + ]; + + const mockEntity = entityFactory.build({ + id: mockLinode.id, + label: `${randomLabel()}-entity`, + type: 'linode', + url: 'https://www.example.com', + }); + + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + entity: mockEntity, + summary: `${randomLabel()}-support-ticket`, + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + // Get severity label for numeric severity level. + // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. + const severityLabel = SEVERITY_LABEL_MAP.get(mockTicket.severity!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` + ); + } + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockGetSupportTickets([mockTicket]); + mockGetSupportTicket(mockTicket).as('getSupportTicket'); + mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, [mockVolume]).as('getVolumes'); + + cy.visitWithLogin('/support/tickets'); + + // Confirm that tickets are listed as expected. + cy.findByText(mockTicket.summary).should('be.visible').click(); + + cy.wait(['@getSupportTicket', '@getReplies']); + + cy.url().should('endWith', `/tickets/${mockTicket.id}`); + cy.findByText( + mockTicket.status.substring(0, 1).toUpperCase() + + mockTicket.status.substring(1) + ).should('be.visible'); + cy.findByText(`#${mockTicket.id}: ${mockTicket.summary}`).should( + 'be.visible' + ); + cy.findByText(mockTicket.description).should('be.visible'); + cy.findByText(severityLabel).should('be.visible'); + + // Clicking on the entity will redirect to the entity's page. + cy.findByText(`${mockEntity.label}`).should('be.visible').click(); + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + }); +}); diff --git a/packages/manager/cypress/support/constants/tickets.ts b/packages/manager/cypress/support/constants/tickets.ts new file mode 100644 index 00000000000..dc91654fa39 --- /dev/null +++ b/packages/manager/cypress/support/constants/tickets.ts @@ -0,0 +1,7 @@ +import { TicketSeverity } from '@linode/api-v4'; + +export const severityLabelMap: Map = new Map([ + [1, '1-Major Impact'], + [2, '2-Moderate Impact'], + [3, '3-Low Impact'], +]); From cf5f612d75454a6649717ee2974c5dbd6d4678cf Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 18 Jul 2024 16:02:30 -0400 Subject: [PATCH 07/58] upcoming: [M3-8311] - Auto-generate Linode Labels in Linode Create v2 (#10678) * initial attempt * more progress * all tabs kind of work * make sure only valid labels are generated * add size clamping * more testing * fix type --------- Co-authored-by: Banks Nussman --- .../Linodes/LinodeCreatev2/Region.tsx | 76 ++++++--- .../Linodes/LinodeCreatev2/Tabs/Images.tsx | 17 +- .../Tabs/Marketplace/AppsList.tsx | 25 ++- .../LinodeCreatev2/Tabs/OperatingSystems.tsx | 35 ++++- .../StackScripts/StackScriptSelection.tsx | 1 + .../StackScripts/StackScriptSelectionList.tsx | 30 +++- .../features/Linodes/LinodeCreatev2/index.tsx | 13 +- .../shared/LinodeSelectTable.tsx | 31 +++- .../Linodes/LinodeCreatev2/utilities.test.tsx | 51 ++++++ .../Linodes/LinodeCreatev2/utilities.ts | 145 ++++++++++++++---- 10 files changed, 355 insertions(+), 69 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 89d7dd01fc8..d99da6b402f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -24,10 +24,15 @@ import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricin import { CROSS_DATA_CENTER_CLONE_WARNING } from '../LinodesCreate/constants'; import { getDisabledRegions } from './Region.utils'; -import { defaultInterfaces, useLinodeCreateQueryParams } from './utilities'; +import { + defaultInterfaces, + getGeneratedLinodeLabel, + useLinodeCreateQueryParams, +} from './utilities'; import type { LinodeCreateFormValues } from './utilities'; import type { Region as RegionType } from '@linode/api-v4'; +import { useQueryClient } from '@tanstack/react-query'; export const Region = () => { const { @@ -35,10 +40,19 @@ export const Region = () => { } = useIsDiskEncryptionFeatureEnabled(); const flags = useFlags(); + const queryClient = useQueryClient(); const { params } = useLinodeCreateQueryParams(); - const { control, reset } = useFormContext(); + const { + control, + formState: { + dirtyFields: { label: isLabelFieldDirty }, + }, + getValues, + reset, + setValue, + } = useFormContext(); const { field, fieldState } = useController({ control, name: 'region', @@ -65,7 +79,7 @@ export const Region = () => { const { data: regions } = useRegionsQuery(); - const onChange = (region: RegionType) => { + const onChange = async (region: RegionType) => { const isDistributedRegion = region.site_type === 'distributed' || region.site_type === 'edge'; @@ -75,26 +89,44 @@ export const Region = () => { ? 'enabled' : undefined; - reset((prev) => ({ - ...prev, - // Reset interfaces because VPC and VLANs are region-sepecific - interfaces: defaultInterfaces, - // Reset Cloud-init metadata because not all regions support it - metadata: undefined, - // Reset the placement group because they are region-specific - placement_group: undefined, - // Set the region - region: region.id, - // Backups and Private IP are not supported in distributed compute regions - ...(isDistributedRegion && { - backups_enabled: false, - private_ip: false, + reset( + (prev) => ({ + ...prev, + // Reset interfaces because VPC and VLANs are region-sepecific + interfaces: defaultInterfaces, + // Reset Cloud-init metadata because not all regions support it + metadata: undefined, + // Reset the placement group because they are region-specific + placement_group: undefined, + // Set the region + region: region.id, + // Backups and Private IP are not supported in distributed compute regions + ...(isDistributedRegion && { + backups_enabled: false, + private_ip: false, + }), + // If disk encryption is enabled, set the default value to "enabled" if the region supports it + ...(isDiskEncryptionFeatureEnabled && { + disk_encryption: defaultDiskEncryptionValue, + }), }), - // If disk encryption is enabled, set the default value to "enabled" if the region supports it - ...(isDiskEncryptionFeatureEnabled && { - disk_encryption: defaultDiskEncryptionValue, - }), - })); + { + keepDirty: true, + keepDirtyValues: true, + keepErrors: true, + keepSubmitCount: true, + keepTouched: true, + } + ); + + if (!isLabelFieldDirty) { + const label = await getGeneratedLinodeLabel({ + queryClient, + tab: params.type ?? 'OS', + values: getValues(), + }); + setValue('label', label); + } }; const showCrossDataCenterCloneWarning = diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 17542f0e313..75e05439f3b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -12,15 +12,17 @@ import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGran import { useAllImagesQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { LinodeCreateFormValues } from '../utilities'; +import { getGeneratedLinodeLabel, type LinodeCreateFormValues } from '../utilities'; import type { Image } from '@linode/api-v4'; +import { useQueryClient } from '@tanstack/react-query'; export const Images = () => { - const { control, setValue } = useFormContext(); + const { control, formState: { dirtyFields: { label: isLabelFieldDirty }}, getValues, setValue } = useFormContext(); const { field, fieldState } = useController({ control, name: 'image', }); + const queryClient = useQueryClient(); const isCreateLinodeRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', @@ -30,7 +32,7 @@ export const Images = () => { const { data: regions } = useRegionsQuery(); - const onChange = (image: Image | null) => { + const onChange = async (image: Image | null) => { field.onChange(image?.id ?? null); const selectedRegion = regions?.find((r) => r.id === regionId); @@ -45,6 +47,15 @@ export const Images = () => { ) { setValue('region', ''); } + + if (!isLabelFieldDirty) { + const label = await getGeneratedLinodeLabel({ + queryClient, + tab: "Images", + values: getValues(), + }); + setValue('label', label); + } }; const { data: images } = useAllImagesQuery( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx index a3e1d720328..cca356bfef0 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx @@ -1,4 +1,5 @@ import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { useController, useFormContext } from 'react-hook-form'; @@ -9,6 +10,7 @@ import { Stack } from 'src/components/Stack'; import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; +import { getGeneratedLinodeLabel } from '../../utilities'; import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities'; import { AppSection } from './AppSection'; import { AppSelectionCard } from './AppSelectionCard'; @@ -38,18 +40,37 @@ export const AppsList = (props: Props) => { const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery( true ); + const queryClient = useQueryClient(); + + const { + formState: { + dirtyFields: { label: isLabelFieldDirty }, + }, + getValues, + setValue, + } = useFormContext(); - const { setValue } = useFormContext(); const { field } = useController({ name: 'stackscript_id', }); - const onSelect = (stackscript: StackScript) => { + const onSelect = async (stackscript: StackScript) => { setValue( 'stackscript_data', getDefaultUDFData(stackscript.user_defined_fields) ); field.onChange(stackscript.id); + + if (!isLabelFieldDirty) { + setValue( + 'label', + await getGeneratedLinodeLabel({ + queryClient, + tab: 'StackScripts', + values: getValues(), + }) + ); + } }; if (isLoading) { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/OperatingSystems.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/OperatingSystems.tsx index f4f0a315486..ec3b526fc33 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/OperatingSystems.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/OperatingSystems.tsx @@ -1,15 +1,29 @@ +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; import { ImageSelectv2 } from 'src/components/ImageSelectv2/ImageSelectv2'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import { getGeneratedLinodeLabel } from '../utilities'; + +import type { LinodeCreateFormValues } from '../utilities'; +import type { Image } from '@linode/api-v4'; export const OperatingSystems = () => { - const { field, fieldState } = useController({ + const { + formState: { + dirtyFields: { label: isLabelFieldDirty }, + }, + getValues, + setValue, + } = useFormContext(); + + const queryClient = useQueryClient(); + + const { field, fieldState } = useController({ name: 'image', }); @@ -17,6 +31,19 @@ export const OperatingSystems = () => { globalGrantType: 'add_linodes', }); + const onChange = async (image: Image | null) => { + field.onChange(image?.id ?? null); + + if (!isLabelFieldDirty) { + const label = await getGeneratedLinodeLabel({ + queryClient, + tab: 'OS', + values: getValues(), + }); + setValue('label', label); + } + }; + return ( Choose an OS @@ -25,7 +52,7 @@ export const OperatingSystems = () => { errorText={fieldState.error?.message} label="Linux Distribution" onBlur={field.onBlur} - onChange={(image) => field.onChange(image?.id ?? null)} + onChange={onChange} placeholder="Choose a Linux distribution" value={field.value} variant="public" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx index 91bcffd5696..3edf2b0e66c 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx @@ -27,6 +27,7 @@ export const StackScriptSelection = () => { reset((prev) => ({ ...prev, image: undefined, + label: '', // @todo use generate here to retain region in label? stackscript_data: undefined, stackscript_id: undefined, })); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index 7b9c379620a..c7af0a8abf1 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -1,5 +1,6 @@ import { getAPIFilterFromQuery } from '@linode/search'; import CloseIcon from '@mui/icons-material/Close'; +import { useQueryClient } from '@tanstack/react-query'; import React, { useState } from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { Waypoint } from 'react-waypoint'; @@ -28,7 +29,10 @@ import { useStackScriptsInfiniteQuery, } from 'src/queries/stackscripts'; -import { useLinodeCreateQueryParams } from '../../utilities'; +import { + getGeneratedLinodeLabel, + useLinodeCreateQueryParams, +} from '../../utilities'; import { StackScriptDetailsDialog } from './StackScriptDetailsDialog'; import { StackScriptSelectionRow } from './StackScriptSelectionRow'; import { getDefaultUDFData } from './UserDefinedFields/utilities'; @@ -47,12 +51,21 @@ interface Props { export const StackScriptSelectionList = ({ type }: Props) => { const [query, setQuery] = useState(); + const queryClient = useQueryClient(); + const { handleOrderChange, order, orderBy } = useOrder({ order: 'desc', orderBy: 'deployments_total', }); - const { control, setValue } = useFormContext(); + const { + control, + formState: { + dirtyFields: { label: isLabelFieldDirty }, + }, + getValues, + setValue, + } = useFormContext(); const { field } = useController({ control, @@ -189,13 +202,24 @@ export const StackScriptSelectionList = ({ type }: Props) => { {stackscripts?.map((stackscript) => ( { + onSelect={async () => { setValue('image', null); setValue( 'stackscript_data', getDefaultUDFData(stackscript.user_defined_fields) ); field.onChange(stackscript.id); + + if (!isLabelFieldDirty) { + setValue( + 'label', + await getGeneratedLinodeLabel({ + queryClient, + tab: 'StackScripts', + values: getValues(), + }) + ); + } }} isSelected={field.value === stackscript.id} key={stackscript.id} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 57e03788fa1..73aa4ee7d2d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -39,7 +39,6 @@ import { UserData } from './UserData/UserData'; import { captureLinodeCreateAnalyticsEvent, defaultValues, - defaultValuesMap, getLinodeCreatePayload, getTabIndex, tabs, @@ -55,7 +54,7 @@ export const LinodeCreatev2 = () => { const { params, setParams } = useLinodeCreateQueryParams(); const form = useForm({ - defaultValues, + defaultValues: () => defaultValues(queryClient), mode: 'onBlur', resolver: linodeCreateResolvers[params.type ?? 'OS'], shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView` @@ -71,10 +70,12 @@ export const LinodeCreatev2 = () => { const onTabChange = (index: number) => { const newTab = tabs[index]; - // Update tab "type" query param. (This changes the selected tab) - setParams({ type: newTab }); - // Reset the form values - form.reset(defaultValuesMap[newTab]); + defaultValues(queryClient).then((values) => { + // Reset the form values + form.reset(values); + // Update tab "type" query param. (This changes the selected tab) + setParams({ type: newTab }); + }); }; const onSubmit: SubmitHandler = async (values) => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx index c98bfc1d6a5..bb6da070f08 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx @@ -1,5 +1,6 @@ import Grid from '@mui/material/Unstable_Grid2'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { useQueryClient } from '@tanstack/react-query'; import React, { useState } from 'react'; import { useController, useFormContext } from 'react-hook-form'; @@ -27,7 +28,10 @@ import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnal import { privateIPRegex } from 'src/utilities/ipUtils'; import { isNumeric } from 'src/utilities/stringUtils'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { + getGeneratedLinodeLabel, + useLinodeCreateQueryParams, +} from '../utilities'; import { LinodeSelectTableRow } from './LinodeSelectTableRow'; import type { LinodeCreateFormValues } from '../utilities'; @@ -49,7 +53,15 @@ export const LinodeSelectTable = (props: Props) => { theme.breakpoints.up('md') ); - const { control, reset } = useFormContext(); + const { + control, + formState: { + dirtyFields: { label: isLabelFieldDirty }, + }, + getValues, + reset, + setValue, + } = useFormContext(); const { field, fieldState } = useController( { @@ -90,7 +102,9 @@ export const LinodeSelectTable = (props: Props) => { filter ); - const handleSelect = (linode: Linode) => { + const queryClient = useQueryClient(); + + const handleSelect = async (linode: Linode) => { const hasPrivateIP = linode.ipv4.some((ipv4) => privateIPRegex.test(ipv4)); reset((prev) => ({ ...prev, @@ -100,6 +114,17 @@ export const LinodeSelectTable = (props: Props) => { region: linode.region, type: linode.type ?? '', })); + + if (!isLabelFieldDirty) { + setValue( + 'label', + await getGeneratedLinodeLabel({ + queryClient, + tab: params.type, + values: getValues(), + }) + ); + } }; const handlePowerOff = (linode: Linode) => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx index b08528c0f79..3586e1736c9 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx @@ -3,7 +3,9 @@ import { createLinodeRequestFactory } from 'src/factories'; import { base64UserData, userData } from '../LinodesCreate/utilities.test'; import { getInterfacesPayload, + getIsValidLinodeLabelCharacter, getLinodeCreatePayload, + getLinodeLabelFromLabelParts, getTabIndex, } from './utilities'; @@ -300,3 +302,52 @@ describe('getInterfacesPayload', () => { ]); }); }); + +describe('getLinodeLabelFromLabelParts', () => { + it('should join items', () => { + expect(getLinodeLabelFromLabelParts(['my-linode', 'us-east'])).toBe( + 'my-linode-us-east' + ); + }); + it('should not include special characters in the generated label', () => { + expect(getLinodeLabelFromLabelParts(['redis&app', 'us-east'])).toBe( + 'redisapp-us-east' + ); + }); + it('should replace spaces with a -', () => { + expect(getLinodeLabelFromLabelParts(['banks test'])).toBe('banks-test'); + }); + it('should not generate consecutive - _ or .', () => { + expect(getLinodeLabelFromLabelParts(['banks - test', 'us-east'])).toBe( + 'banks-test-us-east' + ); + }); + it('should ensure the generated label is less than 64 characters', () => { + const linodeLabel = 'a'.repeat(64); + const region = 'us-east'; + + expect(getLinodeLabelFromLabelParts([linodeLabel, region])).toBe( + 'a'.repeat(31) + '-us-east' + ); + }); +}); + +describe('getIsValidLinodeLabelCharacter', () => { + it('should allow a-z characters', () => { + expect(getIsValidLinodeLabelCharacter('a')).toBe(true); + expect(getIsValidLinodeLabelCharacter('z')).toBe(true); + }); + it('should allow A-Z characters', () => { + expect(getIsValidLinodeLabelCharacter('A')).toBe(true); + expect(getIsValidLinodeLabelCharacter('Z')).toBe(true); + }); + it('should allow 0-9 characters', () => { + expect(getIsValidLinodeLabelCharacter('0')).toBe(true); + expect(getIsValidLinodeLabelCharacter('9')).toBe(true); + }); + it('should not allow special characters', () => { + expect(getIsValidLinodeLabelCharacter('&')).toBe(false); + expect(getIsValidLinodeLabelCharacter('!')).toBe(false); + expect(getIsValidLinodeLabelCharacter(' ')).toBe(false); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 8ff23345863..99fd9d3c139 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -2,6 +2,7 @@ import { getLinode, getStackScript } from '@linode/api-v4'; import { omit } from 'lodash'; import { useHistory } from 'react-router-dom'; +import { imageQueries } from 'src/queries/images'; import { stackscriptQueries } from 'src/queries/stackscripts'; import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; @@ -253,7 +254,9 @@ export interface LinodeCreateFormValues extends CreateLinodeRequest { * * The default values are dependent on the query params present. */ -export const defaultValues = async (): Promise => { +export const defaultValues = async ( + queryClient: QueryClient +): Promise => { const queryParams = getQueryParamsFromQueryString(window.location.search); const params = getParsedLinodeCreateQueryParams(queryParams); @@ -268,7 +271,7 @@ export const defaultValues = async (): Promise => { const privateIp = linode?.ipv4.some((ipv4) => privateIPRegex.test(ipv4)) ?? false; - return { + const values: LinodeCreateFormValues = { backup_id: params.backupID, image: getDefaultImageId(params), interfaces: defaultInterfaces, @@ -281,6 +284,14 @@ export const defaultValues = async (): Promise => { stackscript_id: stackscriptId, type: linode?.type ? linode.type : '', }; + + values.label = await getGeneratedLinodeLabel({ + queryClient, + tab: params.type, + values, + }); + + return values; }; const getDefaultImageId = (params: ParsedLinodeCreateQueryParams) => { @@ -303,37 +314,119 @@ const getDefaultImageId = (params: ParsedLinodeCreateQueryParams) => { return null; }; -const defaultValuesForImages = { - interfaces: defaultInterfaces, - region: '', - type: '', -}; +interface GeneratedLinodeLabelOptions { + queryClient: QueryClient; + tab: LinodeCreateType | undefined; + values: LinodeCreateFormValues; +} + +export const getGeneratedLinodeLabel = async ( + options: GeneratedLinodeLabelOptions +) => { + const { queryClient, tab, values } = options; + + if (tab === 'OS') { + const generatedLabelParts: string[] = []; + if (values.image) { + const image = await queryClient.ensureQueryData( + imageQueries.image(values.image) + ); + if (image.vendor) { + generatedLabelParts.push(image.vendor.toLowerCase()); + } + } + if (values.region) { + generatedLabelParts.push(values.region); + } + return getLinodeLabelFromLabelParts(generatedLabelParts); + } + + if (tab === 'Images') { + const generatedLabelParts: string[] = []; + if (values.image) { + const image = await queryClient.ensureQueryData( + imageQueries.image(values.image) + ); + generatedLabelParts.push(image.label); + } + if (values.region) { + generatedLabelParts.push(values.region); + } + return getLinodeLabelFromLabelParts(generatedLabelParts); + } -const defaultValuesForOS = { - image: DEFAULT_OS, - interfaces: defaultInterfaces, - region: '', - type: '', + if (tab === 'StackScripts' || tab === 'One-Click') { + const generatedLabelParts: string[] = []; + if (values.stackscript_id) { + const stackscript = await queryClient.ensureQueryData( + stackscriptQueries.stackscript(values.stackscript_id) + ); + generatedLabelParts.push(stackscript.label.toLowerCase()); + } + if (values.region) { + generatedLabelParts.push(values.region); + } + return getLinodeLabelFromLabelParts(generatedLabelParts); + } + + if (tab === 'Backups') { + const generatedLabelParts: string[] = []; + if (values.linode) { + generatedLabelParts.push(values.linode.label); + } + generatedLabelParts.push('backup'); + return getLinodeLabelFromLabelParts(generatedLabelParts); + } + + if (tab === 'Clone Linode') { + const generatedLabelParts: string[] = []; + if (values.linode) { + generatedLabelParts.push(values.linode.label); + } + generatedLabelParts.push('clone'); + return getLinodeLabelFromLabelParts(generatedLabelParts); + } + + return ''; }; -const defaultValuesForStackScripts = { - image: undefined, - interfaces: defaultInterfaces, - region: '', - stackscript_id: undefined, - type: '', +export const getIsValidLinodeLabelCharacter = (char: string) => { + return /^[0-9a-zA-Z]$/.test(char); }; /** - * A map that conatins default values for each Tab of the Linode Create flow. + * Given an array of strings, this function joins them together by + * "-" and ensures that the generated label is <= 64 characters. + * + * @param parts an array of strings that will be joined together by a "-" + * @returns a generated Linode label that is <= 64 characters */ -export const defaultValuesMap: Record = { - Backups: defaultValuesForImages, - 'Clone Linode': defaultValuesForImages, - Images: defaultValuesForImages, - OS: defaultValuesForOS, - 'One-Click': defaultValuesForStackScripts, - StackScripts: defaultValuesForStackScripts, +export const getLinodeLabelFromLabelParts = (parts: string[]) => { + const numberOfSeperaterDashes = parts.length - 1; + const maxSizeOfEachPart = Math.floor( + (64 - numberOfSeperaterDashes) / parts.length + ); + let label = ''; + + for (const part of parts) { + for (let i = 0; i < Math.min(part.length, maxSizeOfEachPart); i++) { + if ( + getIsValidLinodeLabelCharacter(part[i]) || + (part[i] === '-' && label[label.length - 1] !== '-') || + (part[i] === '.' && label[label.length - 1] !== '.') || + (part[i] === '_' && label[label.length - 1] !== '_') + ) { + label += part[i]; + } else if (part[i] === ' ' && label[label.length - 1] !== '-') { + label += '-'; + } + } + if (part !== parts[parts.length - 1]) { + label += '-'; + } + } + + return label; }; interface LinodeCreateAnalyticsEventOptions { From 99750952f6ba3245158ab24f975df73ced028bdd Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 19 Jul 2024 10:25:08 +0530 Subject: [PATCH 08/58] change: [M3-5785] - Mislabelling of SRV fields in Linode's DNS Manager (#10687) * Change SRV column headers in linode's DNS manager * Added changeset: Mislabelling of SRV fields in Linode's DNS Manager * Update pr-10687-changed-1721198360943.md * Update pr-10687-changed-1721198360943.md --- .../manager/.changeset/pr-10687-changed-1721198360943.md | 5 +++++ packages/manager/src/features/Domains/DomainRecords.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10687-changed-1721198360943.md diff --git a/packages/manager/.changeset/pr-10687-changed-1721198360943.md b/packages/manager/.changeset/pr-10687-changed-1721198360943.md new file mode 100644 index 00000000000..d74d60b6f97 --- /dev/null +++ b/packages/manager/.changeset/pr-10687-changed-1721198360943.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Rename SRV column headers in Linode's DNS Manager ([#10687](https://github.com/linode/manager/pull/10687)) diff --git a/packages/manager/src/features/Domains/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainRecords.tsx index c0d451b5f8c..4e5737bc556 100644 --- a/packages/manager/src/features/Domains/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainRecords.tsx @@ -584,10 +584,10 @@ class DomainRecords extends React.Component { /** SRV Record */ { columns: [ - { render: (r: DomainRecord) => r.name, title: 'Name' }, + { render: (r: DomainRecord) => r.name, title: 'Service/Protocol' }, { render: () => this.props.domain.domain, - title: 'Domain', + title: 'Name', }, { render: (r: DomainRecord) => String(r.priority), From 4bc02991ec1afad3f82c77727876bd16b30ace5b Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Fri, 19 Jul 2024 11:37:53 -0400 Subject: [PATCH 09/58] test: [M3-8156] - Confirm UI flow when a user changes their Longview plan (#10668) * Confirms that UI flow when a user changes their Longview plan. * Added changeset: Confirms that UI flow when a user changes their Longview plan * Update after reviews --- .../pr-10668-tests-1720639411564.md | 5 ++ .../e2e/core/longview/longview-plan.spec.ts | 60 +++++++++++++++++++ .../cypress/support/intercepts/longview.ts | 26 +++++++- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10668-tests-1720639411564.md create mode 100644 packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts diff --git a/packages/manager/.changeset/pr-10668-tests-1720639411564.md b/packages/manager/.changeset/pr-10668-tests-1720639411564.md new file mode 100644 index 00000000000..3ab37b08a10 --- /dev/null +++ b/packages/manager/.changeset/pr-10668-tests-1720639411564.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Confirm UI flow when a user changes their Longview plan ([#10668](https://github.com/linode/manager/pull/10668)) diff --git a/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts new file mode 100644 index 00000000000..def2a6a8b67 --- /dev/null +++ b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts @@ -0,0 +1,60 @@ +import type { ActiveLongviewPlan } from '@linode/api-v4'; +import { longviewActivePlanFactory } from 'src/factories'; +import { authenticate } from 'support/api/authentication'; +import { + mockGetLongviewPlan, + mockUpdateLongviewPlan, +} from 'support/intercepts/longview'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; + +authenticate(); +describe('longview plan', () => { + before(() => { + cleanUp(['linodes', 'longview-clients']); + }); + + /* + * - Tests Longview change plan end-to-end using mock API data. + * - Confirm UI flow when a user changes their Longview plan. + */ + it('can change longview plan', () => { + const newPlan: ActiveLongviewPlan = longviewActivePlanFactory.build(); + + mockGetLongviewPlan({}).as('getLongviewPlan'); + + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewPlan'); + + // Confirms that Longview Plan Details tab is visible on the page. + cy.findByText('Plan Details').should('be.visible').click(); + + // Confirms that Longview current plan is visible and enabled by default. + cy.findByTestId('lv-sub-radio-longview-free').should('be.enabled'); + cy.findByTestId('current-plan-longview-free').should('be.visible'); + ui.button + .findByTitle('Change Plan') + .should('be.visible') + .should('be.disabled'); + + mockUpdateLongviewPlan(newPlan).as('updateLongviewPlan'); + + // Confirms that Longview plan can be changed. + cy.findByTestId('lv-sub-table-row-longview-3').click(); + ui.button + .findByTitle('Change Plan') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirms the Longview plan details shown correctly after plan changed + cy.wait('@updateLongviewPlan'); + cy.findByText('Plan updated successfully.').should('be.visible'); + cy.findByTestId('lv-sub-table-row-longview-3').should('be.enabled'); + cy.findByTestId('current-plan-longview-3').should('be.visible'); + ui.button + .findByTitle('Change Plan') + .should('be.visible') + .should('be.disabled'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index 9c9b04b5ba5..aed09c20f08 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -1,7 +1,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import { LongviewClient } from '@linode/api-v4'; +import { LongviewClient, ActiveLongviewPlan } from '@linode/api-v4'; import type { LongviewAction, LongviewResponse, @@ -93,6 +93,28 @@ export const mockCreateLongviewClient = ( ); }; +export const mockGetLongviewPlan = ( + plan: ActiveLongviewPlan +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('longview/plan'), makeResponse(plan)); +}; + +export const mockUpdateLongviewPlan = ( + newPlan: ActiveLongviewPlan +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher('longview/plan'), + makeResponse(newPlan) + ); +}; + +export const mockCreateLongviewPlan = ( + plan: ActiveLongviewPlan +): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher('longview/plan'), makeResponse(plan)); +}; + /** * Mocks request to delete a Longview Client. * @@ -122,4 +144,4 @@ export const mockUpdateLongviewClient = ( apiMatcher(`longview/clients/${clientID}`), makeResponse(newClient) ); -}; +}; \ No newline at end of file From ba294a589374ce6ec115a47691459b4aa4efc00f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:13:40 -0400 Subject: [PATCH 10/58] upcoming: [M3-8315] - Add EU Agreement to Linode Create v2 (#10692) * inital work * conditional api request * sign agreement * Added changeset: Add EU Agreement to Linode Create v2 * add unit test * revert * feedback @mjac0bs * fix spelling * update cypress test to account for optimization of fetching agreements only if EU region is selected * don't reset default values if user clicks the same tab * try to fix gdpr failure --------- Co-authored-by: Banks Nussman --- ...r-10692-upcoming-features-1721254990748.md | 5 + .../e2e/core/general/gdpr-agreement.spec.ts | 10 +- .../Agreements/EUAgreementCheckbox.tsx | 11 +- .../LinodeCreatev2/EUAgreement.test.tsx | 49 ++++++ .../Linodes/LinodeCreatev2/EUAgreement.tsx | 51 ++++++ .../Linodes/LinodeCreatev2/Region.tsx | 4 +- .../Linodes/LinodeCreatev2/SMTP.test.tsx | 27 +++ .../features/Linodes/LinodeCreatev2/SMTP.tsx | 7 + .../Linodes/LinodeCreatev2/Tabs/Images.tsx | 17 +- .../features/Linodes/LinodeCreatev2/index.tsx | 41 +++-- .../Linodes/LinodeCreatev2/resolvers.ts | 155 ++++++------------ .../Linodes/LinodeCreatev2/utilities.ts | 10 +- 12 files changed, 251 insertions(+), 136 deletions(-) create mode 100644 packages/manager/.changeset/pr-10692-upcoming-features-1721254990748.md create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.tsx diff --git a/packages/manager/.changeset/pr-10692-upcoming-features-1721254990748.md b/packages/manager/.changeset/pr-10692-upcoming-features-1721254990748.md new file mode 100644 index 00000000000..89583bfa3d2 --- /dev/null +++ b/packages/manager/.changeset/pr-10692-upcoming-features-1721254990748.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add EU Agreement to Linode Create v2 ([#10692](https://github.com/linode/manager/pull/10692)) diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index da7d1b36e3d..4aa65a74491 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -49,13 +49,15 @@ describe('GDPR agreement', () => { }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); - cy.wait(['@getAgreements', '@getRegions']); + cy.wait('@getRegions'); // Paris should have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('fr-par').click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('be.visible'); + cy.wait('@getAgreements'); + // London should have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('eu-west').click(); @@ -75,12 +77,14 @@ describe('GDPR agreement', () => { }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); - cy.wait(['@getAgreements', '@getRegions']); + cy.wait('@getRegions'); // Paris should not have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('fr-par').click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); + + cy.wait('@getAgreements'); // London should not have the agreement ui.regionSelect.find().click(); @@ -120,7 +124,7 @@ describe('GDPR agreement', () => { cy.get('[data-qa-deploy-linode="true"]').should('be.disabled'); // check the agreement - getClick('[data-testid="eu-agreement-checkbox"]'); + getClick('#gdpr-checkbox'); // expect the button to be enabled cy.get('[data-qa-deploy-linode="true"]').should('not.be.disabled'); diff --git a/packages/manager/src/features/Account/Agreements/EUAgreementCheckbox.tsx b/packages/manager/src/features/Account/Agreements/EUAgreementCheckbox.tsx index 761c0e17aee..2772ac26c51 100644 --- a/packages/manager/src/features/Account/Agreements/EUAgreementCheckbox.tsx +++ b/packages/manager/src/features/Account/Agreements/EUAgreementCheckbox.tsx @@ -20,16 +20,9 @@ export const EUAgreementCheckbox = (props: Props) => { const { centerCheckbox, checked, className, onChange } = props; const theme = useTheme(); - const baseCheckboxStyle = { - [theme.breakpoints.up('md')]: { - marginLeft: '-8px', - }, - }; - const checkboxStyle = centerCheckbox - ? baseCheckboxStyle + ? {} : { - ...baseCheckboxStyle, marginTop: '-5px', }; @@ -56,7 +49,7 @@ export const EUAgreementCheckbox = (props: Props) => { I have read and agree to the{' '} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.test.tsx new file mode 100644 index 00000000000..fd4f89a0519 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { accountAgreementsFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { EUAgreement } from './EUAgreement'; + +import type { LinodeCreateFormValues } from './utilities'; + +describe('EUAgreement', () => { + it('it renders if an EU region is selected and you have not already agreed to the agreement', async () => { + const region = regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }), + http.get('*/v4/account/agreements', () => { + return HttpResponse.json( + accountAgreementsFactory.build({ eu_model: false }) + ); + }) + ); + + const { + findByText, + getByRole, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + region: 'eu-west', + }, + }, + }); + + await findByText('Agreements'); + const checkbox = getByRole('checkbox'); + + expect(checkbox).toBeEnabled(); + expect(checkbox).not.toBeChecked(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.tsx new file mode 100644 index 00000000000..70e61f78697 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/EUAgreement.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useController, useWatch } from 'react-hook-form'; + +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; +import { useAccountAgreements } from 'src/queries/account/agreements'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { getRegionCountryGroup, isEURegion } from 'src/utilities/formatRegion'; + +import type { LinodeCreateFormValues } from './utilities'; + +export const EUAgreement = () => { + const { field, fieldState } = useController({ + name: 'hasSignedEUAgreement', + }); + + const { data: regions } = useRegionsQuery(); + + const regionId = useWatch({ name: 'region' }); + + const selectedRegion = regions?.find((r) => r.id === regionId); + + const hasSelectedAnEURegion = isEURegion( + getRegionCountryGroup(selectedRegion) + ); + + const { data: agreements } = useAccountAgreements(hasSelectedAnEURegion); + + if (hasSelectedAnEURegion && agreements?.eu_model === false) { + return ( + + + Agreements + {fieldState.error?.message && ( + + )} + field.onChange(checked)} + /> + + + ); + } + + return null; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index d99da6b402f..af2133284d8 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; @@ -32,7 +33,6 @@ import { import type { LinodeCreateFormValues } from './utilities'; import type { Region as RegionType } from '@linode/api-v4'; -import { useQueryClient } from '@tanstack/react-query'; export const Region = () => { const { @@ -92,6 +92,8 @@ export const Region = () => { reset( (prev) => ({ ...prev, + // reset EU agreement + hasSignedEUAgreement: undefined, // Reset interfaces because VPC and VLANs are region-sepecific interfaces: defaultInterfaces, // Reset Cloud-init metadata because not all regions support it diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.test.tsx new file mode 100644 index 00000000000..9b1348c6f99 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { accountFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SMTP } from './SMTP'; + +describe('SMTP', () => { + it('should render if the account was activated before MAGIC_DATE_THAT_EMAIL_RESTRICTIONS_WERE_IMPLEMENTED', async () => { + const account = accountFactory.build({ + active_since: '2022-11-29T00:00:00.000', + }); + + server.use( + http.get('*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { findByText } = renderWithTheme(); + + await findByText('SMTP ports may be restricted on this Linode', { + exact: false, + }); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.tsx new file mode 100644 index 00000000000..9603d6bf33b --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/SMTP.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import { SMTPRestrictionText } from '../SMTPRestrictionText'; + +export const SMTP = () => { + return {({ text }) => text}; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 75e05439f3b..045a84c8394 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; @@ -12,12 +13,20 @@ import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGran import { useAllImagesQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { getGeneratedLinodeLabel, type LinodeCreateFormValues } from '../utilities'; +import { getGeneratedLinodeLabel } from '../utilities'; + +import type { LinodeCreateFormValues } from '../utilities'; import type { Image } from '@linode/api-v4'; -import { useQueryClient } from '@tanstack/react-query'; export const Images = () => { - const { control, formState: { dirtyFields: { label: isLabelFieldDirty }}, getValues, setValue } = useFormContext(); + const { + control, + formState: { + dirtyFields: { label: isLabelFieldDirty }, + }, + getValues, + setValue, + } = useFormContext(); const { field, fieldState } = useController({ control, name: 'image', @@ -51,7 +60,7 @@ export const Images = () => { if (!isLabelFieldDirty) { const label = await getGeneratedLinodeLabel({ queryClient, - tab: "Images", + tab: 'Images', values: getValues(), }); setValue('label', label); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 73aa4ee7d2d..db5792cf785 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -13,6 +13,7 @@ import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { useMutateAccountAgreements } from 'src/queries/account/agreements'; import { useCloneLinodeMutation, useCreateLinodeMutation, @@ -23,11 +24,13 @@ import { Actions } from './Actions'; import { Addons } from './Addons/Addons'; import { Details } from './Details/Details'; import { Error } from './Error'; +import { EUAgreement } from './EUAgreement'; import { Firewall } from './Firewall'; import { Plan } from './Plan'; import { Region } from './Region'; -import { linodeCreateResolvers } from './resolvers'; +import { getLinodeCreateResolver } from './resolvers'; import { Security } from './Security'; +import { SMTP } from './SMTP'; import { Summary } from './Summary/Summary'; import { Backups } from './Tabs/Backups/Backups'; import { Clone } from './Tabs/Clone/Clone'; @@ -53,29 +56,34 @@ import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { const { params, setParams } = useLinodeCreateQueryParams(); + const queryClient = useQueryClient(); + const form = useForm({ - defaultValues: () => defaultValues(queryClient), + defaultValues: () => defaultValues(params, queryClient), mode: 'onBlur', - resolver: linodeCreateResolvers[params.type ?? 'OS'], + resolver: getLinodeCreateResolver(params.type, queryClient), shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView` }); const history = useHistory(); - const queryClient = useQueryClient(); + const { enqueueSnackbar } = useSnackbar(); + const { mutateAsync: createLinode } = useCreateLinodeMutation(); const { mutateAsync: cloneLinode } = useCloneLinodeMutation(); - const { enqueueSnackbar } = useSnackbar(); + const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const currentTabIndex = getTabIndex(params.type); const onTabChange = (index: number) => { - const newTab = tabs[index]; - defaultValues(queryClient).then((values) => { - // Reset the form values - form.reset(values); - // Update tab "type" query param. (This changes the selected tab) - setParams({ type: newTab }); - }); + if (index !== currentTabIndex) { + const newTab = tabs[index]; + defaultValues({ ...params, type: newTab }, queryClient).then((values) => { + // Reset the form values + form.reset(values); + // Update tab "type" query param. (This changes the selected tab) + setParams({ type: newTab }); + }); + } }; const onSubmit: SubmitHandler = async (values) => { @@ -101,6 +109,13 @@ export const LinodeCreatev2 = () => { type: params.type ?? 'OS', values, }); + + if (values.hasSignedEUAgreement) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }); + } } catch (errors) { for (const error of errors) { if (error.field) { @@ -174,7 +189,9 @@ export const LinodeCreatev2 = () => { {params.type !== 'Clone Linode' && } + + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts index cd4d90d3cf5..eb4d5fd63e3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts @@ -1,6 +1,10 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { CreateLinodeSchema } from '@linode/validation'; +import { accountQueries } from 'src/queries/account/queries'; +import { regionQueries } from 'src/queries/regions/regions'; +import { getRegionCountryGroup, isEURegion } from 'src/utilities/formatRegion'; + import { CreateLinodeByCloningSchema, CreateLinodeFromBackupSchema, @@ -11,123 +15,68 @@ import { getLinodeCreatePayload } from './utilities'; import type { LinodeCreateType } from '../LinodesCreate/types'; import type { LinodeCreateFormValues } from './utilities'; +import type { QueryClient } from '@tanstack/react-query'; import type { Resolver } from 'react-hook-form'; -export const resolver: Resolver = async ( - values, - context, - options -) => { - const transformedValues = getLinodeCreatePayload(structuredClone(values)); - - const { errors } = await yupResolver( - CreateLinodeSchema, - {}, - { mode: 'async', rawValues: true } - )(transformedValues, context, options); - - if (errors) { - return { errors, values }; - } - - return { errors: {}, values }; -}; - -export const stackscriptResolver: Resolver = async ( - values, - context, - options +export const getLinodeCreateResolver = ( + tab: LinodeCreateType | undefined, + queryClient: QueryClient ) => { - const transformedValues = getLinodeCreatePayload(structuredClone(values)); - - const { errors } = await yupResolver( - CreateLinodeFromStackScriptSchema, - {}, - { mode: 'async', rawValues: true } - )(transformedValues, context, options); - - if (errors) { - return { errors, values }; - } - - return { errors: {}, values }; -}; + const schema = linodeCreateResolvers[tab ?? 'OS']; -export const marketplaceResolver: Resolver = async ( - values, - context, - options -) => { - const transformedValues = getLinodeCreatePayload(structuredClone(values)); + // eslint-disable-next-line sonarjs/prefer-immediate-return + const resolver: Resolver = async ( + values, + context, + options + ) => { + const transformedValues = getLinodeCreatePayload(structuredClone(values)); - const { errors } = await yupResolver( - CreateLinodeFromMarketplaceAppSchema, - {}, - { mode: 'async', rawValues: true } - )(transformedValues, context, options); + const { errors } = await yupResolver( + schema, + {}, + { mode: 'async', rawValues: true } + )(transformedValues, context, options); - if (errors) { - return { errors, values }; - } + const regions = await queryClient.ensureQueryData(regionQueries.regions); - return { errors: {}, values }; -}; + const selectedRegion = regions.find((r) => r.id === values.region); -export const cloneResolver: Resolver = async ( - values, - context, - options -) => { - const transformedValues = getLinodeCreatePayload(structuredClone(values)); - - const { errors } = await yupResolver( - CreateLinodeByCloningSchema, - {}, - { mode: 'async', rawValues: true } - )( - { - linode: values.linode ?? undefined, - ...transformedValues, - }, - context, - options - ); + const hasSelectedAnEURegion = isEURegion( + getRegionCountryGroup(selectedRegion) + ); - if (errors) { - return { errors, values }; - } + if (hasSelectedAnEURegion) { + const agreements = await queryClient.ensureQueryData( + accountQueries.agreements + ); - return { errors: {}, values }; -}; + const hasSignedEUAgreement = agreements.eu_model; -export const backupResolver: Resolver = async ( - values, - context, - options -) => { - const transformedValues = getLinodeCreatePayload(structuredClone(values)); + if (!hasSignedEUAgreement && !values.hasSignedEUAgreement) { + errors['hasSignedEUAgreement'] = { + message: + 'You must agree to the EU agreement to deploy to this region.', + type: 'validate', + }; + } + } - const { errors } = await yupResolver( - CreateLinodeFromBackupSchema, - {}, - { mode: 'async', rawValues: true } - )(transformedValues, context, options); + if (errors) { + return { errors, values }; + } - if (errors) { - return { errors, values }; - } + return { errors: {}, values }; + }; - return { errors: {}, values }; + return resolver; }; -export const linodeCreateResolvers: Record< - LinodeCreateType, - Resolver -> = { - Backups: backupResolver, - 'Clone Linode': cloneResolver, - Images: resolver, - OS: resolver, - 'One-Click': marketplaceResolver, - StackScripts: stackscriptResolver, +export const linodeCreateResolvers = { + Backups: CreateLinodeFromBackupSchema, + 'Clone Linode': CreateLinodeByCloningSchema, + Images: CreateLinodeSchema, + OS: CreateLinodeSchema, + 'One-Click': CreateLinodeFromMarketplaceAppSchema, + StackScripts: CreateLinodeFromStackScriptSchema, }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 99fd9d3c139..1c4d3c0a3b3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -143,7 +143,7 @@ export const tabs: LinodeCreateType[] = [ export const getLinodeCreatePayload = ( formValues: LinodeCreateFormValues ): CreateLinodeRequest => { - const values = omit(formValues, ['linode']); + const values = omit(formValues, ['linode', 'hasSignedEUAgreement']); if (values.metadata?.user_data) { values.metadata.user_data = utoa(values.metadata.user_data); } @@ -242,6 +242,10 @@ export const defaultInterfaces: InterfacePayload[] = [ * removes them from the payload before it is sent to the API. */ export interface LinodeCreateFormValues extends CreateLinodeRequest { + /** + * Whether or not the user has signed the EU agreement + */ + hasSignedEUAgreement?: boolean; /** * The currently selected Linode */ @@ -255,11 +259,9 @@ export interface LinodeCreateFormValues extends CreateLinodeRequest { * The default values are dependent on the query params present. */ export const defaultValues = async ( + params: ParsedLinodeCreateQueryParams, queryClient: QueryClient ): Promise => { - const queryParams = getQueryParamsFromQueryString(window.location.search); - const params = getParsedLinodeCreateQueryParams(queryParams); - const stackscriptId = params.stackScriptID ?? params.appID; const stackscript = stackscriptId From c02066f98caf2586a3f0d3dd35d075537d16e986 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 19 Jul 2024 14:46:44 -0400 Subject: [PATCH 11/58] test: [M3-8359] - Make Existing Linode Create Cypress Tests work for Linode Create v2 (#10695) * `create-linode-from-image.spec.ts` supports v1 and v2 * update `create-stackscripts.spec.ts\ to support both flows * update `smoke-community-stackscrips.spec.ts` * make `legacy-create-linode.spec.ts` * fix more tests * Added changeset: Make Existing Linode Create Cypress Test Compatible with Linode Create v2 * fix password scroll bug on safari * only run OCA e2e on old flow for now * fix `create-linode.spec.ts` --------- Co-authored-by: Banks Nussman --- .../pr-10695-tests-1721358768207.md | 5 +++ .../e2e/core/general/gdpr-agreement.spec.ts | 10 ++++++ .../images/create-linode-from-image.spec.ts | 27 +++++++++----- .../e2e/core/linodes/create-linode.spec.ts | 17 ++++----- .../core/linodes/legacy-create-linode.spec.ts | 15 ++++---- .../core/oneClickApps/one-click-apps.spec.ts | 15 ++++---- .../stackscripts/create-stackscripts.spec.ts | 14 +++----- .../smoke-community-stackscrips.spec.ts | 17 +++++---- .../components/SelectControl.tsx | 1 + .../PasswordInput/PasswordInput.tsx | 36 +++++++++---------- .../Linodes/LinodeCreatev2/Addons/Backups.tsx | 1 + .../Linodes/LinodeCreatev2/Security.tsx | 1 + .../Linodes/LinodeCreatev2/Tabs/Images.tsx | 17 +++++++++ .../Tabs/Marketplace/AppDetailDrawer.tsx | 1 + .../Tabs/Marketplace/AppSelectionCard.tsx | 17 ++++----- .../Tabs/Marketplace/Marketplace.tsx | 2 +- .../Linodes/LinodeCreatev2/VPC/VPC.tsx | 2 +- .../LinodesCreate/SelectionCardWrapper.tsx | 4 +-- .../TabbedContent/FromAppsContent.tsx | 2 +- .../manager/src/features/Linodes/index.tsx | 6 +++- .../features/OneClickApps/AppDetailDrawer.tsx | 2 +- .../features/OneClickApps/oneClickAppsv2.ts | 1 + 22 files changed, 131 insertions(+), 82 deletions(-) create mode 100644 packages/manager/.changeset/pr-10695-tests-1721358768207.md diff --git a/packages/manager/.changeset/pr-10695-tests-1721358768207.md b/packages/manager/.changeset/pr-10695-tests-1721358768207.md new file mode 100644 index 00000000000..aa905a5fe6d --- /dev/null +++ b/packages/manager/.changeset/pr-10695-tests-1721358768207.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Make Existing Linode Create Cypress Test Compatible with Linode Create v2 ([#10695](https://github.com/linode/manager/pull/10695)) diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index 4aa65a74491..3fd6bff8e2b 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -4,6 +4,8 @@ import { regionFactory } from '@src/factories'; import { randomString, randomLabel } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetAccountAgreements } from 'support/intercepts/account'; +import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream } from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; import type { Region } from '@linode/api-v4'; @@ -98,6 +100,14 @@ describe('GDPR agreement', () => { }); it('needs the agreement checked to validate the form', () => { + // This test does not apply to Linode Create v2 because + // Linode Create v2 allows you to press "Create Linode" + // without checking the GDPR checkbox. (The user will + // get a validation error if they have not agreed). + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ privacy_policy: false, diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index c6ae84c86ab..d2799cb7419 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,4 +1,4 @@ -import { containsClick, fbtClick, fbtVisible, getClick } from 'support/helpers'; +import { fbtClick, fbtVisible, getClick } from 'support/helpers'; import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { mockGetAllImages } from 'support/intercepts/images'; @@ -33,14 +33,17 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.visitWithLogin(url); cy.wait('@mockImage'); + if (!preselectedImage) { - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image'); - }); - cy.get(`[data-qa-image-select-item="${mockImage.id}"]`).within(() => { - cy.get('span').should('have.class', 'fl-tux'); - fbtClick(mockImage.label); - }); + cy.findByPlaceholderText('Choose an image') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText(mockImage.label) + .should('be.visible') + .should('be.enabled') + .click(); } ui.regionSelect.find().click(); @@ -49,7 +52,13 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { fbtClick('Shared CPU'); getClick('[id="g6-nanode-1"][type="radio"]'); cy.get('[id="root-password"]').type(randomString(32)); - getClick('[data-qa-deploy-linode="true"]'); + + ui.button + .findByTitle('Create Linode') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); cy.wait('@mockLinodeRequest'); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 5ade90c2328..dfbf35f77ef 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -294,15 +294,14 @@ describe('Create Linode', () => { getVisible('[data-testid="vpc-panel"]').within(() => { containsVisible('Assign this Linode to an existing VPC.'); // select VPC - cy.get('[data-qa-enhanced-select="None"]') + cy.findByLabelText('Assign VPC') .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); + .focus() + .type(`${mockVPC.label}{downArrow}{enter}`); // select subnet - cy.findByText('Select Subnet') + cy.findByPlaceholderText('Select Subnet') .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); + .type(`${mockSubnet.label}{downArrow}{enter}`) }); // The drawer opens when clicking "Add an SSH Key" button @@ -341,11 +340,13 @@ describe('Create Linode', () => { ui.toast.assertMessage('Successfully created SSH key.'); // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user - cy.findAllByText(sshPublicKeyLabel).should('be.visible'); + cy.findByText(sshPublicKeyLabel, { exact: false }).should('be.visible'); getClick('#linode-label').clear().type(linodeLabel); cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); + + ui.button.findByTitle("Create Linode").click(); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); fbtVisible(linodeLabel); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts index cf92b39a21b..5f72107572b 100644 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -505,20 +505,21 @@ describe('create linode', () => { getVisible('[data-testid="vpc-panel"]').within(() => { containsVisible('Assign this Linode to an existing VPC.'); // select VPC - cy.get('[data-qa-enhanced-select="None"]') + cy.findByLabelText('Assign VPC') .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); + .focus() + .type(`${mockVPC.label}{downArrow}{enter}`); // select subnet - cy.findByText('Select Subnet') + cy.findByPlaceholderText('Select Subnet') .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); + .type(`${mockSubnet.label}{downArrow}{enter}`); }); getClick('#linode-label').clear().type(linodeLabel); cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); + + ui.button.findByTitle('Create Linode').click(); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); fbtVisible(linodeLabel); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); 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 3a08199557f..8dfa671222d 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 @@ -49,14 +49,14 @@ describe('OneClick Apps (OCA)', () => { // Check the content of the OCA listing cy.findByTestId('one-click-apps-container').within(() => { // Check that all sections are present (note: New apps can be empty so not asserting its presence) - cy.findByTestId('Popular apps').should('exist'); - cy.findByTestId('All apps').should('exist'); + cy.findByText('Popular apps').should('be.visible'); + cy.findByText('All apps').should('be.visible'); trimmedApps.forEach((stackScript) => { const { decodedLabel, label } = handleAppLabel(stackScript); // Check that every OCA is listed with the correct label - cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist'); + cy.get(`[data-qa-select-card-heading="${label.trim()}"]`).should('exist'); // Check that every OCA has a drawer match // This validates the regex in `mapStackScriptLabelToOCA` @@ -76,7 +76,7 @@ describe('OneClick Apps (OCA)', () => { const candidateLabel = handleAppLabel(trimmedApps[0]).label; const stackScriptCandidate = cy - .get(`[data-qa-selection-card-info="${candidateLabel}"]`) + .get(`[data-qa-selection-card-info="${candidateLabel.trim()}"]`) .first(); stackScriptCandidate.should('exist').click(); @@ -92,7 +92,7 @@ describe('OneClick Apps (OCA)', () => { } ui.drawer - .findByTitle(trimmedApps[0].label) + .findByTitle(trimmedApps[0].label.trim()) .should('be.visible') .within(() => { containsVisible(app.description); @@ -113,7 +113,7 @@ describe('OneClick Apps (OCA)', () => { 'have.length.below', initialNumberOfApps ); - cy.get(`[data-qa-selection-card-info="${candidateLabel}"]`).should( + cy.get(`[data-qa-selection-card-info="${candidateLabel.trim()}"]`).should( 'be.visible' ); }); @@ -168,6 +168,7 @@ describe('OneClick Apps (OCA)', () => { mockGetStackScripts([stackScripts]).as('getStackScripts'); mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), oneClickApps: makeFeatureFlagData({ 401709: 'E2E Test App', }), @@ -181,7 +182,7 @@ describe('OneClick Apps (OCA)', () => { cy.findByTestId('one-click-apps-container').within(() => { // Since it is mock data we can assert the New App section is present - cy.findByTestId('New apps').should('exist'); + cy.findByText('New apps').should('be.visible'); // Check that the app is listed and select it cy.get('[data-qa-selection-card="true"]').should('have.length', 3); diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 2733a68d940..9d73880a322 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -351,22 +351,16 @@ describe('Create stackscripts', () => { .click(); // Confirm that expected images are present in "Choose an image" drop-down. - cy.findByText('Choose an image').should('be.visible').click(); + cy.findByPlaceholderText('Choose an image').should('be.visible').click(); imageSamples.forEach((imageSample) => { const imageLabel = imageSample.label; - const imageSelector = imageSample.sel; - - cy.get(`[data-qa-image-select-item="${imageSelector}"]`) - .scrollIntoView() - .should('be.visible') - .within(() => { - cy.findByText(imageLabel).should('be.visible'); - }); + + cy.findByText(imageLabel).scrollIntoView().should('be.visible'); }); // Select private image. - cy.get(`[data-qa-image-select-item="${privateImage.id}"]`) + cy.findByText(privateImage.label) .scrollIntoView() .should('be.visible') .click(); diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 9bc796d2c6e..fcd63a2a5b7 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -5,7 +5,6 @@ import { mockGetStackScripts, mockGetStackScript, } from 'support/intercepts/stackscripts'; -import { containsClick } from 'support/helpers'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -326,9 +325,9 @@ describe('Community Stackscripts integration tests', () => { cy.get('[id="vpn-password"]').should('have.value', vpnPassword); // Choose an image - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); + cy.findByPlaceholderText('Choose an image').should('be.visible').click(); + + cy.findByText(image).should('be.visible').click(); // Choose a region ui.button @@ -370,7 +369,10 @@ describe('Community Stackscripts integration tests', () => { .should('be.visible') .should('be.enabled') .click(); - cy.contains('Password does not meet complexity requirements.'); + + cy.findByText('Password does not meet', { exact: false }).should( + 'be.visible' + ); cy.get('[id="root-password"]').clear().type(fairPassword); ui.button @@ -378,7 +380,10 @@ describe('Community Stackscripts integration tests', () => { .should('be.visible') .should('be.enabled') .click(); - cy.contains('Password does not meet complexity requirements.'); + + cy.findByText('Password does not meet', { exact: false }).should( + 'be.visible' + ); // Only strong password is allowed to rebuild the linode cy.get('[id="root-password"]').type(rootPassword); diff --git a/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx b/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx index b57df52062c..590b194b65d 100644 --- a/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx @@ -8,6 +8,7 @@ type Props = ControlProps; const SelectControl: React.FC = (props) => { return ( { const strength = React.useMemo(() => maybeStrength(value), [value]); return ( - - - - + + {!hideValidation && ( - - - + )} - + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx index 2f2a590e576..723bc222228 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx @@ -108,6 +108,7 @@ export const Backups = () => { } checked={checked} control={} + data-testid="backups" onChange={field.onChange} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx index 916632806b6..18a3febf968 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx @@ -67,6 +67,7 @@ export const Security = () => { label="Root Password" name="password" noMarginTop + id="linode-password" onBlur={field.onBlur} onChange={field.onChange} placeholder="Enter a password." diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 045a84c8394..f2539544a46 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -3,10 +3,13 @@ import React from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import ImageIcon from 'src/assets/icons/entityIcons/image.svg'; import { Box } from 'src/components/Box'; import { ImageSelectv2 } from 'src/components/ImageSelectv2/ImageSelectv2'; import { getAPIFilterForImageSelect } from 'src/components/ImageSelectv2/utilities'; +import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -77,6 +80,20 @@ export const Images = () => { image.capabilities.includes('distributed-images') ); + if (images?.length === 0) { + return ( + + + + You don’t have any private Images. Visit the{' '} + Images section to create an Image from one + of your Linode’s disks. + + + + ); + } + return ( Choose an Image diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx index 6d6118c2d66..b451a8d9bc6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx @@ -117,6 +117,7 @@ export const AppDetailDrawerv2 = (props: Props) => { text: selectedApp.name, }), }} + data-qa-drawer-title={selectedApp.name} className={classes.appName} data-testid="app-name" variant="h2" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx index 44bc65f9d06..b6ef6a53f86 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx @@ -48,6 +48,13 @@ export const AppSelectionCard = (props: Props) => { } }; + const displayLabel = decode( + label + .replace(' Null One-Click', '') + .replace(' One-Click', '') + .replace(' Cluster', '') + ).trim(); + const renderIcon = iconUrl === '' ? () => @@ -55,7 +62,8 @@ export const AppSelectionCard = (props: Props) => { const renderVariant = () => ( @@ -67,13 +75,6 @@ export const AppSelectionCard = (props: Props) => { ) : undefined; - const displayLabel = decode( - label - .replace('Null One-Click', '') - .replace('One-Click', '') - .replace('Cluster', '') - ); - return ( { }; return ( - + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx index 9da60ed6c84..7c54772b66a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx @@ -73,7 +73,7 @@ export const VPC = () => { : 'Assign this Linode to an existing VPC.'; return ( - + VPC diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx index eb659144193..9a0c216be63 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx @@ -77,7 +77,7 @@ export const SelectionCardWrapper = (props: Props) => { { checked={checked} data-qa-selection-card disabled={disabled} - heading={label} + heading={label.trim()} headingDecoration={labelDecoration} id={`app-${String(id)}`} key={id} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index 2cdd81c488c..d1b24940b06 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -289,7 +289,7 @@ export class FromAppsContent extends React.Component { ); diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index effa5f91bb5..5d5e707f8e7 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -32,7 +32,11 @@ export const LinodesRoutes = () => { // Hold this feature flag in state so that the user's Linode creation // isn't interupted when the flag is toggled. - const [isLinodeCreateV2Enabled] = useState(flags.linodeCreateRefactor); + const [isLinodeCreateV2EnabledStale] = useState(flags.linodeCreateRefactor); + + const isLinodeCreateV2Enabled = import.meta.env.DEV + ? flags.linodeCreateRefactor + : isLinodeCreateV2EnabledStale; return ( }> diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx index 16d9851948c..a9e1480e50c 100644 --- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx +++ b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx @@ -156,7 +156,7 @@ export const AppDetailDrawer: React.FunctionComponent = (props) => { }), }} className={classes.appName} - data-qa-drawer-title={stackScriptLabel} + data-qa-drawer-title={stackScriptLabel.trim()} data-testid="app-name" variant="h2" /> diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index 43cea629711..f6de4b900c6 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -13,6 +13,7 @@ export const oneClickApps: Record = { ...oneClickAppFactory.build({ name: 'E2E Test App', }), + isNew: true, }, 401697: { alt_description: 'Popular website content management system.', From 36e8ad79a16f3e3e2667454062f82cdc98f3d4ae Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:05:25 -0400 Subject: [PATCH 12/58] test: [M3-8138] - Add Cypress test for login redirect upon API unauthorized response (#10655) * M3-8138 Add Cypress test for login redirect upon API unauthorized response * Added changeset: Add Cypress test for login redirect upon API unauthorized response --- .../pr-10655-tests-1721330081453.md | 5 ++++ .../general/account-login-redirect.spec.ts | 20 ++++++++++++++++ .../cypress/support/intercepts/general.ts | 24 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 packages/manager/.changeset/pr-10655-tests-1721330081453.md create mode 100644 packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts diff --git a/packages/manager/.changeset/pr-10655-tests-1721330081453.md b/packages/manager/.changeset/pr-10655-tests-1721330081453.md new file mode 100644 index 00000000000..5317b7b6029 --- /dev/null +++ b/packages/manager/.changeset/pr-10655-tests-1721330081453.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test for login redirect upon API unauthorized response ([#10655](https://github.com/linode/manager/pull/10655)) diff --git a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts new file mode 100644 index 00000000000..2f7048bb057 --- /dev/null +++ b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts @@ -0,0 +1,20 @@ +import { mockApiRequestWithError } from 'support/intercepts/general'; +import { LOGIN_ROOT } from 'src/constants'; + +describe('account login redirect', () => { + /** + * The API will return 401 with the body below for all the endpoints. + * + * { "errors": [ { "reason": "Your account must be authorized to use this endpoint" } ] } + */ + it('should redirect to the login page when the user is not authorized', () => { + const errorReason = 'Your account must be authorized to use this endpoint'; + + mockApiRequestWithError(401, errorReason); + + cy.visitWithLogin('/linodes/create'); + + cy.url().should('contain', `${LOGIN_ROOT}/login?`, { exact: false }); + cy.findByText('Please log in to continue.').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/general.ts b/packages/manager/cypress/support/intercepts/general.ts index 09cd74e0167..3c53386a098 100644 --- a/packages/manager/cypress/support/intercepts/general.ts +++ b/packages/manager/cypress/support/intercepts/general.ts @@ -57,3 +57,27 @@ export const mockApiMaintenanceMode = (): Cypress.Chainable => { return cy.intercept(apiMatcher('**'), errorResponse); }; + +/** + * Intercepts all requests to Linode API v4 and mocks an error HTTP response. + * + * @param errorCode - HTTP status code to mock. + * @param errorMessage - Response error message to mock. + * + * @returns Cypress chainable. + */ +export const mockApiRequestWithError = ( + errorCode: number, + errorReason: string +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('*'), { + statusCode: errorCode, + body: { + errors: [ + { + reason: errorReason, + }, + ], + }, + }); +}; From 173d8230b1d8e25ca6a818e685f0917e425bf9bd Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:43:39 -0700 Subject: [PATCH 13/58] feat: [M3-8335] - Link Account Limit ticket in more flows with error notice (#10684) * Conditionally show the Linode plan type field * Fix Volumes bug and render general errors * Link account limit ticket in Create Cluster flow * Link account limit ticket in Add a Node Pool flow * Lint * Link account limit ticket in NodeBalancer Create flow * Link account limit ticket in Create Firewall flow * Link account limit ticket in Create Database flow * Another attempt to fix the CI flake for open-support-ticket.spec.ts * Added changeset: Account Limit support ticket to remaining create flows * Remove global interception of support error * Fix variable name * Address feedback: replace generic 'entities' with entity name * Revert e1db32b; this is a pain with formatDescription --- .../pr-10684-added-1721165645259.md | 5 ++ .../open-support-ticket.spec.ts | 13 ++--- .../manager/src/components/ErrorMessage.tsx | 11 +++-- .../manager/src/components/SupportError.tsx | 49 ------------------- .../components/SupportTicketGeneralError.tsx | 12 ++--- .../DatabaseCreate/DatabaseCreate.tsx | 39 ++++++++------- .../FirewallLanding/CreateFirewallDrawer.tsx | 13 ++--- .../CreateCluster/CreateCluster.tsx | 9 +++- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 14 +++--- .../NodeBalancers/NodeBalancerCreate.tsx | 3 +- .../SupportTicketAccountLimitFields.tsx | 49 +++++++++++-------- .../src/features/Volumes/VolumeCreate.tsx | 16 +++--- packages/manager/src/request.tsx | 11 ----- .../manager/src/utilities/formikErrorUtils.ts | 18 ++++--- 14 files changed, 116 insertions(+), 146 deletions(-) create mode 100644 packages/manager/.changeset/pr-10684-added-1721165645259.md delete mode 100644 packages/manager/src/components/SupportError.tsx diff --git a/packages/manager/.changeset/pr-10684-added-1721165645259.md b/packages/manager/.changeset/pr-10684-added-1721165645259.md new file mode 100644 index 00000000000..2c990749fdc --- /dev/null +++ b/packages/manager/.changeset/pr-10684-added-1721165645259.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Account Limit support ticket to remaining create flows ([#10684](https://github.com/linode/manager/pull/10684)) 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 94b0aba76e6..1161505d52b 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 @@ -422,14 +422,11 @@ describe('help & support', () => { .click(); cy.wait('@createLinode'); - cy.get('[data-qa-error="true"]') - .first() - .scrollIntoView() - .within(() => { - cy.contains(ACCOUNT_THING_LIMIT_ERROR); - // Navigate to the account limit ticket form. - cy.findByText('contact Support').should('be.visible').click(); - }); + cy.get('[data-qa-error="true"]').first().scrollIntoView(); + cy.contains(ACCOUNT_THING_LIMIT_ERROR); + + // Navigate to the account limit ticket form. + cy.findByText('contact Support').should('be.visible').click(); // Fill out ticket form. ui.dialog diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index 218fc9b0b7e..b0092809348 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -7,15 +7,16 @@ import type { EntityType } from 'src/features/Support/SupportTickets/SupportTick interface Props { entityType: EntityType; - message: JSX.Element | string; + message: string; } +export const supportTextRegex = /(open a support ticket|contact Support)/i; + export const ErrorMessage = (props: Props) => { const { entityType, message } = props; + const isSupportTicketError = supportTextRegex.test(message); - if (typeof message === 'string') { - return {message}; - } else { + if (isSupportTicketError) { return ( { /> ); } + + return {message}; }; diff --git a/packages/manager/src/components/SupportError.tsx b/packages/manager/src/components/SupportError.tsx deleted file mode 100644 index 4436952c82d..00000000000 --- a/packages/manager/src/components/SupportError.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTheme } from '@mui/material/styles'; -import * as React from 'react'; - -import { SupportLink } from 'src/components/SupportLink'; -import { Typography } from 'src/components/Typography'; -import { capitalize } from 'src/utilities/capitalize'; - -import type { APIError } from '@linode/api-v4/lib/types'; - -interface Props { - errors: APIError[]; -} - -export const SupportError = (props: Props) => { - const theme = useTheme(); - const { errors } = props; - const supportTextRegex = new RegExp( - /(open a support ticket|contact Support)/i - ); - const errorMsg = errors[0].reason.split(supportTextRegex); - - return ( - - {errorMsg.map((substring: string, idx) => { - const openTicket = substring.match(supportTextRegex); - if (openTicket) { - return ( - - ); - } else { - return substring; - } - })} - - ); -}; diff --git a/packages/manager/src/components/SupportTicketGeneralError.tsx b/packages/manager/src/components/SupportTicketGeneralError.tsx index 5d49c71fd33..6e09556dc37 100644 --- a/packages/manager/src/components/SupportTicketGeneralError.tsx +++ b/packages/manager/src/components/SupportTicketGeneralError.tsx @@ -4,28 +4,28 @@ import React from 'react'; import { SupportLink } from 'src/components/SupportLink'; import { capitalize } from 'src/utilities/capitalize'; +import { supportTextRegex } from './ErrorMessage'; import { Typography } from './Typography'; import type { EntityType } from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface SupportTicketGeneralErrorProps { entityType: EntityType; - generalError: JSX.Element; + generalError: string; } +const accountLimitRegex = /(limit|limit for the number of active services) on your account/i; + export const SupportTicketGeneralError = ( props: SupportTicketGeneralErrorProps ) => { const { entityType, generalError } = props; const theme = useTheme(); - const supportTextRegex = /(open a support ticket|contact Support)/i; - const reason: string = generalError.props.errors[0].reason; - const limitError = reason.split(supportTextRegex); + const limitError = generalError.split(supportTextRegex); // Determine whether we'll need to link to a specific support ticket form based on ticketType. - const accountLimitRegex = /(limit|limit for the number of active services) on your account/i; - const isAccountLimitSupportTicket = accountLimitRegex.test(reason); + const isAccountLimitSupportTicket = accountLimitRegex.test(generalError); return ( ({ btnCtn: { @@ -461,7 +460,11 @@ const DatabaseCreate = () => { title="Create" /> - {createError ? : null} + {createError && ( + + + + )} Name Your Cluster ) : null} {generalError && ( - + + + )} { errors ); + const generalError = errorMap.none; + const { hasSelectedRegion, isPlanPanelDisabled, @@ -207,7 +210,11 @@ export const CreateCluster = () => { title="Create Cluster" /> - {errorMap.none && } + {generalError && ( + + + + )} ) => diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index 9ccd1979c3c..e424e4759e7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,10 +1,10 @@ -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { Drawer } from 'src/components/Drawer'; +import { ErrorMessage } from 'src/components/ErrorMessage'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useCreateNodePoolMutation } from 'src/queries/kubernetes'; @@ -24,6 +24,7 @@ import { nodeWarning } from '../../kubeUtils'; import { hasInvalidNodePoolPrice } from './utils'; import type { Region } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()((theme: Theme) => ({ boxOuter: { @@ -162,11 +163,12 @@ export const AddNodePoolDrawer = (props: Props) => { wide > {error && ( - + + + )}
{ /> {generalError && !isRestricted && ( - {generalError} + )} {isRestricted && ( diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx index cc1752548d1..3c1f061adaa 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx @@ -17,18 +17,23 @@ export interface AccountLimitCustomFields extends CustomFields { } export const SupportTicketAccountLimitFields = () => { - const { control, formState, reset } = useFormContext< + const { control, formState, reset, watch } = useFormContext< AccountLimitCustomFields & SupportTicketFormFields >(); const { data: account } = useAccount(); + const { entityType } = watch(); + const defaultValues = { companyName: account?.company, customerName: `${account?.first_name} ${account?.last_name}`, ...formState.defaultValues, }; + const shouldShowLinodePlanField = + entityType === 'linode_id' || entityType === 'lkecluster_id'; + React.useEffect(() => { reset(defaultValues); }, []); @@ -65,26 +70,28 @@ export const SupportTicketAccountLimitFields = () => { name="companyName" /> - ( - - View types of plans - - } - data-qa-ticket-linode-plan - errorText={fieldState.error?.message} - label={ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP.linodePlan} - name="linodePlan" - onChange={field.onChange} - placeholder="Dedicated 4GB, Shared 8GB, High Memory 24GB, etc." - value={field.value} - /> - )} - control={control} - name="linodePlan" - /> + {shouldShowLinodePlanField && ( + ( + + View types of plans + + } + data-qa-ticket-linode-plan + errorText={fieldState.error?.message} + label={ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP.linodePlan} + name="linodePlan" + onChange={field.onChange} + placeholder="Dedicated 4GB, Shared 8GB, High Memory 24GB, etc." + value={field.value} + /> + )} + control={control} + name="linodePlan" + /> + )} ( ({ @@ -281,12 +284,9 @@ export const VolumeCreate = () => { {error && ( - + + + )} { @@ -22,8 +23,11 @@ describe('Betas landing page', () => { }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); + // Ensure that the Primary Nav is open + mockGetUserPreferences({ desktop_sidebar_open: false }).as('getPreferences'); + cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getPreferences']); ui.nav.findItemByTitle('Betas').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index fcd63a2a5b7..54b39a0b1e6 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -15,6 +15,7 @@ import { Profile } from '@linode/api-v4'; import { formatDate } from '@src/utilities/formatDate'; import type { StackScript } from '@linode/api-v4'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; const mockStackScripts: StackScript[] = [ stackScriptFactory.build({ @@ -262,9 +263,11 @@ describe('Community Stackscripts integration tests', () => { const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); + // Ensure that the Primary Nav is open + mockGetUserPreferences({ desktop_sidebar_open: false }).as('getPreferences'); interceptGetStackScripts().as('getStackScripts'); cy.visitWithLogin('/stackscripts/community'); - cy.wait('@getStackScripts'); + cy.wait(['@getStackScripts', '@getPreferences']); cy.get('[id="search-by-label,-username,-or-description"]') .click() diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts index 5128eea733f..5bc87ee84fd 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts @@ -8,6 +8,7 @@ import { } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; import { ui } from 'support/ui'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; // TODO Remove feature flag mocks when feature flag is removed from codebase. describe('VPC navigation', () => { @@ -21,8 +22,13 @@ describe('VPC navigation', () => { }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); + // Ensure that the Primary Nav is open + mockGetUserPreferences({ desktop_sidebar_open: false }).as( + 'getPreferences' + ); + cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getPreferences']); ui.nav.findItemByTitle('VPC').should('be.visible').click(); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts index eb4d5fd63e3..70666df27a5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts @@ -6,7 +6,6 @@ import { regionQueries } from 'src/queries/regions/regions'; import { getRegionCountryGroup, isEURegion } from 'src/utilities/formatRegion'; import { - CreateLinodeByCloningSchema, CreateLinodeFromBackupSchema, CreateLinodeFromMarketplaceAppSchema, CreateLinodeFromStackScriptSchema, @@ -38,6 +37,13 @@ export const getLinodeCreateResolver = ( { mode: 'async', rawValues: true } )(transformedValues, context, options); + if (tab === 'Clone Linode' && !values.linode) { + errors['linode'] = { + message: 'You must select a Linode to clone from.', + type: 'validate', + }; + } + const regions = await queryClient.ensureQueryData(regionQueries.regions); const selectedRegion = regions.find((r) => r.id === values.region); @@ -74,7 +80,7 @@ export const getLinodeCreateResolver = ( export const linodeCreateResolvers = { Backups: CreateLinodeFromBackupSchema, - 'Clone Linode': CreateLinodeByCloningSchema, + 'Clone Linode': CreateLinodeSchema, Images: CreateLinodeSchema, OS: CreateLinodeSchema, 'One-Click': CreateLinodeFromMarketplaceAppSchema, diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts index b97945a9868..ce05bba3593 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts @@ -1,15 +1,6 @@ import { CreateLinodeSchema } from '@linode/validation'; import { number, object } from 'yup'; -/** - * Extends the Linode Create schema to make `linode` required for the Clone Linode tab - */ -export const CreateLinodeByCloningSchema = CreateLinodeSchema.concat( - object({ - linode: object().required('You must select a Linode to clone from.'), - }) -); - /** * Extends the Linode Create schema to make backup_id required for the backups tab */ From eb17f61a2eeb1081e82dd191841eae7736620a00 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 22 Jul 2024 18:31:44 +0530 Subject: [PATCH 15/58] refactor: [M3-6901, M3-6918] - Replace `react-select` with `Autocomplete` in Domains (#10693) * Replace react-select with Autocomplete in CreateDomain component * Replace react-select with Autocomplete in DomainRecordDrawer component * Few updates on CreateDomain component * Added changeset: Replace 'react-select' with Autocomplete in Domains --- .../pr-10693-tech-stories-1721292487290.md | 5 ++ .../Domains/CreateDomain/CreateDomain.tsx | 62 ++++++++++--------- .../features/Domains/DomainRecordDrawer.tsx | 36 +++++------ 3 files changed, 55 insertions(+), 48 deletions(-) create mode 100644 packages/manager/.changeset/pr-10693-tech-stories-1721292487290.md diff --git a/packages/manager/.changeset/pr-10693-tech-stories-1721292487290.md b/packages/manager/.changeset/pr-10693-tech-stories-1721292487290.md new file mode 100644 index 00000000000..29f2af94d93 --- /dev/null +++ b/packages/manager/.changeset/pr-10693-tech-stories-1721292487290.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace 'react-select' with Autocomplete in Domains ([#10693](https://github.com/linode/manager/pull/10693)) diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index a92bb23da93..04ceb6f4966 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -7,16 +7,16 @@ import { import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; import { APIError } from '@linode/api-v4/lib/types'; import { createDomainSchema } from '@linode/validation/lib/domains.schema'; -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { useFormik } from 'formik'; import { path } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { FormHelperText } from 'src/components/FormHelperText'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -47,7 +47,10 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { generateDefaultDomainRecords } from '../domainUtils'; -type DefaultRecordsType = 'linode' | 'nodebalancer' | 'none'; +interface DefaultRecordsSetting { + label: string; + value: 'linode' | 'nodebalancer' | 'none'; +} export const CreateDomain = () => { const { data: profile } = useProfile(); @@ -63,12 +66,25 @@ export const CreateDomain = () => { const history = useHistory(); - const [defaultRecordsSetting, setDefaultRecordsSetting] = React.useState< - Item - >({ - label: 'Do not insert default records for me.', - value: 'none', - }); + const defaultRecords: DefaultRecordsSetting[] = [ + { + label: 'Do not insert default records for me.', + value: 'none', + }, + { + label: 'Insert default records from one of my Linodes.', + value: 'linode', + }, + { + label: 'Insert default records from one of my NodeBalancers.', + value: 'nodebalancer', + }, + ]; + + const [ + defaultRecordsSetting, + setDefaultRecordsSetting, + ] = React.useState(defaultRecords[0]); const [selectedDefaultLinode, setSelectedDefaultLinode] = React.useState< Linode | undefined @@ -358,29 +374,15 @@ export const CreateDomain = () => { )} {isCreatingPrimaryDomain && ( - fn(e.value)} + onChange={(_, selected) => fn(selected.value)} options={MSSelectOptions} + value={defaultOption} /> ); }; @@ -364,17 +364,17 @@ export class DomainRecordDrawer extends React.Component< }); return ( - this.setTag(e.value)} + onChange={(_, selected) => this.setTag(selected.value)} options={tagOptions} + value={defaultTag} /> ); }; From dfe2e6126adfd1d533db9f62df6fe1a78da95de5 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Mon, 22 Jul 2024 19:01:16 +0530 Subject: [PATCH 16/58] refactor: [M3-6901, M3-6909] - Replace Select with Autocomplete in: nodebalancers (#10688) * refactor: [M3-6901, M3-6909] - Replace Select with Autocomplete in: nodebalancers * Added changeset: Replace Select with Autocomplete component on NodeBalancers Create page * refactor: [M3-6901, M3-6909] - replaced individual arrow functions to inline anonymous functions for onChange in AutoComplete : nodebalancers * refactor: [M3-6901, M3-6909] - Added autoHiglight prop to allow select on [Enter] for AutoComplete : nodebalancers * refactor: [M3-6901, 6909] - Removed redundant value for disableClearable prop in AutoComplete: nodebalancers --- .../pr-10688-tech-stories-1721212457181.md | 5 ++ .../NodeBalancers/NodeBalancerActiveCheck.tsx | 21 +++-- .../NodeBalancers/NodeBalancerConfigPanel.tsx | 78 ++++++++++--------- 3 files changed, 55 insertions(+), 49 deletions(-) create mode 100644 packages/manager/.changeset/pr-10688-tech-stories-1721212457181.md diff --git a/packages/manager/.changeset/pr-10688-tech-stories-1721212457181.md b/packages/manager/.changeset/pr-10688-tech-stories-1721212457181.md new file mode 100644 index 00000000000..641c4504cc3 --- /dev/null +++ b/packages/manager/.changeset/pr-10688-tech-stories-1721212457181.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace Select with Autocomplete component on NodeBalancers Create page ([#10688](https://github.com/linode/manager/pull/10688)) diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx index c125d4f8a2e..33e8cf66a97 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx @@ -1,7 +1,7 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import Select from 'src/components/EnhancedSelect/Select'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; import { TextField } from 'src/components/TextField'; @@ -10,7 +10,6 @@ import { Typography } from 'src/components/Typography'; import { setErrorMap } from './utils'; import type { NodeBalancerConfigPanelProps } from './types'; -import type { Item } from 'src/components/EnhancedSelect'; interface ActiveCheckProps extends NodeBalancerConfigPanelProps { errorMap: Record; @@ -60,9 +59,6 @@ export const ActiveCheck = (props: ActiveCheckProps) => { const onHealthCheckTimeoutChange = (e: React.ChangeEvent) => props.onHealthCheckTimeoutChange(e.target.value); - const onHealthCheckTypeChange = (e: Item) => - props.onHealthCheckTypeChange(e.value); - const conditionalText = displayProtocolText(protocol); const typeOptions = [ @@ -99,22 +95,25 @@ export const ActiveCheck = (props: ActiveCheckProps) => { - @@ -258,22 +251,25 @@ export const NodeBalancerConfigPanel = ( {tcpSelected && ( - { + props.onAlgorithmChange(selected.value); + }} options={algOptions} - small + size="small" value={defaultAlg || algOptions[0]} /> @@ -315,22 +314,25 @@ export const NodeBalancerConfigPanel = ( - handleTypeChange(selected)} options={firewallOptionItemsShort} placeholder="Select a rule preset..." /> @@ -236,32 +241,48 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { placeholder="Enter a description..." value={values.description} /> - 0 ? ' ' : 'Select a port...'} + onChange={(_, selected) => handlePortPresetChange(selected)} options={portOptions} - required value={presetPorts} /> {hasCustomInput ? ( @@ -276,17 +297,26 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { value={values.ports} /> ) : null} - ) => - handlePolicyChange(selected.value) - } + handlePolicyChange(selected?.value)} value={policyOptions.find( (thisOption) => thisOption.value === policy )} disabled={disabled} - hideLabel - isClearable={false} + disableClearable label={`${category} policy`} - menuPlacement="top" options={policyOptions} /> diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts index 44b3d7a7a28..c53ccaaefe9 100644 --- a/packages/manager/src/features/Firewalls/shared.ts +++ b/packages/manager/src/features/Firewalls/shared.ts @@ -4,11 +4,14 @@ import { FirewallRuleType, } from '@linode/api-v4/lib/firewalls/types'; -import { Item } from 'src/components/EnhancedSelect/Select'; import { truncateAndJoinList } from 'src/utilities/stringUtils'; export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; +export interface FirewallOptionItem { + label: L; + value: T; +} // Predefined Firewall options for Select components (long-form). export const firewallOptionItemsLong = [ { @@ -57,7 +60,7 @@ export const firewallOptionItemsShort = [ }, ]; -export const protocolOptions: Item[] = [ +export const protocolOptions: FirewallOptionItem[] = [ { label: 'TCP', value: 'TCP' }, { label: 'UDP', value: 'UDP' }, { label: 'ICMP', value: 'ICMP' }, From 10dd9cae8787e5d9333f49cc8174acbbf204ea61 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Fri, 26 Jul 2024 09:34:37 -0400 Subject: [PATCH 29/58] change: [M3-8376] - bump to latest design language system version (#10711) Co-authored-by: Jaalah Ramos --- .../pr-10711-tech-stories-1721919417354.md | 5 + packages/manager/package.json | 2 +- .../manager/src/foundations/themes/dark.ts | 28 +-- yarn.lock | 206 ++++++++++-------- 4 files changed, 136 insertions(+), 105 deletions(-) create mode 100644 packages/manager/.changeset/pr-10711-tech-stories-1721919417354.md diff --git a/packages/manager/.changeset/pr-10711-tech-stories-1721919417354.md b/packages/manager/.changeset/pr-10711-tech-stories-1721919417354.md new file mode 100644 index 00000000000..5938402eeb9 --- /dev/null +++ b/packages/manager/.changeset/pr-10711-tech-stories-1721919417354.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Bumped to latest version of @linode/design-language-system ([#10711](https://github.com/linode/manager/pull/10711)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 8b9524f4243..807f75deb1d 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -18,7 +18,7 @@ "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", - "@linode/design-language-system": "^2.3.0", + "@linode/design-language-system": "^2.6.0", "@linode/validation": "*", "@linode/search": "*", "@lukemorales/query-key-factory": "^1.3.4", diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 1e9f84e34f2..bd13f9ceb58 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -358,34 +358,34 @@ export const darkTheme: ThemeOptions = { color: Color.Brand[100], }, colorError: { - backgroundColor: Badge.Bold.Red.Background, - color: Badge.Bold.Red.Text, + backgroundColor: Badge.Negative.Background, + color: Badge.Negative.Text, }, colorInfo: { - backgroundColor: Badge.Bold.Ultramarine.Background, - color: Badge.Bold.Ultramarine.Text, + backgroundColor: Badge.Informative.Background, + color: Badge.Informative.Text, }, colorPrimary: { - backgroundColor: Badge.Bold.Ultramarine.Background, - color: Badge.Bold.Ultramarine.Text, + backgroundColor: Badge.Informative.Background, + color: Badge.Informative.Text, }, colorSecondary: { '&.MuiChip-clickable': { '&:hover': { - backgroundColor: Badge.Bold.Ultramarine.Background, - color: Badge.Bold.Ultramarine.Text, + backgroundColor: Badge.Informative.Background, + color: Badge.Informative.Text, }, }, - backgroundColor: Badge.Bold.Ultramarine.Background, - color: Badge.Bold.Ultramarine.Text, + backgroundColor: Badge.Informative.Background, + color: Badge.Informative.Text, }, colorSuccess: { - backgroundColor: Badge.Bold.Green.Background, - color: Badge.Bold.Green.Text, + backgroundColor: Badge.Positive.Background, + color: Badge.Positive.Text, }, colorWarning: { - backgroundColor: Badge.Bold.Amber.Background, - color: Badge.Bold.Amber.Text, + backgroundColor: Badge.Warning.Background, + color: Badge.Warning.Text, }, outlined: { '& .MuiChip-label': { diff --git a/yarn.lock b/yarn.lock index af358776a48..2046c89aabb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1529,33 +1529,40 @@ dependencies: deepmerge "^4.3.1" -"@bundled-es-modules/glob@^10.3.13": - version "10.3.13" - resolved "https://registry.yarnpkg.com/@bundled-es-modules/glob/-/glob-10.3.13.tgz#162af7285f224cbeacd8112754babf80adc0b732" - integrity sha512-eK+st/vwMmQy0pVvHLa2nzsS+p6NkNVR34e8qfiuzpzS1he4bMU3ODl0gbyv4r9INq5x41GqvRmFr8PtNw4yRA== +"@bundled-es-modules/glob@^10.4.2": + version "10.4.2" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/glob/-/glob-10.4.2.tgz#ef8f58b5d33ec8a1d4ca739bb49c09e5d0874bb5" + integrity sha512-740y5ofkzydsFao5EXJrGilcIL6EFEw/cmPf2uhTw9J6G1YOhiIFjNFCHdpgEiiH5VlU3G0SARSjlFlimRRSMA== dependencies: buffer "^6.0.3" events "^3.3.0" - glob "^10.3.10" + glob "^10.4.2" patch-package "^8.0.0" path "^0.12.7" - stream "^0.0.2" + stream "^0.0.3" string_decoder "^1.3.0" - url "^0.11.1" + url "^0.11.3" -"@bundled-es-modules/memfs@^4.8.1": - version "4.8.1" - resolved "https://registry.yarnpkg.com/@bundled-es-modules/memfs/-/memfs-4.8.1.tgz#0a37f5a7050eced8d03d3af81f44579548437fa6" - integrity sha512-9BodQuihWm3XJGKYuV/vXckK8Tkf9EDiT/au1NJeFUyBMe7EMYRtOqL9eLzrjqJSDJUFoGwQFHvraFHwR8cysQ== +"@bundled-es-modules/memfs@^4.9.4": + version "4.9.4" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/memfs/-/memfs-4.9.4.tgz#9c68d1ff10f485d59d45778c930aa8d60d0095a2" + integrity sha512-1XyYPUaIHwEOdF19wYVLBtHJRr42Do+3ctht17cZOHwHf67vkmRNPlYDGY2kJps4RgE5+c7nEZmEzxxvb1NZWA== dependencies: assert "^2.0.0" buffer "^6.0.3" events "^3.3.0" - memfs "^4.8.1" + memfs "^4.9.3" path "^0.12.7" - stream "^0.0.2" + stream "^0.0.3" util "^0.12.5" +"@bundled-es-modules/postcss-calc-ast-parser@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.6.tgz#c15f422c0300b2daba9cff6a9d07ef6c5e7cc673" + integrity sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA== + dependencies: + postcss-calc-ast-parser "^0.1.4" + "@bundled-es-modules/statuses@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz#761d10f44e51a94902c4da48675b71a76cc98872" @@ -2297,15 +2304,16 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== -"@linode/design-language-system@^2.3.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@linode/design-language-system/-/design-language-system-2.4.0.tgz#c405b98ec64adf73381e81bc46136aa8b07aab50" - integrity sha512-UNwmtYTCAC5w/Q4RbbWY/qY4dhqCbq231glWDfbacoMq3NRmT75y3MCwmsXSPt9XwkUJepGz6L/PV/Mm6MfTsA== +"@linode/design-language-system@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@linode/design-language-system/-/design-language-system-2.6.0.tgz#be3083c07bfa6ede803357a31dcf7b812d9b4ef0" + integrity sha512-SOhTXpUlgqYIvsUD9CqL+R4duM/04vpksYPPxK5wVRL6RLa4GEXiN3l0QwHRRTHHZry7zQu8eMWYGFQwm3vbLw== dependencies: - "@tokens-studio/sd-transforms" "^0.15.2" + "@tokens-studio/sd-transforms" "1.2.0" react "^17.0.2" + react-copy-to-clipboard "^5.1.0" react-dom "^17.0.2" - style-dictionary "4.0.0-prerelease.25" + style-dictionary "4.0.1" "@linode/eslint-plugin-cloud-manager@^0.0.3": version "0.0.3" @@ -3772,24 +3780,22 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== -"@tokens-studio/sd-transforms@^0.15.2": - version "0.15.2" - resolved "https://registry.yarnpkg.com/@tokens-studio/sd-transforms/-/sd-transforms-0.15.2.tgz#2cd374b89a1167d66a9c29c2779623103221fac7" - integrity sha512-0ryA1xdZ75cmneUZ/0UQIpzMFUyKPsfQgeu/jZguGFF7vB3/Yr+JsjGU/HFFvWtZfy0c4EQToCSHYwI0g13cBg== +"@tokens-studio/sd-transforms@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@tokens-studio/sd-transforms/-/sd-transforms-1.2.0.tgz#467ca4adf53cd955d8b6ebb2704ed147f10f1145" + integrity sha512-Ygj0nHiS0b/xMOcCovgrBylKk7EgDM9K3DiWEvdSGQZHAfOshAR7UCYQ5vEIH7NZaIVZqgqu2rB8FCIA9PDxbw== dependencies: - "@tokens-studio/types" "^0.4.0" - color2k "^2.0.1" + "@bundled-es-modules/deepmerge" "^4.3.1" + "@bundled-es-modules/postcss-calc-ast-parser" "^0.1.6" + "@tokens-studio/types" "^0.5.1" colorjs.io "^0.4.3" - deepmerge "^4.3.1" expr-eval-fork "^2.0.2" is-mergeable-object "^1.1.1" - postcss-calc-ast-parser "^0.1.4" - style-dictionary "^4.0.0-prerelease.22" -"@tokens-studio/types@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@tokens-studio/types/-/types-0.4.0.tgz#882088f22201e8f9112279f3ebacf8557213c615" - integrity sha512-rp5t0NP3Kai+Z+euGfHRUMn3AvPQ0bd9Dd2qbtfgnTvujxM5QYVr4psx/mwrVwA3NS9829mE6cD3ln+PIaptBA== +"@tokens-studio/types@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@tokens-studio/types/-/types-0.5.1.tgz#5037e58c4b2c306762f12e8d9685e9aeebb21685" + integrity sha512-LdCF9ZH5ej4Gb6n58x5fTkhstxjXDZc1SWteMWY6EiddLQJVONMIgYOrWrf1extlkSLjagX8WS0B63bAqeltnA== "@tootallnate/once@2": version "2.0.0" @@ -5994,11 +6000,6 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color2k@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.3.tgz#a771244f6b6285541c82aa65ff0a0c624046e533" - integrity sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog== - colorette@^2.0.16, colorette@^2.0.20: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" @@ -6051,6 +6052,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +component-emitter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-2.0.0.tgz#3a137dfe66fcf2efe3eab7cb7d5f51741b3620c6" + integrity sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw== + compressible@~2.0.16: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -6150,7 +6156,7 @@ copy-anything@^3.0.2: dependencies: is-what "^4.1.8" -copy-to-clipboard@^3.0.8: +copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.3.1: version "3.3.3" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA== @@ -6822,11 +6828,6 @@ electron-to-chromium@^1.4.668: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.767.tgz#b885cfefda5a2e7a7ee356c567602012294ed260" integrity sha512-nzzHfmQqBss7CE3apQHkHjXW77+8w3ubGCIoEijKCJebPufREaFETgGXWTkh32t259F3Kcq+R8MZdFdOJROgYw== -emitter-component@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.2.tgz#d65af5833dc7c682fd0ade35f902d16bc4bad772" - integrity sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw== - emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -8224,6 +8225,18 @@ glob@>=7, glob@^10.0.0, glob@^10.3.1, glob@^10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" +glob@^10.4.2: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -9168,7 +9181,7 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -jackspeak@2.1.1, jackspeak@^2.3.5: +jackspeak@2.1.1, jackspeak@^2.3.5, jackspeak@^3.1.2: version "2.1.1" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.1.1.tgz#2a42db4cfbb7e55433c28b6f75d8b796af9669cd" integrity sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw== @@ -9753,6 +9766,11 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -10013,14 +10031,14 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" -memfs@^4.8.1: - version "4.9.2" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.9.2.tgz#42e7b48207268dad8c9c48ea5d4952c5d3840433" - integrity sha512-f16coDZlTG1jskq3mxarwB+fGRrd0uXWt+o1WIhRfOwbXQZqUDsTVxQBFK9JjRQHblg8eAG2JSbprDXKjc7ijQ== +memfs@^4.9.3: + version "4.9.4" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.9.4.tgz#803eb7f2091d1c6198ec9ba9b582505ad8699c9e" + integrity sha512-Xlj8b2rU11nM6+KU6wC7cuWcHQhVINWCUgdPS4Ar9nPxLaOya3RghqK7ALyDW2QtGebYAYs6uEdEVnwPVT942A== dependencies: "@jsonjoy.com/json-pack" "^1.0.3" "@jsonjoy.com/util" "^1.1.2" - sonic-forest "^1.0.0" + tree-dump "^1.0.1" tslib "^2.0.0" memoize-one@^5.0.0, memoize-one@^5.1.1: @@ -10406,6 +10424,13 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -10428,6 +10453,11 @@ minipass@^5.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== +minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + minizlib@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -10964,6 +10994,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + pako@~0.2.0: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" @@ -11095,6 +11130,14 @@ path-scurry@^1.10.1: lru-cache "^9.1.1 || ^10.0.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -11597,6 +11640,14 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== +react-copy-to-clipboard@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c" + integrity sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A== + dependencies: + copy-to-clipboard "^3.3.1" + prop-types "^15.8.1" + react-csv@^2.0.3: version "2.2.2" resolved "https://registry.yarnpkg.com/react-csv/-/react-csv-2.2.2.tgz#5bbf0d72a846412221a14880f294da9d6def9bfb" @@ -12747,13 +12798,6 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" -sonic-forest@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sonic-forest/-/sonic-forest-1.0.3.tgz#81363af60017daba39b794fce24627dc412563cb" - integrity sha512-dtwajos6IWMEWXdEbW1IkEkyL2gztCAgDplRIX+OT5aRKnEd5e7r7YCxRgXZdhRP1FBdOBf8axeTPhzDv8T4wQ== - dependencies: - tree-dump "^1.0.0" - source-map-generator@0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/source-map-generator/-/source-map-generator-0.8.0.tgz#10d5ca0651e2c9302ea338739cbd4408849c5d00" @@ -12896,12 +12940,12 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== -stream@^0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef" - integrity sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g== +stream@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.3.tgz#3f3934a900a561ce3e2b9ffbd2819cead32699d9" + integrity sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A== dependencies: - emitter-component "^1.1.1" + component-emitter "^2.0.0" strict-event-emitter@^0.5.1: version "0.5.1" @@ -13108,32 +13152,14 @@ strip-literal@^2.0.0: dependencies: js-tokens "^9.0.0" -style-dictionary@4.0.0-prerelease.25: - version "4.0.0-prerelease.25" - resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.0-prerelease.25.tgz#df3d552e4324a277c13880e377f6be756db6db61" - integrity sha512-1dqKBBSvGbXPH2WFLUqqZBrmLnuNyXRkUOG1SEGJ0vDVrx+o4guOcx5aIBI9sLz2pyL7B8Yo0r4FizltFPi9WA== - dependencies: - "@bundled-es-modules/deepmerge" "^4.3.1" - "@bundled-es-modules/glob" "^10.3.13" - "@bundled-es-modules/memfs" "^4.8.1" - chalk "^5.3.0" - change-case "^5.3.0" - commander "^8.3.0" - is-plain-obj "^4.1.0" - json5 "^2.2.2" - lodash-es "^4.17.21" - patch-package "^8.0.0" - path-unified "^0.1.0" - tinycolor2 "^1.6.0" - -style-dictionary@^4.0.0-prerelease.22: - version "4.0.0-prerelease.35" - resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.0-prerelease.35.tgz#3085de0d9212b56be2c9ed0c1c51de8005a4e68f" - integrity sha512-03e05St/a9XdorK0pN30zprI7J8rrRDnGCiga4Do2rjbR3jfKEKSvtUe6Inl/HQBZXm0RBFrMhVGX9MF1P2sdw== +style-dictionary@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.1.tgz#d8347d18874e7dff3f4a6faed0ddcb30c797cff0" + integrity sha512-aZ2iouI0i0DIXk3QhCkwOeo5rQeuk5Ja0PhHo32/EXCNuay4jK4CZ+hQJW0Er0J74VWniR+qaeoWgjklcULxOQ== dependencies: "@bundled-es-modules/deepmerge" "^4.3.1" - "@bundled-es-modules/glob" "^10.3.13" - "@bundled-es-modules/memfs" "^4.8.1" + "@bundled-es-modules/glob" "^10.4.2" + "@bundled-es-modules/memfs" "^4.9.4" "@zip.js/zip.js" "^2.7.44" chalk "^5.3.0" change-case "^5.3.0" @@ -13484,10 +13510,10 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== -tree-dump@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.1.tgz#b448758da7495580e6b7830d6b7834fca4c45b96" - integrity sha512-WCkcRBVPSlHHq1dc/px9iOfqklvzCbdRwvlNfxGZsrHqf6aZttfPrd7DJTt6oR10dwUfpFFQeVTkPbBIZxX/YA== +tree-dump@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.2.tgz#c460d5921caeb197bde71d0e9a7b479848c5b8ac" + integrity sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ== tree-kill@^1.2.1, tree-kill@^1.2.2: version "1.2.2" @@ -13896,7 +13922,7 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -url@^0.11.1: +url@^0.11.3: version "0.11.3" resolved "https://registry.yarnpkg.com/url/-/url-0.11.3.tgz#6f495f4b935de40ce4a0a52faee8954244f3d3ad" integrity sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw== From ac6471ce92e1a3ac1f4fb95f838575b8d9520717 Mon Sep 17 00:00:00 2001 From: Hana Xu Date: Fri, 26 Jul 2024 12:23:57 -0400 Subject: [PATCH 30/58] Revert "upcoming: [M3-8209] - Region label updates (#10702)" This reverts commit 9f4d09d873b2b27a7b09654425088cedad66446c. --- ...r-10702-upcoming-features-1721746934983.md | 5 - .../RegionSelect/RegionSelect.utils.tsx | 4 +- .../SelectRegionPanel/SelectRegionPanel.tsx | 10 +- .../TransferDisplay/TransferDisplay.tsx | 6 +- .../src/containers/regions.container.ts | 12 +- .../src/features/Backups/BackupLinodeRow.tsx | 9 +- .../BillingActivityPanel.tsx | 21 ++- .../Billing/InvoiceDetail/InvoiceDetail.tsx | 22 +-- .../Billing/InvoiceDetail/InvoiceTable.tsx | 11 +- .../shared/CloudPulseRegionSelect.tsx | 6 +- .../DatabaseCreate/DatabaseCreate.tsx | 6 +- .../DatabaseResizeCurrentConfiguration.tsx | 19 +-- .../DatabaseSummaryClusterConfiguration.tsx | 21 ++- .../Databases/DatabaseLanding/DatabaseRow.tsx | 19 +-- .../LinodeTransferTable.tsx | 15 +- .../src/features/Events/factories/linode.tsx | 6 +- .../RegionStatusBanner.tsx | 6 +- .../Images/ImagesCreate/ImageUpload.tsx | 18 +-- .../ImageRegions/ImageRegionRow.tsx | 6 +- .../ImageRegions/ManageImageRegionsForm.tsx | 6 +- .../Images/ImagesLanding/RegionsList.tsx | 6 +- .../ClusterList/KubernetesClusterRow.tsx | 9 +- .../CreateCluster/CreateCluster.tsx | 7 +- .../KubeClusterSpecs.tsx | 6 +- .../features/Linodes/CloneLanding/Details.tsx | 17 ++- .../Linodes/LinodeCreatev2/Region.tsx | 10 +- .../LinodeCreatev2/Summary/Summary.tsx | 6 +- .../shared/LinodeSelectTableRow.tsx | 6 +- .../features/Linodes/LinodeEntityDetail.tsx | 10 +- .../LinodesCreate/LinodeCreateContainer.tsx | 13 +- .../SelectLinodePanel/SelectLinodeCard.tsx | 9 +- .../LinodesCreate/VLANAvailabilityNotice.tsx | 9 +- .../NetworkTransfer.tsx | 10 +- .../LinodeNetworking/ViewIPDrawer.tsx | 9 +- .../LinodeNetworking/ViewRangeDrawer.tsx | 9 +- .../LinodesLanding/RegionIndicator.tsx | 6 +- .../Linodes/MigrateLinode/ConfigureForm.tsx | 7 +- .../ServiceTargets/LinodeOrIPSelect.tsx | 130 ++++++++++++++++++ .../NodeBalancers/NodeBalancerCreate.tsx | 6 +- .../NodeBalancerSummary/SummaryPanel.tsx | 6 +- .../useFormattedNotifications.tsx | 25 ++-- .../AccessKeyRegions/AccessKeyRegions.tsx | 6 +- .../AccessKeyTable/HostNameTableCell.tsx | 6 +- .../BucketPermissionsTable.tsx | 14 +- .../AccessKeyLanding/HostNamesDrawer.tsx | 9 +- .../AccessKeyLanding/HostNamesList.tsx | 6 +- .../BucketLanding/BucketDetailsDrawer.tsx | 6 +- .../BucketLanding/BucketLanding.tsx | 21 ++- .../BucketLanding/BucketRegions.tsx | 6 +- .../BucketLanding/BucketTableRow.tsx | 9 +- .../BucketLanding/ClusterSelect.tsx | 9 +- .../BucketLanding/OMC_BucketLanding.tsx | 18 +-- .../PlacementGroupsCreateDrawer.tsx | 15 +- .../PlacementGroupsDetail.tsx | 6 +- .../PlacementGroupsDetailPanel.tsx | 16 ++- .../PlacementGroupsLanding.tsx | 8 +- .../SecretTokenDialog/SecretTokenDialog.tsx | 8 +- .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 12 +- .../VPCs/VPCLanding/VPCEditDrawer.tsx | 6 +- .../src/features/VPCs/VPCLanding/VPCRow.tsx | 11 +- .../src/features/Volumes/VolumeCreate.tsx | 6 +- .../src/features/Volumes/VolumeTableRow.tsx | 9 +- .../components/PlansPanel/MetalNotice.tsx | 6 +- packages/manager/src/hooks/useCreateVPC.ts | 29 ++-- .../manager/src/queries/regions/regions.ts | 16 +-- 65 files changed, 349 insertions(+), 453 deletions(-) delete mode 100644 packages/manager/.changeset/pr-10702-upcoming-features-1721746934983.md create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx diff --git a/packages/manager/.changeset/pr-10702-upcoming-features-1721746934983.md b/packages/manager/.changeset/pr-10702-upcoming-features-1721746934983.md deleted file mode 100644 index a3570f769a4..00000000000 --- a/packages/manager/.changeset/pr-10702-upcoming-features-1721746934983.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Region label updates ([#10702](https://github.com/linode/manager/pull/10702)) diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index b6fe19b74d7..6d329cfad77 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -166,9 +166,7 @@ export const useIsGeckoEnabled = () => { const flags = useFlags(); const isGeckoGA = flags?.gecko2?.enabled && flags.gecko2.ga; const isGeckoBeta = flags.gecko2?.enabled && !flags.gecko2?.ga; - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGA, - }); + const { data: regions } = useRegionsQuery(isGeckoGA); const hasDistributedRegionCapability = regions?.some((region: Region) => region.capabilities.includes('Distributed Plans') diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 897c0f09aa0..dcdaf79d58f 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -5,10 +5,8 @@ import { useLocation } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { - isDistributedRegionSupported, - useIsGeckoEnabled, -} from 'src/components/RegionSelect/RegionSelect.utils'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TwoStepRegionSelect } from 'src/components/RegionSelect/TwoStepRegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Typography } from 'src/components/Typography'; @@ -74,9 +72,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(isGeckoGAEnabled); const isCloning = /clone/i.test(params.type); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx index bd28757f45f..971ec80d6b7 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useAccountNetworkTransfer } from 'src/queries/account/transfer'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -27,10 +26,7 @@ export const TransferDisplay = React.memo(({ spacingTop }: Props) => { isError, isLoading, } = useAccountNetworkTransfer(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const generalPoolUsagePct = calculatePoolUsagePct(generalPoolUsage); const regionTransferPools = getRegionTransferPools(generalPoolUsage, regions); diff --git a/packages/manager/src/containers/regions.container.ts b/packages/manager/src/containers/regions.container.ts index 80dcc83afd8..c9641ecd959 100644 --- a/packages/manager/src/containers/regions.container.ts +++ b/packages/manager/src/containers/regions.container.ts @@ -1,11 +1,9 @@ +import { Region } from '@linode/api-v4/lib/regions'; +import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { Region } from '@linode/api-v4/lib/regions'; -import type { APIError } from '@linode/api-v4/lib/types'; - export interface RegionsProps { regionsData: Region[]; regionsError?: APIError[]; @@ -27,11 +25,7 @@ export interface RegionsProps { export const withRegions = ( Component: React.ComponentType ) => (props: Props) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data, error, isLoading } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); - + const { data, error, isLoading } = useRegionsQuery(); return React.createElement(Component, { regionsData: data ?? [], regionsError: error ?? undefined, diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.tsx index a7b9bec9c3e..7ebeaae2b3a 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.tsx @@ -1,6 +1,6 @@ +import { Linode, PriceObject } from '@linode/api-v4'; import * as React from 'react'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; @@ -12,8 +12,6 @@ import { UNKNOWN_PRICE, } from 'src/utilities/pricing/constants'; -import type { Linode, PriceObject } from '@linode/api-v4'; - interface Props { error?: string; linode: Linode; @@ -22,10 +20,7 @@ interface Props { export const BackupLinodeRow = (props: Props) => { const { error, linode } = props; const { data: type } = useTypeQuery(linode.type ?? '', Boolean(linode.type)); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const backupsMonthlyPrice: | PriceObject['monthly'] diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index d7d759c8250..9a7235d8729 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -1,5 +1,10 @@ -import { getInvoiceItems } from '@linode/api-v4/lib/account'; -import { styled } from '@mui/material/styles'; +import { + Invoice, + InvoiceItem, + Payment, + getInvoiceItems, +} from '@linode/api-v4/lib/account'; +import { Theme, styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; @@ -7,13 +12,12 @@ import { makeStyles } from 'tss-react/mui'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import Select from 'src/components/EnhancedSelect/Select'; +import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Link } from 'src/components/Link'; import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -43,10 +47,6 @@ import { getAll } from 'src/utilities/getAll'; import { getTaxID } from '../../billingUtils'; -import type { Invoice, InvoiceItem, Payment } from '@linode/api-v4/lib/account'; -import type { Theme } from '@mui/material/styles'; -import type { Item } from 'src/components/EnhancedSelect/Select'; - const useStyles = makeStyles()((theme: Theme) => ({ activeSince: { marginRight: theme.spacing(1.25), @@ -187,10 +187,7 @@ export const BillingActivityPanel = (props: Props) => { const { data: profile } = useProfile(); const { data: account } = useAccount(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const isAkamaiCustomer = account?.billing_source === 'akamai'; diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 4fd7c38da1f..47bb07eba4a 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx @@ -1,7 +1,14 @@ -import { getInvoice, getInvoiceItems } from '@linode/api-v4/lib/account'; +import { + Account, + Invoice, + InvoiceItem, + getInvoice, + getInvoiceItems, +} from '@linode/api-v4/lib/account'; +import { APIError } from '@linode/api-v4/lib/types'; import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; -import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { useParams } from 'react-router-dom'; @@ -13,7 +20,6 @@ import { IconButton } from 'src/components/IconButton'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { printInvoice } from 'src/features/Billing/PdfGenerator/PdfGenerator'; import { useFlags } from 'src/hooks/useFlags'; @@ -22,13 +28,10 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAll } from 'src/utilities/getAll'; -import { getShouldUseAkamaiBilling } from '../billingUtils'; import { invoiceCreatedAfterDCPricingLaunch } from '../PdfGenerator/utils'; +import { getShouldUseAkamaiBilling } from '../billingUtils'; import { InvoiceTable } from './InvoiceTable'; -import type { Account, Invoice, InvoiceItem } from '@linode/api-v4/lib/account'; -import type { APIError } from '@linode/api-v4/lib/types'; - export const InvoiceDetail = () => { const { invoiceId } = useParams<{ invoiceId: string }>(); const theme = useTheme(); @@ -36,10 +39,7 @@ export const InvoiceDetail = () => { const csvRef = React.useRef(); const { data: account } = useAccount(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const [invoice, setInvoice] = React.useState(undefined); const [items, setItems] = React.useState( diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx index e23acc83015..e633213429d 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx @@ -1,10 +1,11 @@ +import { InvoiceItem } from '@linode/api-v4/lib/account'; +import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -18,9 +19,6 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { getInvoiceRegion } from '../PdfGenerator/utils'; -import type { InvoiceItem } from '@linode/api-v4/lib/account'; -import type { APIError } from '@linode/api-v4/lib/types'; - interface Props { errors?: APIError[]; items?: InvoiceItem[]; @@ -31,14 +29,11 @@ interface Props { export const InvoiceTable = (props: Props) => { const MIN_PAGE_SIZE = 25; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); const { data: regions, error: regionsError, isLoading: regionsLoading, - } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + } = useRegionsQuery(); const { errors, items, loading, shouldShowRegion } = props; const NUM_COLUMNS = shouldShowRegion ? 9 : 8; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index c6661634d44..47c04be2bb8 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { REGION, RESOURCES } from '../Utils/constants'; @@ -19,10 +18,7 @@ export interface CloudPulseRegionSelectProps { export const CloudPulseRegionSelect = React.memo( (props: CloudPulseRegionSelectProps) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const [selectedRegion, setSelectedRegion] = React.useState(); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index b14975a19ac..35127bcd302 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -62,7 +62,6 @@ import type { Theme } from '@mui/material/styles'; import type { Item } from 'src/components/EnhancedSelect/Select'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; const useStyles = makeStyles()((theme: Theme) => ({ btnCtn: { @@ -194,15 +193,12 @@ const DatabaseCreate = () => { const { classes } = useStyles(); const history = useHistory(); const flags = useFlags(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); const { data: regionsData, error: regionsError, isLoading: regionsLoading, - } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + } = useRegionsQuery(); const { data: engines, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx index bd5e62192aa..4206f31ba66 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx @@ -1,10 +1,15 @@ +import { Region } from '@linode/api-v4'; +import { + Database, + DatabaseInstance, + DatabaseType, +} from '@linode/api-v4/lib/databases/types'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; @@ -22,13 +27,6 @@ import { StyledTitleTypography, } from './DatabaseResizeCurrentConfiguration.style'; -import type { Region } from '@linode/api-v4'; -import type { - Database, - DatabaseInstance, - DatabaseType, -} from '@linode/api-v4/lib/databases/types'; - interface Props { database: Database; } @@ -44,10 +42,7 @@ export const DatabaseResizeCurrentConfiguration = ({ database }: Props) => { isLoading: typesLoading, } = useDatabaseTypesQuery(); const theme = useTheme(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find((r: Region) => r.id === database.region); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx index 95a933d30d5..8eb09fd8b07 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx @@ -1,8 +1,14 @@ +import { Region } from '@linode/api-v4'; +import { + Database, + DatabaseInstance, + DatabaseType, +} from '@linode/api-v4/lib/databases/types'; +import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; @@ -14,14 +20,6 @@ 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', @@ -56,10 +54,7 @@ export const DatabaseSummaryClusterConfiguration = (props: Props) => { const { database } = props; const { data: types } = useDatabaseTypesQuery(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find((r: Region) => r.id === database.region); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index d45dce8195b..5646cb54753 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -1,9 +1,14 @@ +import { Event } from '@linode/api-v4'; +import { + Database, + DatabaseInstance, + Engine, +} from '@linode/api-v4/lib/databases/types'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { Chip } from 'src/components/Chip'; import { Hidden } from 'src/components/Hidden'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { useProfile } from 'src/queries/profile/profile'; @@ -13,13 +18,6 @@ import { formatDate } from 'src/utilities/formatDate'; import { DatabaseStatusDisplay } from '../DatabaseDetail/DatabaseStatusDisplay'; -import type { Event } from '@linode/api-v4'; -import type { - Database, - DatabaseInstance, - Engine, -} from '@linode/api-v4/lib/databases/types'; - export const databaseEngineMap: Record = { mongodb: 'MongoDB', mysql: 'MySQL', @@ -43,10 +41,7 @@ export const DatabaseRow = ({ database, events }: Props) => { version, } = database; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: profile } = useProfile(); const actualRegion = regions?.find((r) => r.id === region); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index a04fbc4c9f3..55d4c031f37 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -1,9 +1,10 @@ -import { useTheme } from '@mui/material'; +import { Linode } from '@linode/api-v4/lib/linodes'; +import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { SelectableTableRow } from 'src/components/SelectableTableRow/SelectableTableRow'; import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; @@ -14,10 +15,7 @@ import { useSpecificTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; import { TransferTable } from './TransferTable'; - -import type { Entity, TransferEntity } from './transferReducer'; -import type { Linode } from '@linode/api-v4/lib/linodes'; -import type { Theme } from '@mui/material/styles'; +import { Entity, TransferEntity } from './transferReducer'; interface Props { handleRemove: (linodesToRemove: string[]) => void; @@ -112,10 +110,7 @@ const LinodeRow = (props: RowProps) => { const type = typesQuery[0]?.data ? extendType(typesQuery[0].data) : undefined; const displayType = type?.formattedLabel ?? linode.type; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find((r) => r.id === linode.region); const displayRegion = region?.label ?? linode.region; return ( diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index ec6ddcb0a0a..ba13193cdf1 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; @@ -540,10 +539,7 @@ export const linode: PartialEventMap<'linode'> = { const LinodeMigrateDataCenterMessage = ({ event }: { event: Event }) => { const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find((r) => r.id === linode?.region); return ( diff --git a/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx b/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx index 94eda8c82ed..f369b2e6146 100644 --- a/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -54,10 +53,7 @@ const renderBanner = (statusWarnings: string[]): JSX.Element => { }; export const RegionStatusBanner = React.memo(() => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const labelsOfRegionsWithOutages = regions ?.filter((region) => region.status === 'outage') diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index 2abfc59b60a..f1a95fdac2e 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -16,13 +16,13 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Prompt } from 'src/components/Prompt/Prompt'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } 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'; import { Typography } from 'src/components/Typography'; import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; +import { Dispatch } from 'src/hooks/types'; import { useFlags } from 'src/hooks/useFlags'; import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -40,16 +40,15 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { EUAgreementCheckbox } from '../../Account/Agreements/EUAgreementCheckbox'; import { getRestrictedResourceText } from '../../Account/utils'; -import { uploadImageFile } from '../requests'; import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; -import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; - -import type { +import { ImageUploadFormData, ImageUploadNavigationState, } from './ImageUpload.utils'; +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; +import { uploadImageFile } from '../requests'; + import type { AxiosError, AxiosProgressEvent } from 'axios'; -import type { Dispatch } from 'src/hooks/types'; export const ImageUpload = () => { const { location } = useHistory(); @@ -67,10 +66,7 @@ export const ImageUpload = () => { const { data: profile } = useProfile(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { mutateAsync: createImage } = useUploadImageMutation(); const { enqueueSnackbar } = useSnackbar(); @@ -376,8 +372,8 @@ export const ImageUpload = () => { diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx index 6d7795ed205..2479578d43c 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; import { IconButton } from 'src/components/IconButton'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { Typography } from 'src/components/Typography'; @@ -25,10 +24,7 @@ interface Props { export const ImageRegionRow = (props: Props) => { const { disableRemoveButton, onRemove, region, status } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const actualRegion = regions?.find((r) => r.id === region); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx index a4f36663d53..03f9fdb71a3 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -9,7 +9,6 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useUpdateImageRegionsMutation } from 'src/queries/images'; @@ -30,10 +29,7 @@ export const ManageImageRegionsForm = (props: Props) => { const imageRegionIds = image?.regions.map(({ region }) => region) ?? []; const { enqueueSnackbar } = useSnackbar(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); const { diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx index fa61e55e4e5..e17785ea634 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -13,10 +12,7 @@ interface Props { } export const RegionsList = ({ onManageRegions, regions }: Props) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData } = useRegionsQuery(); return ( diff --git a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx index cbb56d31dcb..966a9d37aac 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx @@ -1,3 +1,4 @@ +import { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -6,7 +7,6 @@ import { makeStyles } from 'tss-react/mui'; import { Chip } from 'src/components/Chip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { @@ -23,8 +23,6 @@ import { } from '../kubeUtils'; import { ClusterActionMenu } from './ClusterActionMenu'; -import type { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; - const useStyles = makeStyles()(() => ({ clusterRow: { '&:before': { @@ -70,10 +68,7 @@ export const KubernetesClusterRow = (props: Props) => { const { data: pools } = useAllKubernetesNodePoolQuery(cluster.id); const typesQuery = useSpecificTypes(pools?.map((pool) => pool.type) ?? []); const types = extendTypesQueryResult(typesQuery); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find((r) => r.id === cluster.region); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index bd93e77c531..2ff221c7f2c 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -14,7 +14,6 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; @@ -72,10 +71,8 @@ export const CreateCluster = () => { const [hasAgreed, setAgreed] = React.useState(false); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const [highAvailability, setHighAvailability] = React.useState(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data, error: regionsError } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + + const { data, error: regionsError } = useRegionsQuery(); const regionsData = data ?? []; const history = useHistory(); const { data: account } = useAccount(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index 97fbb04d083..d7f2407acb3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -4,7 +4,6 @@ import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { CircleProgress } from 'src/components/CircleProgress'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { @@ -66,10 +65,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ export const KubeClusterSpecs = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const theme = useTheme(); const { data: pools } = useAllKubernetesNodePoolQuery(cluster.id); const typesQuery = useSpecificTypes(pools?.map((pool) => pool.type) ?? []); diff --git a/packages/manager/src/features/Linodes/CloneLanding/Details.tsx b/packages/manager/src/features/Linodes/CloneLanding/Details.tsx index bb6b9753dfa..771ce94a07c 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/Details.tsx +++ b/packages/manager/src/features/Linodes/CloneLanding/Details.tsx @@ -1,3 +1,4 @@ +import { Disk, Linode } from '@linode/api-v4/lib/linodes'; import Close from '@mui/icons-material/Close'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -10,7 +11,6 @@ import { List } from 'src/components/List'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -21,10 +21,12 @@ import { StyledHeader, StyledTypography, } from './Details.styles'; -import { getAllDisks, getEstimatedCloneTime } from './utilities'; - -import type { EstimatedCloneTimeMode, ExtendedConfig } from './utilities'; -import type { Disk, Linode } from '@linode/api-v4/lib/linodes'; +import { + EstimatedCloneTimeMode, + ExtendedConfig, + getAllDisks, + getEstimatedCloneTime, +} from './utilities'; interface Props { clearAll: () => void; @@ -61,10 +63,7 @@ export const Details = (props: Props) => { thisLinodeRegion, } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find((r) => r.id === thisLinodeRegion); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 1b63357d218..b2f7d1c4d15 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -9,10 +9,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { - isDistributedRegionSupported, - useIsGeckoEnabled, -} from 'src/components/RegionSelect/RegionSelect.utils'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; @@ -79,10 +76,7 @@ export const Region = () => { globalGrantType: 'add_linodes', }); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const onChange = async (region: RegionType) => { const values = getValues(); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx index 982f283d469..73277af3bc7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx @@ -5,7 +5,6 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { Divider } from 'src/components/Divider'; import { Paper } from 'src/components/Paper'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useImageQuery } from 'src/queries/images'; @@ -56,10 +55,7 @@ export const Summary = () => { ], }); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: type } = useTypeQuery(typeId ?? '', Boolean(typeId)); const { data: image } = useImageQuery(imageId ?? '', Boolean(imageId)); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTableRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTableRow.tsx index d55aacf2a2c..9f68f2fa8ae 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTableRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTableRow.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Radio } from 'src/components/Radio/Radio'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -31,10 +30,7 @@ export const LinodeSelectTableRow = (props: Props) => { Boolean(linode.image) ); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: type } = useTypeQuery(linode.type ?? '', Boolean(linode.type)); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index 3f8f7282e02..3783116c89b 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -2,10 +2,7 @@ import * as React from 'react'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { Notice } from 'src/components/Notice/Notice'; -import { - getIsDistributedRegion, - useIsGeckoEnabled, -} from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; @@ -60,10 +57,7 @@ export const LinodeEntityDetail = (props: Props) => { const numberOfVolumes = volumes?.results ?? 0; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { configInterfaceWithVPC, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index cfa8a03ac3d..01b45af41a6 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -10,6 +10,7 @@ import { connect } from 'react-redux'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; import { withAccount } from 'src/containers/account.container'; import { withAccountSettings } from 'src/containers/accountSettings.container'; import { withEventsPollingActions } from 'src/containers/events.container'; @@ -330,10 +331,20 @@ class LinodeCreateContainer extends React.PureComponent { const selectedRegion = this.props.regionsData.find( (region) => region.id === selectedRegionID ); + const isGeckoGAEnabled = + this.props.flags.gecko2?.enabled && + this.props.flags.gecko2?.ga && + this.props.regionsData.some((region) => + region.capabilities.includes('Distributed Plans') + ); return ( selectedRegion && { - title: selectedRegion.label, + title: isGeckoGAEnabled + ? getNewRegionLabel({ + region: selectedRegion, + }) + : selectedRegion.label, } ); }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx index 7404064133b..434c1c4b4d9 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx @@ -1,8 +1,8 @@ +import { Linode } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; import React from 'react'; import { Button } from 'src/components/Button/Button'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; @@ -16,8 +16,6 @@ import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; import { getLinodeIconStatus } from '../../LinodesLanding/utils'; -import type { Linode } from '@linode/api-v4'; - interface Props { disabled?: boolean; handlePowerOff: () => void; @@ -35,10 +33,7 @@ export const SelectLinodeCard = ({ selected, showPowerActions, }: Props) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: linodeType } = useTypeQuery( linode?.type ?? '', diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx index 78f20f486a2..1305bf811aa 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx @@ -1,23 +1,18 @@ -import { styled } from '@mui/material/styles'; +import { Theme, styled } from '@mui/material/styles'; import * as React from 'react'; import { List } from 'src/components/List'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { regionsWithFeature } from 'src/utilities/doesRegionSupportFeature'; import type { Region } from '@linode/api-v4'; -import type { Theme } from '@mui/material/styles'; export const VLANAvailabilityNotice = () => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - - const regions = - useRegionsQuery({ transformRegionLabel: isGeckoGAEnabled }).data ?? []; + const regions = useRegionsQuery().data ?? []; const regionsThatSupportVLANs: Region[] = regionsWithFeature(regions, 'Vlans') .map((region) => region) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx index 4fdb4834f08..8561c60f10d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx @@ -1,7 +1,6 @@ import { useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useAccountNetworkTransfer } from 'src/queries/account/transfer'; import { useLinodeTransfer } from 'src/queries/linodes/stats'; @@ -29,10 +28,7 @@ export const NetworkTransfer = React.memo((props: Props) => { const theme = useTheme(); const linodeTransfer = useLinodeTransfer(linodeId); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const regions = useRegionsQuery(); const { data: type } = useTypeQuery(linodeType || '', Boolean(linodeType)); const { data: accountTransfer, @@ -40,7 +36,9 @@ export const NetworkTransfer = React.memo((props: Props) => { isLoading: accountTransferLoading, } = useAccountNetworkTransfer(); - const currentRegion = regions?.find((region) => region.id === linodeRegionId); + const currentRegion = regions.data?.find( + (region) => region.id === linodeRegionId + ); const dynamicDClinodeTransferData = getDynamicDCNetworkTransferData({ networkTransferData: linodeTransfer.data, regionId: linodeRegionId, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewIPDrawer.tsx index 2e4628f90f9..3e0b23e06e5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewIPDrawer.tsx @@ -1,14 +1,12 @@ +import { IPAddress } from '@linode/api-v4/lib/networking'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { IPAddress } from '@linode/api-v4/lib/networking'; - interface Props { ip?: IPAddress; onClose: () => void; @@ -18,10 +16,7 @@ interface Props { export const ViewIPDrawer = (props: Props) => { const { ip } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const actualRegion = regions?.find((r) => r.id === ip?.region); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx index 36317171dbb..a92e9e2e182 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx @@ -1,14 +1,12 @@ +import { IPRange } from '@linode/api-v4/lib/networking'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { IPRange } from '@linode/api-v4/lib/networking'; - interface Props { onClose: () => void; open: boolean; @@ -19,10 +17,7 @@ export const ViewRangeDrawer = (props: Props) => { const { range } = props; const region = (range && range.region) || ''; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const actualRegion = regions?.find((r) => r.id === region); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/RegionIndicator.tsx b/packages/manager/src/features/Linodes/LinodesLanding/RegionIndicator.tsx index 44d2e381dde..57ed7b65112 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/RegionIndicator.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/RegionIndicator.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; interface Props { @@ -9,10 +8,7 @@ interface Props { export const RegionIndicator = (props: Props) => { const { region } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const actualRegion = regions?.find((r) => r.id === region); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index ab3ac231528..da0a2bae841 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -61,10 +61,7 @@ export const ConfigureForm = React.memo((props: Props) => { const flags = useFlags(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); - const { isGeckoBetaEnabled, isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: currentLinodeType } = useTypeQuery( linodeType || '', @@ -152,6 +149,8 @@ export const ConfigureForm = React.memo((props: Props) => { currentActualRegion?.site_type === 'distributed' || currentActualRegion?.site_type === 'edge'; + const { isGeckoBetaEnabled } = useIsGeckoEnabled(); + return ( Configure Migration diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx new file mode 100644 index 00000000000..df3a31e1765 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx @@ -0,0 +1,130 @@ +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; +import { Box } from 'src/components/Box'; +import { Stack } from 'src/components/Stack'; +import { linodeFactory } from 'src/factories'; +import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { Filter } from '@linode/api-v4'; +import type { TextFieldProps } from 'src/components/TextField'; + +interface Props { + /** + * Error text to display as helper text under the TextField. Useful for validation errors. + */ + errorText?: string; + /** + * Called when the value of the Select changes + */ + onChange: (ip: string) => void; + /** + * Optional props passed to the TextField + */ + textFieldProps?: Partial; + /** + * The id of the selected certificate + */ + value: null | string; +} + +export const LinodeOrIPSelect = (props: Props) => { + const { errorText, onChange, textFieldProps, value } = props; + + const [inputValue, setInputValue] = React.useState(''); + + const filter: Filter = {}; + + // If the user types in the Autocomplete, API filter for Linodes. + if (inputValue) { + filter['+or'] = [ + { label: { '+contains': inputValue } }, + { ipv4: { '+contains': inputValue } }, + ]; + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isLoading, + } = useInfiniteLinodesQuery(filter); + + const { data: regions } = useRegionsQuery(); + + const linodes = data?.pages.flatMap((page) => page.data) ?? []; + + const selectedLinode = value + ? linodes?.find((linode) => linode.ipv4.includes(value)) ?? null + : null; + + const onScroll = (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + if ( + listboxNode.scrollTop + listboxNode.clientHeight >= + listboxNode.scrollHeight && + hasNextPage + ) { + fetchNextPage(); + } + }; + + const customIpPlaceholder = linodeFactory.build({ + ipv4: [inputValue], + label: `Use IP ${inputValue}`, + }); + + const options = [...linodes]; + + if (linodes.length === 0 && !isLoading) { + options.push(customIpPlaceholder); + } + + return ( + { + if (reason === 'input' || reason === 'clear') { + setInputValue(value); + onChange(value); + } + }} + renderOption={(props, option, state) => { + const region = + regions?.find((r) => r.id === option.region)?.label ?? option.region; + + const isCustomIp = option === customIpPlaceholder; + + return ( +
  • + + + {isCustomIp ? 'Custom IP' : option.label} + + + {isCustomIp ? option.ipv4[0] : `${option.ipv4[0]} - ${region}`} + + + +
  • + ); + }} + errorText={error?.[0]?.reason ?? errorText} + filterOptions={(x) => x} + fullWidth + inputValue={selectedLinode ? selectedLinode.label : inputValue} + label="Linode or Public IP Address" + loading={isLoading} + onChange={(e, value) => onChange(value?.ipv4[0] ?? '')} + options={options} + placeholder="Select Linode or Enter IP Address" + textFieldProps={textFieldProps} + value={linodes.length === 0 ? customIpPlaceholder : selectedLinode} + /> + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index c13f2faa199..c06b15f7b15 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -26,7 +26,6 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { SelectFirewallPanel } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Stack } from 'src/components/Stack'; @@ -110,10 +109,7 @@ const defaultFieldsStates = { const NodeBalancerCreate = () => { const { data: agreements } = useAccountAgreements(); const { data: profile } = useProfile(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: types } = useNodeBalancerTypesQuery(); const { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index bf5817afe76..b79f93fe010 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { Link, useParams } from 'react-router-dom'; import { Paper } from 'src/components/Paper'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TagCell } from 'src/components/TagCell/TagCell'; import { Typography } from 'src/components/Typography'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; @@ -22,10 +21,7 @@ export const SummaryPanel = () => { const id = Number(nodeBalancerId); const { data: nodebalancer } = useNodeBalancerQuery(id); const { data: configs } = useAllNodeBalancerConfigsQuery(id); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery(id); const linkText = attachedFirewallData?.data[0]?.label; const linkID = attachedFirewallData?.data[0]?.id; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx index acfb0e73522..d67fdbb5807 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx @@ -1,3 +1,10 @@ +import { Profile } from '@linode/api-v4'; +import { + Notification, + NotificationSeverity, + NotificationType, +} from '@linode/api-v4/lib/account'; +import { Region } from '@linode/api-v4/lib/regions'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; import { path } from 'ramda'; @@ -5,7 +12,6 @@ import * as React from 'react'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Link } from 'src/components/Link'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { complianceUpdateContext } from 'src/context/complianceUpdateContext'; import { reportException } from 'src/exceptionReporting'; @@ -16,17 +22,9 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { formatDate } from 'src/utilities/formatDate'; import { notificationContext as _notificationContext } from '../NotificationContext'; -import { checkIfMaintenanceNotification } from './notificationUtils'; +import { NotificationItem } from '../NotificationSection'; import RenderNotification from './RenderNotification'; - -import type { NotificationItem } from '../NotificationSection'; -import type { Profile } from '@linode/api-v4'; -import type { - Notification, - NotificationSeverity, - NotificationType, -} from '@linode/api-v4/lib/account'; -import type { Region } from '@linode/api-v4/lib/regions'; +import { checkIfMaintenanceNotification } from './notificationUtils'; export interface ExtendedNotification extends Notification { jsx?: JSX.Element; @@ -39,10 +37,7 @@ export const useFormattedNotifications = (): NotificationItem[] => { hasDismissedNotifications, } = useDismissibleNotifications(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: profile } = useProfile(); const { data: notifications } = useNotificationsQuery(); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx index f25ce3f0fa8..972caeb5611 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { sortByString } from 'src/utilities/sort-by'; @@ -23,10 +22,7 @@ const sortRegionOptions = (a: Region, b: Region) => { export const AccessKeyRegions = (props: Props) => { const { disabled, error, onChange, required, selectedRegion } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions, error: regionsError } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions, error: regionsError } = useRegionsQuery(); // Error could be: 1. General Regions error, 2. Field error, 3. Nothing const errorText = error || regionsError?.[0]?.reason; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index d62935827be..ca4e0fbb6e5 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableCell } from 'src/components/TableCell'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionsByRegionId } from 'src/utilities/regions'; @@ -24,10 +23,7 @@ export const HostNameTableCell = ({ setShowHostNamesDrawers, storageKeyData, }: Props) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData } = useRegionsQuery(); const regionsLookup = regionsData && getRegionsByRegionId(regionsData); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx index 5f97e8d9961..d8d9240d2b4 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx @@ -1,8 +1,11 @@ +import { + ObjectStorageKeyBucketAccessPermissions, + ObjectStorageKeyBucketAccess, +} from '@linode/api-v4/lib/object-storage/types'; import { update } from 'ramda'; import * as React from 'react'; import { Radio } from 'src/components/Radio/Radio'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; @@ -22,10 +25,6 @@ import { } from './AccessTable.styles'; import type { MODE } from './types'; -import type { - ObjectStorageKeyBucketAccess, - ObjectStorageKeyBucketAccessPermissions, -} from '@linode/api-v4/lib/object-storage/types'; export const getUpdatedScopes = ( oldScopes: ObjectStorageKeyBucketAccess[], @@ -60,10 +59,7 @@ interface Props { export const BucketPermissionsTable = React.memo((props: Props) => { const { bucket_access, checked, mode, selectedRegions, updateScopes } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData } = useRegionsQuery(); const regionsLookup = regionsData && getRegionsByRegionId(regionsData); if (!bucket_access || !regionsLookup) { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx index 9b772536661..c6f22605603 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -1,16 +1,14 @@ +import { ObjectStorageKeyRegions } from '@linode/api-v4'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { Drawer } from 'src/components/Drawer'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionsByRegionId } from 'src/utilities/regions'; import { CopyAllHostnames } from './CopyAllHostnames'; -import type { ObjectStorageKeyRegions } from '@linode/api-v4'; - interface Props { onClose: () => void; open: boolean; @@ -19,10 +17,7 @@ interface Props { export const HostNamesDrawer = (props: Props) => { const { onClose, open, regions } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData } = useRegionsQuery(); const regionsLookup = regionsData && getRegionsByRegionId(regionsData); if (!regionsData || !regionsLookup) { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.tsx index 43c7abe9d2d..da1e0406a3f 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.tsx @@ -4,7 +4,6 @@ import React, { useRef } from 'react'; import { Box } from 'src/components/Box'; import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { List } from 'src/components/List'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { omittedProps } from 'src/utilities/omittedProps'; import { getRegionsByRegionId } from 'src/utilities/regions'; @@ -18,10 +17,7 @@ interface Props { } export const HostNamesList = ({ objectStorageKey }: Props) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData } = useRegionsQuery(); const regionsLookup = regionsData && getRegionsByRegionId(regionsData); const listRef = useRef(null); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 5fd826dcef8..efaeee2688a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -9,7 +9,6 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; @@ -65,10 +64,7 @@ export const BucketDetailsDrawer = React.memo( const { data: clusters } = useObjectStorageClusters( !isObjMultiClusterEnabled ); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: profile } = useProfile(); const actualCluster = clusters?.find((c) => c.id === cluster); const region = regions?.find((r) => r.id === actualCluster?.region); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index 54ec094b028..f3e6c7b16f7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -1,3 +1,9 @@ +import { + ObjectStorageBucket, + ObjectStorageCluster, +} from '@linode/api-v4/lib/object-storage'; +import { APIError } from '@linode/api-v4/lib/types'; +import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -8,7 +14,6 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import OrderBy from 'src/components/OrderBy'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; @@ -16,6 +21,7 @@ import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useOpenClose } from 'src/hooks/useOpenClose'; import { + BucketError, useDeleteBucketMutation, useObjectStorageBuckets, useObjectStorageClusters, @@ -34,14 +40,6 @@ import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; -import type { - ObjectStorageBucket, - ObjectStorageCluster, -} from '@linode/api-v4/lib/object-storage'; -import type { APIError } from '@linode/api-v4/lib/types'; -import type { Theme } from '@mui/material/styles'; -import type { BucketError } from 'src/queries/objectStorage'; - const useStyles = makeStyles()((theme: Theme) => ({ copy: { marginTop: theme.spacing(), @@ -300,10 +298,7 @@ interface UnavailableClustersDisplayProps { const UnavailableClustersDisplay = React.memo( ({ unavailableClusters }: UnavailableClustersDisplayProps) => { - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const regionsAffected = unavailableClusters.map( (cluster) => diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index cc2911fe835..40d12acbfab 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; interface Props { @@ -16,10 +15,7 @@ interface Props { export const BucketRegions = (props: Props) => { const { disabled, error, onBlur, onChange, required, selectedRegion } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions, error: regionsError } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions, error: regionsError } = useRegionsQuery(); // Error could be: 1. General Regions error, 2. Field error, 3. Nothing const errorText = error || regionsError?.[0]?.reason; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index 764ae70fcf3..48a52341fd7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -1,9 +1,9 @@ +import { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableCell } from 'src/components/TableCell'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; @@ -24,8 +24,6 @@ import { StyledBucketSizeCell, } from './BucketTableRow.styles'; -import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; - export interface BucketTableRowProps extends ObjectStorageBucket { onDetails: () => void; onRemove: () => void; @@ -44,10 +42,7 @@ export const BucketTableRow = (props: BucketTableRowProps) => { size, } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const flags = useFlags(); const { account } = useAccountManagement(); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index e68295d1d0b..21d431ff761 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -1,12 +1,10 @@ +import { Region } from '@linode/api-v4/lib/regions'; import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { Region } from '@linode/api-v4/lib/regions'; - interface Props { disabled?: boolean; error?: string; @@ -27,10 +25,7 @@ export const ClusterSelect: React.FC = (props) => { } = props; const { data: clusters, error: clustersError } = useObjectStorageClusters(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const regionOptions = clusters?.reduce((acc, cluster) => { const region = regions?.find((r) => r.id === cluster.region); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index 7cbac5dbd6e..ab2f8f2a12e 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -1,3 +1,7 @@ +import { Region } from '@linode/api-v4'; +import { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; +import { APIError } from '@linode/api-v4/lib/types'; +import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -8,7 +12,6 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import OrderBy from 'src/components/OrderBy'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; @@ -16,6 +19,7 @@ import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useOpenClose } from 'src/hooks/useOpenClose'; import { + BucketError, useDeleteBucketWithRegionMutation, useObjectStorageBuckets, } from 'src/queries/objectStorage'; @@ -34,12 +38,6 @@ import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; -import type { Region } from '@linode/api-v4'; -import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; -import type { APIError } from '@linode/api-v4/lib/types'; -import type { Theme } from '@mui/material/styles'; -import type { BucketError } from 'src/queries/objectStorage'; - const useStyles = makeStyles()((theme: Theme) => ({ copy: { marginTop: theme.spacing(), @@ -59,14 +57,12 @@ export const OMC_BucketLanding = () => { Boolean(flags.objMultiCluster), account?.capabilities ?? [] ); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const { data: regions, error: regionErrors, isLoading: areRegionsLoading, - } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + } = useRegionsQuery(); const regionsLookup = regions && getRegionsByRegionId(regions); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index 55e8b072a32..1c2c7e486a0 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -12,6 +12,7 @@ import { List } from 'src/components/List'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; @@ -53,10 +54,7 @@ export const PlacementGroupsCreateDrawer = ( open, selectedRegionId, } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: allPlacementGroupsInRegion } = useAllPlacementGroupsQuery({ enabled: Boolean(selectedRegionId), filter: { @@ -147,6 +145,8 @@ export const PlacementGroupsCreateDrawer = ( selectedRegion )}`; + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const disabledRegions = regions?.reduce>( (acc, region) => { const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity({ @@ -212,7 +212,12 @@ export const PlacementGroupsCreateDrawer = ( { })), } ); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const region = regions?.find( (region) => region.id === placementGroup?.region diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx index 3cc733331fd..a214a2399e6 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx @@ -6,6 +6,7 @@ import { Button } from 'src/components/Button/Button'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; import { PlacementGroupsSelect } from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; @@ -43,10 +44,7 @@ export const PlacementGroupsDetailPanel = (props: Props) => { region: selectedRegionId, }, }); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const [ isCreatePlacementGroupDrawerOpen, @@ -74,9 +72,17 @@ export const PlacementGroupsDetailPanel = (props: Props) => { ); const isPlacementGroupSelectDisabled = !selectedRegionId || !hasRegionPlacementGroupCapability; + const { isGeckoGAEnabled } = useIsGeckoEnabled(); const placementGroupSelectLabel = selectedRegion - ? `Placement Groups in ${`${selectedRegion.label} (${selectedRegion.id})`}` + ? `Placement Groups in ${ + isGeckoGAEnabled + ? getNewRegionLabel({ + includeSlug: true, + region: selectedRegion, + }) + : `${selectedRegion.label} (${selectedRegion.id})` + }` : 'Placement Group'; return ( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index 6022430de02..92d7d75778d 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -1,8 +1,8 @@ import CloseIcon from '@mui/icons-material/Close'; import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { useHistory } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -12,7 +12,6 @@ import { IconButton } from 'src/components/IconButton'; import { InputAdornment } from 'src/components/InputAdornment'; import { LandingHeader } from 'src/components/LandingHeader'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -98,10 +97,7 @@ export const PlacementGroupsLanding = React.memo(() => { } ); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const getPlacementGroupRegion = ( placementGroup: PlacementGroup | undefined ) => { diff --git a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx index 53492595128..26219cf22e2 100644 --- a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx +++ b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx @@ -6,9 +6,8 @@ import { Box } from 'src/components/Box'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { CopyableAndDownloadableTextField } from 'src/components/CopyableAndDownloadableTextField'; import { Notice } from 'src/components/Notice/Notice'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; -import { CopyAllHostnames } from 'src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames'; import { HostNamesList } from 'src/features/ObjectStorage/AccessKeyLanding/HostNamesList'; +import { CopyAllHostnames } from 'src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -41,10 +40,7 @@ const renderActions = ( export const SecretTokenDialog = (props: Props) => { const { objectStorageKey, onClose, open, title, value } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData } = useRegionsQuery(); const regionsLookup = regionsData && getRegionsByRegionId(regionsData); const flags = useFlags(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index e69cd194cfc..46ee3584406 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -11,20 +11,19 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useVPCQuery } from 'src/queries/vpcs/vpcs'; import { truncate } from 'src/utilities/truncate'; -import { REBOOT_LINODE_WARNING_VPCDETAILS } from '../constants'; -import { getUniqueLinodesFromSubnets } from '../utils'; import { VPCDeleteDialog } from '../VPCLanding/VPCDeleteDialog'; import { VPCEditDrawer } from '../VPCLanding/VPCEditDrawer'; +import { REBOOT_LINODE_WARNING_VPCDETAILS } from '../constants'; +import { getUniqueLinodesFromSubnets } from '../utils'; import { StyledActionButton, - StyledBox, StyledDescriptionBox, + StyledBox, StyledSummaryBox, StyledSummaryTextTypography, } from './VPCDetail.styles'; @@ -35,10 +34,7 @@ const VPCDetail = () => { const theme = useTheme(); const { data: vpc, error, isLoading } = useVPCQuery(+vpcId); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const [editVPCDrawerOpen, setEditVPCDrawerOpen] = React.useState(false); const [deleteVPCDialogOpen, setDeleteVPCDialogOpen] = React.useState(false); diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index bfe559f1266..104da2923f8 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -6,7 +6,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TextField } from 'src/components/TextField'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -87,10 +86,7 @@ export const VPCEditDrawer = (props: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [error]); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regionsData, error: regionsError } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regionsData, error: regionsError } = useRegionsQuery(); return ( diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index 90059052455..cbdcf2496fd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -1,16 +1,14 @@ +import { VPC } from '@linode/api-v4/lib/vpcs/types'; import * as React from 'react'; import { Link } from 'react-router-dom'; +import { Action } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { VPC } from '@linode/api-v4/lib/vpcs/types'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; - interface Props { handleDeleteVPC: () => void; handleEditVPC: () => void; @@ -19,10 +17,7 @@ interface Props { export const VPCRow = ({ handleDeleteVPC, handleEditVPC, vpc }: Props) => { const { id, label, subnets } = vpc; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? ''; const numLinodes = subnets.reduce( diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 0d97bacac8c..fded98105d3 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -14,7 +14,6 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; @@ -120,10 +119,7 @@ export const VolumeCreate = () => { const { data: profile } = useProfile(); const { data: grants } = useGrants(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { mutateAsync: createVolume } = useCreateVolumeMutation(); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 1d727bcfbcf..8a43fe50fb8 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -5,7 +5,6 @@ import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; import { Hidden } from 'src/components/Hidden'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -19,9 +18,8 @@ import { getEventProgress, volumeStatusIconMap, } from './utils'; -import { VolumesActionMenu } from './VolumesActionMenu'; +import { ActionHandlers, VolumesActionMenu } from './VolumesActionMenu'; -import type { ActionHandlers } from './VolumesActionMenu'; import type { Volume } from '@linode/api-v4'; export const useStyles = makeStyles()({ @@ -43,10 +41,7 @@ export const VolumeTableRow = React.memo((props: Props) => { const history = useHistory(); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const { data: notifications } = useNotificationsQuery(); const { data: inProgressEvents } = useInProgressEvents(); diff --git a/packages/manager/src/features/components/PlansPanel/MetalNotice.tsx b/packages/manager/src/features/components/PlansPanel/MetalNotice.tsx index 29f2f90a13a..35ef6810ec4 100644 --- a/packages/manager/src/features/components/PlansPanel/MetalNotice.tsx +++ b/packages/manager/src/features/components/PlansPanel/MetalNotice.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Notice } from 'src/components/Notice/Notice'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { StyledTypography } from './PlansPanel.styles'; @@ -15,10 +14,7 @@ interface Props { export const MetalNotice = (props: Props) => { const { dataTestId, hasDisabledClass } = props; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); // Until BM-426 is merged, we aren't filtering for regions in getDisabledClass // so this branch will never run. diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts index a4c6a566b1f..2ba217f9d8a 100644 --- a/packages/manager/src/hooks/useCreateVPC.ts +++ b/packages/manager/src/hooks/useCreateVPC.ts @@ -1,23 +1,25 @@ +import { + APIError, + CreateSubnetPayload, + CreateVPCPayload, +} from '@linode/api-v4'; import { createVPCSchema } from '@linode/validation'; import { useFormik } from 'formik'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useCreateVPCMutation } from 'src/queries/vpcs/vpcs'; -import { handleVPCAndSubnetErrors } from 'src/utilities/formikErrorUtils'; +import { + SubnetError, + handleVPCAndSubnetErrors, +} from 'src/utilities/formikErrorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { DEFAULT_SUBNET_IPV4_VALUE } from 'src/utilities/subnets'; - -import type { - APIError, - CreateSubnetPayload, - CreateVPCPayload, -} from '@linode/api-v4'; -import type { SubnetError } from 'src/utilities/formikErrorUtils'; -import type { SubnetFieldState } from 'src/utilities/subnets'; +import { + DEFAULT_SUBNET_IPV4_VALUE, + SubnetFieldState, +} from 'src/utilities/subnets'; // Custom hook to consolidate shared logic between VPCCreate.tsx and VPCCreateDrawer.tsx @@ -48,10 +50,7 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { const { data: grants } = useGrants(); const userCannotAddVPC = profile?.restricted && !grants?.global.add_vpcs; - const { isGeckoGAEnabled } = useIsGeckoEnabled(); - const { data: regions } = useRegionsQuery({ - transformRegionLabel: isGeckoGAEnabled, - }); + const { data: regions } = useRegionsQuery(); const regionsData = regions ?? []; const [ diff --git a/packages/manager/src/queries/regions/regions.ts b/packages/manager/src/queries/regions/regions.ts index 07b7c538c39..4016e6914c5 100644 --- a/packages/manager/src/queries/regions/regions.ts +++ b/packages/manager/src/queries/regions/regions.ts @@ -13,11 +13,6 @@ import { import type { Region, RegionAvailability } from '@linode/api-v4/lib/regions'; import type { APIError } from '@linode/api-v4/lib/types'; -interface TransformRegionLabelOptions { - includeSlug?: boolean; - transformRegionLabel?: boolean; -} - export const regionQueries = createQueryKeys('regions', { availability: { contextQueries: { @@ -38,21 +33,16 @@ export const regionQueries = createQueryKeys('regions', { }, }); -export const useRegionsQuery = ( - options: Partial = {} -) => +export const useRegionsQuery = (transformRegionLabel: boolean = false) => useQuery({ ...regionQueries.regions, ...queryPresets.longLived, select: (regions: Region[]) => { // Display Country, City instead of City, State - if (options.transformRegionLabel) { + if (transformRegionLabel) { return regions.map((region) => ({ ...region, - label: getNewRegionLabel({ - includeSlug: options.includeSlug, - region, - }), + label: getNewRegionLabel({ region }), })); } return regions; From f35d89be5000efa0a0fe31a0c0197bd4d8a199b8 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:14:34 -0400 Subject: [PATCH 31/58] test: [M3-7891] - Improve Cypress feature flag mocking ergonomics (#10635) * Improve `mockAppendFeatureFlag` ergonomics, remove `mockGetFeatureFlag`, mock LaunchDarkly client stream request automatically * Clean up feature flag mocks in a few tests --------- Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Co-authored-by: Alban Bailly --- .../pr-10635-tests-1719934051918.md | 5 ++ .../cypress/e2e/core/account/betas.spec.ts | 16 ++-- .../e2e/core/images/create-image.spec.ts | 32 +------- .../e2e/core/linodes/create-linode.spec.ts | 12 +-- .../core/linodes/legacy-create-linode.spec.ts | 32 ++------ .../placement-groups-navigation.spec.ts | 30 +++----- .../cypress/e2e/core/vpc/vpc-create.spec.ts | 19 +---- .../e2e/core/vpc/vpc-details-page.spec.ts | 18 +---- .../e2e/core/vpc/vpc-landing-page.spec.ts | 31 ++------ .../e2e/core/vpc/vpc-linodes-update.spec.ts | 17 +---- .../e2e/core/vpc/vpc-navigation.spec.ts | 13 ---- packages/manager/cypress/support/e2e.ts | 2 + .../support/intercepts/feature-flags.ts | 33 +++----- .../setup/feature-flag-clientstream.ts | 16 ++++ .../cypress/support/util/feature-flags.ts | 75 +++++++++++++++++-- 15 files changed, 136 insertions(+), 215 deletions(-) create mode 100644 packages/manager/.changeset/pr-10635-tests-1719934051918.md create mode 100644 packages/manager/cypress/support/setup/feature-flag-clientstream.ts diff --git a/packages/manager/.changeset/pr-10635-tests-1719934051918.md b/packages/manager/.changeset/pr-10635-tests-1719934051918.md new file mode 100644 index 00000000000..95d0d2dab4d --- /dev/null +++ b/packages/manager/.changeset/pr-10635-tests-1719934051918.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve feature flag mocking ergonomics for Cypress tests ([#10635](https://github.com/linode/manager/pull/10635)) diff --git a/packages/manager/cypress/e2e/core/account/betas.spec.ts b/packages/manager/cypress/e2e/core/account/betas.spec.ts index 4abc86d3e2f..cf3375fb402 100644 --- a/packages/manager/cypress/e2e/core/account/betas.spec.ts +++ b/packages/manager/cypress/e2e/core/account/betas.spec.ts @@ -2,11 +2,7 @@ * @file Integration tests for Betas landing page. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { mockGetUserPreferences } from 'support/intercepts/profile'; @@ -19,9 +15,8 @@ describe('Betas landing page', () => { */ it('can navigate to Betas landing page', () => { mockAppendFeatureFlags({ - selfServeBetas: makeFeatureFlagData(true), + selfServeBetas: true, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Ensure that the Primary Nav is open mockGetUserPreferences({ desktop_sidebar_open: false }).as( @@ -29,7 +24,7 @@ describe('Betas landing page', () => { ); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getPreferences']); + cy.wait('@getFeatureFlags'); ui.nav.findItemByTitle('Betas').should('be.visible').click(); @@ -47,12 +42,11 @@ describe('Betas landing page', () => { it('cannot access Betas landing page when feature is disabled', () => { // TODO Delete this test when betas feature flag is removed from codebase. mockAppendFeatureFlags({ - selfServeBetas: makeFeatureFlagData(false), + selfServeBetas: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/betas'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); cy.findByText('Not Found').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index ac1b6e794ba..1415f28d93c 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -2,10 +2,7 @@ import type { Linode, Region } from '@linode/api-v4'; import { accountFactory, linodeFactory, regionFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { mockGetAccount } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { makeFeatureFlagData } from 'support/util/feature-flags'; @@ -132,7 +129,6 @@ describe('create image (e2e)', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(true), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -145,13 +141,7 @@ describe('create image (e2e)', () => { // intercept request cy.visitWithLogin('/images/create'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccount', - '@getLinodes', - '@getRegions', - ]); + cy.wait(['@getFeatureFlags', '@getAccount', '@getLinodes', '@getRegions']); // Find the Linode select and open it cy.findByLabelText('Linode') @@ -176,7 +166,6 @@ describe('create image (e2e)', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(false), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -189,13 +178,7 @@ describe('create image (e2e)', () => { // intercept request cy.visitWithLogin('/images/create'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccount', - '@getLinodes', - '@getRegions', - ]); + cy.wait(['@getFeatureFlags', '@getAccount', '@getLinodes', '@getRegions']); // Find the Linode select and open it cy.findByLabelText('Linode') @@ -220,7 +203,6 @@ describe('create image (e2e)', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(true), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -234,13 +216,7 @@ describe('create image (e2e)', () => { // intercept request cy.visitWithLogin('/images/create'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccount', - '@getRegions', - '@getLinodes', - ]); + cy.wait(['@getFeatureFlags', '@getAccount', '@getRegions', '@getLinodes']); // Find the Linode select and open it cy.findByLabelText('Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 7ec29086c38..34d33345230 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -257,11 +257,6 @@ describe('Create Linode', () => { mockGetLinodeType(dcPricingMockLinodeTypes[1]); mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); mockGetVLANs(mockVLANs); @@ -274,12 +269,7 @@ describe('Create Linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); + cy.wait(['@getLinodeTypes', '@getVPCs']); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts index 5f72107572b..b49ba275dd8 100644 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -47,10 +47,7 @@ import { } from 'support/intercepts/linodes'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; import { checkboxTestId, @@ -94,7 +91,6 @@ describe('create linode', () => { mockAppendFeatureFlags({ linodeCreateRefactor: makeFeatureFlagData(false), }); - mockGetFeatureFlagClientstream(); }); /* @@ -368,17 +364,11 @@ describe('create linode', () => { mockGetLinodeType(dcPricingMockLinodeTypes[0]); mockGetLinodeType(dcPricingMockLinodeTypes[1]); mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetRegions([mockNoVPCRegion]).as('getRegions'); // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); + cy.wait('@getLinodeTypes'); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); @@ -468,11 +458,6 @@ describe('create linode', () => { mockGetLinodeType(dcPricingMockLinodeTypes[1]); mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); mockGetVLANs(mockVLANs); @@ -485,12 +470,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); + cy.wait(['@getLinodeTypes', '@getVPCs']); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); @@ -541,7 +521,6 @@ describe('create linode', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(false), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock account response const mockAccount = accountFactory.build({ @@ -552,7 +531,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + cy.wait(['@getFeatureFlags', '@getAccount']); // Check if section is visible cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); @@ -563,7 +542,6 @@ describe('create linode', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(true), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock account response const mockAccount = accountFactory.build({ @@ -585,7 +563,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + cy.wait(['@getFeatureFlags', '@getAccount']); // Check if section is visible cy.get(`[data-testid="${headerTestId}"]`).should('exist'); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts index 4e1d4b0a686..e405cf03829 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -2,17 +2,11 @@ * @file Integration tests for Placement Groups navigation. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories'; import { ui } from 'support/ui'; -import type { Flags } from 'src/featureFlags'; - const mockAccount = accountFactory.build(); describe('Placement Groups navigation', () => { @@ -27,18 +21,16 @@ describe('Placement Groups navigation', () => { */ it('can navigate to Placement Groups landing page', () => { mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ + placementGroups: { beta: true, enabled: true, - }), + }, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); ui.nav.findItemByTitle('Placement Groups').should('be.visible').click(); - cy.url().should('endWith', '/placement-groups'); }); @@ -47,15 +39,14 @@ describe('Placement Groups navigation', () => { */ it('does not show Placement Groups navigation item when feature is disabled', () => { mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ + placementGroups: { beta: true, enabled: false, - }), + }, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); ui.nav.find().within(() => { cy.findByText('Placement Groups').should('not.exist'); @@ -67,15 +58,14 @@ describe('Placement Groups navigation', () => { */ it('displays Not Found when manually navigating to /placement-groups with feature flag disabled', () => { mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ + placementGroups: { beta: true, enabled: false, - }), + }, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/placement-groups'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); cy.findByText('Not Found').should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index 148646e4019..3a1d3db5d1e 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -9,17 +9,12 @@ import { linodeFactory, regionFactory, } from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateVPCError, mockCreateVPC, mockGetSubnets, } from 'support/intercepts/vpc'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomPhrase, @@ -77,15 +72,10 @@ describe('VPC create flow', () => { const vpcCreationErrorMessage = 'An unknown error has occurred.'; const totalSubnetUniqueLinodes = getUniqueLinodesFromSubnets(mockSubnets); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); cy.visitWithLogin('/vpcs/create'); - cy.wait(['@getFeatureFlags', '@getClientstream', '@getRegions']); + cy.wait('@getRegions'); ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); @@ -292,15 +282,10 @@ describe('VPC create flow', () => { const totalSubnetUniqueLinodes = getUniqueLinodesFromSubnets([]); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); cy.visitWithLogin('/vpcs/create'); - cy.wait(['@getFeatureFlags', '@getClientstream', '@getRegions']); + cy.wait('@getRegions'); ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index e7672f90c3c..dd7bd443c96 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -8,13 +8,8 @@ import { mockEditSubnet, mockGetSubnets, } from 'support/intercepts/vpc'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { subnetFactory, vpcFactory } from '@src/factories'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import type { VPC } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; @@ -40,16 +35,12 @@ describe('VPC details page', () => { const vpcRegion = getRegionById(mockVPC.region); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPC(mockVPC).as('getVPC'); mockUpdateVPC(mockVPC.id, mockVPCUpdated).as('updateVPC'); mockDeleteVPC(mockVPC.id).as('deleteVPC'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC']); + cy.wait('@getVPC'); // Confirm that VPC details are displayed. cy.findByText(mockVPC.label).should('be.visible'); @@ -144,17 +135,12 @@ describe('VPC details page', () => { subnets: [mockSubnet], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetSubnets(mockVPC.id, []).as('getSubnets'); mockCreateSubnet(mockVPC.id).as('createSubnet'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets']); // confirm that vpc and subnet details get displayed cy.findByText(mockVPC.label).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index 00ac5c910de..e2863a1a22d 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -1,8 +1,3 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetVPCs, mockDeleteVPC, @@ -23,14 +18,10 @@ describe('VPC landing page', () => { */ it('lists VPC instances', () => { const mockVPCs = vpcFactory.buildList(5); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Confirm each VPC is listed with expected data. mockVPCs.forEach((mockVPC) => { @@ -58,14 +49,10 @@ describe('VPC landing page', () => { * - Confirms VPC landing page empty state is shown when no VPCs are present. */ it('shows empty state when there are no VPCs', () => { - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs([]).as('getVPCs'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Confirm that empty state is shown and that each section is present. cy.findByText(VPC_LABEL).should('be.visible'); @@ -109,15 +96,11 @@ describe('VPC landing page', () => { description: randomPhrase(), }; - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs([mockVPCs[1]]).as('getVPCs'); mockUpdateVPC(mockVPCs[1].id, mockUpdatedVPC).as('updateVPC'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Find mocked VPC and click its "Edit" button. cy.findByText(mockVPCs[1].label) @@ -182,7 +165,7 @@ describe('VPC landing page', () => { mockDeleteVPC(mockVPCs[0].id).as('deleteVPC'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Delete the first VPC instance cy.findByText(mockVPCs[0].label) @@ -271,15 +254,11 @@ describe('VPC landing page', () => { }), ]; - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); mockDeleteVPCError(mockVPCs[0].id).as('deleteVPCError'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Try to delete VPC cy.findByText(mockVPCs[0].label) diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index 1c030b31225..a4ac2977d21 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -2,11 +2,6 @@ * @file Integration tests for VPC assign/unassign Linodes flows. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetSubnets, mockCreateSubnet, @@ -82,17 +77,13 @@ describe('VPC assign/unassign flows', () => { subnets: [mockSubnetAfterLinodeAssignment], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, []).as('getSubnets'); mockCreateSubnet(mockVPC.id).as('createSubnet'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets']); // confirm that vpc and subnet details get displayed cy.findByText(mockVPC.label).should('be.visible'); @@ -230,16 +221,12 @@ describe('VPC assign/unassign flows', () => { ], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets']); // confirm that subnet should get displayed on VPC's detail page cy.findByText(mockVPC.label).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts index 5bc87ee84fd..38ecad7a0d9 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts @@ -2,36 +2,23 @@ * @file Integration tests for VPC navigation. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { ui } from 'support/ui'; import { mockGetUserPreferences } from 'support/intercepts/profile'; -// TODO Remove feature flag mocks when feature flag is removed from codebase. describe('VPC navigation', () => { /* * - Confirms that VPC navigation item is shown when feature is enabled. * - Confirms that clicking VPC navigation item directs user to VPC landing page. */ it('can navigate to VPC landing page', () => { - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Ensure that the Primary Nav is open mockGetUserPreferences({ desktop_sidebar_open: false }).as( 'getPreferences' ); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getPreferences']); ui.nav.findItemByTitle('VPC').should('be.visible').click(); - cy.url().should('endWith', '/vpcs'); }); }); diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index 36169150553..989f53c5caf 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -61,6 +61,8 @@ chai.use(function (chai, utils) { // Test setup. import { mockAccountRequest } from './setup/mock-account-request'; import { trackApiRequests } from './setup/request-tracking'; +import { mockFeatureFlagClientstream } from './setup/feature-flag-clientstream'; trackApiRequests(); mockAccountRequest(); +mockFeatureFlagClientstream(); diff --git a/packages/manager/cypress/support/intercepts/feature-flags.ts b/packages/manager/cypress/support/intercepts/feature-flags.ts index 9fdf442a267..9bf393efb58 100644 --- a/packages/manager/cypress/support/intercepts/feature-flags.ts +++ b/packages/manager/cypress/support/intercepts/feature-flags.ts @@ -2,9 +2,9 @@ * @file Cypress intercepts and mocks for Cloud Manager feature flags. */ -import { makeResponse } from 'support/util/response'; +import { getResponseDataFromMockData } from 'support/util/feature-flags'; -import type { FeatureFlagResponseData } from 'support/util/feature-flags'; +import type { FeatureFlagMockData } from 'support/util/feature-flags'; // LaunchDarkly URL pattern for feature flag retrieval. const launchDarklyUrlPattern = @@ -27,44 +27,29 @@ export const mockGetFeatureFlagClientstream = () => { /** * Intercepts GET request to fetch feature flags and modifies the response. * - * The given feature flag data is merged with the actual response data so that - * existing but unrelated feature flags are left intact. + * The given feature flag mock data is merged with the actual response data so + * that existing but unrelated feature flags are unmodified. * * The response from LaunchDarkly is not modified if the status code is * anything other than 200. * - * @param featureFlags - Feature flag response data with which to append response. + * @param featureFlags - Feature flag mock data with which to append response. * * @returns Cypress chainable. */ export const mockAppendFeatureFlags = ( - featureFlags: FeatureFlagResponseData + featureFlags: FeatureFlagMockData ): Cypress.Chainable => { + const mockFeatureFlagResponse = getResponseDataFromMockData(featureFlags); + return cy.intercept('GET', launchDarklyUrlPattern, (req) => { req.continue((res) => { if (res.statusCode === 200) { res.body = { ...res.body, - ...featureFlags, + ...mockFeatureFlagResponse, }; } }); }); }; - -/** - * Intercepts GET request to fetch feature flags and mocks the response. - * - * @param featureFlags - Feature flag response data with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetFeatureFlags = ( - featureFlags: FeatureFlagResponseData -): Cypress.Chainable => { - return cy.intercept( - 'GET', - launchDarklyUrlPattern, - makeResponse(featureFlags) - ); -}; diff --git a/packages/manager/cypress/support/setup/feature-flag-clientstream.ts b/packages/manager/cypress/support/setup/feature-flag-clientstream.ts new file mode 100644 index 00000000000..1c50282297b --- /dev/null +++ b/packages/manager/cypress/support/setup/feature-flag-clientstream.ts @@ -0,0 +1,16 @@ +/** + * @file Mocks feature flag clientstream request across all tests. + */ + +import { mockGetFeatureFlagClientstream } from 'support/intercepts/feature-flags'; + +/** + * Mocks LaunchDarkly feature flag clientstream request across all tests. + * + * This prevents our feature flag mocks from being overridden. + */ +export const mockFeatureFlagClientstream = () => { + beforeEach(() => { + mockGetFeatureFlagClientstream(); + }); +}; diff --git a/packages/manager/cypress/support/util/feature-flags.ts b/packages/manager/cypress/support/util/feature-flags.ts index 5331031140c..8d09514edec 100644 --- a/packages/manager/cypress/support/util/feature-flags.ts +++ b/packages/manager/cypress/support/util/feature-flags.ts @@ -2,6 +2,15 @@ * @file Types and utilities related to Cloud Manager feature flags. */ +import type { Flags } from 'src/featureFlags'; + +const defaultFeatureFlagData = { + flagVersion: 1, + trackEvents: false, + variation: 0, + version: 1, +}; + /** * Data for a Cloud Manager feature flag. */ @@ -13,13 +22,72 @@ export interface FeatureFlagData { version: number; } +/** + * Cloud Manager feature flag mock data. + * + * This allows feature flag data to be expressed more flexibly, but must be + * converted to a `FeatureFlagResponseData` object before it can be used in a + * mocked LaunchDarkly response. + * + * See also `getResponseDataFromMockData()`. + */ +export type FeatureFlagMockData = Partial< + Record< + keyof Flags, + Partial> | Flags[keyof Flags]> + > +>; + /** * Cloud Manager feature flag response. + * + * This data requires that all feature flag properties (`value`, `version`, + * `variation`, etc.) are specified. See also `FeatureFlagMockData` for a more + * flexible way to define and represent feature flag data. */ export interface FeatureFlagResponseData { [key: string]: FeatureFlagData; } +/** + * Determines whether the given data is a partial representation of `FeatureFlagData`. + * + * @returns `true` if `data` is a partial feature flag object, `false` otherwise. + */ +export const isPartialFeatureFlagData = ( + data: any +): data is Partial> => { + if (typeof data === 'object' && data !== null && 'value' in data) { + return true; + } + return false; +}; + +/** + * Returns a new `FeatureFlagResponseData` object for the given + * `FeatureFlagMockData` object. + * + * @param data - Feature flag mock data from which to create response data. + * + * @returns Feature flag response data that can be used for mocking purposes. + */ +export const getResponseDataFromMockData = (data: FeatureFlagMockData) => { + const output = { ...data }; + return Object.keys(output).reduce((acc: FeatureFlagMockData, cur: string) => { + const mockData = output[cur]; + if (isPartialFeatureFlagData(mockData)) { + output[cur] = { + ...defaultFeatureFlagData, + ...mockData, + }; + return output; + } else { + output[cur] = makeFeatureFlagData(mockData); + } + return output; + }, output); +}; + /** * Returns an object containing feature flag data. * @@ -36,13 +104,6 @@ export const makeFeatureFlagData = ( trackEvents?: boolean, flagVersion?: number ): FeatureFlagData => { - const defaultFeatureFlagData = { - flagVersion: 1, - trackEvents: false, - variation: 0, - version: 1, - }; - return { flagVersion: flagVersion ?? defaultFeatureFlagData.flagVersion, trackEvents: trackEvents ?? defaultFeatureFlagData.trackEvents, From 62b5b7ba08277c52761cf768f36bdb52c527ae8e Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Fri, 26 Jul 2024 21:13:19 -0400 Subject: [PATCH 32/58] upcoming: [M3-8364] - Use React Hook Form instead of Formik for Create Bucket (#10699) Co-authored-by: Jaalah Ramos --- ...r-10699-upcoming-features-1721669303749.md | 5 + .../enable-object-storage.spec.ts | 4 + .../object-storage.smoke.spec.ts | 7 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 4 +- .../AccessKeyLanding/AccessKeyDrawer.tsx | 4 +- .../AccessKeyLanding/AccessKeyLanding.tsx | 4 +- .../AccessKeyTable/AccessKeyActionMenu.tsx | 4 +- .../AccessKeyTable/AccessKeyTable.tsx | 4 +- .../AccessKeyTable/AccessKeyTableRow.tsx | 4 +- .../LimitedAccessControls.tsx | 4 +- .../AccessKeyLanding/OMC_AccessKeyDrawer.tsx | 4 +- .../ViewPermissionsDrawer.tsx | 4 +- .../BucketDetail/BucketDetail.tsx | 4 +- .../BucketLanding/BucketDetailsDrawer.tsx | 4 +- .../BucketLanding/BucketLanding.tsx | 4 +- .../BucketLanding/BucketTableRow.tsx | 4 +- .../BucketLanding/ClusterSelect.tsx | 4 +- .../BucketLanding/CreateBucketDrawer.tsx | 188 +++++++++--------- .../BucketLanding/OMC_BucketLanding.tsx | 4 +- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 175 ++++++++-------- .../ObjectStorage/ObjectStorageLanding.tsx | 4 +- .../SecretTokenDialog/SecretTokenDialog.tsx | 4 +- .../src/features/Search/SearchLanding.tsx | 4 +- .../features/TopMenu/SearchBar/SearchBar.tsx | 4 +- packages/manager/src/queries/objectStorage.ts | 22 +- .../pr-10699-added-1721669275091.md | 5 + packages/validation/src/buckets.schema.ts | 21 +- 27 files changed, 278 insertions(+), 225 deletions(-) create mode 100644 packages/manager/.changeset/pr-10699-upcoming-features-1721669303749.md create mode 100644 packages/validation/.changeset/pr-10699-added-1721669275091.md diff --git a/packages/manager/.changeset/pr-10699-upcoming-features-1721669303749.md b/packages/manager/.changeset/pr-10699-upcoming-features-1721669303749.md new file mode 100644 index 00000000000..e0974cf807b --- /dev/null +++ b/packages/manager/.changeset/pr-10699-upcoming-features-1721669303749.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Replaced Formik with React Hook Form for Create Bucket Drawer ([#10699](https://github.com/linode/manager/pull/10699)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 08e52e042a4..869fa8aaaf1 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -149,6 +149,10 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { + cy.findByLabelText('Label (required)') + .should('be.visible') + .type(randomLabel()); + // Select a region with special pricing structure. ui.regionSelect.find().click().type('Jakarta, ID{enter}'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 505ba19b880..cabb5c8e2a5 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -82,12 +82,6 @@ describe('object storage smoke tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - // Submit button is disabled when fields are empty. - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .should('be.disabled'); - // Enter label. cy.contains('Label').click().type(mockBucket.label); @@ -169,6 +163,7 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), + gecko2: makeFeatureFlagData(false), }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index b227032126f..99530ef1ddb 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -36,7 +36,7 @@ import { } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; @@ -102,7 +102,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { _isManagedAccount, account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx index 07b18f514d5..e34cd68cf09 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx @@ -17,7 +17,7 @@ import { useObjectStorageClusters, } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { confirmObjectStorage } from '../utilities'; @@ -98,7 +98,7 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { const flags = useFlags(); const { data: regions } = useRegionsQuery(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index 666f035f64f..339380b97ea 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -19,7 +19,7 @@ import { useOpenClose } from 'src/hooks/useOpenClose'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountSettings } from 'src/queries/account/settings'; import { useObjectStorageAccessKeys } from 'src/queries/objectStorage'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendCreateAccessKeyEvent, sendEditAccessKeyEvent, @@ -89,7 +89,7 @@ export const AccessKeyLanding = (props: Props) => { const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx index ac87472e441..5b08be32e43 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyActionMenu.tsx @@ -7,7 +7,7 @@ import { Hidden } from 'src/components/Hidden'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { OpenAccessDrawer } from '../types'; @@ -27,7 +27,7 @@ export const AccessKeyActionMenu = ({ const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx index 4aa05b3b442..121dead1fc6 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -13,7 +13,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { HostNamesDrawer } from '../HostNamesDrawer'; import { OpenAccessDrawer } from '../types'; @@ -46,7 +46,7 @@ export const AccessKeyTable = (props: AccessKeyTableProps) => { const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx index fd7a753fa2d..92ea578fb46 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx @@ -11,7 +11,7 @@ import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { OpenAccessDrawer } from '../types'; import { AccessKeyActionMenu } from './AccessKeyActionMenu'; @@ -35,7 +35,7 @@ export const AccessKeyTableRow = ({ const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx index f3205015681..d533ccb0c55 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx @@ -7,7 +7,7 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { AccessTable } from './AccessTable'; import { BucketPermissionsTable } from './BucketPermissionsTable'; @@ -43,7 +43,7 @@ export const LimitedAccessControls = React.memo((props: Props) => { const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx index a40b5ee2769..e91b61a857a 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx @@ -26,7 +26,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { useAccountSettings } from 'src/queries/account/settings'; import { useObjectStorageBuckets } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getRegionsByRegionId } from 'src/utilities/regions'; import { sortByString } from 'src/utilities/sort-by'; @@ -115,7 +115,7 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx index d2f37ca978a..73ebb5ec950 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx @@ -5,7 +5,7 @@ import { Drawer } from 'src/components/Drawer'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { AccessTable } from './AccessTable'; import { BucketPermissionsTable } from './BucketPermissionsTable'; @@ -24,7 +24,7 @@ export const ViewPermissionsDrawer: React.FC = (props) => { const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx index 8a3fe6b83a7..357b70a2ddc 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx @@ -29,7 +29,7 @@ import { useObjectStorageClusters, } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { truncateMiddle } from 'src/utilities/truncate'; @@ -84,7 +84,7 @@ export const BucketDetail = () => { const flags = useFlags(); const { data: account } = useAccount(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index efaeee2688a..fc3002b0817 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -15,7 +15,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { formatDate } from 'src/utilities/formatDate'; import { pluralize } from 'src/utilities/pluralize'; import { truncateMiddle } from 'src/utilities/truncate'; @@ -54,7 +54,7 @@ export const BucketDetailsDrawer = React.memo( const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index f3e6c7b16f7..2822e4e0fe1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -28,7 +28,7 @@ import { } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendDeleteBucketEvent, sendDeleteBucketFailedEvent, @@ -54,7 +54,7 @@ export const BucketLanding = () => { const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index 48a52341fd7..4faaf4dcb3f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -10,7 +10,7 @@ import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getRegionsByRegionId } from 'src/utilities/regions'; import { readableBytes } from 'src/utilities/unitConversions'; @@ -47,7 +47,7 @@ export const BucketTableRow = (props: BucketTableRowProps) => { const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 21d431ff761..11817c77cfb 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -11,7 +11,7 @@ interface Props { onBlur: (e: any) => void; onChange: (value: string) => void; required?: boolean; - selectedCluster: string; + selectedCluster: string | undefined; } export const ClusterSelect: React.FC = (props) => { @@ -55,7 +55,7 @@ export const ClusterSelect: React.FC = (props) => { placeholder="Select a Region" regions={regionOptions ?? []} required={required} - value={selectedCluster} + value={selectedCluster ?? undefined} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 7610a54c6fe..8610ba9d76f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -1,6 +1,8 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { CreateBucketSchema } from '@linode/validation'; import { styled } from '@mui/material/styles'; -import { useFormik } from 'formik'; import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; @@ -24,9 +26,8 @@ import { } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; @@ -34,6 +35,8 @@ import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import ClusterSelect from './ClusterSelect'; import { OveragePricing } from './OveragePricing'; +import type { CreateObjectStorageBucketPayload } from '@linode/api-v4'; + interface Props { isOpen: boolean; onClose: () => void; @@ -47,7 +50,7 @@ export const CreateBucketDrawer = (props: Props) => { const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] @@ -69,7 +72,8 @@ export const CreateBucketDrawer = (props: Props) => { As part of the process of cleaning up after the 'objMultiCluster' feature flag, we will remove 'cluster' and retain 'regions'. */ - const { data: buckets } = useObjectStorageBuckets({ + + const { data: bucketsData } = useObjectStorageBuckets({ clusters: isObjMultiClusterEnabled ? undefined : clusters, isObjMultiClusterEnabled, regions: isObjMultiClusterEnabled @@ -93,12 +97,7 @@ export const CreateBucketDrawer = (props: Props) => { const isInvalidPrice = !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - const { - error, - isLoading, - mutateAsync: createBucket, - reset, - } = useCreateBucketMutation(); + const { isLoading, mutateAsync: createBucket } = useCreateBucketMutation(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); @@ -109,71 +108,78 @@ export const CreateBucketDrawer = (props: Props) => { false ); - const formik = useFormik({ - initialValues: { + const { + control, + formState: { errors }, + handleSubmit, + reset, + setError, + watch, + } = useForm({ + context: { buckets: bucketsData?.buckets ?? [] }, + defaultValues: { cluster: '', - cors_enabled: true, // For Gen1, CORS is always enabled + cors_enabled: true, label: '', }, - async onSubmit(values) { - await createBucket(values); - sendCreateBucketEvent(values.cluster); + mode: 'onBlur', + resolver: yupResolver(CreateBucketSchema), + }); + + const watchCluster = watch('cluster'); + + const onSubmit = async (data: CreateObjectStorageBucketPayload) => { + try { + await createBucket(data); + + if (data.cluster) { + sendCreateBucketEvent(data.cluster); + } + if (hasSignedAgreement) { - updateAccountAgreements({ - eu_model: true, - }).catch(reportAgreementSigningError); + try { + await updateAccountAgreements({ eu_model: true }); + } catch (error) { + reportAgreementSigningError(error); + } } + onClose(); - }, - validate(values) { - reset(); - const doesBucketExist = buckets?.buckets.find( - (b) => b.label === values.label && b.cluster === values.cluster - ); - if (doesBucketExist) { - return { - label: - 'A bucket with this label already exists in your selected region', - }; + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); } - return {}; - }, - }); + } + }; - const onSubmit: React.FormEventHandler = (e) => { + const handleBucketFormSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (accountSettings?.object_storage === 'active') { - formik.handleSubmit(e); - } else { + if (accountSettings?.object_storage !== 'active') { setIsEnableObjDialogOpen(true); + } else { + handleSubmit(onSubmit)(); } }; - React.useEffect(() => { - if (isOpen) { - formik.resetForm(); - reset(); - } - }, [isOpen]); - - const clusterRegion = - regions && - regions.filter((region) => { - return formik.values.cluster.includes(region.id); - }); + const clusterRegion = watchCluster + ? regions?.find((region) => watchCluster.includes(region.id)) + : undefined; const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, regions, - selectedRegionId: clusterRegion?.[0]?.id ?? '', + selectedRegionId: clusterRegion?.id ?? '', }); - const errorMap = getErrorMap(['label', 'cluster'], error); - return ( - - + + {isRestrictedUser && ( { variant="error" /> )} - {Boolean(errorMap.none) && ( - + {errors.root?.message && ( + )} - ( + + )} + control={control} name="label" - onBlur={formik.handleBlur} - onChange={formik.handleChange} - required - value={formik.values.label} + rules={{ required: 'Label is required' }} /> - formik.setFieldValue('cluster', value)} - required - selectedCluster={formik.values.cluster} + ( + field.onChange(value)} + required + selectedCluster={field.value ?? undefined} + /> + )} + control={control} + name="cluster" + rules={{ required: 'Cluster is required' }} /> - {clusterRegion?.[0]?.id && ( - - )} - {showGDPRCheckbox ? ( + {clusterRegion?.id && } + {showGDPRCheckbox && ( setHasSignedAgreement(e.target.checked)} /> - ) : null} + )} { }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} /> - setIsEnableObjDialogOpen(false)} open={isEnableObjDialogOpen} - regionId={clusterRegion?.[0]?.id} + regionId={clusterRegion?.id} /> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index ab2f8f2a12e..bad670aafa8 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -25,7 +25,7 @@ import { } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendDeleteBucketEvent, sendDeleteBucketFailedEvent, @@ -52,7 +52,7 @@ export const OMC_BucketLanding = () => { const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 40fa2049e17..8343222ccbb 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -1,5 +1,7 @@ -import { useFormik } from 'formik'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { CreateBucketSchema } from '@linode/validation'; import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; @@ -21,9 +23,8 @@ import { } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; @@ -32,6 +33,8 @@ import { BucketRegions } from './BucketRegions'; import { StyledEUAgreementCheckbox } from './OMC_CreateBucketDrawer.styles'; import { OveragePricing } from './OveragePricing'; +import type { CreateObjectStorageBucketPayload } from '@linode/api-v4'; + interface Props { isOpen: boolean; onClose: () => void; @@ -44,7 +47,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] @@ -56,7 +59,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { region.capabilities.includes('Object Storage') ); - const { data: buckets } = useObjectStorageBuckets({ + const { data: bucketsData } = useObjectStorageBuckets({ isObjMultiClusterEnabled, regions: regionsSupportingObjectStorage, }); @@ -77,12 +80,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const isInvalidPrice = !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - const { - error, - isLoading, - mutateAsync: createBucket, - reset, - } = useCreateBucketMutation(); + const { isLoading, mutateAsync: createBucket } = useCreateBucketMutation(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); @@ -93,65 +91,78 @@ export const OMC_CreateBucketDrawer = (props: Props) => { false ); - const formik = useFormik({ - initialValues: { - cors_enabled: true, // Gen1 = true, Gen2 = false @TODO: OBJGen2 - Future PR will implement this... + const { + control, + formState: { errors }, + handleSubmit, + reset, + setError, + watch, + } = useForm({ + context: { buckets: bucketsData?.buckets ?? [] }, + defaultValues: { + cors_enabled: true, label: '', region: '', }, - async onSubmit(values) { - await createBucket(values); - sendCreateBucketEvent(values.region); + mode: 'onBlur', + resolver: yupResolver(CreateBucketSchema), + }); + + const watchRegion = watch('region'); + + const onSubmit = async (data: CreateObjectStorageBucketPayload) => { + try { + await createBucket(data); + + if (data.region) { + sendCreateBucketEvent(data.region); + } + if (hasSignedAgreement) { - updateAccountAgreements({ - eu_model: true, - }).catch(reportAgreementSigningError); + try { + await updateAccountAgreements({ eu_model: true }); + } catch (error) { + reportAgreementSigningError(error); + } } + onClose(); - }, - validate(values) { - reset(); - const doesBucketExist = buckets?.buckets.find( - (b) => b.label === values.label && b.region === values.region - ); - if (doesBucketExist) { - return { - label: - 'A bucket with this label already exists in your selected region', - }; + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); } - return {}; - }, - }); + } + }; - const onSubmit: React.FormEventHandler = (e) => { + const handleBucketFormSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (accountSettings?.object_storage === 'active') { - formik.handleSubmit(e); - } else { + if (accountSettings?.object_storage !== 'active') { setIsEnableObjDialogOpen(true); + } else { + handleSubmit(onSubmit)(); } }; - React.useEffect(() => { - if (isOpen) { - formik.resetForm(); - reset(); - } - }, [isOpen]); + const region = watchRegion + ? regions?.find((region) => watchRegion.includes(region.id)) + : undefined; const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, regions, - selectedRegionId: formik.values.region ?? '', + selectedRegionId: region?.id ?? '', }); - const errorMap = getErrorMap(['label', 'cluster'], error); - return ( - -
    + + {isRestrictedUser && ( { variant="error" /> )} - {Boolean(errorMap.none) && ( - + {errors.root?.message && ( + )} - ( + + )} + control={control} name="label" - onBlur={formik.handleBlur} - onChange={formik.handleChange} - required - value={formik.values.label} + rules={{ required: 'Label is required' }} /> - formik.setFieldValue('region', value)} - required - selectedRegion={formik.values.region} + ( + field.onChange(value)} + required + selectedRegion={field.value} + /> + )} + control={control} + name="region" + rules={{ required: 'Region is required' }} /> - {formik.values.region && ( - - )} + {region?.id && } {showGDPRCheckbox ? ( { { }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} /> - setIsEnableObjDialogOpen(false)} open={isEnableObjDialogOpen} - regionId={formik.values.region} + regionId={region?.id} />
    diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index fc383f3caac..7512ee0dc41 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -23,7 +23,7 @@ import { useObjectStorageClusters, } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { MODE } from './AccessKeyLanding/types'; import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; @@ -57,7 +57,7 @@ export const ObjectStorageLanding = () => { const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx index 26219cf22e2..d4468be8382 100644 --- a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx +++ b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx @@ -11,7 +11,7 @@ import { CopyAllHostnames } from 'src/features/ObjectStorage/AccessKeyLanding/Co import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getRegionsByRegionId } from 'src/utilities/regions'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; @@ -46,7 +46,7 @@ export const SecretTokenDialog = (props: Props) => { const flags = useFlags(); const { account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 0948d665bc3..3fb825f23ac 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -25,7 +25,7 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { useSpecificTypes } from 'src/queries/types'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { formatLinode } from 'src/store/selectors/getSearchEntities'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; @@ -80,7 +80,7 @@ export const SearchLanding = (props: SearchLandingProps) => { const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 8e5d83a53d1..150f69fb327 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -28,7 +28,7 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { useSpecificTypes } from 'src/queries/types'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { formatLinode } from 'src/store/selectors/getSearchEntities'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; @@ -92,7 +92,7 @@ const SearchBar = (props: SearchProps) => { const isLargeAccount = useIsLargeAccount(searchActive); const { account } = useAccountManagement(); const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] diff --git a/packages/manager/src/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts index 7c316b6363f..9aafae7a0e1 100644 --- a/packages/manager/src/queries/objectStorage.ts +++ b/packages/manager/src/queries/objectStorage.ts @@ -61,7 +61,7 @@ export interface BucketError { region?: Region; } -interface BucketsResponce { +interface BucketsResponse { buckets: ObjectStorageBucket[]; errors: BucketError[]; } @@ -117,7 +117,7 @@ export const useObjectStorageBuckets = ({ isObjMultiClusterEnabled = false, regions, }: UseObjectStorageBucketsOptions) => - useQuery( + useQuery( [`${queryKey}-buckets`], // Ideally we would use the line below, but if a cluster is down, the buckets on that // cluster don't show up in the responce. We choose to fetch buckets per-cluster so @@ -155,7 +155,7 @@ export const useCreateBucketMutation = () => { onSuccess: (newEntity) => { // Invalidate account settings because it contains obj information queryClient.invalidateQueries(accountQueries.settings.queryKey); - queryClient.setQueryData( + queryClient.setQueryData( [`${queryKey}-buckets`], (oldData) => ({ buckets: [...(oldData?.buckets || []), newEntity], @@ -173,7 +173,7 @@ export const useDeleteBucketMutation = () => { (data) => deleteBucket(data), { onSuccess: (_, variables) => { - queryClient.setQueryData( + queryClient.setQueryData( [`${queryKey}-buckets`], (oldData) => { return { @@ -206,7 +206,7 @@ export const useDeleteBucketWithRegionMutation = () => { (data) => deleteBucketWithRegion(data), { onSuccess: (_, variables) => { - queryClient.setQueryData( + queryClient.setQueryData( [`${queryKey}-buckets`], (oldData) => { return { @@ -249,7 +249,7 @@ export const getAllBucketsFromClusters = async ( clusters: ObjectStorageCluster[] | undefined ) => { if (clusters === undefined) { - return { buckets: [], errors: [] } as BucketsResponce; + return { buckets: [], errors: [] } as BucketsResponse; } const promises = clusters.map((cluster) => @@ -277,14 +277,14 @@ export const getAllBucketsFromClusters = async ( throw new Error('Unable to get Object Storage buckets.'); } - return { buckets, errors } as BucketsResponce; + return { buckets, errors } as BucketsResponse; }; export const getAllBucketsFromRegions = async ( regions: Region[] | undefined ) => { if (regions === undefined) { - return { buckets: [], errors: [] } as BucketsResponce; + return { buckets: [], errors: [] } as BucketsResponse; } const promises = regions.map((region) => @@ -312,7 +312,7 @@ export const getAllBucketsFromRegions = async ( throw new Error('Unable to get Object Storage buckets.'); } - return { buckets, errors } as BucketsResponce; + return { buckets, errors } as BucketsResponse; }; /** @@ -340,7 +340,7 @@ export const updateBucket = async ( queryClient: QueryClient ) => { const bucket = await getBucket(cluster, bucketName); - queryClient.setQueryData( + queryClient.setQueryData( [`${queryKey}-buckets`], (oldData) => { if (oldData === undefined) { @@ -363,7 +363,7 @@ export const updateBucket = async ( return { buckets: updatedBuckets, errors: oldData.errors, - } as BucketsResponce; + } as BucketsResponse; } ); }; diff --git a/packages/validation/.changeset/pr-10699-added-1721669275091.md b/packages/validation/.changeset/pr-10699-added-1721669275091.md new file mode 100644 index 00000000000..cc18903f7e5 --- /dev/null +++ b/packages/validation/.changeset/pr-10699-added-1721669275091.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Added unique label validation for Object Storage label ([#10699](https://github.com/linode/manager/pull/10699)) diff --git a/packages/validation/src/buckets.schema.ts b/packages/validation/src/buckets.schema.ts index b8128239614..3154665200b 100644 --- a/packages/validation/src/buckets.schema.ts +++ b/packages/validation/src/buckets.schema.ts @@ -9,7 +9,26 @@ export const CreateBucketSchema = object() .required('Label is required.') .matches(/^\S*$/, 'Label must not contain spaces.') .min(3, 'Label must be between 3 and 63 characters.') - .max(63, 'Label must be between 3 and 63 characters.'), + .max(63, 'Label must be between 3 and 63 characters.') + .test( + 'unique-label', + 'A bucket with this label already exists in your selected region', + (value, context) => { + const { cluster, region } = context.parent; + const buckets = context.options.context?.buckets; + + if (!Array.isArray(buckets)) { + // If buckets is not an array, assume the label is unique + return true; + } + + return !buckets.some( + (bucket) => + bucket.label === value && + (bucket.cluster === cluster || bucket.region === region) + ); + } + ), cluster: string().when('region', { is: (region: string) => !region || region.length === 0, then: string().required('Cluster is required.'), From 1b332d7904abd3eea18dbe31556054b7fbe1398f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 29 Jul 2024 09:23:55 -0400 Subject: [PATCH 33/58] upcoming: [M3-8284] - Allow Marketplace Apps to be Overwritten with a Feature Flag on Linode Create v2 (#10709) * initial attempt * remove `console.log` * fix ci * try to make the flag more intuitive * add better unit testing * Added changeset: Allow Marketplace Apps to be Overwritten with a Feature Flag on Linode Create v2 * fix spelling mistakes * Apply suggestions from code review Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-10709-upcoming-features-1721923754456.md | 5 + packages/manager/src/featureFlags.ts | 19 ++- .../Tabs/Marketplace/AppDetailDrawer.test.tsx | 17 +- .../Tabs/Marketplace/AppDetailDrawer.tsx | 10 +- .../Tabs/Marketplace/AppSection.test.tsx | 42 +++-- .../Tabs/Marketplace/AppSection.tsx | 20 +-- .../Tabs/Marketplace/AppsList.tsx | 36 ++--- .../Tabs/Marketplace/utilities.test.ts | 149 ++++++++++++++++-- .../Tabs/Marketplace/utilities.ts | 111 +++++++++---- .../features/OneClickApps/oneClickAppsv2.ts | 1 - packages/manager/src/queries/stackscripts.ts | 28 ++-- 11 files changed, 322 insertions(+), 116 deletions(-) create mode 100644 packages/manager/.changeset/pr-10709-upcoming-features-1721923754456.md diff --git a/packages/manager/.changeset/pr-10709-upcoming-features-1721923754456.md b/packages/manager/.changeset/pr-10709-upcoming-features-1721923754456.md new file mode 100644 index 00000000000..5b4698a3053 --- /dev/null +++ b/packages/manager/.changeset/pr-10709-upcoming-features-1721923754456.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Allow Marketplace Apps to be Overwritten with a Feature Flag on Linode Create v2 ([#10709](https://github.com/linode/manager/pull/10709)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 1ef65e8884e..3e2aaa165de 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,4 +1,4 @@ -import type { Doc } from './features/OneClickApps/types'; +import type { Doc, OCA } from './features/OneClickApps/types'; import type { TPAProvider } from '@linode/api-v4/lib/profile'; import type { NoticeVariant } from 'src/components/Notice/Notice'; @@ -93,6 +93,7 @@ export interface Flags { linodeCreateWithFirewall: boolean; linodeDiskEncryption: boolean; mainContentBanner: MainContentBanner; + marketplaceAppOverrides: MarketplaceAppOverride[]; metadata: boolean; objMultiCluster: boolean; objectStorageGen2: BaseFeatureFlag; @@ -113,6 +114,22 @@ export interface Flags { tpaProviders: Provider[]; } +interface MarketplaceAppOverride { + /** + * Define app details that should be overwritten + * + * If you are adding an app that is not already defined in "oneClickAppsv2.ts", + * you *must* include all required OCA properties or Cloud Manager could crash. + * + * Pass `null` to hide the marketplace app + */ + details: Partial | null; + /** + * The ID of the StackScript that powers this Marketplace app + */ + stackscriptId: number; +} + type PromotionalOfferFeature = | 'Kubernetes' | 'Linodes' diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx index da8b1684d81..9421ef505a4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx @@ -1,18 +1,29 @@ import { userEvent } from '@testing-library/user-event'; import React from 'react'; +import { stackScriptFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AppDetailDrawerv2 } from './AppDetailDrawer'; describe('AppDetailDrawer', () => { - it('should render an app', () => { - const { getByText } = renderWithTheme( + it('should render an app', async () => { + const stackscript = stackScriptFactory.build({ id: 401697 }); + + server.use( + http.get('*/v4/linode/stackscripts', () => { + return HttpResponse.json(makeResourcePage([stackscript])); + }) + ); + + const { findByText, getByText } = renderWithTheme( ); // Verify title renders - expect(getByText('WordPress')).toBeVisible(); + expect(await findByText('WordPress')).toBeVisible(); // Verify description renders expect( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx index b451a8d9bc6..2c6f0d88903 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx @@ -1,7 +1,6 @@ import Close from '@mui/icons-material/Close'; import Drawer from '@mui/material/Drawer'; import IconButton from '@mui/material/IconButton'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -9,9 +8,12 @@ import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; +import { useMarketplaceApps } from './utilities'; + +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ appName: { color: '#fff !important', @@ -65,8 +67,10 @@ interface Props { export const AppDetailDrawerv2 = (props: Props) => { const { onClose, open, stackScriptId } = props; const { classes } = useStyles(); + const { apps } = useMarketplaceApps(); - const selectedApp = stackScriptId ? oneClickApps[stackScriptId] : null; + const selectedApp = apps.find((app) => app.stackscript.id === stackScriptId) + ?.details; const gradient = { backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.colors.start}, #${selectedApp?.colors.end})`, diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx index e65774076c6..555fffabe68 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx @@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { stackScriptFactory } from 'src/factories'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AppSection } from './AppSection'; @@ -10,10 +11,10 @@ describe('AppSection', () => { it('should render a title', () => { const { getByText } = renderWithTheme( ); @@ -22,17 +23,20 @@ describe('AppSection', () => { }); it('should render apps', () => { - const app = stackScriptFactory.build({ - id: 0, - label: 'Linode Marketplace App', - }); + const app = { + details: oneClickApps[0], + stackscript: stackScriptFactory.build({ + id: 0, + label: 'Linode Marketplace App', + }), + }; const { getByText } = renderWithTheme( ); @@ -41,40 +45,50 @@ describe('AppSection', () => { }); it('should call `onOpenDetailsDrawer` when the details button is clicked for an app', async () => { - const app = stackScriptFactory.build({ id: 0 }); + const app = { + details: oneClickApps[0], + stackscript: stackScriptFactory.build({ id: 0 }), + }; + const onOpenDetailsDrawer = vi.fn(); const { getByLabelText } = renderWithTheme( ); - await userEvent.click(getByLabelText(`Info for "${app.label}"`)); + await userEvent.click( + getByLabelText(`Info for "${app.stackscript.label}"`) + ); - expect(onOpenDetailsDrawer).toHaveBeenCalledWith(app.id); + expect(onOpenDetailsDrawer).toHaveBeenCalledWith(app.stackscript.id); }); it('should call `onSelect` when an app is clicked', async () => { - const app = stackScriptFactory.build({ id: 0 }); + const app = { + details: oneClickApps[0], + stackscript: stackScriptFactory.build({ id: 0 }), + }; + const onSelect = vi.fn(); const { getByText } = renderWithTheme( ); - await userEvent.click(getByText(app.label)); + await userEvent.click(getByText(app.stackscript.label)); - expect(onSelect).toHaveBeenCalledWith(app); + expect(onSelect).toHaveBeenCalledWith(app.stackscript); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx index 29948e18034..834a4fbecd2 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx @@ -4,17 +4,17 @@ import React from 'react'; import { Divider } from 'src/components/Divider'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { AppSelectionCard } from './AppSelectionCard'; +import type { MarketplaceApp } from './utilities'; import type { StackScript } from '@linode/api-v4'; interface Props { + apps: MarketplaceApp[]; onOpenDetailsDrawer: (stackscriptId: number) => void; onSelect: (stackscript: StackScript) => void; selectedStackscriptId: null | number | undefined; - stackscripts: StackScript[]; title: string; } @@ -23,7 +23,7 @@ export const AppSection = (props: Props) => { onOpenDetailsDrawer, onSelect, selectedStackscriptId, - stackscripts, + apps, title, } = props; @@ -32,14 +32,14 @@ export const AppSection = (props: Props) => { {title} - {stackscripts?.map((stackscript) => ( + {apps?.map((app) => ( onOpenDetailsDrawer(stackscript.id)} - onSelect={() => onSelect(stackscript)} + checked={app.stackscript.id === selectedStackscriptId} + iconUrl={`/assets/${app.details.logo_url}`} + key={app.stackscript.id} + label={app.stackscript.label} + onOpenDetailsDrawer={() => onOpenDetailsDrawer(app.stackscript.id)} + onSelect={() => onSelect(app.stackscript)} /> ))} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx index cca356bfef0..cb647bd20a7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx @@ -7,14 +7,16 @@ import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Stack } from 'src/components/Stack'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; -import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getGeneratedLinodeLabel } from '../../utilities'; import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities'; import { AppSection } from './AppSection'; import { AppSelectionCard } from './AppSelectionCard'; -import { getAppSections, getFilteredApps } from './utilities'; +import { + getAppSections, + getFilteredApps, + useMarketplaceApps, +} from './utilities'; import type { LinodeCreateFormValues } from '../../utilities'; import type { StackScript } from '@linode/api-v4'; @@ -37,9 +39,7 @@ interface Props { export const AppsList = (props: Props) => { const { category, onOpenDetailsDrawer, query } = props; - const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery( - true - ); + const { apps, error, isLoading } = useMarketplaceApps(); const queryClient = useQueryClient(); const { @@ -92,39 +92,39 @@ export const AppsList = (props: Props) => { } if (category || query) { - const filteredStackScripts = getFilteredApps({ + const filteredApps = getFilteredApps({ + apps, category, query, - stackscripts, }); return ( - {filteredStackScripts?.map((stackscript) => ( + {filteredApps?.map((app) => ( onOpenDetailsDrawer(stackscript.id)} - onSelect={() => onSelect(stackscript)} + checked={field.value === app.stackscript.id} + iconUrl={`/assets/${app.details.logo_url}`} + key={app.stackscript.id} + label={app.stackscript.label} + onOpenDetailsDrawer={() => onOpenDetailsDrawer(app.stackscript.id)} + onSelect={() => onSelect(app.stackscript)} /> ))} ); } - const sections = getAppSections(stackscripts); + const sections = getAppSections(apps); return ( - {sections.map(({ stackscripts, title }) => ( + {sections.map(({ apps, title }) => ( ))} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts index 1719bdd48f5..41479639f5f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts @@ -1,29 +1,48 @@ +import { renderHook, waitFor } from '@testing-library/react'; + import { stackScriptFactory } from 'src/factories'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { getFilteredApps, useMarketplaceApps } from './utilities'; + +import type { MarketplaceApp } from './utilities'; + +const mysql = { + details: oneClickApps[607026], + stackscript: stackScriptFactory.build({ id: 607026, label: 'MySQL' }), +}; -import { getFilteredApps } from './utilities'; +const piHole = { + details: oneClickApps[970522], + stackscript: stackScriptFactory.build({ id: 970522, label: 'Pi-Hole' }), +}; -const mysql = stackScriptFactory.build({ id: 607026, label: 'MySQL' }); -const piHole = stackScriptFactory.build({ id: 970522, label: 'Pi-Hole' }); -const vault = stackScriptFactory.build({ id: 1037038, label: 'Vault' }); +const vault = { + details: oneClickApps[1037038], + stackscript: stackScriptFactory.build({ id: 1037038, label: 'Vault' }), +}; -const stackscripts = [mysql, piHole, vault]; +const apps: MarketplaceApp[] = [mysql, piHole, vault]; describe('getFilteredApps', () => { it('should not perform any filtering if the search is empty', () => { const result = getFilteredApps({ + apps, category: undefined, query: '', - stackscripts, }); - expect(result).toStrictEqual(stackscripts); + expect(result).toStrictEqual(apps); }); it('should allow a simple filter on label', () => { const result = getFilteredApps({ + apps, category: undefined, query: 'mysql', - stackscripts, }); expect(result).toStrictEqual([mysql]); @@ -31,9 +50,9 @@ describe('getFilteredApps', () => { it('should allow a filter on label and catergory', () => { const result = getFilteredApps({ + apps, category: undefined, query: 'mysql, database', - stackscripts, }); expect(result).toStrictEqual([mysql]); @@ -41,9 +60,9 @@ describe('getFilteredApps', () => { it('should allow filtering on StackScript id', () => { const result = getFilteredApps({ + apps, category: undefined, query: '1037038', - stackscripts, }); expect(result).toStrictEqual([vault]); @@ -51,9 +70,9 @@ describe('getFilteredApps', () => { it('should allow filtering on alt description with many words', () => { const result = getFilteredApps({ + apps, category: undefined, query: 'HashiCorp password', - stackscripts, }); expect(result).toStrictEqual([vault]); @@ -61,9 +80,9 @@ describe('getFilteredApps', () => { it('should filter if a category is selected in the category dropdown', () => { const result = getFilteredApps({ + apps, category: 'Databases', query: '', - stackscripts, }); expect(result).toStrictEqual([mysql]); @@ -71,9 +90,9 @@ describe('getFilteredApps', () => { it('should allow searching by both a query and a category', () => { const result = getFilteredApps({ + apps, category: 'Databases', query: 'My', - stackscripts, }); expect(result).toStrictEqual([mysql]); @@ -81,11 +100,113 @@ describe('getFilteredApps', () => { it('should return no matches if there are no results when searching by both query and category', () => { const result = getFilteredApps({ + apps, category: 'Databases', query: 'HashiCorp', - stackscripts, }); expect(result).toStrictEqual([]); }); }); + +describe('useMarketplaceApps', () => { + it('should return apps from the stackscripts response', async () => { + const stackscript = stackScriptFactory.build({ + id: 0, + label: 'Linode Marketplace App', + }); + + server.use( + http.get('*/v4/linode/stackscripts', () => { + return HttpResponse.json(makeResourcePage([stackscript])); + }) + ); + + const { result } = renderHook(() => useMarketplaceApps(), { + wrapper: (ui) => + wrapWithTheme(ui, { flags: { marketplaceAppOverrides: [] } }), + }); + + await waitFor(() => { + expect(result.current.apps).toStrictEqual([ + { + details: oneClickApps[0], + stackscript, + }, + ]); + }); + }); + + it('should override app details with the marketplaceAppOverrides feature flag', async () => { + const stackscript = stackScriptFactory.build({ + id: 0, + label: 'Linode Marketplace App', + }); + + server.use( + http.get('*/v4/linode/stackscripts', () => { + return HttpResponse.json(makeResourcePage([stackscript])); + }) + ); + + const { result } = renderHook(() => useMarketplaceApps(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + marketplaceAppOverrides: [ + { + details: { + isNew: true, + related_guides: [ + { href: 'https://akamai.com', title: 'Overwritten Doc' }, + ], + }, + stackscriptId: 0, + }, + ], + }, + }), + }); + + await waitFor(() => { + expect(result.current.apps[0].details.related_guides?.[0].title).toBe( + 'Overwritten Doc' + ); + expect(result.current.apps[0].details.related_guides?.[0].href).toBe( + 'https://akamai.com' + ); + expect(result.current.apps[0].details.isNew).toBe(true); + }); + }); + + it('should be able to hide an app with the marketplaceAppOverrides feature flag', async () => { + const stackscript = stackScriptFactory.build({ + id: 0, + label: 'Linode Marketplace App', + }); + + server.use( + http.get('*/v4/linode/stackscripts', () => { + return HttpResponse.json(makeResourcePage([stackscript])); + }) + ); + + const { result } = renderHook(() => useMarketplaceApps(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + marketplaceAppOverrides: [ + { + details: null, + stackscriptId: 0, + }, + ], + }, + }), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + + expect(result.current.apps).toHaveLength(0); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts index c7dc8eababd..65d15a17cc3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts @@ -1,7 +1,9 @@ import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { useFlags } from 'src/hooks/useFlags'; +import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import type { StackScript } from '@linode/api-v4'; -import type { AppCategory } from 'src/features/OneClickApps/types'; +import type { AppCategory, OCA } from 'src/features/OneClickApps/types'; /** * Get all categories from our marketplace apps list so @@ -24,40 +26,40 @@ export const categoryOptions = uniqueCategories.map((category) => ({ * Returns an array of Marketplace app sections given an array * of Marketplace app StackScripts */ -export const getAppSections = (stackscripts: StackScript[]) => { +export const getAppSections = (apps: MarketplaceApp[]) => { // To check if an app is 'new', we check our own 'oneClickApps' list for the 'isNew' value - const newApps = stackscripts.filter( - (stackscript) => oneClickApps[stackscript.id]?.isNew - ); + const newApps = apps.filter((app) => app.details.isNew); // Items are ordered by popularity already, take the first 10 - const popularApps = stackscripts.slice(0, 10); + const popularApps = apps.slice(0, 10); // In the all apps section, show everything in alphabetical order - const allApps = [...stackscripts].sort((a, b) => - a.label.toLowerCase().localeCompare(b.label.toLowerCase()) + const allApps = [...apps].sort((a, b) => + a.stackscript.label + .toLowerCase() + .localeCompare(b.stackscript.label.toLowerCase()) ); return [ { - stackscripts: newApps, + apps: newApps, title: 'New apps', }, { - stackscripts: popularApps, + apps: popularApps, title: 'Popular apps', }, { - stackscripts: allApps, + apps: allApps, title: 'All apps', }, ]; }; -interface FilterdAppsOptions { +interface FilteredAppsOptions { + apps: MarketplaceApp[]; category: AppCategory | undefined; query: string; - stackscripts: StackScript[]; } /** @@ -69,23 +71,23 @@ interface FilterdAppsOptions { * * @returns Stackscripts that have been filtered based on the options passed */ -export const getFilteredApps = (options: FilterdAppsOptions) => { - const { category, query, stackscripts } = options; +export const getFilteredApps = (options: FilteredAppsOptions) => { + const { apps, category, query } = options; - return stackscripts.filter((stackscript) => { + return apps.filter((app) => { if (query && category) { return ( - getDoesStackScriptMatchQuery(query, stackscript) && - getDoesStackScriptMatchCategory(category, stackscript) + getDoesMarketplaceAppMatchQuery(query, app) && + getDoesMarketplaceAppMatchCategory(category, app) ); } if (query) { - return getDoesStackScriptMatchQuery(query, stackscript); + return getDoesMarketplaceAppMatchQuery(query, app); } if (category) { - return getDoesStackScriptMatchCategory(category, stackscript); + return getDoesMarketplaceAppMatchCategory(category, app); } return true; @@ -99,12 +101,10 @@ export const getFilteredApps = (options: FilterdAppsOptions) => { * @param stackscript the StackScript to compare aginst * @returns true if the StackScript matches the given query */ -const getDoesStackScriptMatchQuery = ( +const getDoesMarketplaceAppMatchQuery = ( query: string, - stackscript: StackScript + app: MarketplaceApp ) => { - const appDetails = oneClickApps[stackscript.id]; - const queryWords = query .replace(/[,.-]/g, '') .trim() @@ -112,12 +112,12 @@ const getDoesStackScriptMatchQuery = ( .split(' '); const searchableAppFields = [ - String(stackscript.id), - stackscript.label, - appDetails.name, - appDetails.alt_name, - appDetails.alt_description, - ...appDetails.categories, + String(app.stackscript.id), + app.stackscript.label, + app.details.name, + app.details.alt_name, + app.details.alt_description, + ...app.details.categories, ]; return searchableAppFields.some((field) => @@ -129,12 +129,53 @@ const getDoesStackScriptMatchQuery = ( * Checks if the given StackScript has a category * * @param category The category to check for - * @param stackscript The StackScript to compare aginst - * @returns true if the given StackScript has the given category + * @param app The Marketplace app to compare against + * @returns true if the given app has the given category */ -const getDoesStackScriptMatchCategory = ( +const getDoesMarketplaceAppMatchCategory = ( category: AppCategory, - stackscript: StackScript + app: MarketplaceApp ) => { - return oneClickApps[stackscript.id].categories.includes(category); + return app.details.categories.includes(category); +}; + +export interface MarketplaceApp { + details: OCA; + stackscript: StackScript; +} + +export const useMarketplaceApps = () => { + const query = useMarketplaceAppsQuery(true); + const flags = useFlags(); + + const stackscripts = query.data ?? []; + + const apps: MarketplaceApp[] = []; + + for (const stackscript of stackscripts) { + const override = flags.marketplaceAppOverrides?.find( + (override) => override.stackscriptId === stackscript.id + ); + + const baseAppDetails = oneClickApps[stackscript.id]; + + if (override === undefined && baseAppDetails) { + // If the StackScript has no overrides, just add it to the apps array. + apps.push({ details: baseAppDetails, stackscript }); + } + + if (override?.details === null) { + // If the feature flag explicitly specifies `null`, it means we don't want it to show, + // so we skip it. + continue; + } + + if (override?.details) { + const details = { ...baseAppDetails, ...override.details }; + + apps.push({ details, stackscript }); + } + } + + return { apps, ...query }; }; diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index a447fe1c6d3..687bad03dac 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -13,7 +13,6 @@ export const oneClickApps: Record = { ...oneClickAppFactory.build({ name: 'E2E Test App', }), - isNew: true, }, 401697: { alt_description: 'Popular website content management system.', diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index f1c1576faea..46f7e0c3615 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -1,24 +1,21 @@ -import { - StackScript, - getStackScript, - getStackScripts, -} from '@linode/api-v4/lib/stackscripts'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +import { getStackScript, getStackScripts } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { getOneClickApps } from 'src/features/StackScripts/stackScriptUtils'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; +import type { + APIError, + Filter, + Params, + ResourcePage, + StackScript, +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; + export const getAllOCAsRequest = (passedParams: Params = {}) => getAll((params) => getOneClickApps({ ...params, ...passedParams }) @@ -31,10 +28,7 @@ export const stackscriptQueries = createQueryKeys('stackscripts', { queryKey: [filter], }), marketplace: { - queryFn: async () => { - const stackscripts = await getAllOCAsRequest(); - return stackscripts.filter((s) => oneClickApps[s.id]); - }, + queryFn: () => getAllOCAsRequest(), queryKey: null, }, stackscript: (id: number) => ({ From 95378758b6c1f80303ede15c8bd5c3917753d774 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 29 Jul 2024 10:28:56 -0400 Subject: [PATCH 34/58] test: [M3-8278] - Cloud Manager changes for Heimdall test pipeline (#10713) * Expose Cypress tag environment variable to Docker containers * Add e2e_heimdall Docker Compose service for Heimdall pipeline * Tag tests for synthetic testing * Allow retries to be disabled in CI via environment variable --- docker-compose.yml | 13 +++++++++++++ docs/development-guide/08-testing.md | 1 + .../pr-10713-tech-stories-1722017812325.md | 5 +++++ .../.changeset/pr-10713-tests-1722017832242.md | 5 +++++ packages/manager/cypress.config.ts | 2 +- .../core/objectStorage/object-storage.e2e.spec.ts | 2 ++ .../cypress/e2e/core/volumes/create-volume.spec.ts | 2 ++ packages/manager/cypress/support/util/tag.ts | 1 + 8 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10713-tech-stories-1722017812325.md create mode 100644 packages/manager/.changeset/pr-10713-tests-1722017832242.md diff --git a/docker-compose.yml b/docker-compose.yml index 229dd704059..51f2f01e6a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ x-e2e-env: # Cloud Manager-specific test configuration. CY_TEST_SUITE: ${CY_TEST_SUITE} CY_TEST_REGION: ${CY_TEST_REGION} + CY_TEST_TAGS: ${CY_TEST_TAGS} + CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} # Cypress environment variables for alternative parallelization. CY_TEST_SPLIT_RUN: ${CY_TEST_SPLIT_RUN} @@ -104,12 +106,23 @@ services: timeout: 10s retries: 10 + # Generic end-to-end test runner for Cloud's primary testing pipeline. + # Configured to run against a local Cloud instance. e2e: <<: *default-runner environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} + # End-to-end test runner for Cloud's synthetic monitoring tests. + # Configured to run against a remote Cloud instance hosted at some URL. + e2e_heimdall: + <<: *default-runner + depends_on: [] + environment: + <<: *default-env + MANAGER_OAUTH: ${MANAGER_OAUTH} + region-1: build: context: . diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index febe5b53519..48fcf586379 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -215,6 +215,7 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_USER_REPORT` | Log test account information when tests begin | `1` | Unset; disabled by default | | `CY_TEST_JUNIT_REPORT` | Enable JUnit reporting | `1` | Unset; disabled by default | | `CY_TEST_DISABLE_FILE_WATCHING` | Disable file watching in Cypress UI | `1` | Unset; disabled by default | +| `CY_TEST_DISABLE_RETRIES` | Disable test retries on failure in CI | `1` | Unset; disabled by default | | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | ### Writing End-to-End Tests diff --git a/packages/manager/.changeset/pr-10713-tech-stories-1722017812325.md b/packages/manager/.changeset/pr-10713-tech-stories-1722017812325.md new file mode 100644 index 00000000000..6e5dd981cb2 --- /dev/null +++ b/packages/manager/.changeset/pr-10713-tech-stories-1722017812325.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Docker Compose changes to facilitate new testing pipeline ([#10713](https://github.com/linode/manager/pull/10713)) diff --git a/packages/manager/.changeset/pr-10713-tests-1722017832242.md b/packages/manager/.changeset/pr-10713-tests-1722017832242.md new file mode 100644 index 00000000000..4e2dbac52ad --- /dev/null +++ b/packages/manager/.changeset/pr-10713-tests-1722017832242.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Tag tests for synthetic monitoring ([#10713](https://github.com/linode/manager/pull/10713)) diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index 1c7efb41af8..c0cc12e20a3 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ video: true, // Only retry test when running via CI. - retries: process.env['CI'] ? 2 : 0, + retries: process.env['CI'] && !process.env['CY_TEST_DISABLE_RETRIES'] ? 2 : 0, experimentalMemoryManagement: true, e2e: { diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 4d415a8fb19..3c6b63c6ec0 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -156,6 +156,8 @@ describe('object storage end-to-end tests', () => { * - Confirms that deleted buckets are no longer listed on landing page. */ it('can create and delete object storage buckets', () => { + cy.tag('purpose:syntheticTesting'); + const bucketLabel = randomLabel(); const bucketRegion = 'Atlanta, GA'; const bucketCluster = 'us-southeast-1'; diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 3041575a061..25115abf26e 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -27,6 +27,8 @@ describe('volume create flow', () => { * - Confirms that volume is listed correctly on volumes landing page. */ it('creates an unattached volume', () => { + cy.tag('purpose:syntheticTesting'); + const region = chooseRegion(); const volume = { label: randomLabel(), diff --git a/packages/manager/cypress/support/util/tag.ts b/packages/manager/cypress/support/util/tag.ts index c7fa37f0a08..9be33d52a39 100644 --- a/packages/manager/cypress/support/util/tag.ts +++ b/packages/manager/cypress/support/util/tag.ts @@ -18,6 +18,7 @@ export type TestTag = // DC testing purposes even if that is not the primary purpose of the test. | 'purpose:dcTesting' | 'purpose:smokeTesting' + | 'purpose:syntheticTesting' // Method-related tags. // Describe the way the tests operate -- either end-to-end using real API requests, From 978ddab108de2d101527241890febc65f50663af Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Mon, 29 Jul 2024 11:43:57 -0400 Subject: [PATCH 35/58] upcoming: [M3-8215] - Hide monthly network transfer section for distributed regions (#10714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Distributed compute instances have no transfer pool, so the Monthly Network Transfer section is irrelevant. This PR hides that section and expands the Network Transfer History chart to occupy the space. ## Changes 🔄 List any change relevant to the reviewer. - Hide the monthly network transfer section for Linodes in a distributed region ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure your account has the `new-dc-testing`, `edge_testing` and `edge_compute` customer tags - Create a Linode in a distributed region if you don't already have one ### Verification steps (How to verify changes) - Go to a Linode's details page and then navigate to the Network tab - The Monthly Network Transfer section should not display for Linodes in a distributed region - The Monthly Network Transfer section should display as normal for Linodes in a core region --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-10714-added-1721926327777.md | 5 ++++ packages/api-v4/src/linodes/types.ts | 3 ++- ...r-10714-upcoming-features-1721926239567.md | 5 ++++ packages/manager/src/__data__/linodes.ts | 22 +++++++++------- .../TransferDisplay/TransferDisplayDialog.tsx | 9 +++++-- packages/manager/src/factories/linodes.ts | 14 ++++++----- .../LinodesCreate/AddonsPanel.test.tsx | 21 ++++++++++------ .../NetworkingSummaryPanel.tsx | 25 ++++++++++++------- .../LinodeRow/LinodeRow.test.tsx | 1 + .../Linodes/LinodesLanding/ListView.tsx | 4 ++- 10 files changed, 73 insertions(+), 36 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10714-added-1721926327777.md create mode 100644 packages/manager/.changeset/pr-10714-upcoming-features-1721926239567.md diff --git a/packages/api-v4/.changeset/pr-10714-added-1721926327777.md b/packages/api-v4/.changeset/pr-10714-added-1721926327777.md new file mode 100644 index 00000000000..1a33afbf8f8 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10714-added-1721926327777.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +site_type to the linode instance type ([#10714](https://github.com/linode/manager/pull/10714)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 2e29b76b3cd..93b462f5fc8 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,4 +1,4 @@ -import type { Region } from '../regions'; +import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; import type { SSHKey } from '../profile/types'; import type { PlacementGroupPayload } from '../placement-groups/types'; @@ -36,6 +36,7 @@ export interface Linode { specs: LinodeSpecs; watchdog_enabled: boolean; tags: string[]; + site_type: RegionSite; } export interface LinodeAlerts { diff --git a/packages/manager/.changeset/pr-10714-upcoming-features-1721926239567.md b/packages/manager/.changeset/pr-10714-upcoming-features-1721926239567.md new file mode 100644 index 00000000000..3dbd4efab77 --- /dev/null +++ b/packages/manager/.changeset/pr-10714-upcoming-features-1721926239567.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Hide monthly network transfer section for distributed regions ([#10714](https://github.com/linode/manager/pull/10714)) diff --git a/packages/manager/src/__data__/linodes.ts b/packages/manager/src/__data__/linodes.ts index 2c12ef53df9..f944613c588 100644 --- a/packages/manager/src/__data__/linodes.ts +++ b/packages/manager/src/__data__/linodes.ts @@ -1,4 +1,4 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; +import type { Linode } from '@linode/api-v4/lib/linodes'; export const linode1: Linode = { alerts: { @@ -26,12 +26,13 @@ export const linode1: Linode = { label: 'test', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'pg-1', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'us-east', + site_type: 'core', specs: { disk: 20480, gpus: 0, @@ -72,12 +73,13 @@ export const linode2: Linode = { label: 'another-test', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'pg-1', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'us-east', + site_type: 'core', specs: { disk: 30720, gpus: 0, @@ -118,12 +120,13 @@ export const linode3: Linode = { label: 'another-test', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'pg-1', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'us-east', + site_type: 'core', specs: { disk: 30720, gpus: 0, @@ -164,12 +167,13 @@ export const linode4: Linode = { label: 'another-test-eu', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'pg-1', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'eu-west', + site_type: 'core', specs: { disk: 30720, gpus: 0, diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx index edaee6e369a..afbbe17f5e3 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx @@ -8,9 +8,10 @@ import { Divider } from 'src/components/Divider'; import { Typography } from 'src/components/Typography'; import { DocsLink } from '../DocsLink/DocsLink'; +import { useIsGeckoEnabled } from '../RegionSelect/RegionSelect.utils'; +import { NETWORK_TRANSFER_QUOTA_DOCS_LINKS } from './constants'; import { TransferDisplayDialogHeader } from './TransferDisplayDialogHeader'; import { TransferDisplayUsage } from './TransferDisplayUsage'; -import { NETWORK_TRANSFER_QUOTA_DOCS_LINKS } from './constants'; import { formatRegionList, getDaysRemaining } from './utils'; import type { RegionTransferPool } from './utils'; @@ -38,6 +39,8 @@ export const TransferDisplayDialog = React.memo( regionTransferPools, } = props; const theme = useTheme(); + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const daysRemainingInMonth = getDaysRemaining(); const listOfOtherRegionTransferPools: string[] = regionTransferPools.length > 0 @@ -62,7 +65,9 @@ export const TransferDisplayDialog = React.memo( * Global Transfer Pool Display */} 0 ? ` except for ${otherRegionPools}.` : '.' diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index 58badc05c70..c6bc613b7e4 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -1,5 +1,9 @@ -import { RegionalNetworkUtilization } from '@linode/api-v4/lib/account'; -import { +import Factory from 'src/factories/factoryProxy'; + +import { placementGroupFactory } from './placementGroups'; + +import type { RegionalNetworkUtilization } from '@linode/api-v4/lib/account'; +import type { CreateLinodeRequest, Linode, LinodeAlerts, @@ -12,9 +16,6 @@ import { Stats, StatsData, } from '@linode/api-v4/lib/linodes/types'; -import Factory from 'src/factories/factoryProxy'; - -import { placementGroupFactory } from './placementGroups'; export const linodeAlertsFactory = Factory.Sync.makeFactory({ cpu: 10, @@ -265,11 +266,12 @@ export const linodeFactory = Factory.Sync.makeFactory({ label: Factory.each((i) => `linode-${i}`), lke_cluster_id: null, placement_group: placementGroupFactory.build({ - placement_group_type: 'anti_affinity:local', id: 1, label: 'pg-1', + placement_group_type: 'anti_affinity:local', }), region: 'us-east', + site_type: 'core', specs: linodeSpecsFactory.build(), status: 'running', tags: [], diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx index 0012f4ba1db..37c33a7ed0d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { imageFactory, linodeTypeFactory } from 'src/factories'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; -import { AddonsPanel, AddonsPanelProps } from './AddonsPanel'; +import { AddonsPanel } from './AddonsPanel'; + +import type { AddonsPanelProps } from './AddonsPanel'; const type = linodeTypeFactory.build({ addons: { @@ -64,12 +66,13 @@ const props: AddonsPanelProps = { label: 'test_instance', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'test', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'us-central', + site_type: 'core', specs: { disk: 51200, gpus: 0, @@ -110,12 +113,13 @@ const props: AddonsPanelProps = { label: 'debian-ca-central', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'test', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'ca-central', + site_type: 'core', specs: { disk: 25600, gpus: 0, @@ -155,12 +159,13 @@ const props: AddonsPanelProps = { label: 'almalinux-us-west', lke_cluster_id: null, placement_group: { - placement_group_type: 'anti_affinity:local', id: 1, - placement_group_policy: 'strict', label: 'test', + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', }, region: 'us-west', + site_type: 'core', specs: { disk: 25600, gpus: 0, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx index c36fcf7c0ab..9ec025ef01b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx @@ -3,6 +3,7 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Paper } from 'src/components/Paper'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { DNSResolvers } from './DNSResolvers'; @@ -16,23 +17,29 @@ interface Props { export const LinodeNetworkingSummaryPanel = React.memo((props: Props) => { // @todo maybe move this query closer to the consuming component const { data: linode } = useLinodeQuery(props.linodeId); + const { isGeckoGAEnabled } = useIsGeckoEnabled(); const theme = useTheme(); if (!linode) { return null; } + const hideNetworkTransfer = + isGeckoGAEnabled && linode.site_type === 'distributed'; + return ( - - - + {hideNetworkTransfer ? null : ( // Distributed compute instances have no transfer pool + + + + )} { paddingBottom: 0, }} md={3.5} - sm={6} + sm={hideNetworkTransfer ? 12 : 6} xs={12} > diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx index 4e082b758c9..8d2a11edd56 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx @@ -45,6 +45,7 @@ describe('LinodeRow', () => { lke_cluster_id={linode.lke_cluster_id} placement_group={linode.placement_group} region={linode.region} + site_type={linode.site_type} specs={linode.specs} status={linode.status} tags={linode.tags} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx index d8b35d2adf8..e09cc3a0326 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx @@ -2,9 +2,10 @@ import * as React from 'react'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { RenderLinodesProps } from './DisplayLinodes'; import { LinodeRow } from './LinodeRow/LinodeRow'; +import type { RenderLinodesProps } from './DisplayLinodes'; + export const ListView = (props: RenderLinodesProps) => { const { data, openDialog, openPowerActionDialog } = props; @@ -50,6 +51,7 @@ export const ListView = (props: RenderLinodesProps) => { maintenance={linode.maintenance} placement_group={linode.placement_group} region={linode.region} + site_type={linode.site_type} specs={linode.specs} status={linode.status} tags={linode.tags} From 566ce75c0e1e3a49b0d04e237b4c69327432bf95 Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Mon, 29 Jul 2024 09:40:53 -0700 Subject: [PATCH 36/58] refactor: [M3-6920] - Replace `react-select` with `Autocomplete` in 'Billing' (#10681) * refactor: [M3-6320] - Replace `react-select` with `Autocomplete` component * Fix e2e test that were failing * Add changeset * Update payment history panel styles to match others * Implement PR feedback to remove extra types * Update styles based on feedback from UX * Add spacing in panel per PR feedback * Fix merge conflicts * Update changes to reflect region label revert * Revert region label code that was missed --- .../pr-10681-tech-stories-1721227051502.md | 5 + .../billing/smoke-billing-activity.spec.ts | 29 +- .../src/features/Billing/BillingDetail.tsx | 6 +- .../BillingActivityPanel.test.tsx | 63 +++-- .../BillingActivityPanel.tsx | 247 ++++++++---------- .../BillingActivityPanel/index.ts | 2 - 6 files changed, 163 insertions(+), 189 deletions(-) create mode 100644 packages/manager/.changeset/pr-10681-tech-stories-1721227051502.md delete mode 100644 packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/index.ts diff --git a/packages/manager/.changeset/pr-10681-tech-stories-1721227051502.md b/packages/manager/.changeset/pr-10681-tech-stories-1721227051502.md new file mode 100644 index 00000000000..07bd820d083 --- /dev/null +++ b/packages/manager/.changeset/pr-10681-tech-stories-1721227051502.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace 'react-select' with Autocomplete in Billing ([#10681](https://github.com/linode/manager/pull/10681)) diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 1cdcbcb1b6c..70079a2d4cb 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -170,11 +170,6 @@ describe('Billing Activity Feed', () => { .scrollIntoView() .should('be.visible'); - cy.contains('[data-qa-enhanced-select]', 'All Transaction Types').should( - 'be.visible' - ); - cy.contains('[data-qa-enhanced-select]', '6 Months').should('be.visible'); - // Confirm that payments and invoices from the past 6 months are displayed, // and that payments and invoices beyond 6 months are not displayed. invoiceMocks6Months.forEach((invoice) => @@ -201,11 +196,12 @@ describe('Billing Activity Feed', () => { mockGetInvoices(invoiceMocks).as('getInvoices'); mockGetPayments(paymentMocks).as('getPayments'); - cy.contains('[data-qa-enhanced-select]', '6 Months') + cy.findByText('Transaction Dates').click().type(`All Time`); + ui.autocompletePopper + .findByTitle(`All Time`) .should('be.visible') .click(); - ui.select.findItemByText('All Time').should('be.visible').click(); cy.wait(['@getInvoices', '@getPayments']); // Confirm that all invoices and payments are displayed. @@ -218,12 +214,12 @@ describe('Billing Activity Feed', () => { }); // Change transaction type drop-down to "Payments" only. - cy.contains('[data-qa-enhanced-select]', 'All Transaction Types') + cy.findByText('Transaction Types').click().type(`Payments`); + ui.autocompletePopper + .findByTitle(`Payments`) .should('be.visible') .click(); - ui.select.findItemByText('Payments').should('be.visible').click(); - // Confirm that all payments are shown and that all invoices are hidden. paymentMocks.forEach((payment) => cy.findByText(`Payment #${payment.id}`).should('be.visible') @@ -233,12 +229,12 @@ describe('Billing Activity Feed', () => { ); // Change transaction type drop-down to "Invoices" only. - cy.contains('[data-qa-enhanced-select]', 'Payments') + cy.findByText('Transaction Types').should('be.visible').focused().click(); + ui.autocompletePopper + .findByTitle('Invoices') .should('be.visible') .click(); - ui.select.findItemByText('Invoices').should('be.visible').click(); - // Confirm that all invoices are shown and that all payments are hidden. invoiceMocks6Months.forEach((invoice) => { cy.findByText(invoice.label).should('be.visible'); @@ -272,11 +268,8 @@ describe('Billing Activity Feed', () => { cy.wait(['@getInvoices', '@getPayments', '@getPaymentMethods']); // Change invoice date selection from "6 Months" to "All Time". - cy.contains('[data-qa-enhanced-select]', '6 Months') - .should('be.visible') - .click(); - - ui.select.findItemByText('All Time').should('be.visible').click(); + cy.findByText('Transaction Dates').click().type('All Time'); + ui.autocompletePopper.findByTitle('All Time').should('be.visible').click(); cy.get('[data-qa-billing-activity-panel]') .should('be.visible') diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index 6102f09b7c8..18dcac982f6 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -1,6 +1,6 @@ import Paper from '@mui/material/Paper'; -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { PayPalScriptProvider } from '@paypal/react-paypal-js'; import * as React from 'react'; @@ -14,7 +14,7 @@ import { useAllPaymentMethodsQuery } from 'src/queries/account/payment'; import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import BillingActivityPanel from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; +import { BillingActivityPanel } from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; import BillingSummary from './BillingPanels/BillingSummary'; import ContactInfo from './BillingPanels/ContactInfoPanel'; import PaymentInformation from './BillingPanels/PaymentInfoPanel'; @@ -120,5 +120,3 @@ export const BillingActionButton = styled(Button)(({ theme, ...props }) => ({ minWidth: 'auto', padding: 0, })); - -export default BillingDetail; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx index ba8d988fd30..dad0f849f76 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx @@ -5,11 +5,13 @@ import * as React from 'react'; import { invoiceFactory, paymentFactory } from 'src/factories/billing'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import BillingActivityPanel, { +import { + BillingActivityPanel, getCutoffFromDateRange, invoiceToActivityFeedItem, makeFilter, paymentToActivityFeedItem, + transactionDateOptions, } from './BillingActivityPanel'; vi.mock('../../../../utilities/getUserTimezone'); @@ -71,13 +73,13 @@ describe('BillingActivityPanel', () => { }); it('should filter by item type', async () => { - const { queryAllByTestId, queryByTestId, queryByText } = renderWithTheme( + const { getByLabelText, queryByTestId, queryByText } = renderWithTheme( ); // Test selecting "Invoices" await waitFor(() => { - const transactionTypeSelect = queryAllByTestId('select')?.[0]; + const transactionTypeSelect = getByLabelText('Transaction Types'); fireEvent.change(transactionTypeSelect, { target: { value: 'invoice' }, }); @@ -86,7 +88,7 @@ describe('BillingActivityPanel', () => { // Test selecting "Payments" await waitFor(() => { - const transactionTypeSelect = queryAllByTestId('select')?.[0]; + const transactionTypeSelect = getByLabelText('Transaction Types'); fireEvent.change(transactionTypeSelect, { target: { value: 'payment' }, }); @@ -95,12 +97,12 @@ describe('BillingActivityPanel', () => { }); it('should filter by transaction date', async () => { - const { queryAllByTestId, queryByTestId, queryByText } = renderWithTheme( + const { getByLabelText, queryByTestId, queryByText } = renderWithTheme( ); await waitFor(() => { - const transactionDateSelect = queryAllByTestId('select')?.[1]; + const transactionDateSelect = getByLabelText('Transaction Dates'); fireEvent.change(transactionDateSelect, { target: { value: '30 Days' }, }); @@ -110,11 +112,11 @@ describe('BillingActivityPanel', () => { }); it('should display transaction selection components with defaults', async () => { - const { getByText } = renderWithTheme(); - await waitFor(() => { - getByText('All Transaction Types'); - getByText('90 Days'); - }); + const { getByLabelText } = renderWithTheme(); + const transactionTypeSelect = getByLabelText('Transaction Types'); + expect(transactionTypeSelect).toHaveValue('All Transaction Types'); + const transactionDateSelect = getByLabelText('Transaction Dates'); + expect(transactionDateSelect).toHaveValue('6 Months'); }); it('should display "Account active since"', async () => { @@ -173,22 +175,29 @@ describe('paymentToActivityFeedItem', () => { throw new Error('Invalid test date'); } - expect(getCutoffFromDateRange('30 Days', testDateISO)).toBe( - testDate.minus({ days: 30 }).toISO() - ); - expect(getCutoffFromDateRange('60 Days', testDateISO)).toBe( - testDate.minus({ days: 60 }).toISO() - ); - expect(getCutoffFromDateRange('90 Days', testDateISO)).toBe( - testDate.minus({ days: 90 }).toISO() - ); - expect(getCutoffFromDateRange('6 Months', testDateISO)).toBe( - testDate.minus({ months: 6 }).toISO() - ); - expect(getCutoffFromDateRange('12 Months', testDateISO)).toBe( - testDate.minus({ months: 12 }).toISO() - ); - expect(getCutoffFromDateRange('All Time', testDateISO)).toBeNull(); + expect( + getCutoffFromDateRange(transactionDateOptions[0], testDateISO) + ).toBe(testDate.minus({ days: 30 }).toISO()); + + expect( + getCutoffFromDateRange(transactionDateOptions[1], testDateISO) + ).toBe(testDate.minus({ days: 60 }).toISO()); + + expect( + getCutoffFromDateRange(transactionDateOptions[2], testDateISO) + ).toBe(testDate.minus({ days: 90 }).toISO()); + + expect( + getCutoffFromDateRange(transactionDateOptions[3], testDateISO) + ).toBe(testDate.minus({ months: 6 }).toISO()); + + expect( + getCutoffFromDateRange(transactionDateOptions[4], testDateISO) + ).toBe(testDate.minus({ months: 12 }).toISO()); + + expect( + getCutoffFromDateRange(transactionDateOptions[5], testDateISO) + ).toBeNull(); }); }); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index 9a7235d8729..de4a54993dc 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -1,18 +1,14 @@ -import { - Invoice, - InvoiceItem, - Payment, - getInvoiceItems, -} from '@linode/api-v4/lib/account'; -import { Theme, styled } from '@mui/material/styles'; +import { getInvoiceItems } from '@linode/api-v4/lib/account'; +import Paper from '@mui/material/Paper'; +import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Link } from 'src/components/Link'; import OrderBy from 'src/components/OrderBy'; @@ -47,9 +43,16 @@ import { getAll } from 'src/utilities/getAll'; import { getTaxID } from '../../billingUtils'; +import type { Invoice, InvoiceItem, Payment } from '@linode/api-v4/lib/account'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ activeSince: { - marginRight: theme.spacing(1.25), + marginBottom: theme.spacing(1), + marginTop: theme.spacing(1), + [theme.breakpoints.down('sm')]: { + marginBottom: theme.spacing(2), + }, }, dateColumn: { width: '25%', @@ -57,16 +60,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ descriptionColumn: { width: '25%', }, - flexContainer: { - alignItems: 'center', - display: 'flex', - flexDirection: 'row', - }, headerContainer: { - alignItems: 'center', - backgroundColor: theme.color.white, display: 'flex', - flexDirection: 'row', justifyContent: 'space-between', [theme.breakpoints.down('sm')]: { alignItems: 'flex-start', @@ -75,9 +70,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, headerLeft: { display: 'flex', - flexGrow: 2, - marginLeft: 10, - paddingLeft: 20, + flexDirection: 'column', [theme.breakpoints.down('sm')]: { paddingLeft: 0, }, @@ -87,21 +80,12 @@ const useStyles = makeStyles()((theme: Theme) => ({ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', - padding: 5, + paddingRight: 20, [theme.breakpoints.down('sm')]: { alignItems: 'flex-start', flexDirection: 'column', - marginLeft: 15, - paddingLeft: 0, }, }, - headline: { - fontSize: '1rem', - lineHeight: '1.5rem', - marginBottom: 8, - marginLeft: 15, - marginTop: 8, - }, pdfDownloadColumn: { '& > .loading': { width: 115, @@ -111,9 +95,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ pdfError: { color: theme.color.red, }, - root: { - padding: '8px 0', - }, totalColumn: { [theme.breakpoints.up('md')]: { textAlign: 'right', @@ -121,6 +102,9 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, }, transactionDate: { + [theme.breakpoints.down('sm')]: { + marginTop: theme.spacing(1), + }, width: 130, }, transactionType: { @@ -137,21 +121,29 @@ interface ActivityFeedItem { type: 'invoice' | 'payment'; } -type TransactionTypes = 'all' | ActivityFeedItem['type']; -const transactionTypeOptions: Item[] = [ +interface TransactionTypeOptions { + label: string; + value: 'all' | 'invoice' | 'payment'; +} + +const transactionTypeOptions: TransactionTypeOptions[] = [ { label: 'Invoices', value: 'invoice' }, { label: 'Payments', value: 'payment' }, { label: 'All Transaction Types', value: 'all' }, ]; -type DateRange = - | '6 Months' - | '12 Months' - | '30 Days' - | '60 Days' - | '90 Days' - | 'All Time'; -const transactionDateOptions: Item[] = [ +interface TransactionDateOptions { + label: string; + value: + | '6 Months' + | '12 Months' + | '30 Days' + | '60 Days' + | '90 Days' + | 'All Time'; +} + +export const transactionDateOptions: TransactionDateOptions[] = [ { label: '30 Days', value: '30 Days' }, { label: '60 Days', value: '60 Days' }, { label: '90 Days', value: '90 Days' }, @@ -160,8 +152,6 @@ const transactionDateOptions: Item[] = [ { label: 'All Time', value: 'All Time' }, ]; -const defaultDateRange: DateRange = '6 Months'; - const AkamaiBillingInvoiceText = ( Charges in the final Akamai invoice should be considered the final source @@ -182,30 +172,26 @@ export interface Props { accountActiveSince?: string; } -export const BillingActivityPanel = (props: Props) => { +export const BillingActivityPanel = React.memo((props: Props) => { const { accountActiveSince } = props; - const { data: profile } = useProfile(); const { data: account } = useAccount(); const { data: regions } = useRegionsQuery(); - const isAkamaiCustomer = account?.billing_source === 'akamai'; - const { classes } = useStyles(); const flags = useFlags(); - const pdfErrors = useSet(); const pdfLoading = useSet(); const [ selectedTransactionType, setSelectedTransactionType, - ] = React.useState('all'); + ] = React.useState(transactionTypeOptions[2]); const [ selectedTransactionDate, setSelectedTransactionDate, - ] = React.useState(defaultDateRange); + ] = React.useState(transactionDateOptions[3]); const endDate = getCutoffFromDateRange(selectedTransactionDate); const filter = makeFilter(endDate); @@ -299,25 +285,6 @@ export const BillingActivityPanel = (props: Props) => { [payments, flags, account, pdfErrors] ); - // Handlers for thisOption.value === selectedTransactionType - ) || null - } - className={classes.transactionType} - hideLabel - inline - isClearable={false} - isSearchable={false} - label="Transaction Types" - onChange={handleTransactionTypeChange} - options={transactionTypeOptions} - small - /> - handleChange(selected.value)} + loading={isLoading} + onChange={(_, selected) => handleChange(selected.value)} options={options} placeholder="Select an IP Address..." value={options.find((option) => option.value === value.value)} diff --git a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx index 99463a060fb..7bef8e245ca 100644 --- a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx +++ b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx @@ -1,8 +1,9 @@ import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + import { Box } from '../Box'; -import Select from '../EnhancedSelect/Select'; import { PaginationControls } from '../PaginationControls/PaginationControls'; export const MIN_PAGE_SIZE = 25; @@ -80,16 +81,13 @@ export const PaginationFooter = (props: Props) => { )} {!fixedSize ? ( - { + const detailsOption = details?.option; + if ( + reason === 'selectOption' && + detailsOption?.label.includes(`Create "${detailsOption?.value}"`) + ) { + createTag(detailsOption.value); + } else { + setErrors([]); + onChange(newValue); + } + }} + renderTags={(tagValue, getTagProps) => { + return tagValue.map((option, index) => ( + } + key={index} + label={option.label} + onDelete={() => handleRemoveOption(option)} + /> + )); + }} + autoHighlight + clearOnBlur + disableCloseOnSelect={false} + disabled={disabled} errorText={error} - hideLabel={hideLabel} - isDisabled={disabled} - isMulti={true} + filterOptions={filterOptions} + isOptionEqualToValue={(option, value) => option.value === value.value} label={label || 'Add Tags'} - menuPlacement={menuPlacement} - name={name} - noMarginTop={noMarginTop} - noOptionsMessage={getEmptyMessage} - onChange={onChange} - onCreateOption={createTag} + multiple + noOptionsText={'No results.'} options={accountTagItems} - placeholder={'Type to choose or create a tag.'} + placeholder={value.length === 0 ? 'Type to choose or create a tag.' : ''} + textFieldProps={{ hideLabel, noMarginTop }} value={value} /> ); diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx index b02932582f1..9252654502c 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { imageFactory } from 'src/factories'; @@ -32,7 +33,7 @@ describe('EditImageDrawer', () => { }); it('should allow editing image details', async () => { - const { getByLabelText, getByText } = renderWithTheme( + const { getByLabelText, getByRole, getByText } = renderWithTheme( ); @@ -44,9 +45,12 @@ describe('EditImageDrawer', () => { target: { value: 'test description' }, }); - fireEvent.change(getByLabelText('Tags'), { - target: { value: 'new-tag' }, - }); + const tagsInput = getByRole('combobox'); + + userEvent.type(tagsInput, 'new-tag'); + + await waitFor(() => expect(tagsInput).toHaveValue('new-tag')); + fireEvent.click(getByText('Create "new-tag"')); fireEvent.click(getByText('Save Changes')); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx index c0952878979..76657711071 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx @@ -28,12 +28,17 @@ describe('Linode Create Details', () => { }); it('renders an "Add Tags" field', () => { - const { getByLabelText, getByText } = renderWithThemeAndHookFormContext({ + const { + getByLabelText, + getByPlaceholderText, + } = renderWithThemeAndHookFormContext({ component:
    , }); expect(getByLabelText('Add Tags')).toBeVisible(); - expect(getByText('Type to choose or create a tag.')).toBeVisible(); + expect( + getByPlaceholderText('Type to choose or create a tag.') + ).toBeVisible(); }); it('renders an placement group details if the flag is on', async () => { From 1d5dde8f0bc30b60adf5e76c717480ac2b3824dd Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Tue, 30 Jul 2024 09:10:15 -0400 Subject: [PATCH 39/58] test: [M3-8226] - Cypress integration tests for account "Maintenance" tab (#10694) * Add test for account maintenance * Update tests * Update tests * Update comments * Added changeset: Cypress integration tests for account Maintenance tab * Update test to make it stable * Add aria-label and update the test * Add truncate util in the test * Added changeset: Adding an ARIA label to these Account Maintenance tables * Add a TODO comment --- .../pr-10694-added-1722002374051.md | 5 + .../pr-10694-tests-1721327150369.md | 5 + .../core/account/account-maintenance.spec.ts | 129 ++++++++++++++++++ .../cypress/support/intercepts/account.ts | 23 ++++ .../Account/Maintenance/MaintenanceTable.tsx | 2 +- 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10694-added-1722002374051.md create mode 100644 packages/manager/.changeset/pr-10694-tests-1721327150369.md create mode 100644 packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts diff --git a/packages/manager/.changeset/pr-10694-added-1722002374051.md b/packages/manager/.changeset/pr-10694-added-1722002374051.md new file mode 100644 index 00000000000..9d666bb57a9 --- /dev/null +++ b/packages/manager/.changeset/pr-10694-added-1722002374051.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Adding an ARIA label to these Account Maintenance tables ([#10694](https://github.com/linode/manager/pull/10694)) diff --git a/packages/manager/.changeset/pr-10694-tests-1721327150369.md b/packages/manager/.changeset/pr-10694-tests-1721327150369.md new file mode 100644 index 00000000000..deff84262c9 --- /dev/null +++ b/packages/manager/.changeset/pr-10694-tests-1721327150369.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Cypress integration tests for account "Maintenance" tab ([#10694](https://github.com/linode/manager/pull/10694)) diff --git a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts new file mode 100644 index 00000000000..56292a96bd5 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts @@ -0,0 +1,129 @@ +import { mockGetMaintenance } from 'support/intercepts/account'; +import { accountMaintenanceFactory } from 'src/factories'; + +describe('Maintenance', () => { + /* + * - Confirm user can navigate to account maintenance page via user menu. + * - When there is no pending maintenance, "No pending maintenance." is shown in the table. + * - When there is no completed maintenance, "No completed maintenance." is shown in the table. + */ + it('table empty when no maintenance', () => { + mockGetMaintenance([], []).as('getMaintenance'); + + cy.visitWithLogin('/linodes'); + // user can navigate to account maintenance page via user menu. + cy.findByTestId('nav-group-profile').click(); + cy.findByTestId('menu-item-Maintenance') + .should('be.visible') + .should('be.enabled') + .click(); + cy.url().should('endWith', '/account/maintenance'); + + cy.wait('@getMaintenance'); + + // Confirm correct messages shown in the table when no maintenance. + cy.contains('No pending maintenance').should('be.visible'); + cy.contains('No completed maintenance').should('be.visible'); + }); + + /* + * - Uses mock API data to confirm maintenance details. + * - When there is pending maintenance, it is shown in the table with expected details. + * - When there is completed maintenance, it is shown in the table with expected details. + * - Confirm "Download CSV" button for pending maintenance visible and enabled. + * - Confirm "Download CSV" button for completed maintenance visible and enabled. + */ + it('confirm maintenance details in the tables', () => { + const pendingMaintenanceNumber = 2; + const completedMaintenanceNumber = 5; + const accountpendingMaintenance = accountMaintenanceFactory.buildList( + pendingMaintenanceNumber + ); + const accountcompletedMaintenance = accountMaintenanceFactory.buildList( + completedMaintenanceNumber, + { status: 'completed' } + ); + + mockGetMaintenance( + accountpendingMaintenance, + accountcompletedMaintenance + ).as('getMaintenance'); + + cy.visitWithLogin('/account/maintenance'); + + cy.wait('@getMaintenance'); + + cy.contains('No pending maintenance').should('not.exist'); + cy.contains('No completed maintenance').should('not.exist'); + + // Confirm Pending table is not empty and contains exact number of pending maintenances + cy.findByLabelText('List of pending maintenance') + .should('be.visible') + .find('tbody') + .within(() => { + accountpendingMaintenance.forEach(() => { + cy.get('tr') + .should('have.length', accountpendingMaintenance.length) + .each((row, index) => { + const pendingMaintenance = accountpendingMaintenance[index]; + cy.wrap(row).within(() => { + cy.contains(pendingMaintenance.entity.label).should( + 'be.visible' + ); + // Confirm that the first 90 characters of each reason string are rendered on screen + const truncatedReason = pendingMaintenance.reason.substring( + 0, + 90 + ); + cy.findByText(truncatedReason, { exact: false }).should( + 'be.visible' + ); + // Check the content of each element + cy.get('td').each(($cell, idx, $cells) => { + cy.wrap($cell).should('not.be.empty'); + }); + }); + }); + }); + }); + + // Confirm Completed table is not empty and contains exact number of completed maintenances + cy.findByLabelText('List of completed maintenance') + .should('be.visible') + .find('tbody') + .within(() => { + accountcompletedMaintenance.forEach(() => { + cy.get('tr') + .should('have.length', accountcompletedMaintenance.length) + .each((row, index) => { + const completedMaintenance = accountcompletedMaintenance[index]; + cy.wrap(row).within(() => { + cy.contains(completedMaintenance.entity.label).should( + 'be.visible' + ); + // Confirm that the first 90 characters of each reason string are rendered on screen + const truncatedReason = completedMaintenance.reason.substring( + 0, + 90 + ); + cy.findByText(truncatedReason, { exact: false }).should( + 'be.visible' + ); + // Check the content of each element + cy.get('td').each(($cell, idx, $cells) => { + cy.wrap($cell).should('not.be.empty'); + }); + }); + }); + }); + }); + + // Confirm download buttons work + cy.get('button') + .filter(':contains("Download CSV")') + .should('be.visible') + .should('be.enabled') + .click({ multiple: true }); + // TODO Need to add assertions to confirm CSV contains the expected contents on first trial (M3-8393) + }); +}); diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 4d51f05b620..d4bcb1afae4 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -11,6 +11,7 @@ import { makeResponse } from 'support/util/response'; import type { Account, AccountLogin, + AccountMaintenance, AccountSettings, Agreements, CancelAccount, @@ -646,3 +647,25 @@ export const mockGetAccountLogins = ( export const interceptGetNetworkUtilization = (): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('account/transfer')); }; + +/** + * Intercepts GET request to fetch the account maintenance and mocks the response. + * + * @param accountMaintenance - Account Maintenance objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetMaintenance = ( + accountPendingMaintenance: AccountMaintenance[], + accountCompletedMaintenance: AccountMaintenance[] +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`account/maintenance*`), (req) => { + const filters = getFilters(req); + + if (filters?.['status'] === 'completed') { + req.reply(paginateResponse(accountCompletedMaintenance)); + } else { + req.reply(paginateResponse(accountPendingMaintenance)); + } + }); +}; diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx index 4b15b35e410..91b33ffee0b 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx @@ -159,7 +159,7 @@ const MaintenanceTable = ({ type }: Props) => { /> - +
    Entity From 6cb5b7a0dbde8180dbd544f1beffa9a20e754dcb Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:17:43 -0400 Subject: [PATCH 40/58] upcoming: [M3-8350] - Obj Gen2 - MSW, Factories, Cypress Updates (#10720) Co-authored-by: Jaalah Ramos Co-authored-by: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-10720-upcoming-features-1722043886339.md | 5 ++ .../core/objectStorage/access-key.e2e.spec.ts | 4 +- .../objectStorage/object-storage.e2e.spec.ts | 26 ++++++-- .../support/intercepts/object-storage.ts | 12 +++- .../manager/src/factories/objectStorage.ts | 63 ++++++++++++++++++- packages/manager/src/mocks/serverHandlers.ts | 15 +++-- 6 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-10720-upcoming-features-1722043886339.md diff --git a/packages/manager/.changeset/pr-10720-upcoming-features-1722043886339.md b/packages/manager/.changeset/pr-10720-upcoming-features-1722043886339.md new file mode 100644 index 00000000000..602cf1d4aa3 --- /dev/null +++ b/packages/manager/.changeset/pr-10720-upcoming-features-1722043886339.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add new MSW, Factory, and E2E intercepts for OBJ Gen2 ([#10720](https://github.com/linode/manager/pull/10720)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 7c9b5c8a799..aa7cf9ee38b 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -2,7 +2,7 @@ * @file End-to-end tests for Object Storage Access Key operations. */ -import { createObjectStorageBucketFactory } from 'src/factories/objectStorage'; +import { createObjectStorageBucketFactoryLegacy } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; import { @@ -120,7 +120,7 @@ describe('object storage access key end-to-end tests', () => { it('can create an access key with limited access - e2e', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-east-1'; - const bucketRequest = createObjectStorageBucketFactory.build({ + const bucketRequest = createObjectStorageBucketFactoryLegacy.build({ label: bucketLabel, cluster: bucketCluster, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 3c6b63c6ec0..e0a15a45e8e 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -4,7 +4,11 @@ import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { accountFactory, objectStorageBucketFactory } from 'src/factories'; +import { + accountFactory, + createObjectStorageBucketFactoryLegacy, + createObjectStorageBucketFactoryGen1, +} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptGetNetworkUtilization, @@ -55,14 +59,20 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => { * * @param label - Bucket label. * @param cluster - Bucket cluster. + * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. * * @returns Promise that resolves to created Bucket. */ -const setUpBucket = (label: string, cluster: string) => { +const setUpBucket = ( + label: string, + cluster: string, + cors_enabled: boolean = true +) => { return createBucket( - objectStorageBucketFactory.build({ + createObjectStorageBucketFactoryLegacy.build({ label, cluster, + cors_enabled, // API accepts either `cluster` or `region`, but not both. Our factory // populates both fields, so we have to manually set `region` to `undefined` @@ -80,14 +90,20 @@ const setUpBucket = (label: string, cluster: string) => { * * @param label - Bucket label. * @param regionId - ID of Bucket region. + * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. * * @returns Promise that resolves to created Bucket. */ -const setUpBucketMulticluster = (label: string, regionId: string) => { +const setUpBucketMulticluster = ( + label: string, + regionId: string, + cors_enabled: boolean = true +) => { return createBucket( - objectStorageBucketFactory.build({ + createObjectStorageBucketFactoryGen1.build({ label, region: regionId, + cors_enabled, // API accepts either `cluster` or `region`, but not both. Our factory // populates both fields, so we have to manually set `cluster` to `undefined` diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 9d5527f011f..ae266ee9482 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -9,6 +9,7 @@ import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import type { + CreateObjectStorageBucketPayload, ObjectStorageBucket, ObjectStorageCluster, ObjectStorageKey, @@ -86,7 +87,7 @@ export const interceptCreateBucket = (): Cypress.Chainable => { * @returns Cypress chainable. */ export const mockCreateBucket = ( - bucket: ObjectStorageBucket + bucket: CreateObjectStorageBucketPayload ): Cypress.Chainable => { return cy.intercept( 'POST', @@ -478,3 +479,12 @@ export const interceptUpdateBucketAccess = ( apiMatcher(`object-storage/buckets/${cluster}/${label}/access`) ); }; + +/** + * Intercepts GET request to get object storage endpoints. + * + * @returns Cypress chainable. + */ +export const interceptGetObjectStorageEndpoints = (): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`object-storage/endpoints`)); +}; diff --git a/packages/manager/src/factories/objectStorage.ts b/packages/manager/src/factories/objectStorage.ts index a60b5b3b5a4..04332dbc20d 100644 --- a/packages/manager/src/factories/objectStorage.ts +++ b/packages/manager/src/factories/objectStorage.ts @@ -1,9 +1,10 @@ import Factory from 'src/factories/factoryProxy'; import type { - ObjectStorageBucket, CreateObjectStorageBucketPayload, + ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageEndpoint, ObjectStorageKey, ObjectStorageObject, } from '@linode/api-v4/lib/object-storage/types'; @@ -22,11 +23,46 @@ export const objectStorageBucketFactory = Factory.Sync.makeFactory( +// TODO: OBJ Gen2 - Once we eliminate legacy and Gen1 support, we can rename this to `objectStorageBucketFactory` and set it as the default. +export const objectStorageBucketFactoryGen2 = Factory.Sync.makeFactory( + { + cluster: 'us-iad-12', + created: '2019-12-12T00:00:00', + endpoint_type: 'E3', + hostname: Factory.each( + (i) => `obj-bucket-${i}.us-iad-12.linodeobjects.com` + ), + label: Factory.each((i) => `obj-bucket-${i}`), + objects: 103, + region: 'us-iad', + s3_endpoint: 'us-iad-12.linodeobjects.com', + size: 999999, + } +); + +export const createObjectStorageBucketFactoryLegacy = Factory.Sync.makeFactory( { acl: 'private', cluster: 'us-east-1', cors_enabled: true, + label: Factory.each((i) => `obj-bucket-${i}`), + } +); + +export const createObjectStorageBucketFactoryGen1 = Factory.Sync.makeFactory( + { + acl: 'private', + cors_enabled: true, + label: Factory.each((i) => `obj-bucket-${i}`), + region: 'us-east-1', + } +); + +// TODO: OBJ Gen2 - Once we eliminate legacy and Gen1 support, we can rename this to `createObjectStorageBucketFactory` and set it as the default. +export const createObjectStorageBucketFactoryGen2 = Factory.Sync.makeFactory( + { + acl: 'private', + cors_enabled: false, endpoint_type: 'E1', label: Factory.each((i) => `obj-bucket-${i}`), region: 'us-east', @@ -67,6 +103,21 @@ export const objectStorageKeyFactory = Factory.Sync.makeFactory( + { + access_key: '4LRW3T5FX5Z55LB3LYQ8', + bucket_access: null, + id: Factory.each((id) => id), + label: Factory.each((id) => `access-key-${id}`), + limited: false, + regions: [ + { endpoint_type: 'E1', id: 'us-east', s3_endpoint: 'us-east.com' }, + ], + secret_key: 'PYiAB02QRb53JeUge872CM6wEvBUyRhl3vHn31Ol', + } +); + export const makeObjectsPage = ( e: ObjectStorageObject[], override: { is_truncated: boolean; next_marker: null | string } @@ -77,3 +128,11 @@ export const makeObjectsPage = ( }); export const staticObjects = objectStorageObjectFactory.buildList(250); + +export const objectStorageEndpointsFactory = Factory.Sync.makeFactory( + { + endpoint_type: 'E2', + region: 'us-east', + s3_endpoint: 'us-east-1.linodeobjects.com', + } +); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 34dbb24517c..6baf82fdfaa 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -28,6 +28,7 @@ import { eventFactory, firewallDeviceFactory, firewallFactory, + objectStorageEndpointsFactory, imageFactory, incidentResponseFactory, invoiceFactory, @@ -60,7 +61,7 @@ import { nodeBalancerTypeFactory, nodePoolFactory, notificationFactory, - objectStorageBucketFactory, + objectStorageBucketFactoryGen2, objectStorageClusterFactory, objectStorageKeyFactory, objectStorageOverageTypeFactory, @@ -841,6 +842,10 @@ export const handlers = [ ]; return HttpResponse.json(makeResourcePage(objectStorageTypes)); }), + http.get('*/v4/object-storage/endpoints', ({}) => { + const endpoint = objectStorageEndpointsFactory.build(); + return HttpResponse.json(endpoint); + }), http.get('*object-storage/buckets/*/*/access', async () => { await sleep(2000); return HttpResponse.json({ @@ -916,11 +921,11 @@ export const handlers = [ const region = params.region as string; - objectStorageBucketFactory.resetSequenceNumber(); + objectStorageBucketFactoryGen2.resetSequenceNumber(); const page = Number(url.searchParams.get('page') || 1); const pageSize = Number(url.searchParams.get('page_size') || 25); - const buckets = objectStorageBucketFactory.buildList(1, { + const buckets = objectStorageBucketFactoryGen2.buildList(1, { cluster: `${region}-1`, hostname: `obj-bucket-1.${region}.linodeobjects.com`, label: `obj-bucket-1`, @@ -938,11 +943,11 @@ export const handlers = [ }); }), http.get('*/object-storage/buckets', () => { - const buckets = objectStorageBucketFactory.buildList(10); + const buckets = objectStorageBucketFactoryGen2.buildList(10); return HttpResponse.json(makeResourcePage(buckets)); }), http.post('*/object-storage/buckets', () => { - return HttpResponse.json(objectStorageBucketFactory.build()); + return HttpResponse.json(objectStorageBucketFactoryGen2.build()); }), http.get('*object-storage/clusters', () => { const jakartaCluster = objectStorageClusterFactory.build({ From caebedceabdb92b6a8d9e5d55b0b4a99bcbee2a1 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:38:21 -0400 Subject: [PATCH 41/58] =?UTF-8?q?upcoming:=20[M3-8357]=20=E2=80=93=20Updat?= =?UTF-8?q?e=20types=20and=20schemas=20for=20Block=20Storage=20Encryption?= =?UTF-8?q?=20(#10716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.changeset/pr-10716-changed-1721936880921.md | 5 +++++ packages/api-v4/src/account/types.ts | 1 + packages/api-v4/src/linodes/types.ts | 1 + packages/api-v4/src/regions/types.ts | 1 + packages/api-v4/src/volumes/types.ts | 11 ++++++++--- packages/manager/src/features/Volumes/utils.ts | 3 ++- .../.changeset/pr-10716-changed-1721936121033.md | 5 +++++ packages/validation/src/volumes.schema.ts | 1 + 8 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10716-changed-1721936880921.md create mode 100644 packages/validation/.changeset/pr-10716-changed-1721936121033.md diff --git a/packages/api-v4/.changeset/pr-10716-changed-1721936880921.md b/packages/api-v4/.changeset/pr-10716-changed-1721936880921.md new file mode 100644 index 00000000000..de9895d7c1e --- /dev/null +++ b/packages/api-v4/.changeset/pr-10716-changed-1721936880921.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Linode, Volume, and VolumeRequestPayload interfaces and VolumeStatus, AccountCapability, and Capabilities types to reflect Block Storage Encryption changes ([#10716](https://github.com/linode/manager/pull/10716)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 7921bdc4543..fafa09e4d3c 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -62,6 +62,7 @@ export type BillingSource = 'linode' | 'akamai'; export type AccountCapability = | 'Akamai Cloud Load Balancer' | 'Block Storage' + | 'Block Storage Encryption' | 'Cloud Firewall' | 'CloudPulse' | 'Disk Encryption' diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 9e7731b5018..9eabb9f5e75 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -19,6 +19,7 @@ export interface Linode { id: number; alerts: LinodeAlerts; backups: LinodeBackups; + bs_encryption_supported?: boolean; // @TODO BSE: Remove optionality once BSE is fully rolled out created: string; disk_encryption?: EncryptionStatus; // @TODO LDE: Remove optionality once LDE is fully rolled out region: string; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index ab102674177..cb159254e95 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -3,6 +3,7 @@ import { COUNTRY_CODE_TO_CONTINENT_CODE } from './constants'; export type Capabilities = | 'Bare Metal' | 'Block Storage' + | 'Block Storage Encryption' | 'Block Storage Migrations' | 'Cloud Firewall' | 'Disk Encryption' diff --git a/packages/api-v4/src/volumes/types.ts b/packages/api-v4/src/volumes/types.ts index 0dd27d1834d..cc1d1c6c4ea 100644 --- a/packages/api-v4/src/volumes/types.ts +++ b/packages/api-v4/src/volumes/types.ts @@ -1,3 +1,5 @@ +export type VolumeEncryption = 'enabled' | 'disabled'; + export interface Volume { id: number; label: string; @@ -11,16 +13,18 @@ export interface Volume { filesystem_path: string; tags: string[]; hardware_type: VolumeHardwareType; + encryption?: VolumeEncryption; // @TODO BSE: Remove optionality once BSE is fully rolled out } type VolumeHardwareType = 'hdd' | 'nvme'; export type VolumeStatus = - | 'creating' | 'active' - | 'resizing' + | 'creating' + | 'key_rotating' | 'migrating' - | 'offline'; + | 'offline' + | 'resizing'; export interface VolumeRequestPayload { label: string; @@ -29,6 +33,7 @@ export interface VolumeRequestPayload { linode_id?: number; config_id?: number; tags?: string[]; + encryption?: VolumeEncryption; } export interface AttachVolumePayload { diff --git a/packages/manager/src/features/Volumes/utils.ts b/packages/manager/src/features/Volumes/utils.ts index f8c33aa11cf..bdc310dac04 100644 --- a/packages/manager/src/features/Volumes/utils.ts +++ b/packages/manager/src/features/Volumes/utils.ts @@ -4,6 +4,7 @@ import type { Status } from 'src/components/StatusIcon/StatusIcon'; export const volumeStatusIconMap: Record = { active: 'active', creating: 'other', + key_rotating: 'other', migrating: 'other', offline: 'inactive', resizing: 'other', @@ -14,7 +15,7 @@ export const volumeStatusIconMap: Record = { * returns a volume's status with event info taken into account. * * We do this to provide users with a real-time feeling experience - * without having to refetch a volume's status agressivly. + * without having to refetch a volume's status aggressively. * * @param status The actual volume status from the volumes endpoint * @param event An in-progress event for the volume diff --git a/packages/validation/.changeset/pr-10716-changed-1721936121033.md b/packages/validation/.changeset/pr-10716-changed-1721936121033.md new file mode 100644 index 00000000000..71c1fc0bc5e --- /dev/null +++ b/packages/validation/.changeset/pr-10716-changed-1721936121033.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Include optional 'encryption' field in CreateVolumeSchema ([#10716](https://github.com/linode/manager/pull/10716)) diff --git a/packages/validation/src/volumes.schema.ts b/packages/validation/src/volumes.schema.ts index 33e886bf707..b98db4c01c9 100644 --- a/packages/validation/src/volumes.schema.ts +++ b/packages/validation/src/volumes.schema.ts @@ -32,6 +32,7 @@ export const CreateVolumeSchema = object({ .max(32, 'Label must be 32 characters or less.'), config_id: number().nullable().typeError('Config ID must be a number.'), tags: array().of(string()), + encryption: string().oneOf(['enabled', 'disabled']).notRequired(), }); export const CloneVolumeSchema = object({ From 8648c867b75058e9cdfde85e6a6155d30f88d9cf Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 31 Jul 2024 11:13:00 -0400 Subject: [PATCH 42/58] test: [M3-8403] - Avoid cleaning up Volumes whose status is not active in Cypress (#10728) * Avoid cleaning up Volumes whose status is not active * Added changeset: Avoid cleaning up Volumes that are not in active state --- packages/manager/.changeset/pr-10728-tests-1722364457535.md | 5 +++++ packages/manager/cypress/support/api/volumes.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10728-tests-1722364457535.md diff --git a/packages/manager/.changeset/pr-10728-tests-1722364457535.md b/packages/manager/.changeset/pr-10728-tests-1722364457535.md new file mode 100644 index 00000000000..9fdda225f5e --- /dev/null +++ b/packages/manager/.changeset/pr-10728-tests-1722364457535.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Avoid cleaning up Volumes that are not in "active" state ([#10728](https://github.com/linode/manager/pull/10728)) diff --git a/packages/manager/cypress/support/api/volumes.ts b/packages/manager/cypress/support/api/volumes.ts index b94b7bcaa95..a52e4784f13 100644 --- a/packages/manager/cypress/support/api/volumes.ts +++ b/packages/manager/cypress/support/api/volumes.ts @@ -18,7 +18,10 @@ export const deleteAllTestVolumes = async (): Promise => { ); const detachDeletePromises = volumes - .filter((volume: Volume) => isTestLabel(volume.label)) + .filter( + (volume: Volume) => + isTestLabel(volume.label) && volume.status === 'active' + ) .map(async (volume: Volume) => { if (volume.linode_id) { await detachVolume(volume.id); From d28beeb0df8a7fb302fb4541849148038b248f05 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Wed, 31 Jul 2024 11:26:45 -0400 Subject: [PATCH 43/58] upcoming: [M3-8401] - Add support for Two-step region select in Linode Create v2 (#10723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Add support for the two-step Gecko GA region select in the Linode Create v2 flow ## Changes 🔄 - Add `TwoStepRegion.tsx` for Linode Create v2 ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure your account has the `new-dc-testing`, `edge_testing` and `edge_compute` customer tags ### Verification steps (How to verify changes) - There should be no visual or functional differences between v1 and v2 Create flows ``` yarn test TwoStepRegion ``` --- ...r-10723-upcoming-features-1722286306937.md | 5 + .../Linodes/LinodeCreatev2/Region.tsx | 32 ++++- .../LinodeCreatev2/Summary/Summary.tsx | 4 +- .../LinodeCreatev2/TwoStepRegion.test.tsx | 57 ++++++++ .../Linodes/LinodeCreatev2/TwoStepRegion.tsx | 131 ++++++++++++++++++ 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10723-upcoming-features-1722286306937.md create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx diff --git a/packages/manager/.changeset/pr-10723-upcoming-features-1722286306937.md b/packages/manager/.changeset/pr-10723-upcoming-features-1722286306937.md new file mode 100644 index 00000000000..cf725930260 --- /dev/null +++ b/packages/manager/.changeset/pr-10723-upcoming-features-1722286306937.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add support for Two-step region select in Linode Create v2 ([#10723](https://github.com/linode/manager/pull/10723)) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index b2f7d1c4d15..e94221d1c6a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -9,7 +9,10 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { + isDistributedRegionSupported, + useIsGeckoEnabled, +} from 'src/components/RegionSelect/RegionSelect.utils'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; @@ -25,6 +28,7 @@ import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricin import { CROSS_DATA_CENTER_CLONE_WARNING } from '../LinodesCreate/constants'; import { getDisabledRegions } from './Region.utils'; +import { TwoStepRegion } from './TwoStepRegion'; import { getGeneratedLinodeLabel, useLinodeCreateQueryParams, @@ -78,6 +82,10 @@ export const Region = () => { const { data: regions } = useRegionsQuery(); + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const showTwoStepRegion = + isGeckoGAEnabled && isDistributedRegionSupported(params.type ?? 'OS'); + const onChange = async (region: RegionType) => { const values = getValues(); @@ -175,6 +183,28 @@ export const Region = () => { selectedImage: image, }); + if (showTwoStepRegion) { + return ( + + ); + } + return ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx index 73277af3bc7..01513ca2bd8 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx @@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { Divider } from 'src/components/Divider'; import { Paper } from 'src/components/Paper'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useImageQuery } from 'src/queries/images'; @@ -55,7 +56,8 @@ export const Summary = () => { ], }); - const { data: regions } = useRegionsQuery(); + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const { data: regions } = useRegionsQuery(isGeckoGAEnabled); const { data: type } = useTypeQuery(typeId ?? '', Boolean(typeId)); const { data: image } = useImageQuery(imageId ?? '', Boolean(imageId)); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx new file mode 100644 index 00000000000..d2be890a68f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx @@ -0,0 +1,57 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { TwoStepRegion } from './TwoStepRegion'; + +describe('TwoStepRegion', () => { + it('should render a heading', () => { + const { getAllByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const heading = getAllByText('Region')[0]; + + expect(heading).toBeVisible(); + expect(heading.tagName).toBe('H2'); + }); + + it('should render two tabs, Core and Distributed', () => { + const { getAllByRole } = renderWithThemeAndHookFormContext({ + component: , + }); + + const tabs = getAllByRole('tab'); + expect(tabs[0]).toHaveTextContent('Core'); + expect(tabs[1]).toHaveTextContent('Distributed'); + }); + + it('should render a Region Select for the Core tab', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + expect(select).toBeVisible(); + expect(select).toBeEnabled(); + }); + + it('should render a Geographical Area select with All pre-selected and a Region Select for the Distributed tab', async () => { + const { getAllByRole } = renderWithThemeAndHookFormContext({ + component: , + }); + + const tabs = getAllByRole('tab'); + await userEvent.click(tabs[1]); + + const inputs = getAllByRole('combobox'); + const geographicalAreaSelect = inputs[0]; + const regionSelect = inputs[1]; + + expect(geographicalAreaSelect).toHaveAttribute('value', 'All'); + expect(regionSelect).toHaveAttribute('placeholder', 'Select a Region'); + expect(regionSelect).toBeEnabled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx new file mode 100644 index 00000000000..2af6b9e6ac8 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; +import { Paper } from 'src/components/Paper'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { Region as RegionType } from '@linode/api-v4'; +import type { + RegionFilterValue, + RegionSelectProps, +} from 'src/components/RegionSelect/RegionSelect.types'; + +interface GeographicalAreaOption { + label: string; + value: RegionFilterValue; +} + +const GEOGRAPHICAL_AREA_OPTIONS: GeographicalAreaOption[] = [ + { + label: 'All', + value: 'distributed-ALL', + }, + { + label: 'North America', + value: 'distributed-NA', + }, + { + label: 'Africa', + value: 'distributed-AF', + }, + { + label: 'Asia', + value: 'distributed-AS', + }, + { + label: 'Europe', + value: 'distributed-EU', + }, + { + label: 'Oceania', + value: 'distributed-OC', + }, + { + label: 'South America', + value: 'distributed-SA', + }, +]; + +interface Props { + onChange: (region: RegionType) => void; +} + +type CombinedProps = Props & Omit, 'onChange'>; + +export const TwoStepRegion = (props: CombinedProps) => { + const { disabled, disabledRegions, errorText, onChange, value } = props; + + const [regionFilter, setRegionFilter] = React.useState( + 'distributed' + ); + + const { data: regions } = useRegionsQuery(true); + + return ( + + Region + + + Core + Distributed + + + + + sendLinodeCreateDocsEvent('Speedtest')} + /> + + onChange(region)} + regionFilter={regionFilter} + regions={regions ?? []} + showDistributedRegionIconHelperText={false} + value={value} + /> + + + { + if (selectedOption?.value) { + setRegionFilter(selectedOption.value); + } + }} + defaultValue={GEOGRAPHICAL_AREA_OPTIONS[0]} + disableClearable + label="Geographical Area" + options={GEOGRAPHICAL_AREA_OPTIONS} + /> + onChange(region)} + regionFilter={regionFilter} + regions={regions ?? []} + showDistributedRegionIconHelperText={false} + value={value} + /> + + + + + ); +}; From 838ab74a563f23061e9911c6662a588d14483bbb Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Wed, 31 Jul 2024 12:25:17 -0400 Subject: [PATCH 44/58] fix: [M3-8219] - Firewall rule sources not displaying correctly (#10724) * Fix logic error causing some addresses to not appear * Added changeset: Sources not displaying correctly in Firewall Rule drawer --- .../.changeset/pr-10724-fixed-1722289139869.md | 5 +++++ .../Rules/FirewallRuleDrawer.utils.ts | 8 +++++--- packages/manager/src/features/Firewalls/shared.ts | 14 ++++++++++---- 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-10724-fixed-1722289139869.md diff --git a/packages/manager/.changeset/pr-10724-fixed-1722289139869.md b/packages/manager/.changeset/pr-10724-fixed-1722289139869.md new file mode 100644 index 00000000000..4f9d94ba0d0 --- /dev/null +++ b/packages/manager/.changeset/pr-10724-fixed-1722289139869.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Sources not displaying correctly in Firewall Rule drawer ([#10724](https://github.com/linode/manager/pull/10724)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index da8ce2458f1..e57bd284795 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -6,12 +6,13 @@ import { parseCIDR, parse as parseIP } from 'ipaddr.js'; import { uniq } from 'ramda'; import { - FirewallOptionItem, allIPs, allIPv4, allIPv6, allowAllIPv4, allowAllIPv6, + allowNoneIPv4, + allowNoneIPv6, allowsAllIPs, predefinedFirewallFromRule, } from 'src/features/Firewalls/shared'; @@ -25,6 +26,7 @@ import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; +import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; import type { ExtendedIP } from 'src/utilities/ipUtils'; export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; @@ -172,11 +174,11 @@ export const getInitialAddressFormValue = ( return 'all'; } - if (allowAllIPv4(addresses)) { + if (allowAllIPv4(addresses) && allowNoneIPv6(addresses)) { return 'allIPv4'; } - if (allowAllIPv6(addresses)) { + if (allowAllIPv6(addresses) && allowNoneIPv4(addresses)) { return 'allIPv6'; } diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts index c53ccaaefe9..5f372cd4a61 100644 --- a/packages/manager/src/features/Firewalls/shared.ts +++ b/packages/manager/src/features/Firewalls/shared.ts @@ -1,11 +1,11 @@ -import { Grants, Profile } from '@linode/api-v4'; -import { +import { truncateAndJoinList } from 'src/utilities/stringUtils'; + +import type { Grants, Profile } from '@linode/api-v4'; +import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls/types'; -import { truncateAndJoinList } from 'src/utilities/stringUtils'; - export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; export interface FirewallOptionItem { @@ -198,6 +198,12 @@ export const allowAllIPv4 = (addresses: FirewallRuleType['addresses']) => export const allowAllIPv6 = (addresses: FirewallRuleType['addresses']) => addresses?.ipv6?.includes(allIPv6); +export const allowNoneIPv4 = (addresses: FirewallRuleType['addresses']) => + !addresses?.ipv4?.length; + +export const allowNoneIPv6 = (addresses: FirewallRuleType['addresses']) => + !addresses?.ipv6?.length; + export const generateRuleLabel = (ruleType?: FirewallPreset) => ruleType ? predefinedFirewalls[ruleType].label : 'Custom'; From e482544741259900863d9b37c15543549d8c7476 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Wed, 31 Jul 2024 21:57:41 +0530 Subject: [PATCH 45/58] fix: [M3-6516] - Liked Answer/Question Notifications from Community Questions Site (#10732) --- packages/manager/.changeset/pr-10732-fixed-1722411934254.md | 5 +++++ packages/manager/src/features/Events/Event.helpers.ts | 1 + .../manager/src/features/Events/eventMessageGenerator.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10732-fixed-1722411934254.md diff --git a/packages/manager/.changeset/pr-10732-fixed-1722411934254.md b/packages/manager/.changeset/pr-10732-fixed-1722411934254.md new file mode 100644 index 00000000000..d33a75c145c --- /dev/null +++ b/packages/manager/.changeset/pr-10732-fixed-1722411934254.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Liked answer/question notifications from Community Questions Site ([#10732](https://github.com/linode/manager/pull/10732)) diff --git a/packages/manager/src/features/Events/Event.helpers.ts b/packages/manager/src/features/Events/Event.helpers.ts index 8ca4f216904..f41f41792b5 100644 --- a/packages/manager/src/features/Events/Event.helpers.ts +++ b/packages/manager/src/features/Events/Event.helpers.ts @@ -17,6 +17,7 @@ const ACTIONS_WITHOUT_USERNAMES = [ 'entity_transfer_fail', 'entity_transfer_stale', 'lassie_reboot', + 'community_like', ]; export const formatEventWithUsername = ( diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index f96aa48932a..800ab1068e7 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -75,7 +75,7 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { community_like: { notification: (e) => e.entity?.label - ? `A post on "${e.entity.label}" has been liked.` + ? `${e.entity.label}` : `There has been a like on your community post.`, }, community_mention: { From 63060c62acaf99a07a74ba5c28d87acdd8d1199a Mon Sep 17 00:00:00 2001 From: hmorris3293 Date: Wed, 31 Jul 2024 13:02:26 -0400 Subject: [PATCH 46/58] [change]: Update Appwrite Marketplace logo (#10729) Update Appwrite Marketplace logo --------- Co-authored-by: Hana Xu --- .../.changeset/pr-10729-changed-1722367013984.md | 5 +++++ packages/manager/public/assets/appwrite.svg | 11 ++++++++--- packages/manager/public/assets/white/appwrite.svg | 11 ++++++++--- 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10729-changed-1722367013984.md diff --git a/packages/manager/.changeset/pr-10729-changed-1722367013984.md b/packages/manager/.changeset/pr-10729-changed-1722367013984.md new file mode 100644 index 00000000000..617f8c39a4b --- /dev/null +++ b/packages/manager/.changeset/pr-10729-changed-1722367013984.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update appwrite marketplace logo ([#10729](https://github.com/linode/manager/pull/10729)) diff --git a/packages/manager/public/assets/appwrite.svg b/packages/manager/public/assets/appwrite.svg index 46b297d934d..7f458121ead 100644 --- a/packages/manager/public/assets/appwrite.svg +++ b/packages/manager/public/assets/appwrite.svg @@ -1,4 +1,9 @@ - - - + + + diff --git a/packages/manager/public/assets/white/appwrite.svg b/packages/manager/public/assets/white/appwrite.svg index 1d226bac9ce..9f068317edd 100644 --- a/packages/manager/public/assets/white/appwrite.svg +++ b/packages/manager/public/assets/white/appwrite.svg @@ -1,4 +1,9 @@ - - - + + + From b8c8c9c3be38289bfceef1a5ba6cda1e966d0b4f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 31 Jul 2024 14:35:46 -0400 Subject: [PATCH 47/58] upcoming: [M3-8406, M3-8407] - Image Service Gen2 - Fix Image Capability and other tweaks (#10731) * update capability, add polling, and add new validation logic * add unit test * work in progress * ux feedback * Added changeset: Image Service Gen2 - Fix Image Capability and other tweaks * final touches * don't allow manage regions on a gen1 image --------- Co-authored-by: Banks Nussman --- packages/api-v4/src/images/types.ts | 2 +- ...r-10731-upcoming-features-1722444371850.md | 5 + .../core/images/manage-image-regions.spec.ts | 2 +- .../ImageSelect/ImageSelect.test.tsx | 2 +- .../components/ImageSelect/ImageSelect.tsx | 4 +- .../ImageSelectv2/ImageOptionv2.test.tsx | 4 +- .../ImageSelectv2/ImageOptionv2.tsx | 2 +- .../RegionSelect/RegionMultiSelect.tsx | 4 + .../RegionSelect/RegionSelect.types.ts | 1 + .../SelectRegionPanel.test.tsx | 2 +- .../ImageRegions/ImageRegionRow.tsx | 25 ++-- .../ManageImageRegionsForm.test.tsx | 49 ++++++++ .../ImageRegions/ManageImageRegionsForm.tsx | 112 ++++++++++++++---- .../Images/ImagesLanding/ImageRow.test.tsx | 2 +- .../Images/ImagesLanding/ImageRow.tsx | 2 +- .../Images/ImagesLanding/ImagesActionMenu.tsx | 2 +- .../Images/ImagesLanding/ImagesLanding.tsx | 7 ++ .../Linodes/LinodeCreatev2/Region.test.tsx | 2 +- .../LinodeCreatev2/Region.utils.test.ts | 2 +- .../Linodes/LinodeCreatev2/Region.utils.ts | 4 +- .../LinodeCreatev2/Tabs/Images.test.tsx | 2 +- .../Linodes/LinodeCreatev2/Tabs/Images.tsx | 4 +- .../TabbedContent/FromImageContent.tsx | 2 +- packages/manager/src/mocks/serverHandlers.ts | 4 +- packages/manager/src/queries/images.ts | 33 ++++-- 25 files changed, 214 insertions(+), 66 deletions(-) create mode 100644 packages/manager/.changeset/pr-10731-upcoming-features-1722444371850.md diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index cd3b34db673..cc1572d449b 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,7 +4,7 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -export type ImageCapabilities = 'cloud-init' | 'distributed-images'; +export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; type ImageType = 'manual' | 'automatic'; diff --git a/packages/manager/.changeset/pr-10731-upcoming-features-1722444371850.md b/packages/manager/.changeset/pr-10731-upcoming-features-1722444371850.md new file mode 100644 index 00000000000..65297a47012 --- /dev/null +++ b/packages/manager/.changeset/pr-10731-upcoming-features-1722444371850.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Image Service Gen2 - Fix Image Capability and other tweaks ([#10731](https://github.com/linode/manager/pull/10731)) 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 c09ccfe63fa..ea570d271f5 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 @@ -22,7 +22,7 @@ describe('Manage Image Regions', () => { const image = imageFactory.build({ size: 50, total_size: 100, - capabilities: ['distributed-images'], + capabilities: ['distributed-sites'], regions: [ { region: region1.id, status: 'available' }, { region: region2.id, status: 'available' }, diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx index 76b63511e1d..ff31a28981a 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -101,7 +101,7 @@ describe('ImageSelect', () => { it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { const images = [ imageFactory.build({ capabilities: [] }), - imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: ['distributed-sites'] }), imageFactory.build({ capabilities: [] }), ]; diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 89314fba547..5756a5f5092 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -122,7 +122,7 @@ export const imagesToGroupedItems = (images: Image[]) => { created, isCloudInitCompatible: capabilities?.includes('cloud-init'), isDistributedCompatible: capabilities?.includes( - 'distributed-images' + 'distributed-sites' ), // Add suffix 'deprecated' to the image at end of life. label: @@ -214,7 +214,7 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { const showDistributedCapabilityNotice = variant === 'private' && filteredImages.some((image) => - image.capabilities.includes('distributed-images') + image.capabilities.includes('distributed-sites') ); return ( diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index 4e9418abc20..757f966d63c 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -36,8 +36,8 @@ describe('ImageOptionv2', () => { getByLabelText('This image is compatible with cloud-init.') ).toBeVisible(); }); - it('renders a distributed icon if image has the "distributed-images" capability', () => { - const image = imageFactory.build({ capabilities: ['distributed-images'] }); + it('renders a distributed icon if image has the "distributed-sites" capability', () => { + const image = imageFactory.build({ capabilities: ['distributed-sites'] }); const { getByLabelText } = renderWithTheme( diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index 314cf125c8f..4fb9f21ddf9 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -35,7 +35,7 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { {image.label} - {image.capabilities.includes('distributed-images') && ( + {image.capabilities.includes('distributed-sites') && (
    diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index ca9a483438e..ea4fd248fd0 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -67,6 +67,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { selectedIds, sortRegionOptions, width, + disabledRegions: disabledRegionsFromProps, ...rest } = props; @@ -88,6 +89,9 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { const disabledRegions = regionOptions.reduce< Record >((acc, region) => { + if (disabledRegionsFromProps?.[region.id]) { + acc[region.id] = disabledRegionsFromProps[region.id]; + } if ( isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 0ace08a3194..0a394d1a833 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -76,6 +76,7 @@ export interface RegionMultiSelectProps selectedRegions: Region[]; }>; currentCapability: Capabilities | undefined; + disabledRegions?: Record; helperText?: string; isClearable?: boolean; label?: string; diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx index 48d8a822850..6ec3a8d17ba 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx @@ -133,7 +133,7 @@ describe('SelectRegionPanel on the Clone Flow', () => { expect(getByTestId('different-price-structure-notice')).toBeInTheDocument(); }); - it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + it('should disable distributed regions if the selected image does not have the `distributed-sites` capability', async () => { const image = imageFactory.build({ capabilities: [] }); const distributedRegion = regionFactory.build({ diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx index 2479578d43c..8b5dc9495d4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -6,6 +6,7 @@ import { Flag } from 'src/components/Flag'; import { IconButton } from 'src/components/IconButton'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -39,14 +40,24 @@ export const ImageRegionRow = (props: Props) => { - - - + + + + + + ); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx index 1a2478f355b..f03788bc7b8 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -102,4 +102,53 @@ describe('ManageImageRegionsDrawer', () => { // Verify the save button is enabled because changes have been made expect(saveButton).toBeEnabled(); }); + + it("should enforce that the image is 'available' in at least one region", async () => { + const region1 = regionFactory.build({ id: 'us-east' }); + const region2 = regionFactory.build({ id: 'us-west' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + { + region: 'us-west', + status: 'available', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + + ); + + // Verify both region labels have been loaded by the API + await findByText(region1.label); + await findByText(region2.label); + + // Both remove buttons should be enabled + expect(getByLabelText('Remove us-east')).toBeEnabled(); + expect(getByLabelText('Remove us-west')).toBeEnabled(); + + // Remove us-west + await userEvent.click(getByLabelText('Remove us-west')); + + // The "Remove us-east" button should become disabled because it is the last 'available' region + expect(getByLabelText('Remove us-east')).toBeDisabled(); + + // Verify tooltip shows + expect( + getByLabelText( + 'You cannot remove this region because at least one available region must be present.' + ) + ).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx index 03f9fdb71a3..d281e3645eb 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -1,5 +1,3 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { updateImageRegionsSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; import React from 'react'; import { useForm } from 'react-hook-form'; @@ -16,12 +14,23 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { ImageRegionRow } from './ImageRegionRow'; -import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; +import type { + Image, + ImageRegion, + Region, + UpdateImageRegionsPayload, +} from '@linode/api-v4'; +import type { Resolver } from 'react-hook-form'; +import type { DisableRegionOption } from 'src/components/RegionSelect/RegionSelect.types'; interface Props { image: Image | undefined; onClose: () => void; } +interface Context { + imageRegions: ImageRegion[] | undefined; + regions: Region[] | undefined; +} export const ManageImageRegionsForm = (props: Props) => { const { image, onClose } = props; @@ -38,9 +47,10 @@ export const ManageImageRegionsForm = (props: Props) => { setError, setValue, watch, - } = useForm({ + } = useForm({ + context: { imageRegions: image?.regions, regions }, defaultValues: { regions: imageRegionIds }, - resolver: yupResolver(updateImageRegionsSchema), + resolver, values: { regions: imageRegionIds }, }); @@ -69,6 +79,24 @@ export const ManageImageRegionsForm = (props: Props) => { const values = watch(); + const disabledRegions: Record = {}; + + const availableRegions = image?.regions.filter( + (regionItem) => regionItem.status === 'available' + ); + const availableRegionIds = availableRegions?.map((r) => r.region); + + const currentlySelectedAvailableRegions = values.regions.filter((r) => + availableRegionIds?.includes(r) + ); + + if (currentlySelectedAvailableRegions.length === 1) { + disabledRegions[currentlySelectedAvailableRegions[0]] = { + reason: + 'You cannot remove this region because at least one available region must be present.', + }; + } + return (
    {errors.root?.message && ( @@ -89,6 +117,7 @@ export const ManageImageRegionsForm = (props: Props) => { }) } currentCapability={undefined} + disabledRegions={disabledRegions} errorText={errors.regions?.message} label="Add Regions" placeholder="Select regions or type to search" @@ -113,25 +142,34 @@ export const ManageImageRegionsForm = (props: Props) => { No Regions Selected )} - {values.regions.map((regionId) => ( - - setValue( - 'regions', - values.regions.filter((r) => r !== regionId), - { shouldDirty: true, shouldValidate: true } - ) - } - status={ - image?.regions.find( - (regionItem) => regionItem.region === regionId - )?.status ?? 'unsaved' - } - disableRemoveButton={values.regions.length <= 1} - key={regionId} - region={regionId} - /> - ))} + {values.regions.map((regionId) => { + const status = + image?.regions.find( + (regionItem) => regionItem.region === regionId + )?.status ?? 'unsaved'; + + const isLastAvailableRegion = + status === 'available' && + image?.regions + .filter((r) => values.regions.includes(r.region)) + .filter((r) => r.status === 'available').length === 1; + + return ( + + setValue( + 'regions', + values.regions.filter((r) => r !== regionId), + { shouldDirty: true, shouldValidate: true } + ) + } + disableRemoveButton={isLastAvailableRegion} + key={regionId} + region={regionId} + status={status} + /> + ); + })} { ); }; + +const resolver: Resolver = async ( + values, + context +) => { + const availableRegionIds = context?.imageRegions + ?.filter((r) => r.status === 'available') + .map((r) => r.region); + + const isMissingAvailableRegion = !values.regions.some((regionId) => + availableRegionIds?.includes(regionId) + ); + + const availableRegionLabels = context?.regions + ?.filter((r) => availableRegionIds?.includes(r.id)) + .map((r) => r.label); + + if (isMissingAvailableRegion) { + const message = `At least one available region must be present (${availableRegionLabels?.join( + ', ' + )}).`; + return { errors: { regions: { message, type: 'validate' } }, values }; + } + + return { errors: {}, values }; +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index 2d09bb8cbbc..4a695e8055c 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -16,7 +16,7 @@ beforeAll(() => mockMatchMedia()); describe('Image Table Row', () => { const image = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-images'], + capabilities: ['cloud-init', 'distributed-sites'], regions: [ { region: 'us-east', status: 'available' }, { region: 'us-southeast', status: 'pending' }, diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 1c3c07dacc2..c37a632c905 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -16,7 +16,7 @@ import type { Event, Image, ImageCapabilities } from '@linode/api-v4'; const capabilityMap: Record = { 'cloud-init': 'Cloud-init', - 'distributed-images': 'Distributed', + 'distributed-sites': 'Distributed', }; interface Props { diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index ba6e25be963..bd53035af38 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -92,7 +92,7 @@ export const ImagesActionMenu = (props: Props) => { ? 'Image is not yet available for use.' : undefined, }, - ...(onManageRegions + ...(onManageRegions && image.regions && image.regions.length > 0 ? [ { disabled: isImageReadOnly || isDisabled, diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 3576eda66fc..8f02bf1927b 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -141,6 +141,13 @@ export const ImagesLanding = () => { ...manualImagesFilter, is_public: false, type: 'manual', + }, + { + // Refetch custom images every 30 seconds. + // We do this because we have no /v4/account/events we can use + // to update Image region statuses. We should make the API + // team and Images team implement events for this. + refetchInterval: 30_000, } ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx index 805b58657a4..7d411f74286 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx @@ -175,7 +175,7 @@ describe('Region', () => { ).toBeVisible(); }); - it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + it('should disable distributed regions if the selected image does not have the `distributed-sites` capability', async () => { const image = imageFactory.build({ capabilities: [] }); const distributedRegion = regionFactory.build({ diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts index 4fb2cd8fb7c..18059e73742 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts @@ -27,7 +27,7 @@ describe('getDisabledRegions', () => { const distributedRegion = regionFactory.build({ site_type: 'distributed' }); const coreRegion = regionFactory.build({ site_type: 'core' }); - const image = imageFactory.build({ capabilities: ['distributed-images'] }); + const image = imageFactory.build({ capabilities: ['distributed-sites'] }); const result = getDisabledRegions({ linodeCreateTab: 'Images', diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts index d611a82971f..54dc6d84412 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts @@ -18,11 +18,11 @@ export const getDisabledRegions = (options: DisabledRegionOptions) => { // On the images tab, we disabled distributed regions if: // - The user has selected an Image - // - The selected image does not have the `distributed-images` capability + // - The selected image does not have the `distributed-sites` capability if ( linodeCreateTab === 'Images' && selectedImage && - !selectedImage.capabilities.includes('distributed-images') + !selectedImage.capabilities.includes('distributed-sites') ) { const disabledRegions: Record = {}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx index b2565653537..39f06009405 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx @@ -36,7 +36,7 @@ describe('Images', () => { http.get('*/v4/images', () => { const images = [ imageFactory.build({ capabilities: [] }), - imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: ['distributed-sites'] }), imageFactory.build({ capabilities: [] }), ]; return HttpResponse.json(makeResourcePage(images)); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index f2539544a46..bed5a6a0faf 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -54,7 +54,7 @@ export const Images = () => { // @todo: delete this logic when all Images are "distributed compatible" if ( image && - !image.capabilities.includes('distributed-images') && + !image.capabilities.includes('distributed-sites') && selectedRegion?.site_type === 'distributed' ) { setValue('region', ''); @@ -77,7 +77,7 @@ export const Images = () => { // @todo: delete this logic when all Images are "distributed compatible" const showDistributedCapabilityNotice = images?.some((image) => - image.capabilities.includes('distributed-images') + image.capabilities.includes('distributed-sites') ); if (images?.length === 0) { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx index 1b9d9bda3a9..2432a268d04 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx @@ -73,7 +73,7 @@ export const FromImageContent = (props: CombinedProps) => { // Clear the region field if the currently selected region is a distributed site and the Image is only core compatible. if ( image && - !image.capabilities.includes('distributed-images') && + !image.capabilities.includes('distributed-sites') && selectedRegion?.site_type === 'distributed' ) { props.updateRegionID(''); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6baf82fdfaa..c04c10d8f4e 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -443,7 +443,7 @@ export const handlers = [ }), http.get<{ id: string }>('*/v4/images/:id', ({ params }) => { const distributedImage = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-images'], + capabilities: ['cloud-init', 'distributed-sites'], id: 'private/distributed-image', label: 'distributed-image', regions: [{ region: 'us-east', status: 'available' }], @@ -499,7 +499,7 @@ export const handlers = [ }); const publicImages = imageFactory.buildList(4, { is_public: true }); const distributedImage = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-images'], + capabilities: ['cloud-init', 'distributed-sites'], id: 'private/distributed-image', label: 'distributed-image', regions: [{ region: 'us-east', status: 'available' }], diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 66cc2eab3d4..0f30b8e2c05 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -1,9 +1,4 @@ import { - CreateImagePayload, - Image, - ImageUploadPayload, - UpdateImageRegionsPayload, - UploadImageResponse, createImage, deleteImage, getImage, @@ -12,20 +7,27 @@ import { updateImageRegions, uploadImage, } from '@linode/api-v4'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { profileQueries } from './profile/profile'; +import type { + APIError, + CreateImagePayload, + Filter, + Image, + ImageUploadPayload, + Params, + ResourcePage, + UpdateImageRegionsPayload, + UploadImageResponse, +} from '@linode/api-v4'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; + export const getAllImages = ( passedParams: Params = {}, passedFilter: Filter = {} @@ -49,10 +51,15 @@ export const imageQueries = createQueryKeys('images', { }), }); -export const useImagesQuery = (params: Params, filters: Filter) => +export const useImagesQuery = ( + params: Params, + filters: Filter, + options?: UseQueryOptions, APIError[]> +) => useQuery, APIError[]>({ ...imageQueries.paginated(params, filters), keepPreviousData: true, + ...options, }); export const useImageQuery = (imageId: string, enabled = true) => From de8be5cf772095d88d4ea34619642c9c632b0c9f Mon Sep 17 00:00:00 2001 From: zaenab-akamai Date: Thu, 1 Aug 2024 00:56:39 +0530 Subject: [PATCH 48/58] refactor: [M3-6912] - Replace `react-select` with MUI Autocomplete for Longview (#10721) --- .../pr-10721-tech-stories-1722318698239.md | 5 +++ .../LongviewLanding/LongviewClients.tsx | 33 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-10721-tech-stories-1722318698239.md diff --git a/packages/manager/.changeset/pr-10721-tech-stories-1722318698239.md b/packages/manager/.changeset/pr-10721-tech-stories-1722318698239.md new file mode 100644 index 00000000000..9b37e4b275e --- /dev/null +++ b/packages/manager/.changeset/pr-10721-tech-stories-1722318698239.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace react-select instances with Autocomplete in Longview ([#10721](https://github.com/linode/manager/pull/10721)) diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index 113752848f3..673aa8fb31c 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -9,9 +9,9 @@ import { connect } from 'react-redux'; import { Link, RouteComponentProps } from 'react-router-dom'; import { compose } from 'recompose'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { Typography } from 'src/components/Typography'; import withLongviewClients, { Props as LongviewProps, @@ -42,6 +42,11 @@ interface Props { newClientLoading: boolean; } +interface SortOption { + label: string; + value: SortKey; +} + export type LongviewClientsCombinedProps = Props & RouteComponentProps & LongviewProps & @@ -70,8 +75,7 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { const [selectedClientLabel, setClientLabel] = React.useState(''); /** Handlers/tracking variables for sorting by different client attributes */ - - const sortOptions: Item[] = [ + const sortOptions: SortOption[] = [ { label: 'Client Name', value: 'name', @@ -172,8 +176,8 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { setQuery(newQuery); }; - const handleSortKeyChange = (selected: Item) => { - setSortKey(selected.value as SortKey); + const handleSortKeyChange = (selected: SortOption) => { + setSortKey(selected.value); }; // If this value is defined they're not on the free plan @@ -208,16 +212,21 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { Sort by: -