From b1ea133c90648ab3ba5dd01f98b33724813cc18a Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 1 Aug 2024 12:45:45 -0500 Subject: [PATCH 01/43] refactor: [M3-6906] Replace Select with Autocomplete in: stackscripts and Images (#10715) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * Revert "Merge branch 'linode:develop' into develop" This reverts commit 1653a6b66eea3908ec04d786510c50c945499b1b, reversing changes made to 6c76508fd254a04ca602d7c7c909ea4610a9eec6. * Revert "Revert "Merge branch 'linode:develop' into develop"" This reverts commit 63d75bfeda31235d324df97c1a1c39dff268a24c. * Replace Select with Autocomplete in ImageSelect component * Code cleanup * Fix type error * Code cleanup * Code cleanup * Replace Select with Autocomplete * Code cleanup * Added changeset: Replace Select with Autocomplete in: stackscripts and Images * Update e2e stackscripts spec. --- .../pr-10715-tech-stories-1722374365063.md | 5 + .../stackscripts/create-stackscripts.spec.ts | 7 +- .../stackscripts/update-stackscripts.spec.ts | 7 +- .../src/features/Images/ImageSelect.test.tsx | 54 ++- .../src/features/Images/ImageSelect.tsx | 66 ++- .../LinodeSettings/ImageAndPassword.test.tsx | 54 +-- .../LinodeSettings/ImageAndPassword.tsx | 7 +- .../StackScriptCreate.test.tsx | 7 +- .../StackScriptCreate/StackScriptCreate.tsx | 435 +++++++++--------- .../StackScriptForm/StackScriptForm.tsx | 13 +- .../StackScriptForm/utils.test.ts} | 8 +- .../StackScripts/StackScriptForm/utils.ts | 14 + .../FieldTypes/UserDefinedMultiSelect.tsx | 48 +- packages/manager/src/utilities/imageToItem.ts | 14 - 14 files changed, 388 insertions(+), 351 deletions(-) create mode 100644 packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md rename packages/manager/src/{utilities/imageToItem.test.ts => features/StackScripts/StackScriptForm/utils.test.ts} (71%) create mode 100644 packages/manager/src/features/StackScripts/StackScriptForm/utils.ts delete mode 100644 packages/manager/src/utilities/imageToItem.ts diff --git a/packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md b/packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md new file mode 100644 index 00000000000..fe28df2fc77 --- /dev/null +++ b/packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace Select with Autocomplete in: stackscripts and Images ([#10715](https://github.com/linode/manager/pull/10715)) 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 9d73880a322..2c78e7586bd 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -62,10 +62,9 @@ const fillOutStackscriptForm = ( .type(description); } - cy.get('[data-qa-multi-select="Select an Image"]') - .should('be.visible') - .click() - .type(`${targetImage}{enter}`); + cy.findByText('Target Images').click().type(`${targetImage}`); + + cy.findByText(`${targetImage}`).should('be.visible').click(); // Insert a script with invalid UDF data. cy.get('[data-qa-textfield-label="Script"]') diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 7e52b8c6362..94a3ffa582d 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -55,10 +55,9 @@ const fillOutStackscriptForm = ( .type(description); } - cy.get('[data-qa-multi-select="Select an Image"]') - .should('be.visible') - .click() - .type(`${targetImage}{enter}`); + cy.findByText('Target Images').click().type(`${targetImage}`); + + cy.findByText(`${targetImage}`).should('be.visible').click(); // Insert a script with invalid UDF data. cy.get('[data-qa-textfield-label="Script"]') diff --git a/packages/manager/src/features/Images/ImageSelect.test.tsx b/packages/manager/src/features/Images/ImageSelect.test.tsx index 58f40b9a9a3..402da31392c 100644 --- a/packages/manager/src/features/Images/ImageSelect.test.tsx +++ b/packages/manager/src/features/Images/ImageSelect.test.tsx @@ -1,10 +1,9 @@ +import { fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; import { imageFactory } from 'src/factories/images'; import { renderWithTheme } from 'src/utilities/testHelpers'; -vi.mock('src/components/EnhancedSelect/Select'); - import { ImageSelect, getImagesOptions, groupNameMap } from './ImageSelect'; const images = imageFactory.buildList(10); @@ -62,7 +61,7 @@ describe('ImageSelect', () => { expect(deleted!.options).toHaveLength(1); }); - it('should properly format GroupType options as RS Item type', () => { + it('should properly format GroupType options', () => { const category = getImagesOptions([recommendedImage1])[0]; const option = category.options[0]; expect(option).toHaveProperty('label', recommendedImage1.label); @@ -76,17 +75,56 @@ describe('ImageSelect', () => { describe('ImageSelect component', () => { it('should render', () => { - const { getByText } = renderWithTheme(); - getByText(/image-1(?!\d)/i); + renderWithTheme(); + screen.getByRole('combobox'); }); it('should display an error', () => { const imageError = 'An error'; - const { getByText } = renderWithTheme( - + renderWithTheme(); + expect(screen.getByText(imageError)).toBeInTheDocument(); + }); + + it('should call onSelect with the selected value', () => { + const onSelectMock = vi.fn(); + renderWithTheme( + ); - getByText(imageError); + const inputElement = screen.getByRole('combobox'); + + fireEvent.change(inputElement, { target: { value: 'image-1' } }); + fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); + fireEvent.keyDown(inputElement, { key: 'Enter' }); + + expect(onSelectMock).toHaveBeenCalledWith({ + label: 'image-1', + value: 'private/1', + }); + }); + + it('should handle multiple selections', () => { + const onSelectMock = vi.fn(); + renderWithTheme( + + ); + + const inputElement = screen.getByRole('combobox'); + + // Select first option + fireEvent.change(inputElement, { target: { value: 'image-1' } }); + fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); + fireEvent.keyDown(inputElement, { key: 'Enter' }); + + // Select second option + fireEvent.change(inputElement, { target: { value: 'image-2' } }); + fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); + fireEvent.keyDown(inputElement, { key: 'Enter' }); + + expect(onSelectMock).toHaveBeenCalledWith([ + { label: 'image-1', value: 'private/1' }, + { label: 'image-2', value: 'private/2' }, + ]); }); }); }); diff --git a/packages/manager/src/features/Images/ImageSelect.tsx b/packages/manager/src/features/Images/ImageSelect.tsx index c3fe1bbfc1e..af442529330 100644 --- a/packages/manager/src/features/Images/ImageSelect.tsx +++ b/packages/manager/src/features/Images/ImageSelect.tsx @@ -1,14 +1,25 @@ -import { Image } from '@linode/api-v4/lib/images'; -import { Box } from 'src/components/Box'; +import Autocomplete from '@mui/material/Autocomplete'; import { clone, propOr } from 'ramda'; import * as React from 'react'; -import Select, { GroupType, Item } from 'src/components/EnhancedSelect/Select'; +import { Box } from 'src/components/Box'; +import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { useAllImagesQuery } from 'src/queries/images'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { groupImages } from 'src/utilities/images'; +import type { Image } from '@linode/api-v4/lib/images'; + +export interface SelectImageOption { + label: string; + value: string; +} + +export interface ImagesGroupType { + label: string; + options: SelectImageOption[]; +} interface BaseProps { anyAllOption?: boolean; disabled?: boolean; @@ -22,14 +33,14 @@ interface BaseProps { interface Props extends BaseProps { isMulti?: false; - onSelect: (selected: Item) => void; - value?: Item; + onSelect: (selected: SelectImageOption) => void; + value?: SelectImageOption; } interface MultiProps extends BaseProps { isMulti: true; - onSelect: (selected: Item[]) => void; - value?: Item[]; + onSelect: (selected: SelectImageOption[]) => void; + value?: SelectImageOption[]; } export const ImageSelect = (props: MultiProps | Props) => { @@ -75,6 +86,10 @@ export const ImageSelect = (props: MultiProps | Props) => { }); } + const formattedOptions = imageSelectOptions.flatMap( + (option) => option.options + ); + return ( { width: '415px', }} > - { + if (selected) { + this.handleSelectManyOf(selected); + } + }} label={field.label} - onChange={this.handleSelectManyOf} - options={manyOfOptions} + multiple + options={manyOfOptions ?? []} value={value} - // small={isOptional} /> ); } - - handleSelectManyOf = (selectedOptions: Item[]) => { - const { field, updateFormState } = this.props; - - const arrayToString = Array.prototype.map - .call(selectedOptions, (opt: Item) => opt.value) - .toString(); - - updateFormState(field.name, arrayToString); - }; - - state: State = { - manyof: this.props.field.manyof!.split(','), - }; } export default RenderGuard(UserDefinedMultiSelect); diff --git a/packages/manager/src/utilities/imageToItem.ts b/packages/manager/src/utilities/imageToItem.ts deleted file mode 100644 index 662fac6a925..00000000000 --- a/packages/manager/src/utilities/imageToItem.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Item } from 'src/components/EnhancedSelect/Select'; - -/** - * Takes a list of images (string[]) and converts - * them to Item[] for use in the EnhancedSelect component. - * - * Also trims 'linode/' off the name of public images. - */ -export const imageToItem = (images: string[]): Item[] => { - return images.map((image) => ({ - label: image.replace('linode/', ''), - value: image, - })); -}; From e0a21b7b02bced48c91ab3691c69a00bfef1d5ba Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 1 Aug 2024 15:32:53 -0400 Subject: [PATCH 02/43] upcoming: [M3-8411] - Data Visualization Tokens (#10739) Co-authored-by: Jaalah Ramos --- .../pr-10739-upcoming-features-1722528251084.md | 5 +++++ packages/manager/src/foundations/themes/dark.ts | 3 +++ packages/manager/src/foundations/themes/index.ts | 8 ++++++++ packages/manager/src/foundations/themes/light.ts | 4 ++++ 4 files changed, 20 insertions(+) create mode 100644 packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md diff --git a/packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md b/packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md new file mode 100644 index 00000000000..2dd918042bb --- /dev/null +++ b/packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Added data visualization tokens to theme files ([#10739](https://github.com/linode/manager/pull/10739)) diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index bd13f9ceb58..ba3458a5486 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -2,6 +2,7 @@ import { Action, Badge, Button, + Chart, Color, Dropdown, Interaction, @@ -57,6 +58,7 @@ export const customDarkModeOptions = { borderTypography: Color.Neutrals[80], divider: Color.Neutrals[80], }, + charts: { ...Chart }, color: { black: Color.Neutrals.White, blueDTwhite: Color.Neutrals.White, @@ -197,6 +199,7 @@ export const darkTheme: ThemeOptions = { bg: customDarkModeOptions.bg, borderColors: customDarkModeOptions.borderColors, breakpoints, + charts: customDarkModeOptions.charts, color: customDarkModeOptions.color, components: { MuiAppBar: { diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts index 113dd754683..ec0d31ce7f1 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/manager/src/foundations/themes/index.ts @@ -5,6 +5,8 @@ import { darkTheme } from 'src/foundations/themes/dark'; import { lightTheme } from 'src/foundations/themes/light'; import { deepMerge } from 'src/utilities/deepMerge'; +import type { Chart as ChartLight } from '@linode/design-language-system'; +import type { Chart as ChartDark } from '@linode/design-language-system/themes/dark'; import type { latoWeb } from 'src/foundations/fonts'; // Types & Interfaces import type { @@ -21,6 +23,10 @@ import type { export type ThemeName = 'dark' | 'light'; +type ChartLightTypes = typeof ChartLight; +type ChartDarkTypes = typeof ChartDark; +type ChartTypes = MergeTypes; + type Fonts = typeof latoWeb; type MergeTypes = Omit & @@ -66,6 +72,7 @@ declare module '@mui/material/styles/createTheme' { applyTableHeaderStyles?: any; bg: BgColors; borderColors: BorderColors; + charts: ChartTypes; color: Colors; font: Fonts; graphs: any; @@ -84,6 +91,7 @@ declare module '@mui/material/styles/createTheme' { applyTableHeaderStyles?: any; bg?: DarkModeBgColors | LightModeBgColors; borderColors?: DarkModeBorderColors | LightModeBorderColors; + charts: ChartTypes; color?: DarkModeColors | LightModeColors; font?: Fonts; graphs?: any; diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 548cfb375cb..e944f242230 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -2,6 +2,7 @@ import { Action, Border, Button, + Chart, Color, Dropdown, Interaction, @@ -16,6 +17,8 @@ import type { ThemeOptions } from '@mui/material/styles'; export const inputMaxWidth = 416; +export const charts = { ...Chart } as const; + export const bg = { app: Color.Neutrals[5], appBar: 'transparent', @@ -239,6 +242,7 @@ export const lightTheme: ThemeOptions = { bg, borderColors, breakpoints, + charts, color, components: { MuiAccordion: { From f9a165ae39529ecae1a9cfc504abe9a80535af77 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 2 Aug 2024 11:58:59 -0400 Subject: [PATCH 03/43] refactor: [M3-7438] - Query Key Factory for Object Storage (#10726) * attempt 1 * more query key factory progress * use factory for objects in bucket * clean up and organize * fix unit test * Added changeset: Query Key Factory for Object Storage * fix more unit tests * readd optimization @hana-linode * feedback @jaalah-akamai --------- Co-authored-by: Banks Nussman --- .../pr-10726-tech-stories-1722355169168.md | 5 + .../src/components/PrimaryNav/PrimaryNav.tsx | 77 +--- .../ObjectUploader/ObjectUploader.tsx | 14 +- .../features/Account/EnableObjectStorage.tsx | 17 +- .../AccessKeyLanding/AccessKeyDrawer.tsx | 44 +- .../AccessKeyLanding/AccessKeyLanding.tsx | 2 +- .../AccessKeyLanding/OMC_AccessKeyDrawer.tsx | 49 +- .../BucketDetail/BucketDetail.tsx | 56 +-- .../ObjectStorage/BucketDetail/BucketSSL.tsx | 2 +- .../BucketDetail/CreateFolderDrawer.tsx | 2 +- .../BucketLanding/BucketDetailsDrawer.tsx | 2 +- .../BucketLanding/BucketLanding.tsx | 64 +-- .../BucketLanding/BucketTableRow.tsx | 2 +- .../BucketLanding/ClusterSelect.tsx | 2 +- .../BucketLanding/CreateBucketDrawer.tsx | 38 +- .../BucketLanding/OMC_BucketLanding.tsx | 32 +- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 22 +- .../BucketLanding/OveragePricing.test.tsx | 6 +- .../BucketLanding/OveragePricing.tsx | 2 +- .../EnableObjectStorageModal.test.tsx | 4 +- .../EnableObjectStorageModal.tsx | 2 +- .../ObjectStorage/ObjectStorageLanding.tsx | 33 +- .../src/features/Search/SearchLanding.tsx | 51 +-- .../features/TopMenu/SearchBar/SearchBar.tsx | 48 +- .../src/queries/object-storage/queries.ts | 295 ++++++++++++ .../src/queries/object-storage/requests.ts | 115 +++++ .../src/queries/object-storage/utilities.ts | 65 +++ packages/manager/src/queries/objectStorage.ts | 433 ------------------ 28 files changed, 604 insertions(+), 880 deletions(-) create mode 100644 packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md create mode 100644 packages/manager/src/queries/object-storage/queries.ts create mode 100644 packages/manager/src/queries/object-storage/requests.ts create mode 100644 packages/manager/src/queries/object-storage/utilities.ts delete mode 100644 packages/manager/src/queries/objectStorage.ts diff --git a/packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md b/packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md new file mode 100644 index 00000000000..a6078d51f1b --- /dev/null +++ b/packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Object Storage ([#10726](https://github.com/linode/manager/pull/10726)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 99530ef1ddb..e3bd6335004 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -27,16 +27,10 @@ import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { usePrefetch } from 'src/hooks/usePreFetch'; -import { - useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useAccountSettings } from 'src/queries/account/settings'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; @@ -93,26 +87,13 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const flags = useFlags(); const location = useLocation(); - const [enableObjectPrefetch, setEnableObjectPrefetch] = React.useState(false); - const [ enableMarketplacePrefetch, setEnableMarketplacePrefetch, ] = React.useState(false); - const { _isManagedAccount, account } = useAccountManagement(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); - - const { data: regions } = useRegionsQuery(); - - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); + const { data: accountSettings } = useAccountSettings(); + const isManaged = accountSettings?.managed ?? false; const { data: oneClickApps, @@ -120,41 +101,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isLoading: oneClickAppsLoading, } = useMarketplaceAppsQuery(enableMarketplacePrefetch); - const { - data: clusters, - error: clustersError, - isLoading: clustersLoading, - } = useObjectStorageClusters( - enableObjectPrefetch && !isObjMultiClusterEnabled - ); - - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ - const { - data: buckets, - error: bucketsError, - isLoading: bucketsLoading, - } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : clusters, - enabled: enableObjectPrefetch, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); - - const allowObjPrefetch = - !buckets && - !clusters && - !clustersLoading && - !bucketsLoading && - !clustersError && - !bucketsError; - const allowMarketplacePrefetch = !oneClickApps && !oneClickAppsLoading && !oneClickAppsError; @@ -165,12 +111,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); - const prefetchObjectStorage = () => { - if (!enableObjectPrefetch) { - setEnableObjectPrefetch(true); - } - }; - const prefetchMarketplace = () => { if (!enableMarketplacePrefetch) { setEnableMarketplacePrefetch(true); @@ -182,7 +122,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { [ { display: 'Managed', - hide: !_isManagedAccount, + hide: !isManaged, href: '/managed', icon: , }, @@ -264,8 +204,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Object Storage', href: '/object-storage/buckets', icon: , - prefetchRequestCondition: allowObjPrefetch, - prefetchRequestFn: prefetchObjectStorage, }, { display: 'Longview', @@ -310,8 +248,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps [ isDatabasesEnabled, - _isManagedAccount, - allowObjPrefetch, + isManaged, allowMarketplacePrefetch, flags.databaseBeta, isPlacementGroupsEnabled, @@ -370,9 +307,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { return (
({ borderColor: theme.name === 'light' diff --git a/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx b/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx index 219be1c8f69..84256163ea0 100644 --- a/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx +++ b/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx @@ -1,13 +1,12 @@ import { getObjectURL } from '@linode/api-v4/lib/object-storage'; -import { AxiosProgressEvent } from 'axios'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { FileRejection, useDropzone } from 'react-dropzone'; -import { useQueryClient } from '@tanstack/react-query'; +import { useDropzone } from 'react-dropzone'; import { debounce } from 'throttle-debounce'; import { Button } from 'src/components/Button/Button'; -import { updateBucket } from 'src/queries/objectStorage'; +import { fetchBucketAndUpdateCache } from 'src/queries/object-storage/utilities'; import { sendObjectsQueuedForUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; import { readableBytes } from 'src/utilities/unitConversions'; @@ -17,7 +16,6 @@ import { MAX_FILE_SIZE_IN_BYTES, MAX_NUM_UPLOADS, MAX_PARALLEL_UPLOADS, - ObjectUploaderAction, curriedObjectUploaderReducer, defaultState, pathOrFileName, @@ -29,6 +27,10 @@ import { useStyles, } from './ObjectUploader.styles'; +import type { ObjectUploaderAction } from '../reducer'; +import type { AxiosProgressEvent } from 'axios'; +import type { FileRejection } from 'react-dropzone'; + interface Props { /** * The Object Storage bucket to upload to. @@ -115,7 +117,7 @@ export const ObjectUploader = React.memo((props: Props) => { // We debounce this request to prevent unnecessary fetches. const debouncedGetBucket = React.useRef( debounce(3000, false, () => - updateBucket(clusterId, bucketName, queryClient) + fetchBucketAndUpdateCache(clusterId, bucketName, queryClient) ) ).current; diff --git a/packages/manager/src/features/Account/EnableObjectStorage.tsx b/packages/manager/src/features/Account/EnableObjectStorage.tsx index 66f4b2ca586..88da8d4a771 100644 --- a/packages/manager/src/features/Account/EnableObjectStorage.tsx +++ b/packages/manager/src/features/Account/EnableObjectStorage.tsx @@ -1,6 +1,4 @@ -import { AccountSettings } from '@linode/api-v4/lib/account'; -import { cancelObjectStorage } from '@linode/api-v4/lib/object-storage'; -import { APIError } from '@linode/api-v4/lib/types'; +import { cancelObjectStorage } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; @@ -12,8 +10,11 @@ import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; import { updateAccountSettingsData } from 'src/queries/account/settings'; -import { queryKey } from 'src/queries/objectStorage'; +import { objectStorageQueries } from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; + +import type { APIError, AccountSettings } from '@linode/api-v4'; + interface Props { object_storage: AccountSettings['object_storage']; } @@ -88,9 +89,13 @@ export const EnableObjectStorage = (props: Props) => { cancelObjectStorage() .then(() => { updateAccountSettingsData({ object_storage: 'disabled' }, queryClient); + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.buckets.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); handleClose(); - queryClient.invalidateQueries([`${queryKey}-buckets`]); - queryClient.invalidateQueries([`${queryKey}-access-keys`]); }) .catch(handleError); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx index e34cd68cf09..8d3b958c817 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx @@ -9,15 +9,8 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccountSettings } from 'src/queries/account/settings'; -import { - useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { confirmObjectStorage } from '../utilities'; @@ -94,43 +87,12 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } = props; const { data: accountSettings } = useAccountSettings(); - const { account } = useAccountManagement(); - const flags = useFlags(); - const { data: regions } = useRegionsQuery(); - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); - - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - - const { - data: objectStorageClusters, - isLoading: areClustersLoading, - } = useObjectStorageClusters(!isObjMultiClusterEnabled); - - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ const { data: objectStorageBucketsResponse, error: bucketsError, isLoading: areBucketsLoading, - } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : objectStorageClusters, - enabled: true, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); + } = useObjectStorageBuckets(); const buckets = objectStorageBucketsResponse?.buckets || []; @@ -202,7 +164,7 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { title={title} wide={createMode && hasBuckets} > - {areBucketsLoading || areClustersLoading ? ( + {areBucketsLoading ? ( ) : ( { open, } = props; - const { account } = useAccountManagement(); - const flags = useFlags(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); - const { data: regions } = useRegionsQuery(); const regionsLookup = regions && getRegionsByRegionId(regions); - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - const { data: objectStorageBuckets, error: bucketsError, isLoading: areBucketsLoading, - } = useObjectStorageBuckets({ - isObjMultiClusterEnabled, - regions: regionsSupportingObjectStorage, - }); + } = useObjectStorageBuckets(); const { data: accountSettings } = useAccountSettings(); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx index 357b70a2ddc..96c41fcb9da 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx @@ -21,14 +21,14 @@ import { OBJECT_STORAGE_DELIMITER } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account/account'; import { - prefixToQueryKey, - queryKey, - updateBucket, - useObjectBucketDetailsInfiniteQuery, + objectStorageQueries, + useObjectBucketObjectsInfiniteQuery, useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +} from 'src/queries/object-storage/queries'; +import { + fetchBucketAndUpdateCache, + prefixToQueryKey, +} from 'src/queries/object-storage/utilities'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; @@ -58,7 +58,7 @@ import type { ObjectStorageClusterID, ObjectStorageObject, ObjectStorageObjectList, -} from '@linode/api-v4/lib/object-storage'; +} from '@linode/api-v4'; interface MatchParams { bucketName: string; @@ -90,18 +90,7 @@ export const BucketDetail = () => { account?.capabilities ?? [] ); - const { data: regions } = useRegionsQuery(); - - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - - const { data: clusters } = useObjectStorageClusters(); - const { data: buckets } = useObjectStorageBuckets({ - clusters, - isObjMultiClusterEnabled, - regions: regionsSupportingObjectStorage, - }); + const { data: buckets } = useObjectStorageBuckets(); const bucket = buckets?.buckets.find((bucket) => { if (isObjMultiClusterEnabled) { @@ -118,7 +107,7 @@ export const BucketDetail = () => { isFetching, isFetchingNextPage, isLoading, - } = useObjectBucketDetailsInfiniteQuery(clusterId, bucketName, prefix); + } = useObjectBucketObjectsInfiniteQuery(clusterId, bucketName, prefix); const [ isCreateFolderDrawerOpen, setIsCreateFolderDrawerOpen, @@ -176,9 +165,9 @@ export const BucketDetail = () => { // If a user deletes many objects in a short amount of time, // we don't want to fetch for every delete action. Debounce // the updateBucket call by 3 seconds. - const debouncedUpdateBucket = debounce(3000, false, () => - updateBucket(clusterId, bucketName, queryClient) - ); + const debouncedUpdateBucket = debounce(3000, false, () => { + fetchBucketAndUpdateCache(clusterId, bucketName, queryClient); + }); const deleteObject = async () => { if (!objectToDelete) { @@ -243,7 +232,11 @@ export const BucketDetail = () => { pageParams: string[]; pages: ObjectStorageObjectList[]; }>( - [queryKey, clusterId, bucketName, 'objects', ...prefixToQueryKey(prefix)], + [ + ...objectStorageQueries.bucket(clusterId, bucketName)._ctx.objects + .queryKey, + ...prefixToQueryKey(prefix), + ], (data) => ({ pageParams: data?.pageParams || [], pages, @@ -340,12 +333,13 @@ export const BucketDetail = () => { if (page.data.find((object) => object.name === folder.name)) { // If a folder already exists in the store, invalidate that store for that specific // prefix. Due to how invalidateQueries works, all subdirectories also get invalidated. - queryClient.invalidateQueries([ - queryKey, - clusterId, - bucketName, - ...`${prefix}${objectName}`.split('/'), - ]); + queryClient.invalidateQueries({ + queryKey: [ + ...objectStorageQueries.bucket(clusterId, bucketName)._ctx.objects + .queryKey, + ...`${prefix}${objectName}`.split('/'), + ], + }); return; } } diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx index fe4e5a70965..ce93bdbacfd 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx @@ -19,7 +19,7 @@ import { useBucketSSLDeleteMutation, useBucketSSLMutation, useBucketSSLQuery, -} from 'src/queries/objectStorage'; +} from 'src/queries/object-storage/queries'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx index 9a05e6ecf68..99bbdb38be7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx @@ -4,7 +4,7 @@ import React, { useEffect } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { TextField } from 'src/components/TextField'; -import { useCreateObjectUrlMutation } from 'src/queries/objectStorage'; +import { useCreateObjectUrlMutation } from 'src/queries/object-storage/queries'; interface Props { bucketName: string; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index fc3002b0817..c9d1968d1ed 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -12,7 +12,7 @@ import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { useObjectStorageClusters } from 'src/queries/objectStorage'; +import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index 2822e4e0fe1..2abf39ac90d 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -1,9 +1,3 @@ -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'; @@ -17,18 +11,13 @@ import OrderBy from 'src/components/OrderBy'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { useOpenClose } from 'src/hooks/useOpenClose'; import { - BucketError, useDeleteBucketMutation, useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; +} from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendDeleteBucketEvent, sendDeleteBucketFailedEvent, @@ -40,6 +29,14 @@ import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; +import type { + APIError, + ObjectStorageBucket, + ObjectStorageCluster, +} from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { BucketError } from 'src/queries/object-storage/requests'; + const useStyles = makeStyles()((theme: Theme) => ({ copy: { marginTop: theme.spacing(), @@ -51,44 +48,11 @@ export const BucketLanding = () => { const isRestrictedUser = profile?.restricted; - const { account } = useAccountManagement(); - const flags = useFlags(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); - - const { data: regions } = useRegionsQuery(); - - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - - const { - data: objectStorageClusters, - error: clustersErrors, - isLoading: areClustersLoading, - } = useObjectStorageClusters(!isObjMultiClusterEnabled); - - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ const { data: objectStorageBucketsResponse, error: bucketsErrors, isLoading: areBucketsLoading, - } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : objectStorageClusters, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); + } = useObjectStorageBuckets(); const { mutateAsync: deleteBucket } = useDeleteBucketMutation(); @@ -164,7 +128,7 @@ export const BucketLanding = () => { return ; } - if (clustersErrors || bucketsErrors) { + if (bucketsErrors) { return ( { ); } - if ( - areClustersLoading || - areBucketsLoading || - objectStorageBucketsResponse === undefined - ) { + if (areBucketsLoading || objectStorageBucketsResponse === undefined) { return ; } diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index 4faaf4dcb3f..322c638ef8a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -8,7 +8,7 @@ import { TableCell } from 'src/components/TableCell'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { useObjectStorageClusters } from 'src/queries/objectStorage'; +import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getRegionsByRegionId } from 'src/utilities/regions'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 11817c77cfb..5619cd67500 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -2,7 +2,7 @@ import { Region } from '@linode/api-v4/lib/regions'; import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useObjectStorageClusters } from 'src/queries/objectStorage'; +import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import { useRegionsQuery } from 'src/queries/regions/regions'; interface Props { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 8610ba9d76f..d0af27ae9d9 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -9,8 +9,6 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { reportAgreementSigningError, useAccountAgreements, @@ -21,12 +19,10 @@ import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, - useObjectStorageClusters, useObjectStorageTypesQuery, -} from 'src/queries/objectStorage'; +} from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; @@ -47,39 +43,9 @@ export const CreateBucketDrawer = (props: Props) => { const { isOpen, onClose } = props; const isRestrictedUser = profile?.restricted; - const { account } = useAccountManagement(); - const flags = useFlags(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); - const { data: regions } = useRegionsQuery(); - const { data: clusters } = useObjectStorageClusters( - !isObjMultiClusterEnabled - ); - - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ - - const { data: bucketsData } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : clusters, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); + const { data: bucketsData } = useObjectStorageBuckets(); const { data: objTypes, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index bad670aafa8..63a7c9803aa 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -1,7 +1,3 @@ -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'; @@ -15,17 +11,13 @@ import OrderBy from 'src/components/OrderBy'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -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'; +} from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendDeleteBucketEvent, sendDeleteBucketFailedEvent, @@ -38,6 +30,10 @@ import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; +import type { APIError, ObjectStorageBucket, Region } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { BucketError } from 'src/queries/object-storage/requests'; + const useStyles = makeStyles()((theme: Theme) => ({ copy: { marginTop: theme.spacing(), @@ -49,15 +45,6 @@ export const OMC_BucketLanding = () => { const isRestrictedUser = profile?.restricted; - const { account } = useAccountManagement(); - const flags = useFlags(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); - const { data: regions, error: regionErrors, @@ -66,18 +53,11 @@ export const OMC_BucketLanding = () => { const regionsLookup = regions && getRegionsByRegionId(regions); - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - const { data: objectStorageBucketsResponse, error: bucketsErrors, isLoading: areBucketsLoading, - } = useObjectStorageBuckets({ - isObjMultiClusterEnabled, - regions: regionsSupportingObjectStorage, - }); + } = useObjectStorageBuckets(); const { mutateAsync: deleteBucket } = useDeleteBucketWithRegionMutation(); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 8343222ccbb..5dc0e2dbde2 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -7,8 +7,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { reportAgreementSigningError, useAccountAgreements, @@ -20,10 +18,9 @@ import { useCreateBucketMutation, useObjectStorageBuckets, useObjectStorageTypesQuery, -} from 'src/queries/objectStorage'; +} from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; @@ -44,25 +41,10 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const { data: profile } = useProfile(); const { isOpen, onClose } = props; const isRestrictedUser = profile?.restricted; - const { account } = useAccountManagement(); - const flags = useFlags(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); const { data: regions } = useRegionsQuery(); - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - - const { data: bucketsData } = useObjectStorageBuckets({ - isObjMultiClusterEnabled, - regions: regionsSupportingObjectStorage, - }); + const { data: bucketsData } = useObjectStorageBuckets(); const { data: objTypes, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx index c7d0c2eba23..be1cc4ff215 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx @@ -31,8 +31,8 @@ const queryMocks = vi.hoisted(() => ({ useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/objectStorage', async () => { - const actual = await vi.importActual('src/queries/objectStorage'); +vi.mock('src/queries/object-storage/queries', async () => { + const actual = await vi.importActual('src/queries/object-storage/queries'); return { ...actual, useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, @@ -40,7 +40,7 @@ vi.mock('src/queries/objectStorage', async () => { }); vi.mock('src/queries/networkTransfer', async () => { - const actual = await vi.importActual('src/queries/networkTransfer'); + const actual = await vi.importActual('src/queries/object-storage/queries'); return { ...actual, useNetworkTransferPricesQuery: queryMocks.useNetworkTransferPricesQuery, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index fa139dac2a0..d8a628b4748 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -6,7 +6,7 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; -import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { useObjectStorageTypesQuery } from 'src/queries/object-storage/queries'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx index 834cd38f4ec..4169b35b960 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx @@ -31,8 +31,8 @@ const queryMocks = vi.hoisted(() => ({ useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/objectStorage', async () => { - const actual = await vi.importActual('src/queries/objectStorage'); +vi.mock('src/queries/object-storage/queries', async () => { + const actual = await vi.importActual('src/queries/object-storage/queries'); return { ...actual, useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx index 1226e9d6ef3..10299d74625 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx @@ -6,7 +6,7 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { useObjectStorageTypesQuery } from 'src/queries/object-storage/queries'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT, UNKNOWN_PRICE, diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 7512ee0dc41..17e2590f239 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -18,18 +18,15 @@ import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useOpenClose } from 'src/hooks/useOpenClose'; -import { - useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; -import { MODE } from './AccessKeyLanding/types'; import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; import { OMC_CreateBucketDrawer } from './BucketLanding/OMC_CreateBucketDrawer'; +import type { MODE } from './AccessKeyLanding/types'; + const BucketLanding = React.lazy(() => import('./BucketLanding/BucketLanding').then((module) => ({ default: module.BucketLanding, @@ -63,33 +60,11 @@ export const ObjectStorageLanding = () => { account?.capabilities ?? [] ); - const { data: objectStorageClusters } = useObjectStorageClusters( - !isObjMultiClusterEnabled - ); - - const { data: regionsData } = useRegionsQuery(); - - const regionsSupportingObjectStorage = regionsData?.filter((region) => - region.capabilities.includes('Object Storage') - ); - - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ const { data: objectStorageBucketsResponse, error: bucketsErrors, isLoading: areBucketsLoading, - } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : objectStorageClusters, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); + } = useObjectStorageBuckets(); const userHasNoBucketCreated = objectStorageBucketsResponse?.buckets.length === 0; diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 3fb825f23ac..9e936ec5d81 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -1,7 +1,6 @@ import Grid from '@mui/material/Unstable_Grid2'; import { equals } from 'ramda'; import * as React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; import { compose } from 'recompose'; import { debounce } from 'throttle-debounce'; @@ -9,23 +8,17 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useAPISearch } from 'src/features/Search/useAPISearch'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; import { useAllDomainsQuery } from 'src/queries/domains'; import { useAllImagesQuery } from 'src/queries/images'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; -import { - useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; 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 { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; @@ -43,7 +36,10 @@ import { StyledStack, } from './SearchLanding.styles'; import { emptyResults } from './utils'; -import withStoreSearch, { SearchProps } from './withStoreSearch'; +import withStoreSearch from './withStoreSearch'; + +import type { SearchProps } from './withStoreSearch'; +import type { RouteComponentProps } from 'react-router-dom'; const displayMap = { buckets: 'Buckets', @@ -71,33 +67,13 @@ export const SearchLanding = (props: SearchLandingProps) => { const { entities, search, searchResultsByEntity } = props; const { data: regions } = useRegionsQuery(); - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') - ); - const isLargeAccount = useIsLargeAccount(); - const { account } = useAccountManagement(); - const flags = useFlags(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); // We only want to fetch all entities if we know they // are not a large account. We do this rather than `!isLargeAccount` // because we don't want to fetch all entities if isLargeAccount is loading (undefined). const shouldFetchAllEntities = isLargeAccount === false; - const { - data: objectStorageClusters, - error: objectStorageClustersError, - isLoading: areClustersLoading, - } = useObjectStorageClusters( - shouldFetchAllEntities && !isObjMultiClusterEnabled - ); - /* @TODO OBJ Multicluster:'region' will become required, and the 'cluster' field will be deprecated once the feature is fully rolled out in production. @@ -108,14 +84,7 @@ export const SearchLanding = (props: SearchLandingProps) => { data: objectStorageBuckets, error: bucketsError, isLoading: areBucketsLoading, - } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : objectStorageClusters, - enabled: shouldFetchAllEntities, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); + } = useObjectStorageBuckets(shouldFetchAllEntities); const { data: domains, @@ -243,11 +212,8 @@ export const SearchLanding = (props: SearchLandingProps) => { [imagesError, 'Images'], [nodebalancersError, 'NodeBalancers'], [kubernetesClustersError, 'Kubernetes'], - [objectStorageClustersError, 'Object Storage'], [ - objectStorageBuckets && - objectStorageBuckets.errors.length > 0 && - !objectStorageClustersError, + objectStorageBuckets && objectStorageBuckets.errors.length > 0, `Object Storage in ${objectStorageBuckets?.errors .map((e) => e.cluster.region) .join(', ')}`, @@ -274,8 +240,7 @@ export const SearchLanding = (props: SearchLandingProps) => { const loading = isLargeAccount ? apiSearchLoading : areLinodesLoading || - (areBucketsLoading && !isObjMultiClusterEnabled) || - (areClustersLoading && !isObjMultiClusterEnabled) || + areBucketsLoading || areDomainsLoading || areVolumesLoading || areKubernetesClustersLoading || diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 150f69fb327..23e5d660889 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -6,29 +6,21 @@ import { useHistory } from 'react-router-dom'; import { components } from 'react-select'; import { debounce } from 'throttle-debounce'; -import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; +import EnhancedSelect from 'src/components/EnhancedSelect/Select'; import { getImageLabelForLinode } from 'src/features/Images/utils'; import { useAPISearch } from 'src/features/Search/useAPISearch'; -import withStoreSearch, { - SearchProps, -} from 'src/features/Search/withStoreSearch'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useFlags } from 'src/hooks/useFlags'; +import withStoreSearch from 'src/features/Search/withStoreSearch'; import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; import { useAllDomainsQuery } from 'src/queries/domains'; import { useAllImagesQuery } from 'src/queries/images'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; -import { - useObjectStorageBuckets, - useObjectStorageClusters, -} from 'src/queries/objectStorage'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; 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 { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; @@ -41,6 +33,9 @@ import { } from './SearchBar.styles'; import { SearchSuggestion } from './SearchSuggestion'; +import type { Item } from 'src/components/EnhancedSelect/Select'; +import type { SearchProps } from 'src/features/Search/withStoreSearch'; + const Control = (props: any) => ; /* The final option in the list will be the "go to search results page" link. @@ -90,45 +85,18 @@ const SearchBar = (props: SearchProps) => { const [apiSearchLoading, setAPILoading] = React.useState(false); const history = useHistory(); const isLargeAccount = useIsLargeAccount(searchActive); - const { account } = useAccountManagement(); - const flags = useFlags(); - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); // Only request things if the search bar is open/active and we // know if the account is large or not const shouldMakeRequests = searchActive && isLargeAccount !== undefined && !isLargeAccount; - // Data fetching - const { data: objectStorageClusters } = useObjectStorageClusters( - shouldMakeRequests && !isObjMultiClusterEnabled - ); - const { data: regions } = useRegionsQuery(); - const regionsSupportingObjectStorage = regions?.filter((region) => - region.capabilities.includes('Object Storage') + const { data: objectStorageBuckets } = useObjectStorageBuckets( + shouldMakeRequests ); - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ - const { data: objectStorageBuckets } = useObjectStorageBuckets({ - clusters: isObjMultiClusterEnabled ? undefined : objectStorageClusters, - enabled: shouldMakeRequests, - isObjMultiClusterEnabled, - regions: isObjMultiClusterEnabled - ? regionsSupportingObjectStorage - : undefined, - }); - const { data: domains } = useAllDomainsQuery(shouldMakeRequests); const { data: clusters } = useAllKubernetesClustersQuery(shouldMakeRequests); const { data: volumes } = useAllVolumesQuery({}, {}, shouldMakeRequests); diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts new file mode 100644 index 00000000000..64cfb7c904d --- /dev/null +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -0,0 +1,295 @@ +import { + createBucket, + deleteBucket, + deleteBucketWithRegion, + deleteSSLCert, + getObjectList, + getObjectStorageKeys, + getObjectURL, + getSSLCert, + uploadSSLCert, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { OBJECT_STORAGE_DELIMITER as delimiter } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; + +import { useAccount } from '../account/account'; +import { accountQueries } from '../account/queries'; +import { queryPresets } from '../base'; +import { useRegionsQuery } from '../regions/regions'; +import { + getAllBucketsFromClusters, + getAllBucketsFromRegions, + getAllObjectStorageClusters, + getAllObjectStorageTypes, +} from './requests'; +import { prefixToQueryKey } from './utilities'; + +import type { BucketsResponse } from './requests'; +import type { + APIError, + CreateObjectStorageBucketPayload, + CreateObjectStorageBucketSSLPayload, + CreateObjectStorageObjectURLPayload, + ObjectStorageBucket, + ObjectStorageBucketSSL, + ObjectStorageCluster, + ObjectStorageKey, + ObjectStorageObjectList, + ObjectStorageObjectURL, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4'; + +export const objectStorageQueries = createQueryKeys('object-storage', { + accessKeys: (params: Params) => ({ + queryFn: () => getObjectStorageKeys(params), + queryKey: [params], + }), + bucket: (clusterOrRegion: string, bucketName: string) => ({ + contextQueries: { + objects: { + // This is a placeholder queryFn and QueryKey. View the `useObjectBucketObjectsInfiniteQuery` implementation for details. + queryFn: null, + queryKey: null, + }, + ssl: { + queryFn: () => getSSLCert(clusterOrRegion, bucketName), + queryKey: null, + }, + }, + queryKey: [clusterOrRegion, bucketName], + }), + buckets: { + queryFn: () => null, // This is a placeholder queryFn. Look at `useObjectStorageBuckets` for the actual logic. + queryKey: null, + }, + clusters: { + queryFn: getAllObjectStorageClusters, + queryKey: null, + }, + types: { + queryFn: getAllObjectStorageTypes, + queryKey: null, + }, +}); + +export const useObjectStorageClusters = (enabled: boolean = true) => + useQuery({ + ...objectStorageQueries.clusters, + ...queryPresets.oneTimeFetch, + enabled, + }); + +export const useObjectStorageBuckets = (enabled = true) => { + const flags = useFlags(); + const { data: account } = useAccount(); + + const isObjMultiClusterEnabled = isFeatureEnabledV2( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + + const { data: allRegions } = useRegionsQuery(); + const { data: clusters } = useObjectStorageClusters( + enabled && !isObjMultiClusterEnabled + ); + + const regions = allRegions?.filter((r) => + r.capabilities.includes('Object Storage') + ); + + return useQuery({ + enabled: isObjMultiClusterEnabled + ? regions !== undefined && enabled + : clusters !== undefined && enabled, + queryFn: isObjMultiClusterEnabled + ? () => getAllBucketsFromRegions(regions) + : () => getAllBucketsFromClusters(clusters), + queryKey: objectStorageQueries.buckets.queryKey, + retry: false, + }); +}; + +export const useObjectStorageAccessKeys = (params: Params) => + useQuery, APIError[]>({ + ...objectStorageQueries.accessKeys(params), + keepPreviousData: true, + }); + +export const useCreateBucketMutation = () => { + const queryClient = useQueryClient(); + return useMutation< + ObjectStorageBucket, + APIError[], + CreateObjectStorageBucketPayload + >({ + mutationFn: createBucket, + onSuccess(bucket) { + // Invalidate account settings because object storage will become enabled + // if a user created their first bucket. + queryClient.invalidateQueries({ + queryKey: accountQueries.settings.queryKey, + }); + + // Add the new bucket to the cache + queryClient.setQueryData( + objectStorageQueries.buckets.queryKey, + (oldData) => ({ + buckets: [...(oldData?.buckets ?? []), bucket], + errors: oldData?.errors ?? [], + }) + ); + + // Invalidate buckets and cancel existing requests to GET buckets + // because a user might create a bucket bfore all buckets have been fetched. + queryClient.invalidateQueries( + { + queryKey: objectStorageQueries.buckets.queryKey, + }, + { + cancelRefetch: true, + } + ); + }, + }); +}; + +export const useDeleteBucketMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { cluster: string; label: string }>({ + mutationFn: deleteBucket, + onSuccess: (_, variables) => { + queryClient.setQueryData( + objectStorageQueries.buckets.queryKey, + (oldData) => ({ + buckets: + oldData?.buckets.filter( + (bucket) => + !( + bucket.cluster === variables.cluster && + bucket.label === variables.label + ) + ) ?? [], + errors: oldData?.errors ?? [], + }) + ); + }, + }); +}; + +/* + @TODO OBJ Multicluster: useDeleteBucketWithRegionMutation is a temporary hook, + once feature is rolled out we replace it with existing useDeleteBucketMutation + by updating it with region instead of cluster. +*/ +export const useDeleteBucketWithRegionMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { label: string; region: string }>({ + mutationFn: deleteBucketWithRegion, + onSuccess: (_, variables) => { + queryClient.setQueryData( + objectStorageQueries.buckets.queryKey, + (oldData) => ({ + buckets: + oldData?.buckets.filter( + (bucket: ObjectStorageBucket) => + !( + bucket.region === variables.region && + bucket.label === variables.label + ) + ) ?? [], + errors: oldData?.errors ?? [], + }) + ); + }, + }); +}; + +export const useObjectBucketObjectsInfiniteQuery = ( + clusterId: string, + bucket: string, + prefix: string +) => + useInfiniteQuery({ + getNextPageParam: (lastPage) => lastPage.next_marker, + queryFn: ({ pageParam }) => + getObjectList({ + bucket, + clusterId, + params: { delimiter, marker: pageParam, prefix }, + }), + queryKey: [ + ...objectStorageQueries.bucket(clusterId, bucket)._ctx.objects.queryKey, + ...prefixToQueryKey(prefix), + ], + }); + +export const useCreateObjectUrlMutation = ( + clusterId: string, + bucketName: string +) => + useMutation< + ObjectStorageObjectURL, + APIError[], + { + method: 'DELETE' | 'GET' | 'POST' | 'PUT'; + name: string; + options?: CreateObjectStorageObjectURLPayload; + } + >({ + mutationFn: ({ method, name, options }) => + getObjectURL(clusterId, bucketName, name, method, options), + }); + +export const useBucketSSLQuery = (cluster: string, bucket: string) => + useQuery(objectStorageQueries.bucket(cluster, bucket)._ctx.ssl); + +export const useBucketSSLMutation = (cluster: string, bucket: string) => { + const queryClient = useQueryClient(); + + return useMutation< + ObjectStorageBucketSSL, + APIError[], + CreateObjectStorageBucketSSLPayload + >({ + mutationFn: (data) => uploadSSLCert(cluster, bucket, data), + onSuccess(data) { + queryClient.setQueryData( + objectStorageQueries.bucket(cluster, bucket)._ctx.ssl.queryKey, + data + ); + }, + }); +}; + +export const useBucketSSLDeleteMutation = (cluster: string, bucket: string) => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteSSLCert(cluster, bucket), + onSuccess() { + queryClient.setQueryData( + objectStorageQueries.bucket(cluster, bucket)._ctx.ssl.queryKey, + { ssl: false } + ); + }, + }); +}; + +export const useObjectStorageTypesQuery = (enabled = true) => + useQuery({ + ...objectStorageQueries.types, + ...queryPresets.oneTimeFetch, + enabled, + }); diff --git a/packages/manager/src/queries/object-storage/requests.ts b/packages/manager/src/queries/object-storage/requests.ts new file mode 100644 index 00000000000..a3452eb6df4 --- /dev/null +++ b/packages/manager/src/queries/object-storage/requests.ts @@ -0,0 +1,115 @@ +import { + getBuckets, + getBucketsInCluster, + getBucketsInRegion, + getClusters, + getObjectStorageTypes, +} from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +import type { + APIError, + ObjectStorageBucket, + ObjectStorageCluster, + PriceType, + Region, +} from '@linode/api-v4'; + +export const getAllObjectStorageClusters = () => + getAll(() => getClusters())().then((data) => data.data); + +export const getAllObjectStorageBuckets = () => + getAll(() => getBuckets())().then((data) => data.data); + +export const getAllObjectStorageTypes = () => + getAll((params) => getObjectStorageTypes(params))().then( + (data) => data.data + ); + +export interface BucketError { + /* + @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 after the 'objMultiCluster' feature flag, we will + remove 'cluster' and retain 'regions'. + */ + cluster: ObjectStorageCluster; + error: APIError[]; + region?: Region; +} + +export interface BucketsResponse { + buckets: ObjectStorageBucket[]; + errors: BucketError[]; +} + +export const getAllBucketsFromClusters = async ( + clusters: ObjectStorageCluster[] | undefined +) => { + if (clusters === undefined) { + return { buckets: [], errors: [] } as BucketsResponse; + } + + const promises = clusters.map((cluster) => + getAll((params) => + getBucketsInCluster(cluster.id, params) + )() + .then((data) => data.data) + .catch((error) => ({ + cluster, + error, + })) + ); + + const data = await Promise.all(promises); + + const bucketsPerCluster = data.filter((item) => + Array.isArray(item) + ) as ObjectStorageBucket[][]; + + const buckets = bucketsPerCluster.reduce((acc, val) => acc.concat(val), []); + + const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; + + if (errors.length === clusters.length) { + throw new Error('Unable to get Object Storage buckets.'); + } + + return { buckets, errors } as BucketsResponse; +}; + +export const getAllBucketsFromRegions = async ( + regions: Region[] | undefined +) => { + if (regions === undefined) { + return { buckets: [], errors: [] } as BucketsResponse; + } + + const promises = regions.map((region) => + getAll((params) => + getBucketsInRegion(region.id, params) + )() + .then((data) => data.data) + .catch((error) => ({ + error, + region, + })) + ); + + const data = await Promise.all(promises); + + const bucketsPerRegion = data.filter((item) => + Array.isArray(item) + ) as ObjectStorageBucket[][]; + + const buckets = bucketsPerRegion.reduce((acc, val) => acc.concat(val), []); + + const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; + + if (errors.length === regions.length) { + throw new Error('Unable to get Object Storage buckets.'); + } + + return { buckets, errors } as BucketsResponse; +}; diff --git a/packages/manager/src/queries/object-storage/utilities.ts b/packages/manager/src/queries/object-storage/utilities.ts new file mode 100644 index 00000000000..a5baec2fc2f --- /dev/null +++ b/packages/manager/src/queries/object-storage/utilities.ts @@ -0,0 +1,65 @@ +import { getBucket } from '@linode/api-v4'; + +import { objectStorageQueries } from './queries'; + +import type { BucketsResponse } from './requests'; +import type { QueryClient } from '@tanstack/react-query'; + +/** + * Used to make a nice React Query queryKey by splitting the prefix + * by the '/' character. + * + * By spreading the result, you can achieve a queryKey that is in the form of: + * ["object-storage","us-southeast-1","test","testfolder"] + * + * @param {string} prefix The Object Stoage prefix path + * @returns {string[]} a list of paths + */ +export const prefixToQueryKey = (prefix: string) => { + return prefix.split('/', prefix.split('/').length - 1); +}; + +/** + * Fetches a single bucket and updates it in the cache. + * + * We have this function so we have an efficent way to update a single bucket + * as opposed to re-fetching all buckets. + */ +export const fetchBucketAndUpdateCache = async ( + regionOrCluster: string, + bucketName: string, + queryClient: QueryClient +) => { + const bucket = await getBucket(regionOrCluster, bucketName); + + queryClient.setQueryData( + objectStorageQueries.buckets.queryKey, + (previousData) => { + if (!previousData) { + return undefined; + } + + const indexOfBucket = previousData.buckets.findIndex( + (b) => + (b.region === regionOrCluster || b.cluster === regionOrCluster) && + b.label === bucketName + ); + + if (indexOfBucket === -1) { + // If the bucket does not exist in the cache don't try to update it. + return undefined; + } + + const newBuckets = [...previousData.buckets]; + + newBuckets[indexOfBucket] = bucket; + + return { + buckets: newBuckets, + errors: previousData?.errors ?? [], + }; + } + ); + + return bucket; +}; diff --git a/packages/manager/src/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts deleted file mode 100644 index 9aafae7a0e1..00000000000 --- a/packages/manager/src/queries/objectStorage.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { - createBucket, - deleteBucket, - deleteBucketWithRegion, - deleteSSLCert, - getBucket, - getBuckets, - getBucketsInCluster, - getBucketsInRegion, - getClusters, - getObjectList, - getObjectStorageKeys, - getObjectStorageTypes, - getObjectURL, - getSSLCert, - uploadSSLCert, -} from '@linode/api-v4'; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; - -import { OBJECT_STORAGE_DELIMITER as delimiter } from 'src/constants'; -import { getAll } from 'src/utilities/getAll'; - -import { accountQueries } from './account/queries'; -import { queryPresets } from './base'; - -import type { - CreateObjectStorageBucketPayload, - CreateObjectStorageBucketSSLPayload, - CreateObjectStorageObjectURLPayload, - ObjectStorageBucket, - ObjectStorageBucketSSL, - ObjectStorageCluster, - ObjectStorageKey, - ObjectStorageObjectList, - ObjectStorageObjectURL, - Region, -} from '@linode/api-v4'; -import type { - APIError, - Params, - PriceType, - ResourcePage, -} from '@linode/api-v4/lib/types'; -import type { QueryClient } from '@tanstack/react-query'; -import type { AtLeastOne } from 'src/utilities/types/typesHelpers'; - -export interface BucketError { - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ - cluster: ObjectStorageCluster; - error: APIError[]; - region?: Region; -} - -interface BucketsResponse { - buckets: ObjectStorageBucket[]; - errors: BucketError[]; -} - -/* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ -interface UseObjectStorageBucketsBaseOptions { - enabled?: boolean; - isObjMultiClusterEnabled?: boolean; -} - -// Use the utility type with your options -type UseObjectStorageBucketsOptions = AtLeastOne<{ - clusters: ObjectStorageCluster[] | undefined; - regions: Region[] | undefined; -}> & - UseObjectStorageBucketsBaseOptions; - -export const queryKey = 'object-storage'; - -/** - * This getAll is probably overkill for getting all - * Object Storage clusters (currently there are only 4), - * but lets use it to be safe. - */ -export const getAllObjectStorageClusters = () => - getAll(() => getClusters())().then((data) => data.data); - -export const getAllObjectStorageBuckets = () => - getAll(() => getBuckets())().then((data) => data.data); - -export const useObjectStorageClusters = (enabled: boolean = true) => - useQuery( - [`${queryKey}-clusters`], - getAllObjectStorageClusters, - { ...queryPresets.oneTimeFetch, enabled } - ); - -/* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ - -export const useObjectStorageBuckets = ({ - clusters, - enabled = true, - isObjMultiClusterEnabled = false, - regions, -}: UseObjectStorageBucketsOptions) => - 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 - // we can tell the user which clusters are having issues. - // getAllObjectStorageBuckets, - () => - isObjMultiClusterEnabled - ? getAllBucketsFromRegions(regions) - : getAllBucketsFromClusters(clusters), - { - ...queryPresets.longLived, - enabled: (clusters !== undefined || regions !== undefined) && enabled, - retry: false, - } - ); - -export const useObjectStorageAccessKeys = (params: Params) => - useQuery, APIError[]>( - [`${queryKey}-access-keys`, params], - () => getObjectStorageKeys(params), - { keepPreviousData: true } - ); - -export const useCreateBucketMutation = () => { - const queryClient = useQueryClient(); - return useMutation< - ObjectStorageBucket, - APIError[], - CreateObjectStorageBucketPayload - >(createBucket, { - onMutate: async () => { - // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await queryClient.cancelQueries([`${queryKey}-buckets`]); - }, - onSuccess: (newEntity) => { - // Invalidate account settings because it contains obj information - queryClient.invalidateQueries(accountQueries.settings.queryKey); - queryClient.setQueryData( - [`${queryKey}-buckets`], - (oldData) => ({ - buckets: [...(oldData?.buckets || []), newEntity], - errors: oldData?.errors || [], - }) - ); - queryClient.invalidateQueries([`${queryKey}-buckets`]); - }, - }); -}; - -export const useDeleteBucketMutation = () => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { cluster: string; label: string }>( - (data) => deleteBucket(data), - { - onSuccess: (_, variables) => { - queryClient.setQueryData( - [`${queryKey}-buckets`], - (oldData) => { - return { - buckets: - oldData?.buckets.filter( - (bucket: ObjectStorageBucket) => - !( - bucket.cluster === variables.cluster && - bucket.label === variables.label - ) - ) || [], - errors: oldData?.errors || [], - }; - } - ); - }, - } - ); -}; - -/* - @TODO OBJ Multicluster: useDeleteBucketWithRegionMutation is a temporary hook, - once feature is rolled out we replace it with existing useDeleteBucketMutation - by updating it with region instead of cluster. - */ - -export const useDeleteBucketWithRegionMutation = () => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { label: string; region: string }>( - (data) => deleteBucketWithRegion(data), - { - onSuccess: (_, variables) => { - queryClient.setQueryData( - [`${queryKey}-buckets`], - (oldData) => { - return { - buckets: - oldData?.buckets.filter( - (bucket: ObjectStorageBucket) => - !( - bucket.region === variables.region && - bucket.label === variables.label - ) - ) || [], - errors: oldData?.errors || [], - }; - } - ); - }, - } - ); -}; - -export const useObjectBucketDetailsInfiniteQuery = ( - clusterId: string, - bucket: string, - prefix: string -) => - useInfiniteQuery( - [queryKey, clusterId, bucket, 'objects', ...prefixToQueryKey(prefix)], - ({ pageParam }) => - getObjectList({ - bucket, - clusterId, - params: { delimiter, marker: pageParam, prefix }, - }), - { - getNextPageParam: (lastPage) => lastPage.next_marker, - } - ); - -export const getAllBucketsFromClusters = async ( - clusters: ObjectStorageCluster[] | undefined -) => { - if (clusters === undefined) { - return { buckets: [], errors: [] } as BucketsResponse; - } - - const promises = clusters.map((cluster) => - getAll((params) => - getBucketsInCluster(cluster.id, params) - )() - .then((data) => data.data) - .catch((error) => ({ - cluster, - error, - })) - ); - - const data = await Promise.all(promises); - - const bucketsPerCluster = data.filter((item) => - Array.isArray(item) - ) as ObjectStorageBucket[][]; - - const buckets = bucketsPerCluster.reduce((acc, val) => acc.concat(val), []); - - const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; - - if (errors.length === clusters.length) { - throw new Error('Unable to get Object Storage buckets.'); - } - - return { buckets, errors } as BucketsResponse; -}; - -export const getAllBucketsFromRegions = async ( - regions: Region[] | undefined -) => { - if (regions === undefined) { - return { buckets: [], errors: [] } as BucketsResponse; - } - - const promises = regions.map((region) => - getAll((params) => - getBucketsInRegion(region.id, params) - )() - .then((data) => data.data) - .catch((error) => ({ - error, - region, - })) - ); - - const data = await Promise.all(promises); - - const bucketsPerRegion = data.filter((item) => - Array.isArray(item) - ) as ObjectStorageBucket[][]; - - const buckets = bucketsPerRegion.reduce((acc, val) => acc.concat(val), []); - - const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; - - if (errors.length === regions.length) { - throw new Error('Unable to get Object Storage buckets.'); - } - - return { buckets, errors } as BucketsResponse; -}; - -/** - * Used to make a nice React Query queryKey by splitting the prefix - * by the '/' character. - * - * By spreading the result, you can achieve a queryKey that is in the form of: - * ["object-storage","us-southeast-1","test","testfolder"] - * - * @param {string} prefix The Object Stoage prefix path - * @returns {string[]} a list of paths - */ -export const prefixToQueryKey = (prefix: string) => { - return prefix.split('/', prefix.split('/').length - 1); -}; - -/** - * Updates the data for a single bucket in the useObjectStorageBuckets query - * @param {string} cluster the id of the Object Storage cluster - * @param {string} bucketName the label of the bucket - */ -export const updateBucket = async ( - cluster: string, - bucketName: string, - queryClient: QueryClient -) => { - const bucket = await getBucket(cluster, bucketName); - queryClient.setQueryData( - [`${queryKey}-buckets`], - (oldData) => { - if (oldData === undefined) { - return undefined; - } - - const idx = oldData.buckets.findIndex( - (thisBucket) => - thisBucket.label === bucketName && thisBucket.cluster === cluster - ); - - if (idx === -1) { - return oldData; - } - - const updatedBuckets = [...oldData.buckets]; - - updatedBuckets[idx] = bucket; - - return { - buckets: updatedBuckets, - errors: oldData.errors, - } as BucketsResponse; - } - ); -}; - -export const useCreateObjectUrlMutation = ( - clusterId: string, - bucketName: string -) => - useMutation< - ObjectStorageObjectURL, - APIError[], - { - method: 'DELETE' | 'GET' | 'POST' | 'PUT'; - name: string; - options?: CreateObjectStorageObjectURLPayload; - } - >(({ method, name, options }) => - getObjectURL(clusterId, bucketName, name, method, options) - ); - -export const useBucketSSLQuery = (cluster: string, bucket: string) => - useQuery([queryKey, cluster, bucket, 'ssl'], () => - getSSLCert(cluster, bucket) - ); - -export const useBucketSSLMutation = (cluster: string, bucket: string) => { - const queryClient = useQueryClient(); - - return useMutation< - ObjectStorageBucketSSL, - APIError[], - CreateObjectStorageBucketSSLPayload - >((data) => uploadSSLCert(cluster, bucket, data), { - onSuccess(data) { - queryClient.setQueryData( - [queryKey, cluster, bucket, 'ssl'], - data - ); - }, - }); -}; - -export const useBucketSSLDeleteMutation = (cluster: string, bucket: string) => { - const queryClient = useQueryClient(); - - return useMutation<{}, APIError[]>(() => deleteSSLCert(cluster, bucket), { - onSuccess() { - queryClient.setQueryData( - [queryKey, cluster, bucket, 'ssl'], - { ssl: false } - ); - }, - }); -}; - -const getAllObjectStorageTypes = () => - getAll((params) => getObjectStorageTypes(params))().then( - (data) => data.data - ); - -export const useObjectStorageTypesQuery = (enabled = true) => - useQuery({ - queryFn: getAllObjectStorageTypes, - queryKey: [queryKey, 'types'], - ...queryPresets.oneTimeFetch, - enabled, - }); From 6c8eac1c2779bc25702c7d6010233c7052ad1fb5 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:21:07 -0400 Subject: [PATCH 04/43] upcoming: [M3-8301] - Added `/object-storage/endpoints` query and request (#10736) Co-authored-by: Banks Nussman Co-authored-by: Jaalah Ramos --- packages/api-v4/src/object-storage/buckets.ts | 18 +++++++++++++-- packages/api-v4/src/object-storage/objects.ts | 19 +-------------- ...r-10736-upcoming-features-1722454894455.md | 5 ++++ .../src/queries/object-storage/queries.ts | 23 +++++++++++++++++++ .../src/queries/object-storage/requests.ts | 7 ++++++ 5 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md diff --git a/packages/api-v4/src/object-storage/buckets.ts b/packages/api-v4/src/object-storage/buckets.ts index 2f974e63392..d05db3855c8 100644 --- a/packages/api-v4/src/object-storage/buckets.ts +++ b/packages/api-v4/src/object-storage/buckets.ts @@ -11,9 +11,15 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { + Filter, + Params, + ResourcePage as Page, + RequestOptions, +} from '../types'; +import type { ObjectStorageBucket, + ObjectStorageEndpoint, UpdateObjectStorageBucketAccessPayload, ObjectStorageBucketAccess, CreateObjectStorageBucketPayload, @@ -255,3 +261,11 @@ export const updateBucketAccess = ( ), setData(params, UpdateBucketAccessSchema) ); + +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/objects.ts b/packages/api-v4/src/object-storage/objects.ts index 69417c4486c..892b79ddb7f 100644 --- a/packages/api-v4/src/object-storage/objects.ts +++ b/packages/api-v4/src/object-storage/objects.ts @@ -1,22 +1,13 @@ import { API_ROOT } from '../constants'; -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; +import Request, { setData, setMethod, setURL } from '../request'; import { ACLType, - ObjectStorageEndpoint, ObjectStorageObjectACL, ObjectStorageObjectURL, GetObjectStorageACLPayload, CreateObjectStorageObjectURLPayload, } from './types'; -import type { ResourcePage, RequestOptions } from '../types'; - /** * Creates a pre-signed URL to access a single object in a bucket. * Use it to share, create, or delete objects by using the appropriate @@ -82,11 +73,3 @@ 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/manager/.changeset/pr-10736-upcoming-features-1722454894455.md b/packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md new file mode 100644 index 00000000000..d64ecc262e4 --- /dev/null +++ b/packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Added Object Storage Gen2 `/endpoints` query ([#10736](https://github.com/linode/manager/pull/10736)) diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 64cfb7c904d..edc3074a808 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -29,6 +29,7 @@ import { getAllBucketsFromClusters, getAllBucketsFromRegions, getAllObjectStorageClusters, + getAllObjectStorageEndpoints, getAllObjectStorageTypes, } from './requests'; import { prefixToQueryKey } from './utilities'; @@ -42,6 +43,7 @@ import type { ObjectStorageBucket, ObjectStorageBucketSSL, ObjectStorageCluster, + ObjectStorageEndpoint, ObjectStorageKey, ObjectStorageObjectList, ObjectStorageObjectURL, @@ -77,12 +79,33 @@ export const objectStorageQueries = createQueryKeys('object-storage', { queryFn: getAllObjectStorageClusters, queryKey: null, }, + endpoints: { + queryFn: getAllObjectStorageEndpoints, + queryKey: null, + }, types: { queryFn: getAllObjectStorageTypes, queryKey: null, }, }); +export const useObjectStorageEndpoints = (enabled = true) => { + const flags = useFlags(); + const { data: account } = useAccount(); + + const isObjectStorageGen2Enabled = isFeatureEnabledV2( + 'Object Storage Endpoint Types', + Boolean(flags.objectStorageGen2?.enabled), + account?.capabilities ?? [] + ); + + return useQuery({ + ...objectStorageQueries.endpoints, + ...queryPresets.oneTimeFetch, + enabled: isObjectStorageGen2Enabled && enabled, + }); +}; + export const useObjectStorageClusters = (enabled: boolean = true) => useQuery({ ...objectStorageQueries.clusters, diff --git a/packages/manager/src/queries/object-storage/requests.ts b/packages/manager/src/queries/object-storage/requests.ts index a3452eb6df4..9d19b7d8d28 100644 --- a/packages/manager/src/queries/object-storage/requests.ts +++ b/packages/manager/src/queries/object-storage/requests.ts @@ -3,6 +3,7 @@ import { getBucketsInCluster, getBucketsInRegion, getClusters, + getObjectStorageEndpoints, getObjectStorageTypes, } from '@linode/api-v4'; @@ -12,6 +13,7 @@ import type { APIError, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageEndpoint, PriceType, Region, } from '@linode/api-v4'; @@ -27,6 +29,11 @@ export const getAllObjectStorageTypes = () => (data) => data.data ); +export const getAllObjectStorageEndpoints = () => + getAll((params, filter) => + getObjectStorageEndpoints({ filter, params }) + )().then((data) => data.data); + export interface BucketError { /* @TODO OBJ Multicluster:'region' will become required, and the From 0fe6dff807dce7d61196530bb2c64bad3149a4a4 Mon Sep 17 00:00:00 2001 From: nikhagra <165884194+nikhagra@users.noreply.github.com> Date: Sat, 3 Aug 2024 01:22:37 +0530 Subject: [PATCH 05/43] upcoming: [DI-19818] - Added line graph to CloudPulse widgets for different metrics (#10710) --- ...r-10710-upcoming-features-1721901283228.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 31 ++++- ...r-10710-upcoming-features-1721901189629.md | 5 + .../src/components/LineGraph/LineGraph.tsx | 33 +++-- packages/manager/src/featureFlags.ts | 7 ++ .../Dashboard/CloudPulseDashboard.tsx | 4 +- .../Utils/CloudPulseWidgetColorPalette.ts | 111 +++++++++++++++++ .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 105 ++++++++++++++++ .../src/features/CloudPulse/Utils/utils.ts | 65 ++++++++++ .../CloudPulse/Widget/CloudPulseWidget.tsx | 116 ++++++++++++++++-- .../components/CloudPulseIntervalSelect.tsx | 2 +- .../Widget/components/CloudPulseLineGraph.tsx | 70 +++++++++++ .../shared/CloudPulseTimeRangeSelect.tsx | 12 ++ packages/manager/src/mocks/serverHandlers.ts | 35 ++++++ .../src/queries/cloudpulse/dashboards.ts | 32 +---- .../manager/src/queries/cloudpulse/metrics.ts | 82 +++++++++++++ .../manager/src/queries/cloudpulse/queries.ts | 76 ++++++++++++ .../src/queries/cloudpulse/resources.ts | 24 +--- .../src/queries/cloudpulse/services.ts | 22 +--- 19 files changed, 743 insertions(+), 94 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md create mode 100644 packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md create mode 100644 packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts create mode 100644 packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx create mode 100644 packages/manager/src/queries/cloudpulse/metrics.ts create mode 100644 packages/manager/src/queries/cloudpulse/queries.ts diff --git a/packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md b/packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md new file mode 100644 index 00000000000..6e0380dd0a6 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulseMetricsRequest, CloudPulseMetricsResponse, CloudPulseMetricsResponseData, CloudPulseMetricsList, CloudPulseMetricValues and getCloudPulseMetricsAPI is added ([#10710](https://github.com/linode/manager/pull/10710)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 53d3b507bbc..ef422e6d97c 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -81,9 +81,38 @@ export interface Dimension { } export interface JWETokenPayLoad { - resource_id: string[]; + resource_id: number[]; } export interface JWEToken { token: string; } + +export interface CloudPulseMetricsRequest { + metric: string; + filters?: Filters[]; + aggregate_function: string; + group_by: string; + relative_time_duration: TimeDuration; + time_granularity: TimeGranularity | undefined; + resource_id: number[]; +} + +export interface CloudPulseMetricsResponse { + data: CloudPulseMetricsResponseData; + isPartial: boolean; + stats: { + series_fetched: number; + }; + status: string; +} + +export interface CloudPulseMetricsResponseData { + result: CloudPulseMetricsList[]; + result_type: string; +} + +export interface CloudPulseMetricsList { + metric: { [resourceName: string]: string }; + values: [number, string][]; +} diff --git a/packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md b/packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md new file mode 100644 index 00000000000..8073707a98f --- /dev/null +++ b/packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulseLineGraph is added with dummy data ([#10710](https://github.com/linode/manager/pull/10710)) diff --git a/packages/manager/src/components/LineGraph/LineGraph.tsx b/packages/manager/src/components/LineGraph/LineGraph.tsx index e93e6845e08..d62d470ed6e 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.tsx @@ -2,16 +2,9 @@ * ONLY USED IN LONGVIEW * Delete when Lonview is sunsetted, along with AccessibleGraphData */ -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { - Chart, - ChartData, - ChartDataSets, - ChartOptions, - ChartTooltipItem, - ChartXAxe, -} from 'chart.js'; +import { Chart } from 'chart.js'; import { curry } from 'ramda'; import * as React from 'react'; @@ -21,7 +14,6 @@ import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; import { setUpCharts } from 'src/utilities/charts'; import { roundTo } from 'src/utilities/roundTo'; -import { Metrics } from 'src/utilities/statMetrics'; import AccessibleGraphData from './AccessibleGraphData'; import { @@ -36,6 +28,16 @@ import { StyledWrapper, } from './LineGraph.styles'; +import type { Theme } from '@mui/material/styles'; +import type { + ChartData, + ChartDataSets, + ChartOptions, + ChartTooltipItem, + ChartXAxe, +} from 'chart.js'; +import type { Metrics } from 'src/utilities/statMetrics'; + setUpCharts(); export interface DataSet { @@ -78,6 +80,11 @@ export interface LineGraphProps { * The function that formats the tooltip text. */ formatTooltip?: (value: number) => string; + + /** + * To check whether legends should be shown in full size or predefined size + */ + isLegendsFullSize?: boolean; /** * Legend row labels that are used in the legend. */ @@ -106,6 +113,7 @@ export interface LineGraphProps { * The timezone the graph should use for interpreting the UNIX date-times in the data set. */ timezone: string; + /** * The unit to add to the mouse-over tooltip in the chart. */ @@ -142,6 +150,7 @@ export const LineGraph = (props: LineGraphProps) => { data, formatData, formatTooltip, + isLegendsFullSize, legendRows, nativeLegend, rowHeaders, @@ -358,6 +367,10 @@ export const LineGraph = (props: LineGraphProps) => { {legendRendered && legendRows && ( diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index e0713cdb7d7..0ffcaa47497 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -63,6 +63,11 @@ interface AclpFlag { enabled: boolean; } +interface CloudPulseResourceTypeMapFlag { + dimensionKey: string; + serviceType: string; +} + interface gpuV2 { planDivider: boolean; } @@ -76,6 +81,8 @@ interface DesignUpdatesBannerFlag extends BaseFeatureFlag { export interface Flags { aclp: AclpFlag; + aclpReadEndpoint: string; + aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; apiMaintenance: APIMaintenance; apicliDxToolsAdditions: boolean; blockStorageEncryption: boolean; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 5790a15d0f3..51a69be77b6 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -73,7 +73,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getJweTokenPayload = (): JWETokenPayLoad => { return { - resource_id: resourceList?.map((resource) => String(resource.id)) ?? [], + resource_id: resourceList?.map((resource) => Number(resource.id)) ?? [], }; }; @@ -167,7 +167,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { } const RenderWidgets = () => { - if (!dashboard || Boolean(dashboard.widgets?.length)) { + if (!dashboard || !Boolean(dashboard?.widgets?.length)) { return renderPlaceHolder( 'No visualizations are available at this moment. Create Dashboards to list here.' ); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts new file mode 100644 index 00000000000..3b9325521d0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts @@ -0,0 +1,111 @@ +export const RED = [ + '#ee2c2c80', + '#ff633d80', + '#F27E7E80', + '#EA7C7280', + '#E2796580', + '#D9775980', + '#D1744D80', + '#C9724080', + '#C16F3480', + '3B86D2880', + '#B06A1B80', + '#A8680F80', +]; + +export const GREEN = [ + '#10a21d80', + '#31ce3e80', + '#d9b0d980', + '#ffdc7d80', + '#7EF29D80', + '#72E39E80', + '#65D3A080', + '#59C4A180', + '#4DB5A280', + '#40A5A480', + '#3496A580', + '#2887A680', + '#1B77A880', + '#0F68A980', +]; + +export const BLUE = [ + '#3683dc80', + '#0F91A880', + '#1B9CAC80', + '#28A7AF80', + '#34B1B380', + '#40BCB680', + '#4DC7BA80', + '#59D2BD80', + '#65DCC180', + '#72E7C480', + '#7EF2C880', +]; + +export const YELLOW = [ + '#ffb34d80', + '#F2EE7E80', + '#E6E67280', + '#DBDE6580', + '#CFD75980', + '#C4CF4D80', + '#B8C74080', + '#ADBF3480', + '#A1B82880', + '#96B01B80', + '#8AA80F80', +]; + +export const PINK = [ + '#F27EE180', + '#EA72D180', + '#E265C280', + '#D959B280', + '#D14DA280', + '#C9409380', + '#C1348380', + '#B8287380', + '#B01B6480', + '#A80F5480', +]; + +export const DEFAULT = [ + // thick colors from each... + '#4067E580', + '#FE993380', + '#12A59480', + '#AB4ABA80', + '#D63C4280', + '#05A2C280', + '#E043A780', + '#00B05080', + '#7259D680', + '#99D52A80', + '#71717880', + '#FFD70080', + '#40E0D080', + '#8DA4EF80', + '#C25D0580', + '#067A6F80', + '#CF91D880', + '#EB909180', + '#0C779280', + '#E38EC380', + '#97CF9C80', + '#AA99EC80', + '#94BA2C80', + '#4B4B5180', + '#FFE76680', + '#33B2A680', +]; + +export const COLOR_MAP = new Map([ + ['blue', BLUE], + ['default', DEFAULT], + ['green', GREEN], + ['pink', PINK], + ['red', RED], + ['yellow', YELLOW], +]); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts new file mode 100644 index 00000000000..8683f91c222 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -0,0 +1,105 @@ +import { isToday } from 'src/utilities/isToday'; +import { roundTo } from 'src/utilities/roundTo'; +import { getMetrics } from 'src/utilities/statMetrics'; + +import { COLOR_MAP } from './CloudPulseWidgetColorPalette'; +import { + convertTimeDurationToStartAndEndTimeRange, + seriesDataFormatter, +} from './utils'; + +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { LegendRow } from '../Widget/CloudPulseWidget'; +import type { + CloudPulseMetricsList, + CloudPulseMetricsRequest, + CloudPulseMetricsResponse, + TimeDuration, + Widgets, +} from '@linode/api-v4'; +import type { DataSet } from 'src/components/LineGraph/LineGraph'; + +export const generateGraphData = ( + widgetColor: string | undefined, + metricsList: CloudPulseMetricsResponse | undefined, + status: string | undefined, + label: string, + unit: string +) => { + const dimensions: DataSet[] = []; + const legendRowsData: LegendRow[] = []; + + // for now we will use this, but once we decide how to work with coloring, it should be dynamic + const colors = COLOR_MAP.get(widgetColor ?? 'default')!; + let today = false; + + if (status === 'success' && Boolean(metricsList?.data.result.length)) { + metricsList!.data.result.forEach( + (graphData: CloudPulseMetricsList, index) => { + // todo, move it to utils at a widget level + if (!graphData) { + return; + } + const color = colors[index]; + const { end, start } = convertTimeDurationToStartAndEndTimeRange({ + unit: 'min', + value: 30, + }) || { + end: graphData.values[graphData.values.length - 1][0], + start: graphData.values[0][0], + }; + + const dimension = { + backgroundColor: color, + borderColor: '', + data: seriesDataFormatter(graphData.values, start, end), + label: `${label} (${unit})`, + }; + // construct a legend row with the dimension + const legendRow = { + data: getMetrics(dimension.data as number[][]), + format: (value: number) => tooltipValueFormatter(value, unit), + legendColor: color, + legendTitle: dimension.label, + }; + legendRowsData.push(legendRow); + dimensions.push(dimension); + today ||= isToday(start, end); + } + ); + } + + return { dimensions, legendRowsData, today }; +}; + +const tooltipValueFormatter = (value: number, unit: string) => + `${roundTo(value)} ${unit}`; + +/** + * + * @returns a CloudPulseMetricRequest object to be passed as data to metric api call + */ +export const getCloudPulseMetricRequest = ( + widget: Widgets, + duration: TimeDuration, + resources: CloudPulseResources[], + resourceIds: string[] +): CloudPulseMetricsRequest => { + return { + aggregate_function: widget.aggregate_function, + filters: undefined, + group_by: widget.group_by, + metric: widget.metric, + relative_time_duration: duration ?? widget.time_duration, + resource_id: resources + ? resourceIds.map((obj) => parseInt(obj, 10)) + : widget.resource_id.map((obj) => parseInt(obj, 10)), + time_granularity: + widget.time_granularity.unit === 'Auto' + ? undefined + : { + unit: widget.time_granularity.unit, + value: widget.time_granularity.value, + }, + }; +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index eb18278833a..8bc4780194d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -1,6 +1,17 @@ +import { convertData } from 'src/features/Longview/shared/formatters'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account/account'; +import type { TimeDuration } from '@linode/api-v4'; +import type { + StatWithDummyPoint, + WithStartAndEnd, +} from 'src/features/Longview/request.types'; + +/** + * + * @returns an object that contains boolean property to check whether aclp is enabled or not + */ export const useIsACLPEnabled = (): { isACLPEnabled: boolean; } => { @@ -19,6 +30,11 @@ export const useIsACLPEnabled = (): { return { isACLPEnabled }; }; +/** + * + * @param nonFormattedString input string that is to be formatted with first letter of each word capital + * @returns the formatted string for the @nonFormattedString + */ export const convertStringToCamelCasesWithSpaces = ( nonFormattedString: string ): string => { @@ -38,3 +54,52 @@ export const createObjectCopy = (object: T): T | null => { return null; } }; + +/** + * + * @param timeDuration object according to which appropriate seconds values are calculated + * @returns WithStartAndEnd object woth starting & ending second value for the @timeDuration + */ +export const convertTimeDurationToStartAndEndTimeRange = ( + timeDuration: TimeDuration +): WithStartAndEnd => { + const nowInSeconds = Date.now() / 1000; + + const unitToSecondsMap: { [unit: string]: number } = { + days: 86400, + hr: 3600, + min: 60, + }; + + const durationInSeconds = + (unitToSecondsMap[timeDuration.unit] || 0) * timeDuration.value; + + return { + end: nowInSeconds, + start: nowInSeconds - durationInSeconds, + }; +}; + +/** + * + * @param data CloudPulseMetricData that has to be formatted + * @param startTime start timestamp for the data + * @param endTime end timestamp for the data + * @returns formatted data based on the time range between @startTime & @endTime + */ +export const seriesDataFormatter = ( + data: [number, string][], + startTime: number, + endTime: number +): [number, null | number][] => { + if (!data || data.length === 0) { + return []; + } + + const formattedArray: StatWithDummyPoint[] = data.map(([x, y]) => ({ + x: Number(x), + y: y ? Number(y) : null, + })); + + return convertData(formattedArray, startTime, endTime); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index b89f55891d2..d7208d5be23 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -1,10 +1,18 @@ import { Box, Grid, Paper, Stack, Typography } from '@mui/material'; +import { DateTime } from 'luxon'; import React from 'react'; +import { CircleProgress } from 'src/components/CircleProgress'; import { Divider } from 'src/components/Divider'; -import { LineGraph } from 'src/components/LineGraph/LineGraph'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { useFlags } from 'src/hooks/useFlags'; +import { useCloudPulseMetricsQuery } from 'src/queries/cloudpulse/metrics'; import { useProfile } from 'src/queries/profile/profile'; +import { + generateGraphData, + getCloudPulseMetricRequest, +} from '../Utils/CloudPulseWidgetUtils'; import { AGGREGATE_FUNCTION, SIZE, TIME_GRANULARITY } from '../Utils/constants'; import { getUserPreferenceObject, @@ -13,6 +21,7 @@ import { import { convertStringToCamelCasesWithSpaces } from '../Utils/utils'; import { CloudPulseAggregateFunction } from './components/CloudPulseAggregateFunction'; import { CloudPulseIntervalSelect } from './components/CloudPulseIntervalSelect'; +import { CloudPulseLineGraph } from './components/CloudPulseLineGraph'; import { ZoomIcon } from './components/Zoomer'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; @@ -22,6 +31,8 @@ import type { TimeGranularity, } from '@linode/api-v4'; import type { Widgets } from '@linode/api-v4'; +import type { DataSet } from 'src/components/LineGraph/LineGraph'; +import type { Metrics } from 'src/utilities/statMetrics'; export interface CloudPulseWidgetProperties { /** @@ -90,17 +101,33 @@ export interface CloudPulseWidgetProperties { widget: Widgets; } +export interface LegendRow { + data: Metrics; + format: (value: number) => {}; + legendColor: string; + legendTitle: string; +} + export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const { data: profile } = useProfile(); - const timezone = profile?.timezone ?? 'US/Eastern'; + const timezone = profile?.timezone ?? DateTime.local().zoneName; const [widget, setWidget] = React.useState({ ...props.widget }); - const { availableMetrics, savePref } = props; + const { + authToken, + availableMetrics, + duration, + resourceIds, + resources, + savePref, + serviceType, + timeStamp, + unit, + } = props; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [today, _] = React.useState(false); // Temporarily disabled eslint for this line. Will be removed in future PRs + const flags = useFlags(); /** * @@ -113,7 +140,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }); } - setWidget((currentWidget) => { + setWidget((currentWidget: Widgets) => { return { ...currentWidget, size: zoomInValue ? 12 : 6, @@ -135,7 +162,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }); } - setWidget((currentWidget) => { + setWidget((currentWidget: Widgets) => { return { ...currentWidget, aggregate_function: aggregateValue, @@ -163,7 +190,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }); } - setWidget((currentWidget) => { + setWidget((currentWidget: Widgets) => { return { ...currentWidget, time_granularity: { ...intervalValue }, @@ -187,6 +214,48 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { } }, []); + /** + * + * @param value number value for the tool tip + * @param unit string unit for the tool tip + * @returns formatted string using @value & @unit + */ + + const { + data: metricsList, + error, + isLoading, + status, + } = useCloudPulseMetricsQuery( + serviceType, + getCloudPulseMetricRequest(widget, duration, resources, resourceIds), + { + authToken, + isFlags: Boolean(flags), + label: widget.label, + timeStamp, + url: flags.aclpReadEndpoint!, + } + ); + + let data: DataSet[] = []; + + let legendRows: LegendRow[] = []; + let today: boolean = false; + + if (!isLoading && metricsList) { + const generatedData = generateGraphData( + widget.color, + metricsList, + status, + widget.label, + unit + ); + + data = generatedData.dimensions; + legendRows = generatedData.legendRowsData; + today = generatedData.today; + } return ( @@ -203,7 +272,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { marginLeft={1} variant="h1" > - {convertStringToCamelCasesWithSpaces(widget.label)}{' '} + {convertStringToCamelCasesWithSpaces(widget.label)} + {` (${unit})`} { - - - + {!isLoading && !Boolean(error) && ( + 0 ? legendRows : undefined + } + ariaLabel={props.ariaLabel ? props.ariaLabel : ''} + data={data} + gridSize={widget.size} + loading={isLoading} + nativeLegend={true} + showToday={today} + timezone={timezone} + title={''} + unit={unit} + /> + )} + {isLoading && } + {Boolean(error?.length) && ( + + )} diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx index 01aef34b1ee..ce9067a804a 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx @@ -57,7 +57,7 @@ export const all_interval_options = [ }, { label: '1 day', - unit: 'day', + unit: 'days', value: 1, }, ]; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx new file mode 100644 index 00000000000..edbc7da792a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -0,0 +1,70 @@ +import { Box, Typography } from '@mui/material'; +import * as React from 'react'; + +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { LineGraph } from 'src/components/LineGraph/LineGraph'; + +import type { LegendRow } from '../CloudPulseWidget'; +import type { + DataSet, + LineGraphProps, +} from 'src/components/LineGraph/LineGraph'; + +export interface CloudPulseLineGraph extends LineGraphProps { + ariaLabel?: string; + error?: string; + gridSize: number; + legendRows?: LegendRow[]; + loading?: boolean; + subtitle?: string; + title: string; +} + +export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { + const { ariaLabel, error, loading, ...rest } = props; + + const message = error // Error state is separate, don't want to put text on top of it + ? undefined + : loading // Loading takes precedence over empty data + ? 'Loading data...' + : isDataEmpty(props.data) + ? 'No data to display' + : undefined; + + return ( + + {error ? ( + + + + ) : ( + + )} + {message && ( + + {message} + + )} + + ); +}); + +export const isDataEmpty = (data: DataSet[]) => { + return data.every( + (thisSeries) => + thisSeries.data.length === 0 || + // If we've padded the data, every y value will be null + thisSeries.data.every((thisPoint) => thisPoint[1] === null) + ); +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index f05a52225de..57073c9ae21 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -8,6 +8,7 @@ import { updateGlobalFilterPreference, } from '../Utils/UserPreference'; +import type { TimeDuration } from '@linode/api-v4'; import type { BaseSelectProps, Item, @@ -135,3 +136,14 @@ export const generateStartTime = (modifier: Labels, nowInSeconds: number) => { return nowInSeconds - 30 * 24 * 60 * 60; } }; + +/** + * + * @param label label for time duration to get the corresponding time duration object + * @returns time duration object for the label + */ +export const getTimeDurationFromTimeRange = (label: string): TimeDuration => { + const options = generateSelectOptions(); + + return options[label] || { unit: 'min', vlaue: 30 }; +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c04c10d8f4e..7e828c68df8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2451,6 +2451,41 @@ export const handlers = [ }; return HttpResponse.json(response); }), + http.post('*/monitor/services/:serviceType/metrics', () => { + const response = { + data: { + result: [ + { + metric: {}, + values: [ + [1721854379, '0.2744841110560275'], + [1721857979, '0.2980357104166823'], + [1721861579, '0.3290476561287732'], + [1721865179, '0.32148793964961897'], + [1721868779, '0.3269247326830727'], + [1721872379, '0.3393055885526987'], + [1721875979, '0.3237102833940027'], + [1721879579, '0.3153372503472701'], + [1721883179, '0.26811506053820466'], + [1721886779, '0.25839295774934357'], + [1721890379, '0.26863082415681144'], + [1721893979, '0.26126998689934394'], + [1721897579, '0.26164641539434685'], + ], + }, + ], + resultType: 'matrix', + }, + isPartial: false, + stats: { + executionTimeMsec: 23, + seriesFetched: '14', + }, + status: 'success', + }; + + return HttpResponse.json(response); + }), ...entityTransfers, ...statusPage, ...databases, diff --git a/packages/manager/src/queries/cloudpulse/dashboards.ts b/packages/manager/src/queries/cloudpulse/dashboards.ts index 737bd6ac8e7..0a12f16aa54 100644 --- a/packages/manager/src/queries/cloudpulse/dashboards.ts +++ b/packages/manager/src/queries/cloudpulse/dashboards.ts @@ -1,38 +1,14 @@ -import { getDashboardById, getDashboards } from '@linode/api-v4'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useQuery } from '@tanstack/react-query'; +import { queryFactory } from './queries'; + import type { Dashboard } from '@linode/api-v4'; import type { APIError, ResourcePage } from '@linode/api-v4/lib/types'; -export const queryKey = 'cloudpulse-dashboards'; - -export const dashboardQueries = createQueryKeys(queryKey, { - dashboardById: (dashboardId: number) => ({ - contextQueries: { - dashboard: { - queryFn: () => getDashboardById(dashboardId), - queryKey: [dashboardId], - }, - }, - queryKey: [dashboardId], - }), - - lists: { - contextQueries: { - allDashboards: { - queryFn: getDashboards, - queryKey: null, - }, - }, - queryKey: null, - }, -}); - // Fetch the list of all the dashboard available export const useCloudPulseDashboardsQuery = (enabled: boolean) => { return useQuery, APIError[]>({ - ...dashboardQueries.lists._ctx.allDashboards, + ...queryFactory.lists._ctx.dashboards, enabled, }); }; @@ -41,7 +17,7 @@ export const useCloudPulseDashboardByIdQuery = ( dashboardId: number | undefined ) => { return useQuery({ - ...dashboardQueries.dashboardById(dashboardId!)._ctx.dashboard, + ...queryFactory.dashboardById(dashboardId!), enabled: dashboardId !== undefined, }); }; diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts new file mode 100644 index 00000000000..bbd4b6ea88f --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -0,0 +1,82 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import Axios from 'axios'; + +import { queryFactory } from './queries'; + +import type { + APIError, + CloudPulseMetricsRequest, + CloudPulseMetricsResponse, + JWEToken, +} from '@linode/api-v4'; +import type { AxiosRequestConfig } from 'axios'; + +const axiosInstance = Axios.create({}); + +export const useCloudPulseMetricsQuery = ( + serviceType: string, + request: CloudPulseMetricsRequest, + obj: { + authToken: string; + isFlags: boolean; + label: string; + timeStamp: number | undefined; + url: string; + } +) => { + const queryClient = useQueryClient(); + return useQuery({ + ...queryFactory.metrics( + obj.authToken, + obj.url, + serviceType, + request, + obj.timeStamp, + obj.label + ), + + enabled: !!obj.isFlags, + onError(err: APIError[]) { + if (err && err.length > 0 && err[0].reason == 'Token expired') { + const currentJWEtokenCache: + | JWEToken + | undefined = queryClient.getQueryData( + queryFactory.token(serviceType, { resource_id: [] }).queryKey + ); + if (currentJWEtokenCache?.token === obj.authToken) { + queryClient.invalidateQueries( + queryFactory.token(serviceType, { resource_id: [] }).queryKey, + {}, + { + cancelRefetch: true, + } + ); + } + } + }, + refetchInterval: 120000, + refetchOnWindowFocus: false, + retry: 0, + }); +}; + +export const fetchCloudPulseMetrics = ( + token: string, + readApiEndpoint: string, + serviceType: string, + requestData: CloudPulseMetricsRequest +) => { + const config: AxiosRequestConfig = { + data: requestData, + headers: { + Authorization: `Bearer ${token}`, + }, + method: 'POST', + url: `${readApiEndpoint}${encodeURIComponent(serviceType!)}/metrics`, + }; + + return axiosInstance + .request(config) + .then((response) => response.data) + .catch((error) => Promise.reject(error.response.data.errors)); +}; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts new file mode 100644 index 00000000000..49ea04eab91 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -0,0 +1,76 @@ +import { + getDashboardById, + getDashboards, + getJWEToken, + getMetricDefinitionsByServiceType, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { getAllLinodesRequest } from '../linodes/requests'; +import { volumeQueries } from '../volumes/volumes'; +import { fetchCloudPulseMetrics } from './metrics'; + +import type { + CloudPulseMetricsRequest, + Filter, + JWETokenPayLoad, + Params, +} from '@linode/api-v4'; + +const key = 'Clousepulse'; + +export const queryFactory = createQueryKeys(key, { + dashboardById: (dashboardId: number) => ({ + queryFn: () => getDashboardById(dashboardId), + queryKey: [dashboardId], + }), + lists: { + contextQueries: { + dashboards: { + queryFn: getDashboards, + queryKey: null, + }, + }, + queryKey: null, + }, + metrics: ( + token: string, + readApiEndpoint: string, + serviceType: string, + requestData: CloudPulseMetricsRequest, + timeStamp: number | undefined, + label: string + ) => ({ + queryFn: () => + fetchCloudPulseMetrics(token, readApiEndpoint, serviceType, requestData), + queryKey: [requestData, timeStamp, label], + }), + metricsDefinitons: (serviceType: string | undefined) => ({ + queryFn: () => getMetricDefinitionsByServiceType(serviceType!), + queryKey: [serviceType], + }), + + resources: ( + resourceType: string | undefined, + params?: Params, + filters?: Filter + ) => { + switch (resourceType) { + case 'linode': + return { + queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys + queryKey: ['linodes', params, filters], + }; + case 'volumes': + return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts + + default: + return volumeQueries.lists._ctx.all(params, filters); // default to volumes + } + }, + + token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({ + queryFn: () => getJWEToken(request, serviceType!), + queryKey: [serviceType], + }), +}); diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index edfa4891648..ee3b80b9aea 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -1,30 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import { getAllLinodesRequest } from '../linodes/requests'; -import { volumeQueries } from '../volumes/volumes'; +import { queryFactory } from './queries'; import type { Filter, Params } from '@linode/api-v4'; import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; -// in this we don't need to define our own query factory, we will reuse existing query factory implementation from services like in volumes.ts, linodes.ts etc -export const QueryFactoryByResources = ( - resourceType: string | undefined, - params?: Params, - filters?: Filter -) => { - switch (resourceType) { - case 'linode': - return { - queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys - queryKey: ['linodes', params, filters], - }; - case 'volumes': - return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts - default: - return volumeQueries.lists._ctx.all(params, filters); // default to volumes - } -}; - export const useResourcesQuery = ( enabled = false, resourceType: string | undefined, @@ -32,7 +12,7 @@ export const useResourcesQuery = ( filters?: Filter ) => useQuery({ - ...QueryFactoryByResources(resourceType, params, filters), + ...queryFactory.resources(resourceType, params, filters), enabled, select: (resources) => { return resources.map((resource) => { diff --git a/packages/manager/src/queries/cloudpulse/services.ts b/packages/manager/src/queries/cloudpulse/services.ts index 0f727400c0c..8428613c2c1 100644 --- a/packages/manager/src/queries/cloudpulse/services.ts +++ b/packages/manager/src/queries/cloudpulse/services.ts @@ -1,7 +1,7 @@ -import { getJWEToken, getMetricDefinitionsByServiceType } from '@linode/api-v4'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useQuery } from '@tanstack/react-query'; +import { queryFactory } from './queries'; + import type { APIError, JWEToken, @@ -9,26 +9,12 @@ import type { MetricDefinitions, } from '@linode/api-v4'; -export const queryKey = 'cloudpulse-services'; -export const serviceTypeKey = 'service-types'; - -const serviceQueries = createQueryKeys(queryKey, { - metricsDefinitons: (serviceType: string | undefined) => ({ - queryFn: () => getMetricDefinitionsByServiceType(serviceType!), - queryKey: [serviceType], - }), - token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({ - queryFn: () => getJWEToken(request, serviceType!), - queryKey: [serviceType], - }), -}); - export const useGetCloudPulseMetricDefinitionsByServiceType = ( serviceType: string | undefined, enabled: boolean ) => { return useQuery({ - ...serviceQueries.metricsDefinitons(serviceType), + ...queryFactory.metricsDefinitons(serviceType), enabled, }); }; @@ -39,7 +25,7 @@ export const useCloudPulseJWEtokenQuery = ( runQuery: boolean ) => { return useQuery({ - ...serviceQueries.token(serviceType, request), + ...queryFactory.token(serviceType, request), enabled: runQuery, keepPreviousData: true, refetchOnWindowFocus: false, From 3e3e342ab4c6d9b2feb9fad2a2ec85ca61faaf92 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Mon, 5 Aug 2024 09:53:43 -0400 Subject: [PATCH 06/43] fix: Region filter for Core tab in Linode Create v2 (#10743) --- .../LinodeCreatev2/TwoStepRegion.test.tsx | 38 +++++++++++++++++++ .../Linodes/LinodeCreatev2/TwoStepRegion.tsx | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx index d2be890a68f..bdc8bfacfec 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.test.tsx @@ -38,6 +38,44 @@ describe('TwoStepRegion', () => { expect(select).toBeEnabled(); }); + it('should only display core regions in the Core tab region select', async () => { + const { + getByPlaceholderText, + getByRole, + } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + await userEvent.click(select); + + const dropdown = getByRole('listbox'); + expect(dropdown.innerHTML).toContain('US, Newark'); + expect(dropdown.innerHTML).not.toContain( + 'US, Gecko Distributed Region Test' + ); + }); + + it('should only display distributed regions in the Distributed tab region select', async () => { + const { + getAllByRole, + getByPlaceholderText, + getByRole, + } = renderWithThemeAndHookFormContext({ + component: , + }); + + const tabs = getAllByRole('tab'); + await userEvent.click(tabs[1]); + + const select = getByPlaceholderText('Select a Region'); + await userEvent.click(select); + + const dropdown = getByRole('listbox'); + expect(dropdown.innerHTML).toContain('US, Gecko Distributed Region Test'); + expect(dropdown.innerHTML).not.toContain('US, Newark'); + }); + it('should render a Geographical Area select with All pre-selected and a Region Select for the Distributed tab', async () => { const { getAllByRole } = renderWithThemeAndHookFormContext({ component: , diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx index 2af6b9e6ac8..21f13c36d80 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/TwoStepRegion.tsx @@ -93,7 +93,7 @@ export const TwoStepRegion = (props: CombinedProps) => { disabledRegions={disabledRegions} errorText={errorText} onChange={(e, region) => onChange(region)} - regionFilter={regionFilter} + regionFilter="core" regions={regions ?? []} showDistributedRegionIconHelperText={false} value={value} From f53442d818808f6483c3af3edb1c6e634179b105 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Tue, 6 Aug 2024 14:38:39 +0530 Subject: [PATCH 07/43] feat: [M3-7447] Added Support for VPCs in 'Open Support Ticket' Flow (#10746) * feat: [M3-7447] Added Support for VPCs in 'Open Support Ticket' Flow * feat: [M3-7447] Implemented functionality to associate a VPC entity with its corresponding VPC link * Added changeset: Support for VPCs in 'Open Support Ticket' Flow --- .../manager/.changeset/pr-10746-added-1722932770115.md | 5 +++++ .../Support/SupportTickets/SupportTicketDialog.tsx | 3 ++- .../SupportTicketProductSelectionFields.tsx | 10 ++++++++++ .../src/features/Support/SupportTickets/constants.tsx | 2 ++ packages/manager/src/utilities/getEventsActionLink.ts | 6 ++++-- 5 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-10746-added-1722932770115.md diff --git a/packages/manager/.changeset/pr-10746-added-1722932770115.md b/packages/manager/.changeset/pr-10746-added-1722932770115.md new file mode 100644 index 00000000000..30a9e6ba7ff --- /dev/null +++ b/packages/manager/.changeset/pr-10746-added-1722932770115.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Support for VPCs in 'Open Support Ticket' Flow ([#10746](https://github.com/linode/manager/pull/10746)) diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index 726816ba3de..8d468debf51 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -83,7 +83,8 @@ export type EntityType = | 'lkecluster_id' | 'nodebalancer_id' | 'none' - | 'volume_id'; + | 'volume_id' + | 'vpc_id'; export type TicketType = 'accountLimit' | 'general' | 'smtp'; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 2fd3a8b7bd0..ee32852f99d 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -11,6 +11,7 @@ import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; +import { useAllVPCsQuery } from 'src/queries/vpcs/vpcs'; import { ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP, @@ -85,6 +86,12 @@ export const SupportTicketProductSelectionFields = (props: Props) => { isLoading: volumesLoading, } = useAllVolumesQuery({}, {}, entityType === 'volume_id'); + const { + data: vpcs, + error: vpcsError, + isLoading: vpcsLoading, + } = useAllVPCsQuery(entityType === 'vpc_id'); + const getEntityOptions = (): { label: string; value: number }[] => { const reactQueryEntityDataMap = { database_id: databases, @@ -94,6 +101,7 @@ export const SupportTicketProductSelectionFields = (props: Props) => { lkecluster_id: clusters, nodebalancer_id: nodebalancers, volume_id: volumes, + vpc_id: vpcs, }; if (!reactQueryEntityDataMap[entityType]) { @@ -130,6 +138,7 @@ export const SupportTicketProductSelectionFields = (props: Props) => { nodebalancer_id: nodebalancersLoading, none: false, volume_id: volumesLoading, + vpc_id: vpcsLoading, }; const errorMap: Record = { @@ -142,6 +151,7 @@ export const SupportTicketProductSelectionFields = (props: Props) => { nodebalancer_id: nodebalancersError, none: null, volume_id: volumesError, + vpc_id: vpcsError, }; const entityOptions = getEntityOptions(); diff --git a/packages/manager/src/features/Support/SupportTickets/constants.tsx b/packages/manager/src/features/Support/SupportTickets/constants.tsx index 00f338bf54b..3bb669ca145 100644 --- a/packages/manager/src/features/Support/SupportTickets/constants.tsx +++ b/packages/manager/src/features/Support/SupportTickets/constants.tsx @@ -69,6 +69,7 @@ export const ENTITY_MAP: Record = { Kubernetes: 'lkecluster_id', Linodes: 'linode_id', NodeBalancers: 'nodebalancer_id', + VPCs: 'vpc_id', Volumes: 'volume_id', }; @@ -82,6 +83,7 @@ export const ENTITY_ID_TO_NAME_MAP: Record = { nodebalancer_id: 'NodeBalancer', none: '', volume_id: 'Volume', + vpc_id: 'VPC', }; // General custom fields common to multiple custom ticket types. diff --git a/packages/manager/src/utilities/getEventsActionLink.ts b/packages/manager/src/utilities/getEventsActionLink.ts index b67101fa5fd..efd25755d06 100644 --- a/packages/manager/src/utilities/getEventsActionLink.ts +++ b/packages/manager/src/utilities/getEventsActionLink.ts @@ -1,7 +1,7 @@ -import { Entity, EventAction } from '@linode/api-v4/lib/account'; - import { nonClickEvents } from 'src/constants'; +import type { Entity, EventAction } from '@linode/api-v4/lib/account'; + export const getEngineFromDatabaseEntityURL = (url: string) => { return url.match(/databases\/(\w*)\/instances/i)?.[1]; }; @@ -147,6 +147,8 @@ export const getLinkTargets = (entity: Entity | null) => { return '/volumes'; case 'placement_group': return `/placement-groups/${entity.id}`; + case 'vpc': + return `/vpcs/${entity.id}`; default: return null; } From b383ea907250aaa83601a0b8a48a28e6f3c22813 Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Wed, 7 Aug 2024 09:10:38 -0400 Subject: [PATCH 08/43] test: [M3-8419] - Fix failing stackscript deploy test (#10757) * fix failing stackscript deploy test * Added changeset: Update StackScript Deploy test --- packages/manager/.changeset/pr-10757-tests-1722971426093.md | 5 +++++ .../core/stackscripts/smoke-community-stackscrips.spec.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10757-tests-1722971426093.md diff --git a/packages/manager/.changeset/pr-10757-tests-1722971426093.md b/packages/manager/.changeset/pr-10757-tests-1722971426093.md new file mode 100644 index 00000000000..4973c90d8e4 --- /dev/null +++ b/packages/manager/.changeset/pr-10757-tests-1722971426093.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Update StackScript Deploy test ([#10757](https://github.com/linode/manager/pull/10757)) 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 9cfc145aa5d..d30c17acd52 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 @@ -330,7 +330,11 @@ describe('Community Stackscripts integration tests', () => { cy.get('[id="vpn-password"]').should('have.value', vpnPassword); // Choose an image - cy.findByPlaceholderText('Choose an image').should('be.visible').click(); + cy.findByPlaceholderText('Choose an image') + .should('be.visible') + .click() + .type(image); + ui.autocompletePopper.findByTitle(image).should('be.visible').click(); cy.findByText(image).should('be.visible').click(); From db33a8df7d34939d1400b07b76bd3c3caba3da56 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:09:28 -0700 Subject: [PATCH 09/43] change: [M3-8418] - Add documentation on changeset best practices (#10758) * Add changeset best practices * Clean up * Added changeset: Documentation for changeset best practices * Add changesets * Address feedback: reduce duplication and include in CONTRIBUTING --- docs/CONTRIBUTING.md | 16 +++++++++++++++- docs/PULL_REQUEST_TEMPLATE.md | 2 +- packages/api-v4/.changeset/README.md | 2 +- packages/manager/.changeset/README.md | 2 +- .../.changeset/pr-10758-added-1722975143465.md | 5 +++++ packages/validation/.changeset/README.md | 2 +- 6 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-10758-added-1722975143465.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 129cbb844e8..946b3f703a9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -27,7 +27,7 @@ Feel free to open an issue to report a bug or request a feature. **Example:** `feat: [M3-1234] - Allow user to view their login history` 6. Open a pull request against `develop` and make sure the title follows the same format as the commit message. -7. If needed, create a changeset to populate our changelog +7. If needed, create a changeset to populate our changelog. - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), - install it via `brew`: https://github.com/cli/cli#installation or upgrade with `brew upgrade gh` - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) @@ -37,9 +37,23 @@ Feel free to open an issue to report a bug or request a feature. - A changeset is optional, but should be included if the PR falls in one of the following categories:
`Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` - Select the changeset category that matches the commit type in your PR title. (Where this isn't a 1:1 match: generally, a `feat` commit type falls under an `Added` change and `refactor` falls under `Tech Stories`.) + - Write your changeset by following our [best practices](#writing-a-changeset). Two reviews from members of the Cloud Manager team are required before merge. After approval, all pull requests are squash merged. +## Writing a changeset + +Follow these best practices to write a good changeset: + +- Use a consistent tense in all changeset entries. We have chosen to use **imperative (present)** tense. (This follows established [git commit message best practices](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).) +- Avoid starting a changeset with the verb "Add", "Remove", "Change" or "Fix" when listed under that respective `Added`, `Removed`, `Changed` or `Fixed` section. It is unnecessary repetition. +- For `Fixed` changesets, describe the bug that needed to be fixed, rather than the fix itself. (e.g. say "Missing button labels in action buttons" rather than "Make label prop required for action buttons"). +- Begin a changeset with a capital letter, but do not end it with a period; it's not a complete sentence. +- When referencing code, consider adding backticks for readability. (e.g. "Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]`"). +- Use the `Upcoming Features` section for ongoing project work that is behind a feature flag. If additional changes are being made that are not feature flagged, add another changeset to describe that work. +- Add changesets for `docs/` documentation changes in the `manager` package, as this is generally best-fit. +- Generally, if the code change is a fix for a previous change that has been merged to `develop` but was never released to production, we don't need to include a changeset. + ## Docs To run the docs development server locally, [install Bun](https://bun.sh/) and start the server: `yarn docs`. diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index e7cb34aec3f..15e72c8495e 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -42,7 +42,7 @@ Please specify a release date to guarantee timely review of this PR. If exact da - [ ] 👀 Doing a self review - [ ] ❔ Our [contribution guidelines](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md) - [ ] 🤏 Splitting feature into small PRs -- [ ] ➕ Adding a changeset +- [ ] ➕ Adding a [changeset](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md#writing-a-changeset) - [ ] 🧪 Providing/Improving test coverage - [ ] 🔐 Removing all sensitive information from the code and PR description - [ ] 🚩 Using a feature flag to protect the release diff --git a/packages/api-v4/.changeset/README.md b/packages/api-v4/.changeset/README.md index 30aa3f3cbd8..d96182a25b0 100644 --- a/packages/api-v4/.changeset/README.md +++ b/packages/api-v4/.changeset/README.md @@ -15,4 +15,4 @@ You must commit them to the repo so they can be picked up for the changelog gene This directory get wiped out when running `yarn generate-changelog`. -See `changeset.mjs` for implementation details. \ No newline at end of file +See `changeset.mjs` for implementation details. diff --git a/packages/manager/.changeset/README.md b/packages/manager/.changeset/README.md index 30aa3f3cbd8..d96182a25b0 100644 --- a/packages/manager/.changeset/README.md +++ b/packages/manager/.changeset/README.md @@ -15,4 +15,4 @@ You must commit them to the repo so they can be picked up for the changelog gene This directory get wiped out when running `yarn generate-changelog`. -See `changeset.mjs` for implementation details. \ No newline at end of file +See `changeset.mjs` for implementation details. diff --git a/packages/manager/.changeset/pr-10758-added-1722975143465.md b/packages/manager/.changeset/pr-10758-added-1722975143465.md new file mode 100644 index 00000000000..d63f063fd50 --- /dev/null +++ b/packages/manager/.changeset/pr-10758-added-1722975143465.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Documentation for changeset best practices ([#10758](https://github.com/linode/manager/pull/10758)) diff --git a/packages/validation/.changeset/README.md b/packages/validation/.changeset/README.md index 30aa3f3cbd8..d96182a25b0 100644 --- a/packages/validation/.changeset/README.md +++ b/packages/validation/.changeset/README.md @@ -15,4 +15,4 @@ You must commit them to the repo so they can be picked up for the changelog gene This directory get wiped out when running `yarn generate-changelog`. -See `changeset.mjs` for implementation details. \ No newline at end of file +See `changeset.mjs` for implementation details. From 7d45a9437c0cadb469189a3f9de4212fad5733fc Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:10:52 -0400 Subject: [PATCH 10/43] test: [M3-8136] - Add Cypress integration test for closing support tickets (#10697) * M3-8136: Add Cypress integration test for closing support tickets * Added changeset: Add Cypress integration test for closing support tickets * Fixed comments --- .../pr-10697-tests-1721417045353.md | 5 + .../close-support-ticket.spec.ts | 156 ++++++++++++++++++ .../open-support-ticket.spec.ts | 2 +- .../support/constants/help-and-support.ts | 14 ++ .../cypress/support/intercepts/support.ts | 19 ++- 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10697-tests-1721417045353.md create mode 100644 packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts create mode 100644 packages/manager/cypress/support/constants/help-and-support.ts diff --git a/packages/manager/.changeset/pr-10697-tests-1721417045353.md b/packages/manager/.changeset/pr-10697-tests-1721417045353.md new file mode 100644 index 00000000000..f0192adb2df --- /dev/null +++ b/packages/manager/.changeset/pr-10697-tests-1721417045353.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress integration test for closing support tickets ([#10697](https://github.com/linode/manager/pull/10697)) diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts new file mode 100644 index 00000000000..f9eb2a840cb --- /dev/null +++ b/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts @@ -0,0 +1,156 @@ +import 'cypress-file-upload'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { ui } from 'support/ui'; +import { + randomItem, + randomLabel, + randomNumber, + randomPhrase, +} from 'support/util/random'; +import { supportTicketFactory } from 'src/factories'; +import { + mockGetSupportTicket, + mockGetSupportTickets, + mockGetSupportTicketReplies, + mockCloseSupportTicket, +} from 'support/intercepts/support'; +import { SEVERITY_LABEL_MAP } from 'src/features/Support/SupportTickets/constants'; +import { + closableMessage, + closeButtonText, +} from 'support/constants/help-and-support'; + +describe('close support tickets', () => { + /* + * - Opens a Help & Support ticket with mocked ticket data. + * - Confirms that there is no "close ticket" button showing up for the default support ticket. + */ + it('cannot close a default support ticket by customers', () => { + 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'); + + // Confirm that the support ticket is not closable by default. + cy.findByText(closableMessage, { exact: false }).should('not.exist'); + }); + + /* + * - Opens a Help & Support ticket with mocked ticket data. + * - Confirms that the closable support ticket can be closed by customers successfully. + */ + it('can close a closable support ticket', () => { + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + closable: true, + }); + + const mockClosedTicket = supportTicketFactory.build({ + ...mockTicket, + status: 'closed', + closed: 'close by customers', + }); + + // 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'); + mockCloseSupportTicket(mockTicket.id).as('closeSupportTicket'); + + cy.visitWithLogin('/support/tickets'); + + // Confirm that tickets are listed as expected. + cy.findByText(mockTicket.summary).should('be.visible').click(); + + cy.wait(['@getSupportTicket', '@getReplies']); + + // Confirm that the closable message shows up. + cy.findByText(closableMessage, { exact: false }).should('be.visible'); + + // Confirm that the "close the ticket" button can be clicked. + ui.button.findByTitle(closeButtonText).should('be.visible').click(); + ui.dialog + .findByTitle('Confirm Ticket Close') + .should('be.visible') + .within(() => { + cy.findByText('Are you sure you want to close this ticket?').should( + 'be.visible' + ); + ui.button + .findByTitle('Confirm') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@closeSupportTicket'); + }); + + mockGetSupportTickets([mockClosedTicket]); + mockGetSupportTicket(mockClosedTicket).as('getClosedSupportTicket'); + cy.visit('/support/tickets'); + + // Confirm that the ticket is closed. + cy.findByText(mockClosedTicket.summary).should('be.visible').click(); + cy.wait('@getClosedSupportTicket'); + cy.get('[aria-label="Ticket status is closed"]').should('be.visible'); + cy.findByText('Closed'); + }); +}); 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 1161505d52b..07636b6f0ee 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 @@ -56,7 +56,7 @@ import { mockGetClusters } from 'support/intercepts/lke'; import { linodeCreatePage } from 'support/ui/pages'; import { chooseRegion } from 'support/util/regions'; -describe('help & support', () => { +describe('open support tickets', () => { after(() => { cleanUp(['linodes']); }); diff --git a/packages/manager/cypress/support/constants/help-and-support.ts b/packages/manager/cypress/support/constants/help-and-support.ts new file mode 100644 index 00000000000..7a781477065 --- /dev/null +++ b/packages/manager/cypress/support/constants/help-and-support.ts @@ -0,0 +1,14 @@ +/** + * Close button text that is displayed in the detail of closable support ticket. + */ +export const closeButtonText = 'close this ticket'; + +/** + * Closable message that is displayed in the detail of closable support ticket. + */ +export const closableMessage = 'If everything is resolved, you can'; + +/** + * Dialog title that is in close ticket dialog. + */ +export const closeTicketDialogTitle = 'Confirm Ticket Close'; diff --git a/packages/manager/cypress/support/intercepts/support.ts b/packages/manager/cypress/support/intercepts/support.ts index d4a65e94b71..4b55900a614 100644 --- a/packages/manager/cypress/support/intercepts/support.ts +++ b/packages/manager/cypress/support/intercepts/support.ts @@ -58,7 +58,24 @@ export const mockGetSupportTicket = ( }; /** - * Intercepts request to fetch support tickets and mocks response. + * Interepts request to close a support ticket and mocks response. + * + * @param ticketId - Numeric ID of support ticket for which to mock replies. + * + * @returns Cypress chainable. + */ +export const mockCloseSupportTicket = ( + ticketId: number +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`support/tickets/${ticketId}/close`), + makeResponse({}) + ); +}; + +/** + * Intercepts request to fetch open support tickets and mocks response. * * @param tickets - Array of support ticket objects with which to mock response. * From 076caba9bda9b37af707cb234552aff8925e3248 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 8 Aug 2024 10:11:21 -0400 Subject: [PATCH 11/43] test: Fix `EditImageDrawer.test.tsx` Unit Test Flake (#10759) * await the `userEvent` * remove unnesseasy `waitFor` * Added changeset: Fix `EditImageDrawer.test.tsx` unit test flake --------- Co-authored-by: Banks Nussman --- packages/manager/.changeset/pr-10759-tests-1723040352780.md | 5 +++++ .../features/Images/ImagesLanding/EditImageDrawer.test.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10759-tests-1723040352780.md diff --git a/packages/manager/.changeset/pr-10759-tests-1723040352780.md b/packages/manager/.changeset/pr-10759-tests-1723040352780.md new file mode 100644 index 00000000000..c8d1ca58a7a --- /dev/null +++ b/packages/manager/.changeset/pr-10759-tests-1723040352780.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix `EditImageDrawer.test.tsx` unit test flake ([#10759](https://github.com/linode/manager/pull/10759)) diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx index 9252654502c..d21af710b74 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx @@ -47,9 +47,9 @@ describe('EditImageDrawer', () => { const tagsInput = getByRole('combobox'); - userEvent.type(tagsInput, 'new-tag'); + await userEvent.type(tagsInput, 'new-tag'); - await waitFor(() => expect(tagsInput).toHaveValue('new-tag')); + expect(tagsInput).toHaveValue('new-tag'); fireEvent.click(getByText('Create "new-tag"')); From 4da2f434d697b7a6c7d9924d5a51a9a975defb69 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 8 Aug 2024 11:27:26 -0400 Subject: [PATCH 12/43] refactor: [M3-8244] - Remove `suppressImplicitAnyIndexErrors` and `ignoreDeprecations` Typescript Options (#10755) * save changes #1 * save changes #2 * save changes #3 * save changes #4 * save changes #5 * save changes #6 * save changes #7 * save changes #8 * fix image select * save changes #9 * save changes #10 * last issue is filtering * fix unit test by fixing button types * finally resolved all tsc errors * Added changeset: Remove `suppressImplicitAnyIndexErrors` and `ignoreDeprecations` Typescript Options * fix cypress feature flag util hopefully * another attemnpt to fix cypress feature flag util function * make image select close onSelect to match previous behavior * feedback @jaalah-akamai --------- Co-authored-by: Banks Nussman --- packages/api-v4/src/account/types.ts | 1 + .../pr-10755-tech-stories-1722965778724.md | 5 + .../account/third-party-access-tokens.spec.ts | 2 +- .../e2e/core/account/user-permissions.spec.ts | 1 + .../core/firewalls/update-firewall.spec.ts | 2 +- .../stackscripts/update-stackscripts.spec.ts | 4 +- .../support/constants/user-permissions.ts | 1 + .../support/plugins/test-tagging-info.ts | 2 +- .../setup/page-visit-tracking-commands.ts | 1 + .../cypress/support/util/feature-flags.ts | 28 +-- .../manager/src/components/Button/Button.tsx | 4 + .../components/DownloadCSV/DownloadCSV.tsx | 2 +- packages/manager/src/components/Flag.tsx | 15 +- .../components/ImageSelect/ImageSelect.tsx | 2 +- .../InlineMenuAction/InlineMenuAction.tsx | 5 +- packages/manager/src/components/OSIcon.tsx | 4 +- .../PaymentMethodRow/ThirdPartyPayment.tsx | 9 +- .../RegionSelect/RegionSelect.utils.tsx | 4 +- .../src/components/Uploaders/reducer.ts | 8 +- .../src/dev-tools/EnvironmentToggleTool.tsx | 8 +- packages/manager/src/factories/grants.ts | 1 + .../SwitchAccounts/ChildAccountList.tsx | 4 +- .../src/features/Betas/BetasLanding.tsx | 20 +- .../PaymentDrawer/PaymentMethodCard.tsx | 15 +- .../shared/CloudPulseTimeRangeSelect.tsx | 12 -- .../DatabaseCreate/DatabaseCreate.tsx | 3 +- .../DatabaseDetail/DatabaseStatusDisplay.tsx | 2 +- .../features/Domains/DomainRecordDrawer.tsx | 40 ++-- .../TransferCheckoutBar.tsx | 2 +- .../ConfirmTransferDialog.tsx | 38 ++-- .../src/features/Events/EventsLanding.tsx | 4 +- .../Events/EventsMessages.stories.tsx | 5 +- .../features/Events/eventMessageGenerator.ts | 8 +- .../features/Events/factories/firewall.tsx | 8 +- .../manager/src/features/Events/factory.tsx | 2 +- .../Rules/FirewallRuleDrawer.utils.ts | 5 +- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 3 +- .../FirewallLanding/CreateFirewallDrawer.tsx | 4 + .../manager/src/features/Firewalls/shared.ts | 5 +- .../src/features/Images/ImageSelect.test.tsx | 170 +++++++---------- .../src/features/Images/ImageSelect.tsx | 180 +++++------------- .../Images/ImagesLanding/ImagesLanding.tsx | 16 +- .../KubernetesPlansPanel.tsx | 88 ++++----- .../Linodes/CloneLanding/utilities.ts | 10 +- .../Tabs/Marketplace/AppDetailDrawer.tsx | 5 +- .../UserDefinedFieldInput.tsx | 3 + .../UserDefinedFields/utilities.ts | 2 +- .../Linodes/LinodeCreatev2/resolvers.ts | 8 +- .../Linodes/LinodeCreatev2/utilities.ts | 4 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 5 +- .../LinodesCreate/LinodeCreateContainer.tsx | 9 +- .../TabbedContent/FromAppsContent.test.tsx | 13 +- .../TabbedContent/FromAppsContent.tsx | 15 +- .../TabbedContent/FromStackScriptContent.tsx | 15 +- .../LinodesDetail/LinodeConfigs/ConfigRow.tsx | 34 +++- .../LinodeConfigs/LinodeConfigDialog.tsx | 12 +- .../LinodeNetworking/IPSharing.tsx | 62 +++--- .../LinodeNetworking/IPTransfer.tsx | 2 +- .../LinodeSettings/ImageAndPassword.tsx | 7 +- .../LinodeSettings/KernelSelect.tsx | 58 ++++-- .../LinodeStorage/CreateDiskDrawer.tsx | 7 +- .../LinodeStorage/LinodeDiskRow.tsx | 4 +- .../MutateDrawer/MutateDrawer.tsx | 43 +++-- .../DetailTabs/Processes/ProcessesGraphs.tsx | 1 + .../DetailTabs/Processes/ProcessesLanding.tsx | 1 + .../manager/src/features/Longview/request.ts | 9 +- .../src/features/Longview/shared/utilities.ts | 7 +- .../Managed/SSHAccess/EditSSHAccessDrawer.tsx | 4 +- .../NodeBalancers/NodeBalancerCreate.tsx | 8 +- .../src/features/ObjectStorage/utilities.ts | 10 +- .../features/OneClickApps/AppDetailDrawer.tsx | 5 +- .../PlacementGroupsLanding.tsx | 9 +- .../src/features/Profile/APITokens/utils.ts | 4 +- .../src/features/Search/SearchLanding.tsx | 18 +- .../src/features/Search/refinedSearch.ts | 6 +- packages/manager/src/features/Search/utils.ts | 4 +- .../StackScriptBase/StackScriptBase.tsx | 2 +- .../StackScriptCreate.test.tsx | 2 +- .../StackScriptCreate/StackScriptCreate.tsx | 16 +- .../StackScriptForm/StackScriptForm.tsx | 9 +- .../StackScriptForm/utils.test.ts | 25 --- .../StackScripts/StackScriptForm/utils.ts | 14 -- .../FieldTypes/UserDefinedMultiSelect.tsx | 6 +- .../features/StackScripts/stackScriptUtils.ts | 2 +- .../SupportTicketProductSelectionFields.tsx | 4 + .../src/features/Users/UserPermissions.tsx | 17 +- .../VPCs/VPCLanding/VPCEditDrawer.tsx | 2 +- .../src/features/Volumes/VolumesLanding.tsx | 11 +- .../components/PlansPanel/PlanInformation.tsx | 5 +- .../components/PlansPanel/PlansPanel.tsx | 122 ++++++------ .../features/components/PlansPanel/utils.ts | 16 +- packages/manager/src/hooks/useCreateVPC.ts | 8 +- .../src/hooks/useDismissibleNotifications.ts | 5 +- packages/manager/src/hooks/useStackScript.ts | 2 +- packages/manager/src/initSentry.ts | 2 +- packages/manager/src/mocks/serverHandlers.ts | 44 +++-- .../manager/src/queries/account/agreements.ts | 6 +- packages/manager/src/queries/base.ts | 26 ++- .../src/queries/events/event.helpers.ts | 2 + .../src/queries/object-storage/queries.ts | 4 +- .../manager/src/queries/profile/profile.ts | 2 +- .../manager/src/store/image/image.helpers.ts | 6 +- .../src/store/longview/longview.reducer.ts | 11 +- .../manager/src/utilities/analytics/utils.ts | 6 +- .../utilities/codesnippets/generate-cURL.ts | 4 +- packages/manager/src/utilities/deepMerge.ts | 12 +- packages/manager/src/utilities/errorUtils.ts | 2 +- .../manager/src/utilities/formikErrorUtils.ts | 2 +- packages/manager/src/utilities/images.test.ts | 73 ------- packages/manager/src/utilities/images.ts | 41 ++-- .../src/utilities/pricing/dynamicPricing.ts | 13 +- packages/manager/src/utilities/regions.ts | 8 +- packages/manager/src/utilities/storage.ts | 2 +- packages/manager/src/utilities/subnets.ts | 6 +- packages/manager/src/utilities/theme.ts | 2 +- .../manager/src/utilities/unitConversions.ts | 6 +- packages/manager/tsconfig.json | 4 - 117 files changed, 818 insertions(+), 881 deletions(-) create mode 100644 packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md delete mode 100644 packages/manager/src/features/StackScripts/StackScriptForm/utils.test.ts delete mode 100644 packages/manager/src/features/StackScripts/StackScriptForm/utils.ts delete mode 100644 packages/manager/src/utilities/images.test.ts diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index fafa09e4d3c..9dfddcaf735 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -182,6 +182,7 @@ export type GlobalGrantTypes = | 'add_images' | 'add_linodes' | 'add_longview' + | 'add_databases' | 'add_nodebalancers' | 'add_stackscripts' | 'add_volumes' diff --git a/packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md b/packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md new file mode 100644 index 00000000000..49e4db22435 --- /dev/null +++ b/packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove `suppressImplicitAnyIndexErrors` and `ignoreDeprecations` Typescript Options ([#10755](https://github.com/linode/manager/pull/10755)) diff --git a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts index 9e918bf9dcd..0f020d73aa0 100644 --- a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts @@ -66,7 +66,7 @@ describe('Third party access tokens', () => { .findByTitle(token.label) .should('be.visible') .within(() => { - Object.keys(access).forEach((key) => { + Object.keys(access).forEach((key: keyof typeof access) => { cy.findByText(key) .closest('tr') .within(() => { diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index d8f21fa1366..53be928aded 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -281,6 +281,7 @@ describe('User permission management', () => { cancel_account: true, child_account_access: true, add_domains: true, + add_databases: true, add_firewalls: true, add_images: true, add_linodes: true, diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 85520e1432e..d67ac52fd3a 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -78,7 +78,7 @@ const addFirewallRules = (rule: FirewallRuleType, direction: string) => { .within(() => { const port = rule.ports ? rule.ports : '22'; cy.findByPlaceholderText('Select a rule preset...').type( - portPresetMap[port] + '{enter}' + portPresetMap[port as keyof typeof portPresetMap] + '{enter}' ); const label = rule.label ? rule.label : 'test-label'; const description = rule.description diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 94a3ffa582d..40ca71afe7a 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -55,9 +55,9 @@ const fillOutStackscriptForm = ( .type(description); } - cy.findByText('Target Images').click().type(`${targetImage}`); + cy.findByText('Target Images').click().type(targetImage); - cy.findByText(`${targetImage}`).should('be.visible').click(); + cy.findByText(targetImage).should('be.visible').click(); // Insert a script with invalid UDF data. cy.get('[data-qa-textfield-label="Script"]') diff --git a/packages/manager/cypress/support/constants/user-permissions.ts b/packages/manager/cypress/support/constants/user-permissions.ts index d6ca785951d..462f1033fa7 100644 --- a/packages/manager/cypress/support/constants/user-permissions.ts +++ b/packages/manager/cypress/support/constants/user-permissions.ts @@ -17,6 +17,7 @@ export const userPermissionsGrants: Grants = grantsFactory.build({ add_longview: false, add_nodebalancers: false, add_stackscripts: false, + add_databases: false, add_volumes: false, add_vpcs: false, longview_subscription: false, diff --git a/packages/manager/cypress/support/plugins/test-tagging-info.ts b/packages/manager/cypress/support/plugins/test-tagging-info.ts index 47aa97cd462..d44af4245ab 100644 --- a/packages/manager/cypress/support/plugins/test-tagging-info.ts +++ b/packages/manager/cypress/support/plugins/test-tagging-info.ts @@ -23,7 +23,7 @@ export const logTestTagInfo: CypressPlugin = (_on, config) => { console.table( getHumanReadableQueryRules(query).reduce( - (acc: {}, cur: string, index: number) => { + (acc: any, cur: string, index: number) => { acc[index] = cur; return acc; }, diff --git a/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts b/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts index 7c03d1751df..cb6275164e7 100644 --- a/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts +++ b/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts @@ -11,6 +11,7 @@ Cypress.Commands.add('trackPageVisit', { prevSubject: false }, () => { const pageLoadId = randomNumber(100000, 999999); cy.window({ log: false }).then((window) => { + // @ts-expect-error not in the cypress type window['cypress-visit-id'] = pageLoadId; }); diff --git a/packages/manager/cypress/support/util/feature-flags.ts b/packages/manager/cypress/support/util/feature-flags.ts index 8d09514edec..a86183e2c7d 100644 --- a/packages/manager/cypress/support/util/feature-flags.ts +++ b/packages/manager/cypress/support/util/feature-flags.ts @@ -72,20 +72,22 @@ export const isPartialFeatureFlagData = ( * @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 Object.keys(data).reduce>>( + (output, cur: keyof FeatureFlagMockData) => { + const mockData = output[cur]; + if (isPartialFeatureFlagData(mockData)) { + output[cur] = { + ...defaultFeatureFlagData, + ...mockData, + }; + return output; + } else { + output[cur] = makeFeatureFlagData(mockData); + } return output; - } else { - output[cur] = makeFeatureFlagData(mockData); - } - return output; - }, output); + }, + data as Record> + ); }; /** diff --git a/packages/manager/src/components/Button/Button.tsx b/packages/manager/src/components/Button/Button.tsx index 3758cbe554b..db54b5cec07 100644 --- a/packages/manager/src/components/Button/Button.tsx +++ b/packages/manager/src/components/Button/Button.tsx @@ -30,6 +30,10 @@ export interface ButtonProps extends _ButtonProps { * @default false */ compactY?: boolean; + /** + * Optional test ID + */ + 'data-testid'?: string; /** * Show a loading indicator * @default false diff --git a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx index 7a9d3791f9d..4a0c5153b61 100644 --- a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx +++ b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx @@ -86,7 +86,7 @@ export const cleanCSVData = (data: any): any => { /** if it's an object, recursively sanitize each key value pair */ if (typeof data === 'object') { - return Object.keys(data).reduce((acc, eachKey) => { + return Object.keys(data).reduce<{ [key: string]: any }>((acc, eachKey) => { acc[eachKey] = cleanCSVData(data[eachKey]); return acc; }, {}); diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx index 76feab1da78..84decd01815 100644 --- a/packages/manager/src/components/Flag.tsx +++ b/packages/manager/src/components/Flag.tsx @@ -18,11 +18,16 @@ interface Props { export const Flag = (props: Props) => { const country = props.country.toLowerCase(); - return ( - - ); + return ; +}; + +const getFlagClass = (country: Country | string) => { + if (country in COUNTRY_FLAG_OVERRIDES) { + return COUNTRY_FLAG_OVERRIDES[ + country as keyof typeof COUNTRY_FLAG_OVERRIDES + ]; + } + return country; }; const StyledFlag = styled('div', { label: 'StyledFlag' })(({ theme }) => ({ diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 5756a5f5092..92e43254322 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -117,7 +117,7 @@ export const imagesToGroupedItems = (images: Image[]) => { acc.push({ className: vendor ? // Use Tux as a fallback. - `fl-${OS_ICONS[vendor] ?? 'tux'}` + `fl-${OS_ICONS[vendor as keyof typeof OS_ICONS] ?? 'tux'}` : `fl-tux`, created, isCloudInitCompatible: capabilities?.includes('cloud-init'), diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index dba7a9ae442..1f91db3d338 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -8,10 +8,14 @@ import { StyledActionButton } from 'src/components/Button/StyledActionButton'; interface InlineMenuActionProps { /** Required action text */ actionText: string; + /** Optional aria label */ + 'aria-label'?: string; /** Optional height when displayed as a button */ buttonHeight?: number; /** Optional class names */ className?: string; + /** Optional test id */ + 'data-testid'?: string; /** Optional disabled */ disabled?: boolean; /** Optional href */ @@ -51,7 +55,6 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { return ( { const { os, ...rest } = props; - const className = os ? `fl-${OS_ICONS[os] ?? 'tux'}` : `fl-tux`; + const className = os + ? `fl-${OS_ICONS[os as keyof typeof OS_ICONS] ?? 'tux'}` + : `fl-tux`; return ; }; diff --git a/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx b/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx index a705ea605be..4e45d4fcf80 100644 --- a/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx +++ b/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx @@ -84,11 +84,14 @@ export const ThirdPartyPayment = (props: Props) => { - {!matchesSmDown ? ( + {!matchesSmDown && ( - {thirdPartyPaymentMap[paymentMethod.type].label} + { + thirdPartyPaymentMap[paymentMethod.type as _ThirdPartyPayment] + .label + } - ) : null} + )} {renderThirdPartyPaymentBody(paymentMethod)} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 6d329cfad77..2333c1fdabc 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -43,7 +43,9 @@ export const getRegionOptions = ({ return ( region.site_type === 'edge' || (region.site_type === 'distributed' && - CONTINENT_CODE_TO_CONTINENT[distributedContinentCode] === group) + CONTINENT_CODE_TO_CONTINENT[ + distributedContinentCode as keyof typeof CONTINENT_CODE_TO_CONTINENT + ] === group) ); } return regionFilter.includes(region.site_type); diff --git a/packages/manager/src/components/Uploaders/reducer.ts b/packages/manager/src/components/Uploaders/reducer.ts index 1e291ae85a9..dbe628312e0 100644 --- a/packages/manager/src/components/Uploaders/reducer.ts +++ b/packages/manager/src/components/Uploaders/reducer.ts @@ -98,9 +98,11 @@ const cloneLandingReducer = ( }); if (existingFile) { - Object.keys(action.data).forEach((key) => { - existingFile[key] = action.data[key]; - }); + Object.keys(action.data).forEach( + (key: keyof Partial) => { + (existingFile as any)[key] = action.data[key]; + } + ); // If the status has been changed, we need to update the count. if (action.data.status) { diff --git a/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx b/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx index f50cd49697c..f0e83faa717 100644 --- a/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx +++ b/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx @@ -35,10 +35,10 @@ export const getOptions = (env: Partial) => { return [ ...acc, { - apiRoot: env[`${base}_API_ROOT`] ?? '', - clientID: env[`${base}_CLIENT_ID`] ?? '', - label: env[thisEnvVariable] ?? '', - loginRoot: env[`${base}_LOGIN_ROOT`] ?? '', + apiRoot: (env as any)[`${base}_API_ROOT`] ?? '', + clientID: (env as any)[`${base}_CLIENT_ID`] ?? '', + label: (env as any)[thisEnvVariable] ?? '', + loginRoot: (env as any)[`${base}_LOGIN_ROOT`] ?? '', }, ]; }, []); diff --git a/packages/manager/src/factories/grants.ts b/packages/manager/src/factories/grants.ts index bfa53a7de8b..b641263ca03 100644 --- a/packages/manager/src/factories/grants.ts +++ b/packages/manager/src/factories/grants.ts @@ -31,6 +31,7 @@ export const grantsFactory = Factory.Sync.makeFactory({ ], global: { account_access: 'read_write', + add_databases: true, add_domains: true, add_firewalls: true, add_images: true, diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx index 0e2242a2c18..145799f445b 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -40,10 +40,8 @@ export const ChildAccountList = React.memo( const filter: Filter = { ['+order']: 'asc', ['+order_by']: 'company', + ...(searchQuery && { company: { '+contains': searchQuery } }), }; - if (searchQuery) { - filter['company'] = { '+contains': searchQuery }; - } const [ isSwitchingChildAccounts, diff --git a/packages/manager/src/features/Betas/BetasLanding.tsx b/packages/manager/src/features/Betas/BetasLanding.tsx index bf868e56529..9cc93fc6a1c 100644 --- a/packages/manager/src/features/Betas/BetasLanding.tsx +++ b/packages/manager/src/features/Betas/BetasLanding.tsx @@ -7,6 +7,7 @@ import { BetaDetailsList } from 'src/features/Betas/BetaDetailsList'; import { useAccountBetasQuery } from 'src/queries/account/betas'; import { useBetasQuery } from 'src/queries/betas'; import { categorizeBetasByStatus } from 'src/utilities/betaUtils'; +import { AccountBeta, Beta } from '@linode/api-v4'; const BetasLanding = () => { const { @@ -24,14 +25,17 @@ const BetasLanding = () => { const betas = betasRequest?.data ?? []; const allBetas = [...accountBetas, ...betas]; - const allBetasMerged = allBetas.reduce((acc, beta) => { - if (acc[beta.id]) { - acc[beta.id] = Object.assign(beta, acc[beta.id]); - } else { - acc[beta.id] = beta; - } - return acc; - }, {}); + const allBetasMerged = allBetas.reduce>( + (acc, beta) => { + if (acc[beta.id]) { + acc[beta.id] = Object.assign(beta, acc[beta.id]); + } else { + acc[beta.id] = beta; + } + return acc; + }, + {} + ); const { active, available, historical } = categorizeBetasByStatus( Object.values(allBetasMerged) diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx index 72ae44d5302..540956a6f39 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx @@ -1,6 +1,5 @@ -import { PaymentMethod, PaymentType } from '@linode/api-v4'; -import Grid from '@mui/material/Unstable_Grid2'; import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Chip } from 'src/components/Chip'; @@ -12,6 +11,8 @@ import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { getIcon as getCreditCardIcon } from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard'; import { formatExpiry, isCreditCardExpired } from 'src/utilities/creditCard'; +import type { PaymentMethod } from '@linode/api-v4'; + interface Props { disabled?: boolean; handlePaymentMethodChange: (id: number, cardExpired: boolean) => void; @@ -38,12 +39,12 @@ const getIcon = (paymentMethod: PaymentMethod) => { } }; -const getHeading = (paymentMethod: PaymentMethod, type: PaymentType) => { +const getHeading = (paymentMethod: PaymentMethod) => { switch (paymentMethod.type) { case 'paypal': - return thirdPartyPaymentMap[type].label; + return thirdPartyPaymentMap[paymentMethod.type].label; case 'google_pay': - return `${thirdPartyPaymentMap[type].label} ${paymentMethod.data.card_type} ****${paymentMethod.data.last_four}`; + return `${thirdPartyPaymentMap[paymentMethod.type].label} ${paymentMethod.data.card_type} ****${paymentMethod.data.last_four}`; default: return `${paymentMethod.data.card_type} ****${paymentMethod.data.last_four}`; } @@ -69,9 +70,9 @@ export const PaymentMethodCard = (props: Props) => { paymentMethod, paymentMethodId, } = props; - const { id, is_default, type } = paymentMethod; + const { id, is_default } = paymentMethod; - const heading = getHeading(paymentMethod, type); + const heading = getHeading(paymentMethod); const cardIsExpired = getIsCardExpired(paymentMethod); const subHeading = getSubHeading(paymentMethod, cardIsExpired); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index 57073c9ae21..f05a52225de 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -8,7 +8,6 @@ import { updateGlobalFilterPreference, } from '../Utils/UserPreference'; -import type { TimeDuration } from '@linode/api-v4'; import type { BaseSelectProps, Item, @@ -136,14 +135,3 @@ export const generateStartTime = (modifier: Labels, nowInSeconds: number) => { return nowInSeconds - 30 * 24 * 60 * 60; } }; - -/** - * - * @param label label for time duration to get the corresponding time duration object - * @returns time duration object for the label - */ -export const getTimeDurationFromTimeRange = (label: string): TimeDuration => { - const options = generateSelectOptions(); - - return options[label] || { unit: 'min', vlaue: 30 }; -}; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 35127bcd302..db6e037c3dd 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -137,6 +137,7 @@ const engineIcons = { mongodb: , mysql: , postgresql: , + redis: null, }; const getEngineOptions = (engines: DatabaseEngine[]) => { @@ -406,7 +407,7 @@ const DatabaseCreate = () => { return; } - const engineType = values.engine.split('/')[0]; + const engineType = values.engine.split('/')[0] as Engine; setNodePricing({ multi: type.engines[engineType].find( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx index 968c2f4d2cb..f35942adede 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx @@ -43,7 +43,7 @@ export const DatabaseStatusDisplay = (props: Props) => { ) { displayedStatus = ( <> - + {`Resizing ${progress ? `(${progress}%)` : '(0%)'}`} diff --git a/packages/manager/src/features/Domains/DomainRecordDrawer.tsx b/packages/manager/src/features/Domains/DomainRecordDrawer.tsx index 4b724e5b06d..3dd0753072c 100644 --- a/packages/manager/src/features/Domains/DomainRecordDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainRecordDrawer.tsx @@ -272,7 +272,9 @@ export class DomainRecordDrawer extends React.Component< eachOption.value === defaultTo( DomainRecordDrawer.defaultFieldsState(this.props)[field], - this.state.fields[field] + (this.state.fields as EditableDomainFields & EditableRecordFields)[ + field + ] ) ); }); @@ -302,9 +304,10 @@ export class DomainRecordDrawer extends React.Component< multiline?: boolean; }) => { const { domain, type } = this.props; - const value = this.state.fields[field]; + const value = (this.state.fields as EditableDomainFields & + EditableRecordFields)[field]; const hasAliasToResolve = - value.indexOf('@') >= 0 && shouldResolve(type, field); + value && value.indexOf('@') >= 0 && shouldResolve(type, field); return ( ) => this.updateField(field)(e.target.value) } + value={ + (this.state.fields as EditableDomainFields & EditableRecordFields)[ + field + ] as number + } data-qa-target={label} label={label} type="number" - value={this.state.fields[field]} {...rest} /> ); @@ -449,8 +456,12 @@ export class DomainRecordDrawer extends React.Component< this.updateField(field)(e.target.value) } value={defaultTo( - DomainRecordDrawer.defaultFieldsState(this.props)[field], - this.state.fields[field] + DomainRecordDrawer.defaultFieldsState(this.props)[field] as + | number + | string, + (this.state.fields as EditableDomainFields & EditableRecordFields)[ + field + ] as number | string )} data-qa-target={label} helperText={helperText} @@ -469,6 +480,7 @@ export class DomainRecordDrawer extends React.Component< */ static defaultFieldsState = (props: Partial) => ({ axfr_ips: getInitialIPs(props.axfr_ips), + description: '', domain: props.domain, expire_sec: props.expire_sec ?? 0, id: props.id, @@ -715,7 +727,12 @@ export class DomainRecordDrawer extends React.Component< }; types = { - // }, + A: { + fields: [], + }, + PTR: { + fields: [], + }, AAAA: { fields: [ (idx: number) => ( @@ -727,12 +744,6 @@ export class DomainRecordDrawer extends React.Component< (idx: number) => , ], }, - // slave: { - // fields: [ - // (idx: number) => , - // (idx: number) => , - // (idx: number) => , - // ], CAA: { fields: [ (idx: number) => ( @@ -842,6 +853,9 @@ export class DomainRecordDrawer extends React.Component< (idx: number) => , ], }, + slave: { + fields: [], + }, }; } diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx index 783e72be4e9..df8283ee4c3 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx @@ -26,7 +26,7 @@ export const generatePayload = ( selectedEntities: TransferState ): CreateTransferPayload => { const entities = Object.keys(selectedEntities).reduce( - (acc, entityType) => { + (acc, entityType: keyof TransferState) => { return { ...acc, [entityType]: Object.keys(selectedEntities[entityType]).map(Number), diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx index 27f7057130d..d1f99dab257 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx @@ -219,24 +219,26 @@ export const DialogContent = React.memo((props: ContentProps) => { This transfer contains: - {Object.keys(entities).map((thisEntityType) => { - // According to spec, all entity names are plural and lowercase - // (NB: This may cause problems for NodeBalancers if/when they are added to the payload) - const entityName = capitalize(thisEntityType).slice(0, -1); - return ( -
  • - - - {pluralize( - entityName, - entityName + 's', - entities[thisEntityType].length - )} - - -
  • - ); - })} + {Object.keys(entities).map( + (thisEntityType: keyof TransferEntities) => { + // According to spec, all entity names are plural and lowercase + // (NB: This may cause problems for NodeBalancers if/when they are added to the payload) + const entityName = capitalize(thisEntityType).slice(0, -1); + return ( +
  • + + + {pluralize( + entityName, + entityName + 's', + entities[thisEntityType].length + )} + + +
  • + ); + } + )}
    {timeRemaining ? ( diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 0caf895222f..68cf12add5d 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -23,6 +23,8 @@ import { StyledTypography, } from './EventsLanding.styles'; +import type { Filter } from '@linode/api-v4'; + interface Props { emptyMessage?: string; // Custom message for the empty state (i.e. no events). entityId?: number; @@ -32,7 +34,7 @@ export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; const flags = useFlags(); - const filter = { ...EVENTS_LIST_FILTER }; + const filter: Filter = { ...EVENTS_LIST_FILTER }; if (entityId) { filter['entity.id'] = entityId; diff --git a/packages/manager/src/features/Events/EventsMessages.stories.tsx b/packages/manager/src/features/Events/EventsMessages.stories.tsx index 72325c69c1f..35ad2beb4cf 100644 --- a/packages/manager/src/features/Events/EventsMessages.stories.tsx +++ b/packages/manager/src/features/Events/EventsMessages.stories.tsx @@ -10,6 +10,7 @@ import { Typography } from 'src/components/Typography'; import { eventFactory } from 'src/factories/events'; import { eventMessages } from 'src/features/Events/factory'; +import type { EventMessage } from './types'; import type { Event } from '@linode/api-v4/lib/account'; import type { Meta, StoryObj } from '@storybook/react'; @@ -51,8 +52,8 @@ export const EventMessages: StoryObj = { - {Object.keys(statuses).map((status, key) => { - const message = statuses[status](event); + {Object.keys(statuses).map((status: keyof EventMessage, key) => { + const message = statuses[status]?.(event); return ( diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 800ab1068e7..6004fd994e4 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -297,7 +297,9 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { notification: (e) => { if (e.secondary_entity?.type) { const secondaryEntityName = - secondaryFirewallEntityNameMap[e.secondary_entity.type]; + secondaryFirewallEntityNameMap[ + e.secondary_entity.type as FirewallDeviceEntityType + ]; return `${secondaryEntityName} ${ e.secondary_entity?.label } has been added to Firewall ${e.entity?.label ?? ''}.`; @@ -309,7 +311,9 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { notification: (e) => { if (e.secondary_entity?.type) { const secondaryEntityName = - secondaryFirewallEntityNameMap[e.secondary_entity.type]; + secondaryFirewallEntityNameMap[ + e.secondary_entity.type as FirewallDeviceEntityType + ]; return `${secondaryEntityName} ${ e.secondary_entity?.label } has been removed from Firewall ${e.entity?.label ?? ''}.`; diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx index 86f180fc1d9..41fcbf8a6ba 100644 --- a/packages/manager/src/features/Events/factories/firewall.tsx +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -41,7 +41,9 @@ export const firewall: PartialEventMap<'firewall'> = { notification: (e) => { if (e.secondary_entity?.type) { const secondaryEntityName = - secondaryFirewallEntityNameMap[e.secondary_entity.type]; + secondaryFirewallEntityNameMap[ + e.secondary_entity.type as FirewallDeviceEntityType + ]; return ( <> {secondaryEntityName} {' '} @@ -62,7 +64,9 @@ export const firewall: PartialEventMap<'firewall'> = { notification: (e) => { if (e.secondary_entity?.type) { const secondaryEntityName = - secondaryFirewallEntityNameMap[e.secondary_entity.type]; + secondaryFirewallEntityNameMap[ + e.secondary_entity.type as FirewallDeviceEntityType + ]; return ( <> {secondaryEntityName} {' '} diff --git a/packages/manager/src/features/Events/factory.tsx b/packages/manager/src/features/Events/factory.tsx index 7a1dcddde67..0654ec21ecc 100644 --- a/packages/manager/src/features/Events/factory.tsx +++ b/packages/manager/src/features/Events/factory.tsx @@ -40,7 +40,7 @@ export const withTypography = (eventMap: EventMap): OptionalEventMap => { export const eventMessages: EventMap = Object.keys(factories).reduce( (acc, factoryName) => ({ ...acc, - ...withTypography(factories[factoryName]), + ...withTypography((factories as any)[factoryName]), }), {} as EventMap ); 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 e57bd284795..cd5a44bf5b0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -283,9 +283,8 @@ export const portStringToItems = ( const customInput: string[] = []; ports.forEach((thisPort) => { - const preset = PORT_PRESETS[thisPort]; - if (preset) { - items.push(preset); + if (thisPort in PORT_PRESETS) { + items.push(PORT_PRESETS[thisPort as keyof typeof PORT_PRESETS]); } else { customInput.push(thisPort); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index f55a8b0fcca..a0beae974dc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -12,6 +12,7 @@ import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { FirewallOptionItem, + FirewallPreset, addressOptions, firewallOptionItemsShort, portPresets, @@ -83,7 +84,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { // These handlers are all memoized because the form was laggy when I tried them inline. const handleTypeChange = React.useCallback( - (item: FirewallOptionItem | null) => { + (item: FirewallOptionItem | null) => { const selectedType = item?.value; // If the user re-selects the same preset or selectedType is undefined, don't do anything diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 844150fdf9a..50d38245280 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -241,7 +241,9 @@ export const CreateFirewallDrawer = React.memo( const generalError = status?.generalError || + // @ts-expect-error this form intentionally breaks Formik's error type errors['rules.inbound'] || + // @ts-expect-error this form intentionally breaks Formik's error type errors['rules.outbound'] || errors.rules; @@ -346,6 +348,7 @@ export const CreateFirewallDrawer = React.memo( linodes.map((linode) => linode.id) ); }} + // @ts-expect-error this form intentionally breaks Formik's error type errorText={errors['devices.linodes']} helperText={deviceSelectGuidance} multiple @@ -364,6 +367,7 @@ export const CreateFirewallDrawer = React.memo( nodebalancers.map((nodebalancer) => nodebalancer.id) ); }} + // @ts-expect-error this form intentionally breaks Formik's error type errorText={errors['devices.nodebalancers']} helperText={deviceSelectGuidance} multiple diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts index 5f372cd4a61..4fd4fc6c04d 100644 --- a/packages/manager/src/features/Firewalls/shared.ts +++ b/packages/manager/src/features/Firewalls/shared.ts @@ -5,6 +5,7 @@ import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls/types'; +import { PORT_PRESETS } from './FirewallDetail/Rules/shared'; export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; @@ -58,7 +59,7 @@ export const firewallOptionItemsShort = [ label: 'DNS', value: 'dns', }, -]; +] as const; export const protocolOptions: FirewallOptionItem[] = [ { label: 'TCP', value: 'TCP' }, @@ -74,7 +75,7 @@ export const addressOptions = [ { label: 'IP / Netmask', value: 'ip/netmask' }, ]; -export const portPresets: Record = { +export const portPresets: Record = { dns: '53', http: '80', https: '443', diff --git a/packages/manager/src/features/Images/ImageSelect.test.tsx b/packages/manager/src/features/Images/ImageSelect.test.tsx index 402da31392c..96960772f2f 100644 --- a/packages/manager/src/features/Images/ImageSelect.test.tsx +++ b/packages/manager/src/features/Images/ImageSelect.test.tsx @@ -1,39 +1,20 @@ -import { fireEvent, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { imageFactory } from 'src/factories/images'; +import { getImageGroup } from 'src/utilities/images'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { ImageSelect, getImagesOptions, groupNameMap } from './ImageSelect'; +import { ImageSelect } from './ImageSelect'; -const images = imageFactory.buildList(10); - -const props = { - images, - onSelect: vi.fn(), -}; - -const privateImage1 = imageFactory.build({ - deprecated: false, - is_public: false, - type: 'manual', -}); - -const recommendedImage1 = imageFactory.build({ +const recommendedImage = imageFactory.build({ created_by: 'linode', deprecated: false, id: 'linode/0001', type: 'manual', }); -const recommendedImage2 = imageFactory.build({ - created_by: 'linode', - deprecated: false, - id: 'linode/0002', - type: 'manual', -}); - -const deletedImage1 = imageFactory.build({ +const deletedImage = imageFactory.build({ created_by: null, deprecated: false, expiry: '2019-04-09T04:13:37', @@ -41,90 +22,67 @@ const deletedImage1 = imageFactory.build({ type: 'automatic', }); +describe('getImageGroup', () => { + it('handles the recommended group', () => { + expect(getImageGroup(recommendedImage)).toBe( + '64-bit Distributions - Recommended' + ); + }); + + it('handles the deleted image group', () => { + expect(getImageGroup(deletedImage)).toBe('Recently Deleted Disks'); + }); +}); + describe('ImageSelect', () => { - describe('getImagesOptions function', () => { - it('should return a list of GroupType', () => { - const items = getImagesOptions([recommendedImage1, recommendedImage2]); - expect(items[0]).toHaveProperty('label', groupNameMap.recommended); - expect(items[0].options).toHaveLength(2); - }); - - it('should handle multiple groups', () => { - const items = getImagesOptions([ - recommendedImage1, - recommendedImage2, - privateImage1, - deletedImage1, - ]); - expect(items).toHaveLength(3); - const deleted = items.find((item) => item.label === groupNameMap.deleted); - expect(deleted!.options).toHaveLength(1); - }); - - it('should properly format GroupType options', () => { - const category = getImagesOptions([recommendedImage1])[0]; - const option = category.options[0]; - expect(option).toHaveProperty('label', recommendedImage1.label); - expect(option).toHaveProperty('value', recommendedImage1.id); - }); - - it('should handle empty input', () => { - expect(getImagesOptions([])).toEqual([]); - }); + it('should display an error', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('An error')).toBeInTheDocument(); }); - describe('ImageSelect component', () => { - it('should render', () => { - renderWithTheme(); - screen.getByRole('combobox'); - }); - - it('should display an error', () => { - const imageError = 'An error'; - renderWithTheme(); - expect(screen.getByText(imageError)).toBeInTheDocument(); - }); - - it('should call onSelect with the selected value', () => { - const onSelectMock = vi.fn(); - renderWithTheme( - - ); - - const inputElement = screen.getByRole('combobox'); - - fireEvent.change(inputElement, { target: { value: 'image-1' } }); - fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); - fireEvent.keyDown(inputElement, { key: 'Enter' }); - - expect(onSelectMock).toHaveBeenCalledWith({ - label: 'image-1', - value: 'private/1', - }); - }); - - it('should handle multiple selections', () => { - const onSelectMock = vi.fn(); - renderWithTheme( - - ); - - const inputElement = screen.getByRole('combobox'); - - // Select first option - fireEvent.change(inputElement, { target: { value: 'image-1' } }); - fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); - fireEvent.keyDown(inputElement, { key: 'Enter' }); - - // Select second option - fireEvent.change(inputElement, { target: { value: 'image-2' } }); - fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); - fireEvent.keyDown(inputElement, { key: 'Enter' }); - - expect(onSelectMock).toHaveBeenCalledWith([ - { label: 'image-1', value: 'private/1' }, - { label: 'image-2', value: 'private/2' }, - ]); - }); + it('should call onSelect with the selected value', async () => { + const onSelect = vi.fn(); + const images = [ + imageFactory.build({ label: 'image-0' }), + imageFactory.build({ label: 'image-1' }), + imageFactory.build({ label: 'image-2' }), + ]; + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Image')); + + await userEvent.click(getByText('image-1')); + + expect(onSelect).toHaveBeenCalledWith(images[1]); + }); + + it('should handle any/all', async () => { + const onSelect = vi.fn(); + const images = [ + imageFactory.build({ label: 'image-0' }), + imageFactory.build({ label: 'image-1' }), + imageFactory.build({ label: 'image-2' }), + ]; + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Image')); + + await userEvent.click(getByText('Any/All')); + + expect(onSelect).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'any/all' }), + ]); }); }); diff --git a/packages/manager/src/features/Images/ImageSelect.tsx b/packages/manager/src/features/Images/ImageSelect.tsx index af442529330..6bbd7435f7a 100644 --- a/packages/manager/src/features/Images/ImageSelect.tsx +++ b/packages/manager/src/features/Images/ImageSelect.tsx @@ -1,31 +1,17 @@ -import Autocomplete from '@mui/material/Autocomplete'; -import { clone, propOr } from 'ramda'; import * as React from 'react'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; -import { TextField } from 'src/components/TextField'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { useAllImagesQuery } from 'src/queries/images'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { groupImages } from 'src/utilities/images'; +import { imageFactory } from 'src/factories'; +import { getImageGroup } from 'src/utilities/images'; import type { Image } from '@linode/api-v4/lib/images'; -export interface SelectImageOption { - label: string; - value: string; -} - -export interface ImagesGroupType { - label: string; - options: SelectImageOption[]; -} interface BaseProps { anyAllOption?: boolean; disabled?: boolean; + errorText?: string; helperText?: string; - imageError?: string; - imageFieldError?: string; images: Image[]; label?: string; required?: boolean; @@ -33,23 +19,22 @@ interface BaseProps { interface Props extends BaseProps { isMulti?: false; - onSelect: (selected: SelectImageOption) => void; - value?: SelectImageOption; + onSelect: (image: Image) => void; + value?: string; } interface MultiProps extends BaseProps { isMulti: true; - onSelect: (selected: SelectImageOption[]) => void; - value?: SelectImageOption[]; + onSelect: (selected: Image[]) => void; + value?: string[]; } export const ImageSelect = (props: MultiProps | Props) => { const { anyAllOption, disabled, + errorText, helperText, - imageError, - imageFieldError, images, isMulti, label, @@ -58,36 +43,19 @@ export const ImageSelect = (props: MultiProps | Props) => { value, } = props; - const { error, isError, isLoading: imagesLoading } = useAllImagesQuery( - {}, - {} - ); - - // Check for request errors in RQ - const rqError = isError - ? getAPIErrorOrDefault(error ?? [], 'Unable to load Images')[0].reason - : undefined; - - const renderedImages = React.useMemo(() => getImagesOptions(images), [ - images, - ]); - - const imageSelectOptions = clone(renderedImages); - - if (anyAllOption) { - imageSelectOptions.unshift({ - label: '', - options: [ - { - label: 'Any/All', - value: 'any/all', - }, - ], - }); - } - - const formattedOptions = imageSelectOptions.flatMap( - (option) => option.options + const options = React.useMemo( + () => [ + ...(anyAllOption + ? [ + imageFactory.build({ + id: 'any/all', + label: 'Any/All', + }), + ] + : []), + ...images, + ], + [anyAllOption, images] ); return ( @@ -98,86 +66,36 @@ export const ImageSelect = (props: MultiProps | Props) => { width: '100%', }} > - { + if (isMulti && Array.isArray(value)) { + onSelect((value ?? []) as Image[]); + } else if (!isMulti) { + onSelect(value as Image); + } + }} + textFieldProps={{ + required, + tooltipText: helperText ?? 'Choosing a 64-bit distro is recommended.', }} - > - { - const group = imageSelectOptions.find((group) => - group.options.includes(option) - ); - return group ? String(group.label) : ''; - }} - onChange={(event, value) => { - onSelect(value ?? []); - }} - renderInput={(params) => ( - - )} - disabled={disabled || Boolean(imageError)} - id={'image-select'} - loading={imagesLoading} - multiple={Boolean(isMulti)} - options={formattedOptions} - value={value} - /> - - - - + value={ + isMulti + ? options.filter((o) => value?.includes(o.id)) ?? [] + : options.find((o) => o.id === value) ?? null + } + disableCloseOnSelect={false} + disableSelectAll + disabled={disabled} + errorText={errorText} + filterSelectedOptions + groupBy={getImageGroup} + label={label ?? 'Image'} + multiple={isMulti} + options={options} + placeholder="Select an Image" + /> ); }; -export const getImagesOptions = (images: Image[]) => { - const groupedImages = groupImages(images); - return ['recommended', 'older', 'images', 'deleted'].reduce( - (accumulator: ImagesGroupType[], category: string) => { - if (groupedImages[category]) { - return [ - ...accumulator, - { - label: getDisplayNameForGroup(category), - options: groupedImages[category].map(({ id, label }: Image) => ({ - label, - value: id, - })), - }, - ]; - } - return accumulator; - }, - [] - ); -}; - -export const groupNameMap = { - _default: 'Other', - deleted: 'Recently Deleted Disks', - images: 'Images', - older: 'Older Distributions', - recommended: '64-bit Distributions - Recommended', -}; - -const getDisplayNameForGroup = (key: string) => - propOr('Other', key, groupNameMap); - export default ImageSelect; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 8f02bf1927b..dcf39b8151d 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -53,7 +53,7 @@ import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { ImageStatus } from '@linode/api-v4'; +import type { Filter, ImageStatus } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; const searchQueryKey = 'query'; @@ -118,15 +118,12 @@ export const ImagesLanding = () => { 'manual' ); - const manualImagesFilter = { + const manualImagesFilter: Filter = { ['+order']: manualImagesOrder, ['+order_by']: manualImagesOrderBy, + ...(imageLabelFromParam && { label: { '+contains': imageLabelFromParam } }), }; - if (imageLabelFromParam) { - manualImagesFilter['label'] = { '+contains': imageLabelFromParam }; - } - const { data: manualImages, error: manualImagesError, @@ -170,15 +167,12 @@ export const ImagesLanding = () => { 'automatic' ); - const automaticImagesFilter = { + const automaticImagesFilter: Filter = { ['+order']: automaticImagesOrder, ['+order_by']: automaticImagesOrderBy, + ...(imageLabelFromParam && { label: { '+contains': imageLabelFromParam } }), }; - if (imageLabelFromParam) { - automaticImagesFilter['label'] = { '+contains': imageLabelFromParam }; - } - const { data: automaticImages, error: automaticImagesError, diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 2dc8bc961ae..1605532560b 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -78,50 +78,52 @@ export const KubernetesPlansPanel = (props: Props) => { : _types ); - const tabs = Object.keys(plans).map((plan: LinodeTypeClass) => { - const plansMap: PlanSelectionType[] = plans[plan]; - const { - allDisabledPlans, - hasMajorityOfPlansDisabled, - plansForThisLinodeTypeClass, - } = extractPlansInformation({ - disableLargestGbPlansFlag: flags.disableLargestGbPlans, - plans: plansMap, - regionAvailabilities, - selectedRegionId, - }); + const tabs = Object.keys(plans).map( + (plan: Exclude) => { + const plansMap: PlanSelectionType[] = plans[plan]!; + const { + allDisabledPlans, + hasMajorityOfPlansDisabled, + plansForThisLinodeTypeClass, + } = extractPlansInformation({ + disableLargestGbPlansFlag: flags.disableLargestGbPlans, + plans: plansMap, + regionAvailabilities, + selectedRegionId, + }); - return { - render: () => { - return ( - <> - - - - ); - }, - title: planTabInfoContent[plan === 'standard' ? 'shared' : plan]?.title, - }; - }); + return { + render: () => { + return ( + <> + + + + ); + }, + title: planTabInfoContent[plan === 'edge' ? 'dedicated' : plan]?.title, + }; + } + ); const initialTab = determineInitialPlanCategoryTab( types, diff --git a/packages/manager/src/features/Linodes/CloneLanding/utilities.ts b/packages/manager/src/features/Linodes/CloneLanding/utilities.ts index 7578050b46d..93c527223b4 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/utilities.ts +++ b/packages/manager/src/features/Linodes/CloneLanding/utilities.ts @@ -1,8 +1,9 @@ -import { Config, Disk } from '@linode/api-v4/lib/linodes'; +import { Config, Devices, Disk } from '@linode/api-v4/lib/linodes'; import { APIError } from '@linode/api-v4/lib/types'; import produce from 'immer'; import { DateTime } from 'luxon'; import { append, compose, flatten, keys, map, pickBy, uniqBy } from 'ramda'; +import { isDiskDevice } from '../LinodesDetail/LinodeConfigs/ConfigRow'; /** * TYPES @@ -242,9 +243,10 @@ export const getAssociatedDisks = ( const disksOnConfig: number[] = []; // Go through the devices and grab all the disks - Object.keys(config.devices).forEach((key) => { - if (config.devices[key] && config.devices[key].disk_id) { - disksOnConfig.push(config.devices[key].disk_id); + Object.keys(config.devices).forEach((key: keyof Devices) => { + const device = config.devices[key]; + if (device && isDiskDevice(device) && device.disk_id) { + disksOnConfig.push(device.disk_id); } }); 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 2c6f0d88903..a01aec66a99 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx @@ -108,8 +108,9 @@ export const AppDetailDrawerv2 = (props: Props) => { > {`${selectedApp.name} { name: `stackscript_data.${userDefinedField.name}`, }); + // @ts-expect-error UDFs don't abide by the form's error type. const error = formState.errors?.[userDefinedField.name]?.message?.replace( 'the UDF', '' ); + // We might be able to fix this by checking the message for "UDF" and fixing the key + // when we put the error message in the react hook form state. if (getIsUDFHeader(userDefinedField)) { return ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts index 869c91867dc..db0dc6b01e7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts @@ -31,7 +31,7 @@ export const getIsUDFRequired = (udf: UserDefinedField) => export const getDefaultUDFData = ( userDefinedFields: UserDefinedField[] ): Record => - userDefinedFields.reduce((accum, field) => { + userDefinedFields.reduce>((accum, field) => { if (field.default) { accum[field.name] = field.default; } else { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts index 70666df27a5..5f1f9e34082 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts @@ -15,7 +15,7 @@ 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'; +import type { FieldErrors, Resolver } from 'react-hook-form'; export const getLinodeCreateResolver = ( tab: LinodeCreateType | undefined, @@ -38,7 +38,7 @@ export const getLinodeCreateResolver = ( )(transformedValues, context, options); if (tab === 'Clone Linode' && !values.linode) { - errors['linode'] = { + (errors as FieldErrors)['linode'] = { message: 'You must select a Linode to clone from.', type: 'validate', }; @@ -60,7 +60,9 @@ export const getLinodeCreateResolver = ( const hasSignedEUAgreement = agreements.eu_model; if (!hasSignedEUAgreement && !values.hasSignedEUAgreement) { - errors['hasSignedEUAgreement'] = { + (errors as FieldErrors)[ + 'hasSignedEUAgreement' + ] = { message: 'You must agree to the EU agreement to deploy to this region.', type: 'validate', diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 0c5d7a879b5..df1af1f046d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -65,10 +65,10 @@ export const useLinodeCreateQueryParams = () => { const newParams = new URLSearchParams(rawParams); for (const key in params) { - if (!params[key]) { + if (!params[key as keyof LinodeCreateQueryParams]) { newParams.delete(key); } else { - newParams.set(key, params[key]); + newParams.set(key, params[key as keyof LinodeCreateQueryParams]!); } } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index b5619b8b0fc..4e68b575e38 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -92,10 +92,11 @@ import type { import type { PlacementGroup } from '@linode/api-v4'; import type { CreateLinodePlacementGroupPayload, + CreateLinodeRequest, EncryptionStatus, InterfacePayload, PriceObject, -} from '@linode/api-v4/lib/linodes'; +} from '@linode/api-v4'; import type { Tag } from '@linode/api-v4/lib/tags/types'; import type { MapDispatchToProps } from 'react-redux'; import type { RouteComponentProps } from 'react-router-dom'; @@ -289,7 +290,7 @@ export class LinodeCreate extends React.PureComponent< // eslint-disable-next-line sonarjs/no-unused-collection const interfaces: InterfacePayload[] = []; - const payload = { + const payload: CreateLinodeRequest = { authorized_users: this.props.authorized_users, backup_id: this.props.selectedBackupID, backups_enabled: this.props.backupsEnabled, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 01b45af41a6..048be836598 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -1001,7 +1001,7 @@ const actionsAndLabels = { fromImage: { action: 'image', labelPayloadKey: 'image' }, fromLinode: { action: 'clone', labelPayloadKey: 'type' }, fromStackScript: { action: 'stackscript', labelPayloadKey: 'stackscript_id' }, -}; +} as const; const handleAnalytics = (details: { label?: string; @@ -1017,12 +1017,7 @@ const handleAnalytics = (details: { if (eventInfo) { eventAction = eventInfo.action; const payloadLabel = payload[eventInfo.labelPayloadKey]; - // Checking if payload label comes back as a number, if so return it as a string, otherwise event won't fire. - if (isNaN(payloadLabel)) { - eventLabel = payloadLabel; - } else { - eventLabel = payloadLabel.toString(); - } + eventLabel = String(payloadLabel); } if (label) { eventLabel = label; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.test.tsx index e4af2472602..193d3d0cb58 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.test.tsx @@ -9,6 +9,8 @@ import { getDefaultUDFData, } from './FromAppsContent'; +import type { Image } from '@linode/api-v4'; + describe('FromAppsContent', () => { it('should exist', () => { expect(FromAppsContent).toBeDefined(); @@ -34,10 +36,13 @@ describe('getCompatibleImages', () => { it('should an array of Images compatible with the StackScript', () => { const imagesDataArray = imageFactory.buildList(5); - const imagesData = imagesDataArray.reduce((acc, imageData) => { - acc[imageData.label] = imageData; - return acc; - }, {}); + const imagesData = imagesDataArray.reduce>( + (acc, imageData) => { + acc[imageData.label] = imageData; + return acc; + }, + {} + ); const stackScriptImages = Object.keys(imagesData).slice(0, 2); const result = getCompatibleImages(imagesData, stackScriptImages); expect(result.length).toBe(2); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index d1b24940b06..6abde7a3f78 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -94,12 +94,15 @@ export const getCompatibleImages = ( ); export const getDefaultUDFData = (userDefinedFields: UserDefinedField[]) => { - return userDefinedFields.reduce((accum, eachField) => { - if (eachField.default) { - accum[eachField.name] = eachField.default; - } - return accum; - }, {}); + return userDefinedFields.reduce>( + (accum, eachField) => { + if (eachField.default) { + accum[eachField.name] = eachField.default; + } + return accum; + }, + {} + ); }; export const handleSelectStackScript = ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx index 20ef138311b..0dc9de9ec00 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx @@ -157,12 +157,15 @@ export class FromStackScriptContent extends React.PureComponent { * if a UDF field comes back from the API with a "default" * value, it means we need to pre-populate the field and form state */ - const defaultUDFData = userDefinedFields.reduce((accum, eachField) => { - if (eachField.default) { - accum[eachField.name] = eachField.default; - } - return accum; - }, {}); + const defaultUDFData = userDefinedFields.reduce>( + (accum, eachField) => { + if (eachField.default) { + accum[eachField.name] = eachField.default; + } + return accum; + }, + {} + ); this.props.updateStackScript( id, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx index 6310baecb29..445fa48bf57 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx @@ -1,4 +1,9 @@ -import { Config } from '@linode/api-v4/lib/linodes'; +import { + Config, + Devices, + DiskDevice, + VolumeDevice, +} from '@linode/api-v4/lib/linodes'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -21,6 +26,18 @@ interface Props { readOnly: boolean; } +export const isDiskDevice = ( + device: VolumeDevice | DiskDevice +): device is DiskDevice => { + return 'disk_id' in device; +}; + +const isVolumeDevice = ( + device: VolumeDevice | DiskDevice +): device is VolumeDevice => { + return 'volume_id' in device; +}; + export const ConfigRow = React.memo((props: Props) => { const { config, linodeId, onBoot, onDelete, onEdit, readOnly } = props; @@ -39,20 +56,17 @@ export const ConfigRow = React.memo((props: Props) => { const validDevices = React.useMemo( () => Object.keys(config.devices) - .map((thisDevice) => { + .map((thisDevice: keyof Devices) => { const device = config.devices[thisDevice]; let label: null | string = null; - if (device?.disk_id) { + if (device && isDiskDevice(device)) { label = - disks?.find( - (thisDisk) => - thisDisk.id === config.devices[thisDevice]?.disk_id - )?.label ?? `disk-${device.disk_id}`; - } else if (device?.volume_id) { + disks?.find((thisDisk) => thisDisk.id === device.disk_id) + ?.label ?? `disk-${device.disk_id}`; + } else if (device && isVolumeDevice(device)) { label = volumes?.data.find( - (thisVolume) => - thisVolume.id === config.devices[thisDevice]?.volume_id + (thisVolume) => thisVolume.id === device.volume_id )?.label ?? `volume-${device.volume_id}`; } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 20f951e8dfb..941a1761340 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -996,11 +996,13 @@ export const LinodeConfigDialog = (props: Props) => { thisInterface.ip_ranges ?? [] ).map((ip_range, index) => { // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ip_ranges[1] is invalid" + // @ts-expect-error this form intentionally breaks formik's error type const errorString: string = formik.errors[ `interfaces[${idx}].ip_ranges[${index}]` ]?.includes('is invalid') ? 'Invalid IP range' - : formik.errors[`interfaces[${idx}].ip_ranges[${index}]`]; + : // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[`interfaces[${idx}].ip_ranges[${index}]`]; return { address: ip_range, @@ -1019,18 +1021,26 @@ export const LinodeConfigDialog = (props: Props) => { { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index 674c5fc885b..736ef2ba0f3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -105,27 +105,30 @@ const IPSharingPanel = (props: Props) => { linodeID: number, linodes: Linode[] ): Record => { - const choiceLabels = linodes.reduce((previousValue, currentValue) => { - // Filter out the current Linode - if (currentValue.id === linodeID) { - return previousValue; - } + const choiceLabels = linodes.reduce>( + (previousValue, currentValue) => { + // Filter out the current Linode + if (currentValue.id === linodeID) { + return previousValue; + } - currentValue.ipv4.forEach((ip) => { - previousValue[ip] = currentValue.label; - }); + currentValue.ipv4.forEach((ip) => { + previousValue[ip] = currentValue.label; + }); - if (flags.ipv6Sharing) { - availableRangesMap?.[currentValue.id]?.forEach((range: string) => { - previousValue[range] = currentValue.label; - updateIPToLinodeID({ - [range]: [...(ipToLinodeID?.[range] ?? []), currentValue.id], + if (flags.ipv6Sharing) { + availableRangesMap?.[currentValue.id]?.forEach((range: string) => { + previousValue[range] = currentValue.label; + updateIPToLinodeID({ + [range]: [...(ipToLinodeID?.[range] ?? []), currentValue.id], + }); }); - }); - } + } - return previousValue; - }, {}); + return previousValue; + }, + {} + ); linodeSharedIPs.forEach((range) => { if (!choiceLabels.hasOwnProperty(range)) { @@ -136,7 +139,7 @@ const IPSharingPanel = (props: Props) => { return choiceLabels; }; - let ipToLinodeID = {}; + let ipToLinodeID: Record = {}; const updateIPToLinodeID = (newData: Record) => { ipToLinodeID = { ...ipToLinodeID, ...newData }; @@ -185,7 +188,7 @@ const IPSharingPanel = (props: Props) => { }; const onSubmit = () => { - const groupedUnsharedRanges = {}; + const groupedUnsharedRanges: Record = {}; const finalIPs: string[] = uniq( ipsToShare.reduce((previousValue, currentValue) => { if (currentValue === undefined || currentValue === null) { @@ -407,15 +410,18 @@ const IPSharingPanel = (props: Props) => { const formatAvailableRanges = ( availableRanges: IPRangeInformation[] ): AvailableRangesMap => { - return availableRanges.reduce((previousValue, currentValue) => { - // use the first entry in linodes as we're only dealing with ranges unassociated with this - // Linode, so we just use whatever the first Linode is to later get the label for this range - previousValue[currentValue.linodes[0]] = [ - ...(previousValue?.[currentValue.linodes[0]] ?? []), - `${currentValue.range}/${currentValue.prefix}`, - ]; - return previousValue; - }, {}); + return availableRanges.reduce>( + (previousValue, currentValue) => { + // use the first entry in linodes as we're only dealing with ranges unassociated with this + // Linode, so we just use whatever the first Linode is to later get the label for this range + previousValue[currentValue.linodes[0]] = [ + ...(previousValue?.[currentValue.linodes[0]] ?? []), + `${currentValue.range}/${currentValue.prefix}`, + ]; + return previousValue; + }, + {} + ); }; interface WrapperProps { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx index d341552d618..0e0a27d989a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx @@ -401,7 +401,7 @@ export const IPTransfer = (props: Props) => { */ if (!equals(previousIPAddresses, ipAddresses)) { setIPs( - ipAddresses.reduce((acc, ip) => { + ipAddresses.reduce((acc, ip) => { acc[ip] = defaultState(ip, linodeId); return acc; }, {}) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx index 97b48199e35..ef341b06ae1 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx @@ -9,13 +9,13 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { LinodePermissionsError } from '../LinodePermissionsError'; -import type { SelectImageOption } from 'src/features/Images/ImageSelect'; +import type { Image } from '@linode/api-v4'; interface Props { authorizedUsers: string[]; imageFieldError?: string; linodeId: number; - onImageChange: (selected: SelectImageOption) => void; + onImageChange: (selected: Image) => void; onPasswordChange: (password: string) => void; password: string; passwordError?: string; @@ -51,8 +51,7 @@ export const ImageAndPassword = (props: Props) => { {disabled && } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx index 4d7c5e3d89f..650c5fb56f3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.tsx @@ -1,13 +1,19 @@ -import { Kernel } from '@linode/api-v4/lib/linodes/types'; import { groupBy } from 'ramda'; import * as React from 'react'; -import Select, { GroupType, Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; + +import type { Kernel } from '@linode/api-v4'; + +interface Option { + label: string; + value: string; +} export interface KernelSelectProps { errorText?: string; kernels: Kernel[]; - onChange: (selected: Item) => void; + onChange: (selected: Option) => void; readOnly?: boolean; selectedKernel?: string; } @@ -40,7 +46,7 @@ export const KernelSelect = React.memo((props: KernelSelectProps) => { export const getSelectedKernelId = ( kernelID: string | undefined, - options: GroupType[] + options: KernelGroupOption[] ) => { if (!kernelID) { return null; @@ -74,27 +80,37 @@ export const groupKernels = (kernel: Kernel) => { return 'Current'; }; +type KernelGroup = ReturnType; + +interface KernelGroupOption { + label: KernelGroup; + options: Option[]; +} + export const kernelsToGroupedItems = (kernels: Kernel[]) => { const groupedKernels = groupBy(groupKernels, kernels); groupedKernels.Current = sortCurrentKernels(groupedKernels.Current); return Object.keys(groupedKernels) - .reduce((accum: GroupType[], thisGroup) => { - const group = groupedKernels[thisGroup]; - if (!group || group.length === 0) { - return accum; - } - return [ - ...accum, - { - label: thisGroup, - options: groupedKernels[thisGroup].map((thisKernel) => ({ - label: thisKernel.label, - value: thisKernel.id, - })), - }, - ]; - }, []) + .reduce( + (accum: KernelGroupOption[], thisGroup: KernelGroup) => { + const group = groupedKernels[thisGroup]; + if (!group || group.length === 0) { + return accum; + } + return [ + ...accum, + { + label: thisGroup, + options: groupedKernels[thisGroup].map((thisKernel) => ({ + label: thisKernel.label, + value: thisKernel.id, + })), + }, + ]; + }, + [] + ) .sort(sortKernelGroups); }; @@ -105,7 +121,7 @@ const PRIORITY = { Deprecated: 1, }; -const sortKernelGroups = (a: GroupType, b: GroupType) => { +const sortKernelGroups = (a: KernelGroupOption, b: KernelGroupOption) => { if (PRIORITY[a.label] > PRIORITY[b.label]) { return -1; } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx index 30fe4d0df74..83d27bf867c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx @@ -10,7 +10,6 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; -import { Item } from 'src/components/EnhancedSelect/Select'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; import { Mode, ModeSelect } from 'src/components/ModeSelect/ModeSelect'; @@ -26,6 +25,8 @@ import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; import { ImageAndPassword } from '../LinodeSettings/ImageAndPassword'; +import type { Image } from '@linode/api-v4' + type FileSystem = 'ext3' | 'ext4' | 'initrd' | 'raw' | 'swap'; type CreateMode = 'empty' | 'from_image'; @@ -171,8 +172,8 @@ export const CreateDiskDrawer = (props: Props) => { imageFieldError={ formik.touched.image ? formik.errors.image : undefined } - onImageChange={(selected: Item) => - formik.setFieldValue('image', selected?.value ?? null) + onImageChange={(selected: Image) => + formik.setFieldValue('image', selected?.id ?? null) } onPasswordChange={(root_pass: string) => formik.setFieldValue('root_pass', root_pass) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx index b93f6090e31..55111a064ed 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx @@ -10,7 +10,7 @@ import { useInProgressEvents } from 'src/queries/events/events'; import { LinodeDiskActionMenu } from './LinodeDiskActionMenu'; -import type { Disk, Linode } from '@linode/api-v4'; +import type { Disk, EventAction, Linode } from '@linode/api-v4'; interface Props { disk: Disk; @@ -35,7 +35,7 @@ export const LinodeDiskRow = React.memo((props: Props) => { readOnly, } = props; - const diskEventLabelMap = { + const diskEventLabelMap: Partial> = { disk_create: 'Creating', disk_delete: 'Deleting', disk_resize: 'Resizing', diff --git a/packages/manager/src/features/Linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx index b06aec5608a..dc4b56a8d77 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx @@ -19,6 +19,7 @@ interface Spec { currentAmount: number; label: string; newAmount: null | number; + unit: string; } interface ExtendedUpgradeInfo { @@ -114,28 +115,30 @@ export class MutateDrawer extends React.Component { ) : (
      - {Object.keys(extendedUpgradeInfo).map((newSpec) => { - const { - currentAmount, - label, - newAmount, - unit, - } = extendedUpgradeInfo[newSpec]; + {Object.keys(extendedUpgradeInfo).map( + (newSpec: keyof typeof extendedUpgradeInfo) => { + const { + currentAmount, + label, + newAmount, + unit, + } = extendedUpgradeInfo[newSpec]; - if (newAmount === null) { - return null; + if (newAmount === null) { + return null; + } + return ( + + + {label} goes from {currentAmount} {unit} to{' '} + + {newAmount} {unit} + + + + ); } - return ( - - - {label} goes from {currentAmount} {unit} to{' '} - - {newAmount} {unit} - - - - ); - })} + )}
    )} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesGraphs.tsx index f96fdfb2819..bc8ac8949dc 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesGraphs.tsx @@ -50,6 +50,7 @@ export const ProcessesGraphs = (props: Props) => { const name = selectedProcess?.name ?? ''; const user = selectedProcess?.user ?? ''; + // @ts-expect-error The types are completely wrong. They don't account for "user" const process = processesData.Processes?.[name]?.[user] ?? {}; const cpu = process.cpu ?? []; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx index 0adbe61d8a4..24227d3d99b 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx @@ -178,6 +178,7 @@ export const extendData = ( const { longname, ...users } = processesData.Processes![processName]; Object.keys(users).forEach((user) => { + // @ts-expect-error The types are completely wrong. They don't account for "user" const userProcess = processesData.Processes![processName][user]; extendedData.push({ diff --git a/packages/manager/src/features/Longview/request.ts b/packages/manager/src/features/Longview/request.ts index 5832b42f2c9..f8081df5e1f 100644 --- a/packages/manager/src/features/Longview/request.ts +++ b/packages/manager/src/features/Longview/request.ts @@ -91,7 +91,7 @@ export const baseRequest = Axios.create({ }); export const handleLongviewResponse = ( - response: AxiosResponse> + response: AxiosResponse[]> ) => { const notifications = response.data[0].NOTIFICATIONS; /** @@ -108,7 +108,7 @@ export const handleLongviewResponse = ( } }; -export const get: Get = ( +export const get: Get = async ( token: string, action: LongviewAction, options: Partial = {} @@ -131,9 +131,10 @@ export const get: Get = ( if (end) { data.set('end', `${end}`); } - return request({ + const response = await request({ data, - }).then(handleLongviewResponse); + }); + return handleLongviewResponse(response); }; export const getLastUpdated = (token: string) => { diff --git a/packages/manager/src/features/Longview/shared/utilities.ts b/packages/manager/src/features/Longview/shared/utilities.ts index dfd23d6b7af..df72d1f1ff3 100644 --- a/packages/manager/src/features/Longview/shared/utilities.ts +++ b/packages/manager/src/features/Longview/shared/utilities.ts @@ -253,9 +253,12 @@ export const sumStatsObject = ( if (thisObject && typeof thisObject === 'object') { Object.keys(thisObject).forEach((thisKey) => { if (thisKey in accum) { - accum[thisKey] = appendStats(accum[thisKey], thisObject[thisKey]); + (accum as any)[thisKey] = appendStats( + (accum as any)[thisKey], + (thisObject as any)[thisKey] + ); } else { - accum[thisKey] = thisObject[thisKey]; + (accum as any)[thisKey] = (thisObject as any)[thisKey]; } }); } diff --git a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx index fba16f19195..1684f27b21e 100644 --- a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx @@ -108,12 +108,14 @@ const EditSSHAccessDrawer = (props: EditSSHAccessDrawerProps) => { }) => { const { access, ip, port, user } = values.ssh; + // @ts-expect-error This form intentionally breaks Formik's error type const userError = errors['ssh.user']; // API oddity: IP errors come back as {field: 'ip'} instead of {field: 'ssh.ip'} liked we'd expect. - // tslint:disable-next-line + // @ts-expect-error This form intentionally breaks Formik's error type const ipError = errors['ssh.ip'] || errors['ip']; + // @ts-expect-error This form intentionally breaks Formik's error type const portError = errors['ssh.port']; return ( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index c06b15f7b15..16dd6225894 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -352,7 +352,13 @@ const NodeBalancerCreate = () => { setDeleteConfigConfirmDialog(clone(defaultDeleteConfigConfirmDialogState)); }; - const onConfigValueChange = (configId: number, key: string, value: any) => { + const onConfigValueChange = < + Key extends keyof NodeBalancerConfigFieldsWithStatusAndErrors + >( + configId: number, + key: Key, + value: NodeBalancerConfigFieldsWithStatusAndErrors[Key] + ) => { setNodeBalancerFields((prev) => { const newConfigs = [...prev.configs]; newConfigs[configId][key] = value; diff --git a/packages/manager/src/features/ObjectStorage/utilities.ts b/packages/manager/src/features/ObjectStorage/utilities.ts index 7cbfde45994..39bd78bd897 100644 --- a/packages/manager/src/features/ObjectStorage/utilities.ts +++ b/packages/manager/src/features/ObjectStorage/utilities.ts @@ -138,8 +138,14 @@ export const confirmObjectStorage = async ( // on fields that have been touched (handleSubmit() does this // implicitly). Object.keys(validationErrors).forEach((key) => { - formikProps.setFieldTouched(key, validationErrors[key]); - formikProps.setFieldError(key, validationErrors[key]); + formikProps.setFieldTouched( + key, + Boolean(validationErrors[key as keyof T]) + ); + formikProps.setFieldError( + key, + validationErrors[key as keyof T] as string + ); }); } else { openConfirmationDialog(); diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx index a9e1480e50c..fab152f1a4d 100644 --- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx +++ b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx @@ -142,8 +142,9 @@ export const AppDetailDrawer: React.FunctionComponent = (props) => { > {`${selectedApp.name} { `${preferenceKey}-order` ); - const filter = { + const filter: Filter = { ['+order']: order, ['+order_by']: orderBy, + ...(query && { label: { '+contains': query } }), }; - if (query) { - filter['label'] = { '+contains': query }; - } - const { data: placementGroups, error, diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index 2e6cf9ce334..4256637f84d 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -4,7 +4,7 @@ import { isPast } from 'src/utilities/isPast'; import { ExcludedScope } from './CreateAPITokenDrawer'; -export type Permission = [string, number]; +export type Permission = [keyof typeof basePermNameMap, number]; export const basePerms = [ 'account', @@ -105,7 +105,7 @@ export const scopeStringToPermTuples = ( const [perm, level] = scopeStr.split(':'); return { ...map, - [perm]: levelMap[level], + [perm]: levelMap[level as keyof typeof levelMap], }; }, defaultScopeMap(basePerms, isCreateFlow)); diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 9e936ec5d81..38d1835cc20 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -296,14 +296,16 @@ export const SearchLanding = (props: SearchLandingProps) => { )} {!loading && ( - {Object.keys(finalResults).map((entityType, idx: number) => ( - - ))} + {Object.keys(finalResults).map( + (entityType: keyof typeof displayMap, idx: number) => ( + + ) + )} )} diff --git a/packages/manager/src/features/Search/refinedSearch.ts b/packages/manager/src/features/Search/refinedSearch.ts index f3329f9b315..6fd093981f1 100644 --- a/packages/manager/src/features/Search/refinedSearch.ts +++ b/packages/manager/src/features/Search/refinedSearch.ts @@ -221,7 +221,11 @@ export const getRealEntityKey = (key: string): SearchField | string => { title: LABEL, }; - return substitutions[key] || key; + if (key in substitutions) { + return substitutions[key as keyof typeof substitutions]; + } + + return key; }; // Returns true if all values in array are true diff --git a/packages/manager/src/features/Search/utils.ts b/packages/manager/src/features/Search/utils.ts index 9000210e92c..4b23ac68683 100644 --- a/packages/manager/src/features/Search/utils.ts +++ b/packages/manager/src/features/Search/utils.ts @@ -26,7 +26,9 @@ export const separateResultsByEntity = ( searchResults.forEach((result) => { // EntityTypes are singular; we'd like the resulting keys to be plural const pluralizedEntityType = result.entityType + 's'; - separatedResults[pluralizedEntityType].push(result); + separatedResults[ + pluralizedEntityType as keyof typeof separatedResults + ].push(result); }); return separatedResults; }; diff --git a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx index 4d183833047..71163057cff 100644 --- a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx @@ -543,7 +543,7 @@ const withStackScriptBase = (options: WithStackScriptBaseOptions) => ( } else { this.setState({ allStackScriptsLoaded: false, - currentSearchFilter: [], + currentSearchFilter: {}, }); } }) diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx index 42c28894a10..c1978cd7c30 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.test.tsx @@ -39,7 +39,7 @@ describe('StackScriptCreate', () => { expect(getByLabelText('StackScript Label (required)')).toBeVisible(); expect(getByLabelText('Description')).toBeVisible(); - expect(getByLabelText('Target Images')).toBeVisible(); + expect(getByLabelText('Target Images (required)')).toBeVisible(); expect(getByLabelText('Script (required)')).toBeVisible(); expect(getByLabelText('Revision Note')).toBeVisible(); diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx index 2d05092f8d9..05d3c515b55 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx @@ -27,18 +27,18 @@ import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { storage } from 'src/utilities/storage'; -import type { Account, Grant } from '@linode/api-v4/lib/account'; import type { + APIError, + Image, StackScript, StackScriptPayload, -} from '@linode/api-v4/lib/stackscripts'; -import type { APIError } from '@linode/api-v4/lib/types'; +} from '@linode/api-v4'; +import type { Account, Grant } from '@linode/api-v4/lib/account'; import type { QueryClient } from '@tanstack/react-query'; import type { RouteComponentProps } from 'react-router-dom'; import type { WithImagesProps } from 'src/containers/images.container'; import type { WithProfileProps } from 'src/containers/profile.container'; import type { WithQueryClientProps } from 'src/containers/withQueryClient.container'; -import type { SelectImageOption } from 'src/features/Images/ImageSelect'; interface State { apiResponse?: StackScript; @@ -129,8 +129,8 @@ export class StackScriptCreate extends React.Component { ); }; - handleChooseImage = (images: SelectImageOption[]) => { - const imageList = images.map((image) => image.value); + handleChooseImage = (images: Image[]) => { + const imageList = images.map((image) => image.id); const anyAllOptionChosen = imageList.includes('any/all'); @@ -435,9 +435,7 @@ export class StackScriptCreate extends React.Component { const hasUnsavedChanges = this.hasUnsavedChanges(); const availableImages = Object.values(_imagesData).filter( - (thisImage) => - !this.state.images.includes(thisImage.id) && - !thisImage.label.match(/kube/i) + (thisImage) => !thisImage.label.match(/kube/i) ); const stackScriptGrants = grants.data?.stackscript; diff --git a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx index 3f3deb0f5ed..0f0d68a2a1d 100644 --- a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx @@ -14,11 +14,9 @@ import { StyledNotice, StyledTextField, } from './StackScriptForm.styles'; -import { imageToImageOptions } from './utils'; import type { Image } from '@linode/api-v4/lib/images'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { SelectImageOption } from 'src/features/Images/ImageSelect'; interface TextFieldHandler { handler: (e: React.ChangeEvent) => void; @@ -43,7 +41,7 @@ interface Props { label: TextFieldHandler; mode: 'create' | 'edit'; onCancel: () => void; - onSelectChange: (image: SelectImageOption[]) => void; + onSelectChange: (image: Image[]) => void; onSubmit: () => void; revision: TextFieldHandler; script: TextFieldHandler; @@ -74,7 +72,6 @@ export const StackScriptForm = React.memo((props: Props) => { } = props; const hasErrorFor = getAPIErrorFor(errorResources, errors); - const selectedImages = imageToImageOptions(images.selected); return ( ({ padding: theme.spacing(2) })}> @@ -113,13 +110,13 @@ export const StackScriptForm = React.memo((props: Props) => { anyAllOption data-qa-stackscript-target-select disabled={disabled} - imageFieldError={hasErrorFor('images')} + errorText={hasErrorFor('images')} images={images.available} isMulti label="Target Images" onSelect={onSelectChange} required - value={selectedImages} + value={images.selected} /> diff --git a/packages/manager/src/features/StackScripts/StackScriptForm/utils.test.ts b/packages/manager/src/features/StackScripts/StackScriptForm/utils.test.ts deleted file mode 100644 index cca9dccaeba..00000000000 --- a/packages/manager/src/features/StackScripts/StackScriptForm/utils.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { imageToImageOptions } from './utils'; - -const mockImages = ['linode/debian9', 'linode/arch']; - -describe('imageToItem utility function', () => { - it('should convert images to Item[]', () => { - const items = imageToImageOptions(mockImages); - expect(items).toHaveLength(mockImages.length); - expect(items[0]).toEqual({ - label: 'debian9', - value: 'linode/debian9', - }); - }); - - it('should return an empty array if the initial list is empty', () => { - expect(imageToImageOptions([])).toEqual([]); - }); - - it('should leave non-linode image labels unchanged', () => { - expect(imageToImageOptions(['exampleuser/myprivateimage'])[0]).toEqual({ - label: 'exampleuser/myprivateimage', - value: 'exampleuser/myprivateimage', - }); - }); -}); diff --git a/packages/manager/src/features/StackScripts/StackScriptForm/utils.ts b/packages/manager/src/features/StackScripts/StackScriptForm/utils.ts deleted file mode 100644 index be87ca209c1..00000000000 --- a/packages/manager/src/features/StackScripts/StackScriptForm/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SelectImageOption } from 'src/features/Images/ImageSelect'; - -/** - * Takes a list of images (string[]) and converts - * them to SelectImageOption[] for use in the ImageSelect component. - * - * Also trims 'linode/' off the name of public images. - */ -export const imageToImageOptions = (images: string[]): SelectImageOption[] => { - return images.map((image) => ({ - label: image.replace('linode/', ''), - value: image, - })); -}; diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx index 42720ec7506..64d230bbc87 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx @@ -5,7 +5,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; import type { UserDefinedField } from '@linode/api-v4/lib/stackscripts'; -import type { SelectImageOption } from 'src/features/Images/ImageSelect'; +import type { Item } from 'src/components/EnhancedSelect'; interface Props { error?: string; @@ -27,11 +27,11 @@ interface State { } class UserDefinedMultiSelect extends React.Component { - handleSelectManyOf = (selectedOptions: SelectImageOption[]) => { + handleSelectManyOf = (selectedOptions: Item[]) => { const { field, updateFormState } = this.props; const arrayToString = Array.prototype.map - .call(selectedOptions, (opt: SelectImageOption) => opt.value) + .call(selectedOptions, (opt: Item) => opt.value) .toString(); updateFormState(field.name, arrayToString); diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts index 144645ead73..ca14507c552 100644 --- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts +++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts @@ -23,8 +23,8 @@ const oneClickFilter = [ }, }, ], + '+order_by': 'ordinal', }, - { '+order_by': 'ordinal' }, ]; export const getOneClickApps = (params?: Params) => diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index ee32852f99d..57deb302df3 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -104,6 +104,10 @@ export const SupportTicketProductSelectionFields = (props: Props) => { vpc_id: vpcs, }; + if (entityType === 'none' || entityType === 'general') { + return []; + } + if (!reactQueryEntityDataMap[entityType]) { return []; } diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index 0d95678a7fc..c17347cc8d4 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -1,4 +1,5 @@ import { + GlobalGrantTypes, Grant, GrantLevel, GrantType, @@ -67,7 +68,7 @@ interface Props { interface TabInfo { showTabs: boolean; - tabs: string[]; + tabs: GrantType[]; } interface State { @@ -84,7 +85,7 @@ interface State { setAllPerm: 'null' | 'read_only' | 'read_write'; /* Large Account Support */ showTabs?: boolean; - tabs?: string[]; + tabs?: GrantType[]; userType: null | string; } @@ -144,7 +145,7 @@ class UserPermissions extends React.Component { } }; - entityIsAll = (entity: string, value: GrantLevel): boolean => { + entityIsAll = (entity: GrantType, value: GrantLevel): boolean => { const { grants } = this.state; if (!(grants && grants[entity])) { return false; @@ -261,7 +262,7 @@ class UserPermissions extends React.Component { } }; - globalBooleanPerms = [ + globalBooleanPerms: GlobalGrantTypes[] = [ 'add_databases', 'add_domains', 'add_firewalls', @@ -478,8 +479,8 @@ class UserPermissions extends React.Component { ); }; - renderGlobalPerm = (perm: string, checked: boolean) => { - const permDescriptionMap = { + renderGlobalPerm = (perm: GlobalGrantTypes, checked: boolean) => { + const permDescriptionMap: Partial> = { add_databases: 'Can add Databases to this account ($)', add_domains: 'Can add Domains using the DNS Manager', add_firewalls: 'Can add Firewalls to this account', @@ -687,7 +688,7 @@ class UserPermissions extends React.Component { ); }; - savePermsType = (type: string) => () => { + savePermsType = (type: keyof Grants) => () => { this.setState({ errors: undefined }); const { clearNewUser, currentUsername } = this.props; const { grants } = this.state; @@ -809,7 +810,7 @@ class UserPermissions extends React.Component { }); }; - setGrantTo = (entity: string, idx: number, value: GrantLevel) => () => { + setGrantTo = (entity: GrantType, idx: number, value: GrantLevel) => () => { const { grants } = this.state; if (!(grants && grants[entity])) { return; diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index 104da2923f8..73935355e6d 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -63,7 +63,7 @@ export const VPCEditDrawer = (props: Props) => { const handleFieldChange = (field: string, value: string) => { form.setFieldValue(field, value); - if (form.errors[field]) { + if (form.errors[field as keyof UpdateVPCPayloadWithNone]) { form.setFieldError(field, undefined); } }; diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index 939107840ef..2f08ed2c211 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -36,7 +36,7 @@ import { VolumeDetailsDrawer } from './VolumeDetailsDrawer'; import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; import { VolumeTableRow } from './VolumeTableRow'; -import type { Volume } from '@linode/api-v4'; +import type { Filter, Volume } from '@linode/api-v4'; const preferenceKey = 'volumes'; const searchQueryKey = 'query'; @@ -59,15 +59,14 @@ export const VolumesLanding = () => { `${preferenceKey}-order` ); - const filter = { + const filter: Filter = { ['+order']: order, ['+order_by']: orderBy, + ...(volumeLabelFromParam && { + label: { '+contains': volumeLabelFromParam }, + }), }; - if (volumeLabelFromParam) { - filter['label'] = { '+contains': volumeLabelFromParam }; - } - const { data: volumes, error, isFetching, isLoading } = useVolumesQuery( { page: pagination.page, diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx index 2f9bd20478d..02b819e3b90 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx @@ -135,7 +135,10 @@ export const ClassDescriptionCopy = (props: ClassDescriptionCopyProps) => { - {planTabInfoContent[planType]?.typography}{' '} + { + planTabInfoContent[planType as keyof typeof planTabInfoContent] + ?.typography + }{' '} Learn more about our {planTypeLabel} plans. ) : null; diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index a4b0cf0ecbd..f1e8f4742bc 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -129,67 +129,71 @@ export const PlansPanel = (props: PlansPanelProps) => { selectedRegionID, }); - const tabs = Object.keys(plans).map((plan: LinodeTypeClass) => { - const plansMap: PlanSelectionType[] = plans[plan]; - const { - allDisabledPlans, - hasMajorityOfPlansDisabled, - plansForThisLinodeTypeClass, - } = extractPlansInformation({ - disableLargestGbPlansFlag: flags.disableLargestGbPlans, - disabledClasses, - disabledSmallerPlans, - plans: plansMap, - regionAvailabilities, - selectedRegionId: selectedRegionID, - }); - - return { - disabled: props.disabledTabs ? props.disabledTabs?.includes(plan) : false, - render: () => { - return ( - <> - ) => { + const plansMap: PlanSelectionType[] = plans[plan]!; + const { + allDisabledPlans, + hasMajorityOfPlansDisabled, + plansForThisLinodeTypeClass, + } = extractPlansInformation({ + disableLargestGbPlansFlag: flags.disableLargestGbPlans, + disabledClasses, + disabledSmallerPlans, + plans: plansMap, + regionAvailabilities, + selectedRegionId: selectedRegionID, + }); + + return { + disabled: props.disabledTabs + ? props.disabledTabs?.includes(plan) + : false, + render: () => { + return ( + <> + + {showDistributedRegionPlanTable && ( + )} - disabledClasses={disabledClasses} - hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} - hasSelectedRegion={hasSelectedRegion} - planType={plan} - regionsData={regionsData || []} - /> - {showDistributedRegionPlanTable && ( - - )} - - - ); - }, - title: planTabInfoContent[plan === 'standard' ? 'shared' : plan]?.title, - }; - }); + + ); + }, + title: planTabInfoContent[plan === 'edge' ? 'dedicated' : plan]?.title, + }; + } + ); const initialTab = determineInitialPlanCategoryTab( types, diff --git a/packages/manager/src/features/components/PlansPanel/utils.ts b/packages/manager/src/features/components/PlansPanel/utils.ts index 5c1081377ad..e79a52c9cfe 100644 --- a/packages/manager/src/features/components/PlansPanel/utils.ts +++ b/packages/manager/src/features/components/PlansPanel/utils.ts @@ -80,12 +80,16 @@ export const getPlanSelectionsByPlanType = < } // filter empty plan group - return Object.keys(plansByType).reduce((acc, key) => { - if (plansByType[key].length > 0) { - acc[key] = plansByType[key]; - } - return acc; - }, {} as PlansByType); + return Object.keys(plansByType).reduce>>( + (acc, key) => { + if (plansByType[key as keyof PlansByType].length > 0) { + acc[key as keyof PlansByType] = + plansByType[key as keyof PlansByType]; + } + return acc; + }, + {} as PlansByType + ); }; export const determineInitialPlanCategoryTab = ( diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts index 2ba217f9d8a..480e77224ad 100644 --- a/packages/manager/src/hooks/useCreateVPC.ts +++ b/packages/manager/src/hooks/useCreateVPC.ts @@ -71,7 +71,7 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { // on the UI and still have any errors returned by the API correspond to the correct subnet const createSubnetsPayloadAndMapping = () => { const subnetsPayload: CreateSubnetPayload[] = []; - const subnetIdxMapping = {}; + const subnetIdxMapping: Record = {}; let apiSubnetIdx = 0; for (let i = 0; i < formik.values.subnets.length; i++) { @@ -93,8 +93,8 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { }; const combineErrorsAndSubnets = ( - errors: {}, - visualToAPISubnetMapping: {} + errors: Record, + visualToAPISubnetMapping: Record ) => { return formik.values.subnets.map((subnet, idx) => { const apiSubnetIdx: number | undefined = visualToAPISubnetMapping[idx]; @@ -206,7 +206,7 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { // Helper method to set a field's value and clear existing errors const onChangeField = (field: string, value: string) => { formik.setFieldValue(field, value); - if (formik.errors[field]) { + if (formik.errors[field as keyof CreateVPCFieldState]) { formik.setFieldError(field, undefined); } }; diff --git a/packages/manager/src/hooks/useDismissibleNotifications.ts b/packages/manager/src/hooks/useDismissibleNotifications.ts index 56cfc021664..bdd30f8da52 100644 --- a/packages/manager/src/hooks/useDismissibleNotifications.ts +++ b/packages/manager/src/hooks/useDismissibleNotifications.ts @@ -6,7 +6,8 @@ import { useMutatePreferences, usePreferences, } from 'src/queries/profile/preferences'; -import { DismissedNotification } from 'src/types/ManagerPreferences'; + +import type { DismissedNotification } from 'src/types/ManagerPreferences'; /** * Handlers for dismissing notifications and checking if a notification has been dismissed. @@ -111,7 +112,7 @@ export const updateDismissedNotifications = ( notificationsToDismiss: unknown[], options: DismissibleNotificationOptions ) => { - const newNotifications = {}; + const newNotifications: Record = {}; notificationsToDismiss.forEach((thisNotification) => { const hashKey = getHashKey(thisNotification, options.prefix); newNotifications[hashKey] = { diff --git a/packages/manager/src/hooks/useStackScript.ts b/packages/manager/src/hooks/useStackScript.ts index fd251d795b3..c7d0ed292c1 100644 --- a/packages/manager/src/hooks/useStackScript.ts +++ b/packages/manager/src/hooks/useStackScript.ts @@ -110,7 +110,7 @@ const getCompatibleImages = ( }; const getDefaultUDFData = (userDefinedFields: UserDefinedField[]) => { - const defaultUDFData = {}; + const defaultUDFData: Record = {}; userDefinedFields.forEach((eachField) => { if (!!eachField.default) { defaultUDFData[eachField.name] = eachField.default; diff --git a/packages/manager/src/initSentry.ts b/packages/manager/src/initSentry.ts index 01bc2aa8e8a..7c83183094e 100644 --- a/packages/manager/src/initSentry.ts +++ b/packages/manager/src/initSentry.ts @@ -166,7 +166,7 @@ const maybeAddCustomFingerprint = (event: SentryEvent): SentryEvent => { !!exception.values[0].value && !!exception.values[0].value.match(new RegExp(value, 'gmi')) ) { - acc = customFingerPrintMap[value]; + acc = customFingerPrintMap[value as keyof typeof customFingerPrintMap]; } return acc; }, ''); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 7e828c68df8..40560ba490c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -95,7 +95,9 @@ import { pickRandom } from 'src/utilities/random'; import { getStorage } from 'src/utilities/storage'; import type { + AccountMaintenance, CreateObjectStorageKeyPayload, + FirewallStatus, NotificationType, SecurityQuestionsPayload, TokenRequest, @@ -789,13 +791,16 @@ export const handlers = [ const firewall = firewallFactory.build(); return HttpResponse.json(firewall); }), - http.put('*/v4beta/networking/firewalls/:firewallId', async ({ request }) => { - const body = await request.json(); - const firewall = firewallFactory.build({ - status: body?.['status'] ?? 'disabled', - }); - return HttpResponse.json(firewall); - }), + http.put<{}, { status: FirewallStatus }>( + '*/v4beta/networking/firewalls/:firewallId', + async ({ request }) => { + const body = await request.json(); + const firewall = firewallFactory.build({ + status: body?.['status'] ?? 'disabled', + }); + return HttpResponse.json(firewall); + } + ), // http.post('*/account/agreements', () => { // return res(ctx.status(500), ctx.json({ reason: 'Unknown error' })); // }), @@ -1234,8 +1239,8 @@ export const handlers = [ if (request.headers.get('x-filter')) { accountMaintenance.sort((a, b) => { - const statusA = a[headers['+order_by']]; - const statusB = b[headers['+order_by']]; + const statusA = a[headers['+order_by'] as keyof AccountMaintenance]; + const statusB = b[headers['+order_by'] as keyof AccountMaintenance]; if (statusA < statusB) { return -1; @@ -1333,8 +1338,12 @@ export const handlers = [ } filteredAccountUsers.sort((a, b) => { - const statusA = a[headers['+order_by']]; - const statusB = b[headers['+order_by']]; + const statusA = a[headers['+order_by'] as keyof User]; + const statusB = b[headers['+order_by'] as keyof User]; + + if (!statusA || !statusB) { + return 0; + } if (statusA < statusB) { return -1; @@ -1908,11 +1917,14 @@ export const handlers = [ http.post('*/networking/vlans', () => { return HttpResponse.json({}); }), - http.post('*/networking/ipv6/ranges', async ({ request }) => { - const body = await request.json(); - const range = body?.['prefix_length']; - return HttpResponse.json({ range, route_target: '2001:DB8::0000' }); - }), + http.post<{}, { prefix_length: number }>( + '*/networking/ipv6/ranges', + async ({ request }) => { + const body = await request.json(); + const range = body?.['prefix_length']; + return HttpResponse.json({ range, route_target: '2001:DB8::0000' }); + } + ), http.post('*/networking/ips/assign', () => { return HttpResponse.json({}); }), diff --git a/packages/manager/src/queries/account/agreements.ts b/packages/manager/src/queries/account/agreements.ts index 4b754a2d950..7c1e51eb613 100644 --- a/packages/manager/src/queries/account/agreements.ts +++ b/packages/manager/src/queries/account/agreements.ts @@ -38,8 +38,10 @@ export const useMutateAccountAgreements = () => { const newAgreements = { ...previousData }; for (const key in variables) { - if (variables[key] !== undefined) { - newAgreements[key] = variables[key]; + if (variables[key as keyof Agreements] !== undefined) { + newAgreements[key as keyof Agreements] = variables[ + key as keyof Agreements + ]!; } } diff --git a/packages/manager/src/queries/base.ts b/packages/manager/src/queries/base.ts index b4157095922..b1728a0a770 100644 --- a/packages/manager/src/queries/base.ts +++ b/packages/manager/src/queries/base.ts @@ -69,17 +69,21 @@ export type ItemsByID = Record; * */ -export const listToItemsByID = ( - entityList: E, +export const listToItemsByID = ( + entityList: E[], indexer: string = 'id' ) => { - return entityList.reduce( + return entityList.reduce>( (map, item) => ({ ...map, [item[indexer]]: item }), {} ); }; -export const mutationHandlers = ( +export const mutationHandlers = < + T, + V extends Record, + E = APIError[] +>( queryKey: QueryKey, indexer: string = 'id', queryClient: QueryClient @@ -89,7 +93,7 @@ export const mutationHandlers = ( // Update the query data to include the newly updated Entity. queryClient.setQueryData>(queryKey, (oldData) => ({ ...oldData, - [variables[indexer]]: updatedEntity, + [variables[indexer as keyof V]]: updatedEntity, })); }, }; @@ -109,7 +113,11 @@ export const simpleMutationHandlers = ( }; }; -export const creationHandlers = ( +export const creationHandlers = < + T extends Record, + V, + E = APIError[] +>( queryKey: QueryKey, indexer: string = 'id', queryClient: QueryClient @@ -125,7 +133,11 @@ export const creationHandlers = ( }; }; -export const deletionHandlers = ( +export const deletionHandlers = < + T, + V extends Record, + E = APIError[] +>( queryKey: QueryKey, indexer: string = 'id', queryClient: QueryClient diff --git a/packages/manager/src/queries/events/event.helpers.ts b/packages/manager/src/queries/events/event.helpers.ts index b82a64a2b50..5d3b500a99e 100644 --- a/packages/manager/src/queries/events/event.helpers.ts +++ b/packages/manager/src/queries/events/event.helpers.ts @@ -63,10 +63,12 @@ export const doesEventMatchAPIFilter = (event: Event, filter: Filter) => { return false; } + // @ts-expect-error todo improve indexability of filter type if (filter?.['entity.id'] && filter['entity.id'] !== event.entity?.id) { return false; } + // @ts-expect-error todo improve indexability of filter type if (filter?.['entity.type'] && filter['entity.type'] !== event.entity?.type) { return false; } diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index edc3074a808..131401a5401 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -276,7 +276,9 @@ export const useCreateObjectUrlMutation = ( }); export const useBucketSSLQuery = (cluster: string, bucket: string) => - useQuery(objectStorageQueries.bucket(cluster, bucket)._ctx.ssl); + useQuery( + objectStorageQueries.bucket(cluster, bucket)._ctx.ssl + ); export const useBucketSSLMutation = (cluster: string, bucket: string) => { const queryClient = useQueryClient(); diff --git a/packages/manager/src/queries/profile/profile.ts b/packages/manager/src/queries/profile/profile.ts index cc57db89c4e..8192d8bbbb2 100644 --- a/packages/manager/src/queries/profile/profile.ts +++ b/packages/manager/src/queries/profile/profile.ts @@ -141,7 +141,7 @@ export const useSSHKeysQuery = ( filter?: Filter, enabled = true ) => - useQuery({ + useQuery, APIError[]>({ ...profileQueries.sshKeys(params, filter), enabled, keepPreviousData: true, diff --git a/packages/manager/src/store/image/image.helpers.ts b/packages/manager/src/store/image/image.helpers.ts index c1e792dcbb1..19228e7c1ea 100644 --- a/packages/manager/src/store/image/image.helpers.ts +++ b/packages/manager/src/store/image/image.helpers.ts @@ -1,10 +1,10 @@ -import { Image } from '@linode/api-v4/lib/images'; +import type { Image } from '@linode/api-v4'; export const filterImagesByType = ( images: Record, type: 'private' | 'public' -): Record => { - return Object.keys(images).reduce((acc, eachKey) => { +) => { + return Object.keys(images).reduce>((acc, eachKey) => { /** keep the public images if we're filtering by public images */ if (type === 'public' && !!images[eachKey].is_public) { acc[eachKey] = images[eachKey]; diff --git a/packages/manager/src/store/longview/longview.reducer.ts b/packages/manager/src/store/longview/longview.reducer.ts index 1d2f6ef2279..6b700bad75a 100644 --- a/packages/manager/src/store/longview/longview.reducer.ts +++ b/packages/manager/src/store/longview/longview.reducer.ts @@ -34,10 +34,13 @@ const reducer = reducerWithInitialState(defaultState) getLongviewClients.done, (state, { payload: { result } }) => ({ ...state, - data: result.data.reduce((acc, client) => { - acc[client.id] = client; - return acc; - }, {}), + data: result.data.reduce>( + (acc, client) => { + acc[client.id] = client; + return acc; + }, + {} + ), lastUpdated: Date.now(), loading: false, results: result.results, diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index bc0b994fe6b..8d3ef2a0101 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -1,6 +1,6 @@ import { ADOBE_ANALYTICS_URL } from 'src/constants'; -import { AnalyticsEvent, FormEventType, FormPayload } from './types'; +import { AnalyticsEvent, BasicFormEvent, FormErrorEvent, FormEventType, FormInputEvent, FormPayload, FormStepEvent } from './types'; /** * Sends a direct call rule events to Adobe for a Component Click (and optionally, with `data`, Component Details). @@ -34,7 +34,9 @@ export const sendFormEvent = ( eventPayload: FormPayload, eventType: FormEventType ): void => { - const formEventPayload = { + const formEventPayload: Partial< + BasicFormEvent & FormErrorEvent & FormInputEvent & FormStepEvent + > = { formName: eventPayload.formName.replace(/\|/g, ''), }; if (!ADOBE_ANALYTICS_URL) { diff --git a/packages/manager/src/utilities/codesnippets/generate-cURL.ts b/packages/manager/src/utilities/codesnippets/generate-cURL.ts index aac77e14372..fbc3e8b63d1 100644 --- a/packages/manager/src/utilities/codesnippets/generate-cURL.ts +++ b/packages/manager/src/utilities/codesnippets/generate-cURL.ts @@ -6,10 +6,10 @@ const headers = [ '-H "Authorization: Bearer $TOKEN" \\', ].join('\n'); -export const generateCurlCommand = (data: {}, path: string) => { +export const generateCurlCommand = (data: any, path: string) => { const keys = Object.keys(data); - const cleanData = {}; + const cleanData: any = {}; for (const key of keys) { if (typeof data[key] === 'string') { diff --git a/packages/manager/src/utilities/deepMerge.ts b/packages/manager/src/utilities/deepMerge.ts index 6852b68b973..b46d4336323 100644 --- a/packages/manager/src/utilities/deepMerge.ts +++ b/packages/manager/src/utilities/deepMerge.ts @@ -24,17 +24,17 @@ export const deepMerge = ( const output = { ...target } as T & S; if (isObject(target) && isObject(source)) { Object.keys(source).forEach((key) => { - if (isObject(source[key])) { + if (isObject((source as any)[key])) { if (!(key in target)) { - Object.assign(output, { [key]: source[key] }); + Object.assign(output, { [key]: (source as any)[key] }); } else { - (output[key] as unknown) = deepMerge( - target[key] as ObjectType, - source[key] as ObjectType + ((output as any)[key] as unknown) = deepMerge( + (target as any)[key] as ObjectType, + (source as any)[key] as ObjectType ); } } else { - Object.assign(output, { [key]: source[key] }); + Object.assign(output, { [key]: (source as any)[key] }); } }); } diff --git a/packages/manager/src/utilities/errorUtils.ts b/packages/manager/src/utilities/errorUtils.ts index c81f09ded23..8f2574a1c2c 100644 --- a/packages/manager/src/utilities/errorUtils.ts +++ b/packages/manager/src/utilities/errorUtils.ts @@ -105,6 +105,6 @@ export const getErrorMap = ( }; } }, - { none: undefined } + { none: undefined } as Record ) as Record<'none' | T, string | undefined>; }; diff --git a/packages/manager/src/utilities/formikErrorUtils.ts b/packages/manager/src/utilities/formikErrorUtils.ts index 90875bb5afa..3e4e14deea4 100644 --- a/packages/manager/src/utilities/formikErrorUtils.ts +++ b/packages/manager/src/utilities/formikErrorUtils.ts @@ -117,7 +117,7 @@ export const handleVPCAndSubnetErrors = ( setFieldError: (field: string, message: string) => void, setError?: (message: string) => void ) => { - const subnetErrors = {}; + const subnetErrors: Record = {}; const nonSubnetErrors: APIError[] = []; errors.forEach((error) => { diff --git a/packages/manager/src/utilities/images.test.ts b/packages/manager/src/utilities/images.test.ts deleted file mode 100644 index 4d8717c216e..00000000000 --- a/packages/manager/src/utilities/images.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { imageFactory } from 'src/factories/images'; - -import { groupImages } from './images'; - -const [privateImage1, privateImage2] = imageFactory.buildList(2, { - is_public: false, -}); - -const [deprecatedImage1, deprecatedImage2] = imageFactory.buildList(2, { - created_by: 'linode', - deprecated: true, -}); - -const [recommendedImage1, recommendedImage2] = imageFactory.buildList(2, { - created_by: 'linode', - id: 'linode/image', -}); - -const [deletedImage1, deletedImage2] = imageFactory.buildList(2, { - created: '', - created_by: null, - deprecated: false, - description: '', - expiry: '2019-05-09T04:13:37', - is_public: false, - label: '', - size: 0, - type: 'automatic', - vendor: null, -}); - -describe('groupImages method', () => { - it("should return a group of the user's private images", () => { - const result = groupImages([privateImage1, privateImage2]); - - const expected = { - images: [privateImage1, privateImage2], - }; - - expect(result).toEqual(expected); - }); - - it('should return group deprecated images', () => { - const result = groupImages([deprecatedImage1, deprecatedImage2]); - - const expected = { - older: [deprecatedImage1, deprecatedImage2], - }; - - expect(result).toEqual(expected); - }); - - it('should return group recommended images', () => { - const _images = [recommendedImage1, recommendedImage2]; - const result = groupImages(_images); - - const expected = { - recommended: _images, - }; - - expect(result).toEqual(expected); - }); - - it('should return group deleted images', () => { - const result = groupImages([deletedImage1, deletedImage2]); - - const expected = { - deleted: [deletedImage1, deletedImage2], - }; - - expect(result).toEqual(expected); - }); -}); diff --git a/packages/manager/src/utilities/images.ts b/packages/manager/src/utilities/images.ts index 48e507ef9b5..75df83c4dbc 100644 --- a/packages/manager/src/utilities/images.ts +++ b/packages/manager/src/utilities/images.ts @@ -1,5 +1,4 @@ -import { Image } from '@linode/api-v4/lib/images'; -import { always, cond, groupBy, propOr } from 'ramda'; +import type { Image } from '@linode/api-v4'; const isRecentlyDeleted = (i: Image) => i.created_by === null && i.type === 'automatic'; @@ -9,31 +8,15 @@ const isDeprecated = (i: Image) => i.deprecated === true; const isRecommended = (i: Image) => isByLinode(i) && !isDeprecated(i); const isOlderImage = (i: Image) => isByLinode(i) && isDeprecated(i); -interface GroupedImages { - deleted?: Image[]; - images?: Image[]; - older?: Image[]; - recommended?: Image[]; -} - -export let groupImages: (i: Image[]) => GroupedImages; -// eslint-disable-next-line -groupImages = groupBy( - cond([ - [isRecentlyDeleted, always('deleted')], - [isRecommended, always('recommended')], - [isOlderImage, always('older')], - [(_) => true, always('images')], - ]) -); - -export const groupNameMap = { - _default: 'Other', - deleted: 'Recently Deleted Disks', - images: 'Images', - older: 'Older Distributions', - recommended: '64-bit Distributions - Recommended', +export const getImageGroup = (image: Image) => { + if (isRecentlyDeleted(image)) { + return 'Recently Deleted Disks'; + } + if (isRecommended(image)) { + return '64-bit Distributions - Recommended'; + } + if (isOlderImage(image)) { + return 'Older Distributions'; + } + return 'Images'; }; - -export const getDisplayNameForGroup = (key: string) => - propOr('Other', key, groupNameMap); diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index b6e17da0b60..85fe244ed59 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -70,13 +70,16 @@ export const getDCSpecificPrice = ({ return undefined; } - const increaseFactor = priceIncreaseMap[regionId] as number | undefined; + if (regionId in priceIncreaseMap) { + const increaseFactor = + priceIncreaseMap[regionId as keyof typeof priceIncreaseMap]; - if (increaseFactor !== undefined) { - // If increaseFactor is defined, it means the region has a price increase and we should apply it. - const increase = basePrice * increaseFactor; + if (increaseFactor !== undefined) { + // If increaseFactor is defined, it means the region has a price increase and we should apply it. + const increase = basePrice * increaseFactor; - return (basePrice + increase).toFixed(2); + return (basePrice + increase).toFixed(2); + } } return basePrice.toFixed(2); diff --git a/packages/manager/src/utilities/regions.ts b/packages/manager/src/utilities/regions.ts index 91f7f62ac3b..b49ac8aeabf 100644 --- a/packages/manager/src/utilities/regions.ts +++ b/packages/manager/src/utilities/regions.ts @@ -1,4 +1,4 @@ -import { Region } from '@linode/api-v4/lib/regions'; +import type { Region } from '@linode/api-v4'; /** * This utility function takes an array of regions data and transforms it into a lookup object. @@ -7,11 +7,11 @@ import { Region } from '@linode/api-v4/lib/regions'; * * @returns {Object} A lookup object where each key is a region ID and its value is the corresponding region object. */ -export const getRegionsByRegionId = (regionsData: Region[] | undefined) => { - if (!Array.isArray(regionsData)) { +export const getRegionsByRegionId = (regions: Region[] | undefined) => { + if (!regions) { return {}; } - return regionsData.reduce((lookup, region) => { + return regions.reduce>((lookup, region) => { lookup[region.id] = region; return lookup; }, {}); diff --git a/packages/manager/src/utilities/storage.ts b/packages/manager/src/utilities/storage.ts index 6d1450f3b2b..1dbf77ccc6b 100644 --- a/packages/manager/src/utilities/storage.ts +++ b/packages/manager/src/utilities/storage.ts @@ -3,7 +3,7 @@ import { shouldEnableDevTools } from 'src/dev-tools/load'; import type { StackScriptPayload } from '@linode/api-v4/lib/stackscripts/types'; import type { SupportTicketFormFields } from 'src/features/Support/SupportTickets/SupportTicketDialog'; -const localStorageCache = {}; +const localStorageCache: Record = {}; export const getStorage = (key: string, fallback?: any) => { if (localStorageCache[key]) { diff --git a/packages/manager/src/utilities/subnets.ts b/packages/manager/src/utilities/subnets.ts index 9f65527a397..31564f72f8f 100644 --- a/packages/manager/src/utilities/subnets.ts +++ b/packages/manager/src/utilities/subnets.ts @@ -35,7 +35,7 @@ export type SubnetIPType = 'ipv4' | 'ipv6'; * - To get available IPs for our VPCs, subtract 4 (the number of reserved IPs) * from the given number */ -export const SubnetMaskToAvailIPv4s = { +export const SubnetMaskToAvailIPv4s: Record = { 0: 4294967296, 1: 2147483648, 2: 1073741824, @@ -107,7 +107,9 @@ export const calculateAvailableIPv4sRFC1918 = ( const [, mask] = address.split('/'); // if the IP is not in the RFC1918 ranges, hold off on displaying number of available IPs - return isValidRFC1918IPv4(address) ? SubnetMaskToAvailIPv4s[mask] : undefined; + return isValidRFC1918IPv4(address) + ? SubnetMaskToAvailIPv4s[Number(mask)] + : undefined; }; /** diff --git a/packages/manager/src/utilities/theme.ts b/packages/manager/src/utilities/theme.ts index 67f66422337..9c2257c6ddb 100644 --- a/packages/manager/src/utilities/theme.ts +++ b/packages/manager/src/utilities/theme.ts @@ -30,7 +30,7 @@ export const getNextThemeValue = (currentTheme: string | undefined) => { * Use this to validate if a value in a user's preferences is a valid value */ export const isValidTheme = (value: unknown): boolean => { - return typeof value === 'string' && themes[value] !== undefined; + return typeof value === 'string' && value in themes; }; /** diff --git a/packages/manager/src/utilities/unitConversions.ts b/packages/manager/src/utilities/unitConversions.ts index 72e950d0f2a..13514e622e1 100644 --- a/packages/manager/src/utilities/unitConversions.ts +++ b/packages/manager/src/utilities/unitConversions.ts @@ -60,12 +60,12 @@ export const readableBytes = ( // If we've been given custom unit labels, make the substitution here. if (options.unitLabels) { - Object.keys(options.unitLabels).forEach((originalLabel) => { - const idx = storageUnits.indexOf(originalLabel as StorageSymbol); + Object.keys(options.unitLabels).forEach((originalLabel: StorageSymbol) => { + const idx = storageUnits.indexOf(originalLabel); if (idx > -1) { // The TS compiler wasn't aware of the null check above, so I added // the non-null assertion operator on options.unitLabels. - storageUnits[idx] = options.unitLabels![originalLabel]; + storageUnits[idx] = options.unitLabels![originalLabel] as StorageSymbol; } }); } diff --git a/packages/manager/tsconfig.json b/packages/manager/tsconfig.json index a1ef543aa04..25ce8001a09 100644 --- a/packages/manager/tsconfig.json +++ b/packages/manager/tsconfig.json @@ -35,10 +35,6 @@ "strictNullChecks": true, "types": ["vitest/globals", "@testing-library/jest-dom"], - /* Goodluck... */ - "ignoreDeprecations": "5.0", - "suppressImplicitAnyIndexErrors": true, - /* Completeness */ "skipLibCheck": true, From d610531c71e3514bc0bef955394c8d149e3166aa Mon Sep 17 00:00:00 2001 From: nikhagra <165884194+nikhagra@users.noreply.github.com> Date: Thu, 8 Aug 2024 23:30:27 +0530 Subject: [PATCH 13/43] upcoming: [DI-20050] - Added Data Rollup logic for graph data (#10747) * upcoming: [DI-20050] - Added Data Rollup logic for graph data * upcoming: [DI-20050] - Added changeset * upcoming: [DI-20050] - Improved code readability & updated test cases * upcoming: [DI-20050] - Added jsdocs comments & changeset * upcoming: [DI-20050] - Removed unused imports * upcoming: [DI-20050] - Updated jsDocs comments * upcoming: [DI-20050] - Removed non-null assertion * upcoming: [DI-20050] - updated interface structure --- ...r-10747-upcoming-features-1723026166451.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 2 +- ...r-10747-upcoming-features-1723026263446.md | 5 + packages/manager/src/featureFlags.ts | 2 +- .../Dashboard/CloudPulseDashboard.tsx | 2 +- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 266 ++++++++++++++++-- .../CloudPulse/Utils/unitConversion.test.ts | 36 +++ .../CloudPulse/Utils/unitConversion.ts | 248 ++++++++++++++++ .../src/features/CloudPulse/Utils/utils.ts | 2 +- .../CloudPulse/Widget/CloudPulseWidget.tsx | 79 +++--- .../Widget/components/CloudPulseLineGraph.tsx | 50 ++-- .../manager/src/queries/cloudpulse/metrics.ts | 4 +- 12 files changed, 608 insertions(+), 93 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md create mode 100644 packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md create mode 100644 packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts create mode 100644 packages/manager/src/features/CloudPulse/Utils/unitConversion.ts diff --git a/packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md b/packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md new file mode 100644 index 00000000000..d426d6c4963 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Change JweTokenPayLoad's `resource_id` to `resource_ids` ([#10747](https://github.com/linode/manager/pull/10747)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index ef422e6d97c..79ea9f9857f 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -81,7 +81,7 @@ export interface Dimension { } export interface JWETokenPayLoad { - resource_id: number[]; + resource_ids: number[]; } export interface JWEToken { diff --git a/packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md b/packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md new file mode 100644 index 00000000000..a6b8e5da77d --- /dev/null +++ b/packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add conversion for data roll up and modify positioning of "No data to display" message ([#10747](https://github.com/linode/manager/pull/10747)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 0ffcaa47497..a74f395497f 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -63,7 +63,7 @@ interface AclpFlag { enabled: boolean; } -interface CloudPulseResourceTypeMapFlag { +export interface CloudPulseResourceTypeMapFlag { dimensionKey: string; serviceType: string; } diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 51a69be77b6..bc291066120 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -73,7 +73,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getJweTokenPayload = (): JWETokenPayLoad => { return { - resource_id: resourceList?.map((resource) => Number(resource.id)) ?? [], + resource_ids: resourceList?.map((resource) => Number(resource.id)) ?? [], }; }; diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 8683f91c222..3ac3a87b3f0 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -1,8 +1,12 @@ import { isToday } from 'src/utilities/isToday'; -import { roundTo } from 'src/utilities/roundTo'; import { getMetrics } from 'src/utilities/statMetrics'; import { COLOR_MAP } from './CloudPulseWidgetColorPalette'; +import { + formatToolTip, + generateUnitByBaseUnit, + transformData, +} from './unitConversion'; import { convertTimeDurationToStartAndEndTimeRange, seriesDataFormatter, @@ -18,14 +22,137 @@ import type { Widgets, } from '@linode/api-v4'; import type { DataSet } from 'src/components/LineGraph/LineGraph'; +import type { CloudPulseResourceTypeMapFlag, FlagSet } from 'src/featureFlags'; + +interface LabelNameOptionsProps { + /** + * flags received from config + */ + flags: FlagSet; + + /** + * label for the graph title + */ + label: string; + + /** + * key-value to generate dimension name + */ + metric: { [label: string]: string }; + + /** + * list of CloudPulseResources available + */ + resources: CloudPulseResources[]; + + /** + * service type of the selected dashboard + */ + serviceType: string; + + /** + * unit of the data + */ + unit: string; +} + +interface graphDataOptionsProps { + /** + * flags associated with metricsList + */ + flags: FlagSet; + + /** + * label for the graph title + */ + label: string; + + /** + * data that will be displayed on graph + */ + metricsList: CloudPulseMetricsResponse | undefined; + + /** + * list of CloudPulse resources + */ + resources: CloudPulseResources[]; + + /** + * service type of the selected dashboard + */ + serviceType: string; + + /** + * status returned from react query ( loading | error | success) + */ + status: string | undefined; + + /** + * unit of the data + */ + unit: string; + + /** + * preferred color for the widget's graph + */ + widgetColor: string | undefined; +} + +interface MetricRequestProps { + /** + * time duration for the metrics data + */ + duration: TimeDuration; + + /** + * resource ids selected by user + */ + resourceIds: string[]; + + /** + * list of CloudPulse resources available + */ + resources: CloudPulseResources[]; + + /** + * widget filters for metrics data + */ + widget: Widgets; +} + +interface DimensionNameProperties { + /** + * flag dimension key mapping for service type + */ + flag: CloudPulseResourceTypeMapFlag | undefined; + + /** + * metric key-value to generate dimension name + */ + metric: { [label: string]: string }; + + /** + * resources list of CloudPulseResources available + */ + resources: CloudPulseResources[]; +} + +/** + * + * @returns parameters which will be necessary to populate graph & legends + */ +export const generateGraphData = (props: graphDataOptionsProps) => { + const { + flags, + label, + metricsList, + resources, + serviceType, + status, + unit, + widgetColor, + } = props; -export const generateGraphData = ( - widgetColor: string | undefined, - metricsList: CloudPulseMetricsResponse | undefined, - status: string | undefined, - label: string, - unit: string -) => { const dimensions: DataSet[] = []; const legendRowsData: LegendRow[] = []; @@ -33,32 +160,44 @@ export const generateGraphData = ( const colors = COLOR_MAP.get(widgetColor ?? 'default')!; let today = false; - if (status === 'success' && Boolean(metricsList?.data.result.length)) { - metricsList!.data.result.forEach( + if (status === 'success') { + metricsList?.data?.result?.forEach( (graphData: CloudPulseMetricsList, index) => { - // todo, move it to utils at a widget level if (!graphData) { return; } + const transformedData = { + metric: graphData.metric, + values: transformData(graphData.values, unit), + }; const color = colors[index]; const { end, start } = convertTimeDurationToStartAndEndTimeRange({ unit: 'min', value: 30, }) || { - end: graphData.values[graphData.values.length - 1][0], - start: graphData.values[0][0], + end: transformedData.values[transformedData.values.length - 1][0], + start: transformedData.values[0][0], + }; + + const labelOptions: LabelNameOptionsProps = { + flags, + label, + metric: transformedData.metric, + resources, + serviceType, + unit, }; const dimension = { backgroundColor: color, borderColor: '', - data: seriesDataFormatter(graphData.values, start, end), - label: `${label} (${unit})`, + data: seriesDataFormatter(transformedData.values, start, end), + label: getLabelName(labelOptions), }; // construct a legend row with the dimension const legendRow = { data: getMetrics(dimension.data as number[][]), - format: (value: number) => tooltipValueFormatter(value, unit), + format: (value: number) => formatToolTip(value, unit), legendColor: color, legendTitle: dimension.label, }; @@ -69,22 +208,37 @@ export const generateGraphData = ( ); } - return { dimensions, legendRowsData, today }; + return { + dimensions, + legendRowsData, + today, + unit: generateMaxUnit(legendRowsData, unit), + }; }; -const tooltipValueFormatter = (value: number, unit: string) => - `${roundTo(value)} ${unit}`; +/** + * + * @param legendRowsData list of legend rows available for the metric + * @param unit base unit of the values + * @returns maximum possible rolled up unit based on the unit + */ +const generateMaxUnit = (legendRowsData: LegendRow[], unit: string) => { + const maxValue = Math.max( + 0, + ...legendRowsData?.map((row) => row?.data.max ?? 0) + ); + + return generateUnitByBaseUnit(maxValue, unit); +}; /** * * @returns a CloudPulseMetricRequest object to be passed as data to metric api call */ export const getCloudPulseMetricRequest = ( - widget: Widgets, - duration: TimeDuration, - resources: CloudPulseResources[], - resourceIds: string[] + props: MetricRequestProps ): CloudPulseMetricsRequest => { + const { duration, resourceIds, resources, widget } = props; return { aggregate_function: widget.aggregate_function, filters: undefined, @@ -103,3 +257,69 @@ export const getCloudPulseMetricRequest = ( }, }; }; + +/** + * + * @returns generated label name for graph dimension + */ +const getLabelName = (props: LabelNameOptionsProps): string => { + const { flags, label, metric, resources, serviceType, unit } = props; + // aggregated metric, where metric keys will be 0 + if (!Object.keys(metric).length) { + // in this case return widget label and unit + return `${label} (${unit})`; + } + + const flag = flags?.aclpResourceTypeMap?.find( + (obj: CloudPulseResourceTypeMapFlag) => obj.serviceType === serviceType + ); + + return getDimensionName({ flag, metric, resources }); +}; + +/** + * + * @returns generated dimension name based on resources + */ +export const getDimensionName = (props: DimensionNameProperties): string => { + const { flag, metric, resources } = props; + return Object.entries(metric) + .map(([key, value]) => { + if (key === flag?.dimensionKey) { + return mapResourceIdToName(value, resources); + } + + return value ?? ''; + }) + .filter(Boolean) + .join('_'); +}; + +/** + * + * @param id resource id that should be searched in resources list + * @param resources list of CloudPulseResources available + * @returns resource label if id is found, the id if label is not found, and fall back on an empty string with an undefined id + */ +export const mapResourceIdToName = ( + id: string | undefined, + resources: CloudPulseResources[] +): string => { + return ( + resources.find((resourceObj) => resourceObj?.id === id)?.label ?? id ?? '' + ); +}; + +/** + * + * @param data data set to be checked for empty + * @returns true if data is not empty or contains all the null values otherwise false + */ +export const isDataEmpty = (data: DataSet[]): boolean => { + return data.every( + (thisSeries) => + thisSeries.data.length === 0 || + // If we've padded the data, every y value will be null + thisSeries.data.every((thisPoint) => thisPoint[1] === null) + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts new file mode 100644 index 00000000000..a2cdde6c9d9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts @@ -0,0 +1,36 @@ +import { + convertValueToUnit, + formatToolTip, + generateCurrentUnit, + generateUnitByBaseUnit, + generateUnitByBitValue, + generateUnitByByteValue, + generateUnitByTimeValue, +} from './unitConversion'; + +describe('Unit conversion', () => { + it('should check current unit to be converted into appropriate unit', () => { + expect(generateCurrentUnit('Bytes')).toBe('B'); + expect(generateCurrentUnit('%')).toBe('%'); + }), + it('should generate rolled up unit based on value', () => { + expect(generateUnitByByteValue(2024)).toBe('KB'); + expect(generateUnitByBitValue(999)).toBe('b'); + expect(generateUnitByTimeValue(364000)).toBe('min'); + }), + it('should roll up value based on unit', () => { + expect(convertValueToUnit(2048, 'KB')).toBe(2); + expect(convertValueToUnit(3000000, 'Mb')).toBe(3); + expect(convertValueToUnit(60000, 'min')).toBe(1); + }), + it('should generate a tooltip', () => { + expect(formatToolTip(1000, 'b')).toBe('1 Kb'); + expect(formatToolTip(2048, 'B')).toBe('2 KB'); + expect(formatToolTip(1000, 'ms')).toBe('1 s'); + }), + it('should generate maximum unit based on the base unit & value', () => { + expect(generateUnitByBaseUnit(1000000, 'b')).toBe('Mb'); + expect(generateUnitByBaseUnit(2048, 'B')).toBe('KB'); + expect(generateUnitByBaseUnit(60001, 'ms')).toBe('min'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts new file mode 100644 index 00000000000..422607f383a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts @@ -0,0 +1,248 @@ +import { roundTo } from 'src/utilities/roundTo'; + +const supportedUnits = { + B: 'Bytes', + Bps: 'Bps', + GB: 'GB', + GBps: 'GBps', + Gb: 'Gb', + Gbps: 'Gbps', + KB: 'KB', + KBps: 'KBps', + Kb: 'Kb', + Kbps: 'Kbps', + MB: 'MB', + MBps: 'MBps', + Mb: 'Mb', + Mbps: 'Mbps', + PB: 'PB', + PBps: 'PBps', + Pb: 'Pb', + Pbps: 'Pbps', + TB: 'TB', + TBps: 'TBps', + Tb: 'Tb', + Tbps: 'Tbps', + b: 'bits', + bps: 'bps', + d: 'd', + h: 'h', + m: 'mo', + min: 'min', + ms: 'ms', + s: 's', + w: 'wk', + y: 'yr', +}; + +const timeUnits = ['d', 'h', 'm', 'min', 'ms', 's', 'w', 'y']; + +/** + * Multipliers to convert the value of particular unit type to its minimum possible unit. + * Ex: For time unit, minimum unit is ms (millisecond) & if value is in minutes then will multiply by 60000 to convert into ms or vice-versa + */ +const multiplier: { [label: string]: number } = { + B: 1, + Bps: 1, + GB: Math.pow(2, 30), + GBps: Math.pow(2, 30), + Gb: 1e9, + Gbps: 1e9, + KB: Math.pow(2, 10), + KBps: Math.pow(2, 10), + Kb: 1e3, + Kbps: 1e3, + MB: Math.pow(2, 20), + MBps: Math.pow(2, 20), + Mb: 1e6, + Mbps: 1e6, + PB: Math.pow(2, 50), + PBps: Math.pow(2, 50), + Pb: 1e15, + Pbps: 1e15, + TB: Math.pow(2, 40), + TBps: Math.pow(2, 40), + Tb: 1e12, + Tbps: 1e12, + b: 1, + bps: 1, + d: 86400000, + h: 36000000, + m: 2592000000, + min: 60000, + ms: 1, + s: 1000, + w: 604800000, + y: 31536000000, +}; + +/** + * + * @param value bit value based on which maximum possible unit will be generated + * @returns maximum possible rolled up unit for the input bit value + */ +export const generateUnitByBitValue = (value: number): string => { + if (value < multiplier.Kb) { + return 'b'; + } + if (value < multiplier.Mb) { + return 'Kb'; + } + if (value < multiplier.Gb) { + return 'Mb'; + } + if (value < multiplier.Tb) { + return 'Gb'; + } + if (value < multiplier.Pb) { + return 'Tb'; + } + return 'Pb'; +}; + +/** + * + * @param value byte value based on which maximum possible unit will be generated + * @returns maximum possible rolled up unit for the input byte value + */ +export const generateUnitByByteValue = (value: number): string => { + if (value < multiplier.KB) { + return 'B'; + } + if (value < multiplier.MB) { + return 'KB'; + } + if (value < multiplier.GB) { + return 'MB'; + } + if (value < multiplier.TB) { + return 'GB'; + } + if (value < multiplier.PB) { + return 'TB'; + } + return 'PB'; +}; + +/** + * + * @param value time value based on which maximum possible unit will be generated + * @returns maximum possible rolled up unit for the input time value + */ +export const generateUnitByTimeValue = (value: number): string => { + if (value < multiplier.s) { + return 'ms'; + } + if (value < multiplier.min) { + return 's'; + } + if (value < multiplier.h) { + return 'min'; + } + if (value < multiplier.d) { + return 'h'; + } + if (value < multiplier.w) { + return 'd'; + } + if (value < multiplier.m) { + return 'w'; + } + if (value < multiplier.y) { + return 'm'; + } + return 'y'; +}; + +/** + * + * @param value bit value to be rolled up based on maxUnit + * @param maxUnit maximum possible unit based on which value will be rolled up + * @returns rolled up value based on maxUnit + */ +export const convertValueToUnit = (value: number, maxUnit: string) => { + const convertingValue = multiplier[maxUnit] ?? 1; + + if (convertingValue === 1) { + return roundTo(value); + } + return value / convertingValue; +}; + +/** + * + * @param value bits or bytes value to be rolled up to highest possible unit according to base unit. + * @param baseUnit bits or bytes unit depends on which unit will be generated for value. + * @returns formatted string for the value rolled up to higher possible unit according to base unit. + */ +export const formatToolTip = (value: number, baseUnit: string): string => { + const unit = generateCurrentUnit(baseUnit); + let generatedUnit = baseUnit; + if (unit.endsWith('b') || unit.endsWith('bps')) { + generatedUnit = generateUnitByBitValue(value); + } else if (unit.endsWith('B') || unit.endsWith('Bps')) { + generatedUnit = generateUnitByByteValue(value); + } else if (timeUnits.includes(unit)) { + generatedUnit = generateUnitByTimeValue(value); + } + const convertedValue = convertValueToUnit(value, generatedUnit); + + return `${roundTo(convertedValue)} ${generatedUnit}${ + unit.endsWith('ps') ? '/s' : '' + }`; +}; + +/** + * + * @param value bits or bytes value for which unit to be generate + * @param baseUnit bits or bytes unit depends on which unit will be generated for value + * @returns Unit object if base unit is bits or bytes otherwise undefined + */ +export const generateUnitByBaseUnit = ( + value: number, + baseUnit: string +): string => { + const unit = generateCurrentUnit(baseUnit); + let generatedUnit = baseUnit; + if (unit.endsWith('b') || unit.endsWith('bps')) { + generatedUnit = generateUnitByBitValue(value); + } else if (unit.endsWith('B') || unit.endsWith('Bps')) { + generatedUnit = generateUnitByByteValue(value); + } else if (timeUnits.includes(unit)) { + generatedUnit = generateUnitByTimeValue(value); + } + return generatedUnit; +}; + +/** + * + * @param baseUnit unit received from configuration + * @returns current unit based on the supported units mapping + */ +export const generateCurrentUnit = (baseUnit: string): string => { + let unit: string = baseUnit; + + for (const [key, value] of Object.entries(supportedUnits)) { + if (value === baseUnit) { + unit = key; + break; + } + } + + return unit; +}; + +/** + * + * @param data data that is to be transformed based on baseUnit + * @param baseUnit baseUnit for the data + * @returns transformed data based on the base unit + */ +export const transformData = ( + data: [number, string][], + baseUnit: string +): [number, number][] => { + const unit: string = generateCurrentUnit(baseUnit); + + return data.map((d) => [d[0], Number(d[1]) * (multiplier[unit] ?? 1)]); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 8bc4780194d..a46f25ebedb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -88,7 +88,7 @@ export const convertTimeDurationToStartAndEndTimeRange = ( * @returns formatted data based on the time range between @startTime & @endTime */ export const seriesDataFormatter = ( - data: [number, string][], + data: [number, number][], startTime: number, endTime: number ): [number, null | number][] => { diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index d7208d5be23..906b537651a 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -2,9 +2,7 @@ import { Box, Grid, Paper, Stack, Typography } from '@mui/material'; import { DateTime } from 'luxon'; import React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; import { Divider } from 'src/components/Divider'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseMetricsQuery } from 'src/queries/cloudpulse/metrics'; import { useProfile } from 'src/queries/profile/profile'; @@ -14,6 +12,7 @@ import { getCloudPulseMetricRequest, } from '../Utils/CloudPulseWidgetUtils'; import { AGGREGATE_FUNCTION, SIZE, TIME_GRANULARITY } from '../Utils/constants'; +import { convertValueToUnit, formatToolTip } from '../Utils/unitConversion'; import { getUserPreferenceObject, updateWidgetPreference, @@ -116,6 +115,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const [widget, setWidget] = React.useState({ ...props.widget }); const { + ariaLabel, authToken, availableMetrics, duration, @@ -182,7 +182,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { if ( !widget.time_granularity || intervalValue.unit !== widget.time_granularity.unit || - intervalValue.value !== widget.time_duration.value + intervalValue.value !== widget.time_granularity.value ) { if (savePref) { updateWidgetPreference(widget.label, { @@ -228,7 +228,12 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { status, } = useCloudPulseMetricsQuery( serviceType, - getCloudPulseMetricRequest(widget, duration, resources, resourceIds), + getCloudPulseMetricRequest({ + duration, + resourceIds, + resources, + widget, + }), { authToken, isFlags: Boolean(flags), @@ -243,18 +248,23 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { let legendRows: LegendRow[] = []; let today: boolean = false; + let currentUnit = unit; if (!isLoading && metricsList) { - const generatedData = generateGraphData( - widget.color, + const generatedData = generateGraphData({ + flags, + label: widget.label, metricsList, + resources, + serviceType, status, - widget.label, - unit - ); + unit, + widgetColor: widget.color, + }); data = generatedData.dimensions; legendRows = generatedData.legendRowsData; today = generatedData.today; + currentUnit = generatedData.unit; } return ( @@ -272,8 +282,9 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { marginLeft={1} variant="h1" > - {convertStringToCamelCasesWithSpaces(widget.label)} - {` (${unit})`} + {convertStringToCamelCasesWithSpaces(widget.label)}{' '} + {!isLoading && + `(${currentUnit}${unit.endsWith('ps') ? '/s' : ''})`} { - {!isLoading && !Boolean(error) && ( - 0 ? legendRows : undefined - } - ariaLabel={props.ariaLabel ? props.ariaLabel : ''} - data={data} - gridSize={widget.size} - loading={isLoading} - nativeLegend={true} - showToday={today} - timezone={timezone} - title={''} - unit={unit} - /> - )} - {isLoading && } - {Boolean(error?.length) && ( - - )} + + 0 ? legendRows : undefined + } + ariaLabel={ariaLabel ? ariaLabel : ''} + data={data} + formatData={(data: number) => convertValueToUnit(data, currentUnit)} + formatTooltip={(value: number) => formatToolTip(value, unit)} + gridSize={widget.size} + loading={isLoading} + nativeLegend + showToday={today} + timezone={timezone} + title={widget.label} + /> diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index edbc7da792a..0f9d5bc7cb1 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -1,14 +1,14 @@ import { Box, Typography } from '@mui/material'; import * as React from 'react'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LineGraph } from 'src/components/LineGraph/LineGraph'; +import { isDataEmpty } from '../../Utils/CloudPulseWidgetUtils'; + import type { LegendRow } from '../CloudPulseWidget'; -import type { - DataSet, - LineGraphProps, -} from 'src/components/LineGraph/LineGraph'; +import type { LineGraphProps } from 'src/components/LineGraph/LineGraph'; export interface CloudPulseLineGraph extends LineGraphProps { ariaLabel?: string; @@ -21,18 +21,20 @@ export interface CloudPulseLineGraph extends LineGraphProps { } export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { - const { ariaLabel, error, loading, ...rest } = props; + const { ariaLabel, data, error, legendRows, loading, ...rest } = props; + + if (loading) { + return ; + } + + if (error) { + return ; + } - const message = error // Error state is separate, don't want to put text on top of it - ? undefined - : loading // Loading takes precedence over empty data - ? 'Loading data...' - : isDataEmpty(props.data) - ? 'No data to display' - : undefined; + const noDataMessage = 'No data to display'; return ( - + {error ? ( @@ -40,31 +42,23 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { ) : ( )} - {message && ( + {isDataEmpty(data) && ( - {message} + {noDataMessage} )} ); }); - -export const isDataEmpty = (data: DataSet[]) => { - return data.every( - (thisSeries) => - thisSeries.data.length === 0 || - // If we've padded the data, every y value will be null - thisSeries.data.every((thisPoint) => thisPoint[1] === null) - ); -}; diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts index bbd4b6ea88f..0e11af6b42a 100644 --- a/packages/manager/src/queries/cloudpulse/metrics.ts +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -41,11 +41,11 @@ export const useCloudPulseMetricsQuery = ( const currentJWEtokenCache: | JWEToken | undefined = queryClient.getQueryData( - queryFactory.token(serviceType, { resource_id: [] }).queryKey + queryFactory.token(serviceType, { resource_ids: [] }).queryKey ); if (currentJWEtokenCache?.token === obj.authToken) { queryClient.invalidateQueries( - queryFactory.token(serviceType, { resource_id: [] }).queryKey, + queryFactory.token(serviceType, { resource_ids: [] }).queryKey, {}, { cancelRefetch: true, From 3030be8bcc74b2f0b90608cfcc055c3078e1bf97 Mon Sep 17 00:00:00 2001 From: zaenab-akamai Date: Fri, 9 Aug 2024 00:37:04 +0530 Subject: [PATCH 14/43] feat: [M3-7685] - Show empty Kubernetes landing page with disabled create button for restricted users (#10756) --- .../pr-10756-added-1723036827057.md | 5 ++ .../manager/src/features/Account/constants.ts | 1 + .../KubernetesLanding.test.tsx | 52 +++++++++++++++++++ .../KubernetesLanding/KubernetesLanding.tsx | 16 ++++-- .../KubernetesLandingEmptyState.tsx | 15 +++++- packages/manager/src/queries/kubernetes.ts | 7 ++- 6 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-10756-added-1723036827057.md create mode 100644 packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.test.tsx diff --git a/packages/manager/.changeset/pr-10756-added-1723036827057.md b/packages/manager/.changeset/pr-10756-added-1723036827057.md new file mode 100644 index 00000000000..20f6778760d --- /dev/null +++ b/packages/manager/.changeset/pr-10756-added-1723036827057.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Empty Kubernetes landing page with the 'Create Cluster' button disabled for restricted users ([#10756](https://github.com/linode/manager/pull/10756)) diff --git a/packages/manager/src/features/Account/constants.ts b/packages/manager/src/features/Account/constants.ts index 1a685b2d4ce..e71cdfcb359 100644 --- a/packages/manager/src/features/Account/constants.ts +++ b/packages/manager/src/features/Account/constants.ts @@ -9,6 +9,7 @@ export const grantTypeMap = { firewall: 'Firewalls', image: 'Images', linode: 'Linodes', + lkeCluster: 'LKE Clusters', // Note: Not included in the user's grants returned from the API. longview: 'Longview Clients', nodebalancer: 'NodeBalancers', placementGroups: 'Placement Groups', diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.test.tsx new file mode 100644 index 00000000000..6decff0fca3 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.test.tsx @@ -0,0 +1,52 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { KubernetesLanding } from './KubernetesLanding'; + +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({ data: { restricted: true } }), +})); + +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + +describe('Kubernetes Landing', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + it('should have the "Create Cluster" button disabled for restricted users', () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: true } }); + + const { container } = renderWithTheme(); + + const createClusterButton = container.querySelector('button'); + + expect(createClusterButton).toBeInTheDocument(); + expect(createClusterButton).toHaveTextContent('Create Cluster'); + expect(createClusterButton).toBeDisabled(); + }); + + it('should have the "Create Cluster" button enabled for users with full access', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: false } }); + + const loadingTestId = 'circle-progress'; + + const { container, getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const createClusterButton = container.querySelector('button'); + + expect(createClusterButton).toBeInTheDocument(); + expect(createClusterButton).toHaveTextContent('Create Cluster'); + expect(createClusterButton).not.toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx index 0fcf4cf75cb..4269a6fd1dc 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx @@ -24,6 +24,7 @@ import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { useProfile } from 'src/queries/profile/profile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { KubernetesClusterRow } from '../ClusterList/KubernetesClusterRow'; @@ -92,12 +93,17 @@ export const KubernetesLanding = () => { ['+order_by']: orderBy, }; - const { data, error, isLoading } = useKubernetesClustersQuery( + const { data: profile } = useProfile(); + + const isRestricted = profile?.restricted ?? false; + + const { data, error, isFetching } = useKubernetesClustersQuery( { page: pagination.page, page_size: pagination.pageSize, }, - filter + filter, + !isRestricted ); const { @@ -150,12 +156,12 @@ export const KubernetesLanding = () => { ); } - if (isLoading) { + if (isFetching) { return ; } - if (data?.results === 0) { - return ; + if (isRestricted || data?.results === 0) { + return ; } return ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLandingEmptyState.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLandingEmptyState.tsx index b9d0d131603..915ac7726b0 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLandingEmptyState.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLandingEmptyState.tsx @@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom'; import KubernetesSvg from 'src/assets/icons/entityIcons/kubernetes.svg'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -12,7 +13,13 @@ import { youtubeLinkData, } from './KubernetesLandingEmptyStateData'; -export const KubernetesEmptyState = () => { +interface Props { + isRestricted: boolean; +} + +export const KubernetesEmptyState = (props: Props) => { + const { isRestricted = false } = props; + const { push } = useHistory(); return ( @@ -20,6 +27,7 @@ export const KubernetesEmptyState = () => { buttonProps={[ { children: 'Create Cluster', + disabled: isRestricted, onClick: () => { sendEvent({ action: 'Click:button', @@ -28,6 +36,11 @@ export const KubernetesEmptyState = () => { }); push('/kubernetes/create'); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'LKE Clusters', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 39048c38627..66b70b73fb9 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -93,9 +93,14 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }, }); -export const useKubernetesClustersQuery = (params: Params, filter: Filter) => { +export const useKubernetesClustersQuery = ( + params: Params, + filter: Filter, + enabled = true +) => { return useQuery, APIError[]>({ ...kubernetesQueries.lists._ctx.paginated(params, filter), + enabled, keepPreviousData: true, }); }; From f368c2df0418d53856ecc3d388d068da5400585e Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:08:12 -0400 Subject: [PATCH 15/43] upcoming: [M3-8301] - Object Storage Gen2 Create Bucket Additions (#10744) Co-authored-by: Jaalah Ramos Co-authored-by: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> --- .../pr-10744-changed-1722619037605.md | 5 + packages/api-v4/src/object-storage/types.ts | 39 ++- ...r-10744-upcoming-features-1722618984741.md | 5 + packages/manager/src/factories/account.ts | 7 +- .../BucketLanding/BucketRateLimitTable.tsx | 96 +++++++ .../BucketLanding/OMC_CreateBucketDrawer.tsx | 263 ++++++++++++++++-- .../BucketLanding/OveragePricing.tsx | 85 +++--- packages/manager/src/mocks/serverHandlers.ts | 42 ++- 8 files changed, 465 insertions(+), 77 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10744-changed-1722619037605.md create mode 100644 packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md create mode 100644 packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx diff --git a/packages/api-v4/.changeset/pr-10744-changed-1722619037605.md b/packages/api-v4/.changeset/pr-10744-changed-1722619037605.md new file mode 100644 index 00000000000..8e08a0e3fff --- /dev/null +++ b/packages/api-v4/.changeset/pr-10744-changed-1722619037605.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Moved `getObjectStorageEndpoints` from `/objects.ts` to `/buckets.ts` ([#10744](https://github.com/linode/manager/pull/10744)) diff --git a/packages/api-v4/src/object-storage/types.ts b/packages/api-v4/src/object-storage/types.ts index cfa15fb7a3c..d96094af59c 100644 --- a/packages/api-v4/src/object-storage/types.ts +++ b/packages/api-v4/src/object-storage/types.ts @@ -1,18 +1,48 @@ -type ObjectStorageEndpointTypes = 'E0' | 'E1' | 'E2' | 'E3'; +export type ObjectStorageEndpointTypes = 'E0' | 'E1' | 'E2' | 'E3'; export interface ObjectStorageKeyRegions { + /** + * Region ID (e.g. 'us-east') + */ id: string; + /** + * The hostname prefix for the region (e.g. 'us-east-1.linodeobjects.com') + */ s3_endpoint: string; + /** + * The type specifying which generation of endpoint this is. + */ endpoint_type?: ObjectStorageEndpointTypes; } export interface ObjectStorageKey { + /** + * A unique string assigned by the API to identify this key, used as a username for S3 API requests. + */ access_key: string; + /** + * Settings that restrict access to specific buckets, each with defined permission levels. + */ bucket_access: ObjectStorageKeyBucketAccess[] | null; + /** + * This Object Storage key's unique ID. + */ id: number; + /** + * The label given to this key. For display purposes only. + */ label: string; + /** + * Indicates if this Object Storage key restricts access to specific buckets and permissions. + */ limited: boolean; + /** + * Each region where this key is valid. + */ regions: ObjectStorageKeyRegions[]; + /** + * The secret key used to authenticate this Object Storage key with the S3 API. + */ secret_key: string; } @@ -45,7 +75,14 @@ export interface CreateObjectStorageBucketPayload { cors_enabled?: boolean; label: string; region?: string; + /** + * To explicitly create a bucket on a specific endpoint type. + */ endpoint_type?: ObjectStorageEndpointTypes; + /** + * Used to create a bucket on a specific already-assigned S3 endpoint. + */ + s3_endpoint?: string; /* @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' diff --git a/packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md b/packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md new file mode 100644 index 00000000000..08d8d2faef7 --- /dev/null +++ b/packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Object Storage Gen2 Create Bucket Additions ([#10744](https://github.com/linode/manager/pull/10744)) diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index eace884c756..274e313fc29 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -1,9 +1,10 @@ -import { +import Factory from 'src/factories/factoryProxy'; + +import type { Account, ActivePromotion, RegionalNetworkUtilization, } from '@linode/api-v4/lib/account/types'; -import Factory from 'src/factories/factoryProxy'; export const promoFactory = Factory.Sync.makeFactory({ credit_monthly_cap: '20.00', @@ -45,6 +46,8 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Machine Images', 'Managed Databases', 'NodeBalancers', + 'Object Storage Access Key Regions', + 'Object Storage Endpoint Types', 'Object Storage', 'Placement Group', 'Vlans', diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx new file mode 100644 index 00000000000..54cf754dd31 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import { Radio } from 'src/components/Radio/Radio'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; + +import type { ObjectStorageEndpointTypes } from '@linode/api-v4'; + +/** + * TODO: This component is currently using static data until + * and API endpoint is available to return rate limits for + * each endpoint type. + */ + +interface BucketRateLimitTableProps { + endpointType: ObjectStorageEndpointTypes | undefined; +} + +const tableHeaders = ['Limits', 'GET', 'PUT', 'LIST', 'DELETE', 'OTHER']; +const tableData = ({ endpointType }: BucketRateLimitTableProps) => [ + { + checked: true, + values: ['1000', '000', '000', '000', '000'], + }, + { + checked: false, + values: [ + endpointType === 'E3' ? '20000' : '5000', + '000', + '000', + '000', + '000', + ], + }, +]; + +export const BucketRateLimitTable = ({ + endpointType, +}: BucketRateLimitTableProps) => ( + + + + {tableHeaders.map((header, index) => { + return ( + + {header} + + ); + })} + + + + {tableData({ endpointType }).map((row, rowIndex) => ( + + + {}} + value="2" + /> + + {row.values.map((value, index) => { + return ( + + {value} + + ); + })} + + ))} + +
    +); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 5dc0e2dbde2..f38daab7343 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -4,9 +4,14 @@ import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; +import { FormLabel } from 'src/components/FormLabel'; +import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { BucketRateLimitTable } from 'src/features/ObjectStorage/BucketLanding/BucketRateLimitTable'; import { reportAgreementSigningError, useAccountAgreements, @@ -17,6 +22,7 @@ import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, + useObjectStorageEndpoints, useObjectStorageTypesQuery, } from 'src/queries/object-storage/queries'; import { useProfile } from 'src/queries/profile/profile'; @@ -30,17 +36,44 @@ import { BucketRegions } from './BucketRegions'; import { StyledEUAgreementCheckbox } from './OMC_CreateBucketDrawer.styles'; import { OveragePricing } from './OveragePricing'; -import type { CreateObjectStorageBucketPayload } from '@linode/api-v4'; +import type { + CreateObjectStorageBucketPayload, + ObjectStorageEndpoint, + ObjectStorageEndpointTypes, +} from '@linode/api-v4'; interface Props { isOpen: boolean; onClose: () => void; } +interface EndpointCount { + [key: string]: number; +} + +interface EndpointOption { + /** + * The type of endpoint. + */ + endpoint_type: ObjectStorageEndpointTypes; + /** + * The label to display in the dropdown. + */ + label: string; + /** + * The hostname of the endpoint. This is only necessary when multiple endpoints of the same type are assigned to a region. + */ + s3_endpoint?: string; +} + export const OMC_CreateBucketDrawer = (props: Props) => { const { data: profile } = useProfile(); const { isOpen, onClose } = props; const isRestrictedUser = profile?.restricted; + const { + data: endpoints, + isFetching: isEndpointLoading, + } = useObjectStorageEndpoints(); const { data: regions } = useRegionsQuery(); @@ -66,28 +99,32 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); - const [isEnableObjDialogOpen, setIsEnableObjDialogOpen] = React.useState( - false - ); - const [hasSignedAgreement, setHasSignedAgreement] = React.useState( - false - ); + + const [state, setState] = React.useState({ + hasSignedAgreement: false, + isEnableObjDialogOpen: false, + }); const { control, formState: { errors }, + getValues, handleSubmit, reset, + resetField, setError, + setValue, watch, } = useForm({ context: { buckets: bucketsData?.buckets ?? [] }, defaultValues: { cors_enabled: true, + endpoint_type: undefined, label: '', region: '', + s3_endpoint: undefined, }, - mode: 'onBlur', + mode: 'onChange', resolver: yupResolver(CreateBucketSchema), }); @@ -101,7 +138,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { sendCreateBucketEvent(data.region); } - if (hasSignedAgreement) { + if (state.hasSignedAgreement) { try { await updateAccountAgreements({ eu_model: true }); } catch (error) { @@ -117,26 +154,134 @@ export const OMC_CreateBucketDrawer = (props: Props) => { } }; - const handleBucketFormSubmit = (e: React.FormEvent) => { + const handleBucketFormSubmit = async ( + e: React.FormEvent + ) => { e.preventDefault(); + + const formValues = getValues(); + + // Custom validation in the handleBucketFormSubmit function + // to catch missing endpoint_type values before form submission + // since this is optional in the schema. + if (Boolean(endpoints) && !formValues.endpoint_type) { + setError('endpoint_type', { + message: 'Endpoint Type is required', + type: 'manual', + }); + return; + } + if (accountSettings?.object_storage !== 'active') { - setIsEnableObjDialogOpen(true); + setState((prev) => ({ ...prev, isEnableObjDialogOpen: true })); } else { - handleSubmit(onSubmit)(); + await handleSubmit(onSubmit)(e); } }; - const region = watchRegion + const selectedRegion = watchRegion ? regions?.find((region) => watchRegion.includes(region.id)) : undefined; + const filteredEndpoints = endpoints?.filter( + (endpoint) => selectedRegion?.id === endpoint.region + ); + + // In rare cases, the dropdown must display a specific endpoint hostname (s3_endpoint) along with + // the endpoint type to distinguish between two assigned endpoints of the same type. + // This is necessary for multiple gen1 (E1) assignments in the same region. + const endpointCounts = filteredEndpoints?.reduce( + (acc: EndpointCount, { endpoint_type }) => { + acc[endpoint_type] = (acc[endpoint_type] || 0) + 1; + return acc; + }, + {} + ); + + const createEndpointOption = ( + endpoint: ObjectStorageEndpoint + ): EndpointOption => { + const { endpoint_type, s3_endpoint } = endpoint; + const isLegacy = endpoint_type === 'E0'; + const typeLabel = isLegacy ? 'Legacy' : 'Standard'; + const shouldShowHostname = + endpointCounts && endpointCounts[endpoint_type] > 1; + const label = + shouldShowHostname && s3_endpoint !== null + ? `${typeLabel} (${endpoint_type}) ${s3_endpoint}` + : `${typeLabel} (${endpoint_type})`; + + return { + endpoint_type, + label, + s3_endpoint: s3_endpoint ?? undefined, + }; + }; + + const filteredEndpointOptions: + | EndpointOption[] + | undefined = filteredEndpoints?.map(createEndpointOption); + + const hasSingleEndpointType = filteredEndpointOptions?.length === 1; + + const selectedEndpointOption = React.useMemo(() => { + const currentEndpointType = watch('endpoint_type'); + const currentS3Endpoint = watch('s3_endpoint'); + return ( + filteredEndpointOptions?.find( + (endpoint) => + endpoint.endpoint_type === currentEndpointType && + endpoint.s3_endpoint === currentS3Endpoint + ) || null + ); + }, [filteredEndpointOptions, watch]); + + const isGen2EndpointType = + selectedEndpointOption && + selectedEndpointOption.endpoint_type !== 'E0' && + selectedEndpointOption.endpoint_type !== 'E1'; + const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, regions, - selectedRegionId: region?.id ?? '', + selectedRegionId: selectedRegion?.id ?? '', }); + const resetSpecificFormFields = () => { + resetField('endpoint_type'); + setValue('s3_endpoint', undefined); + setValue('cors_enabled', true); + }; + + const updateEndpointType = (endpointOption: EndpointOption | null) => { + if (endpointOption) { + const { endpoint_type, s3_endpoint } = endpointOption; + const isGen2Endpoint = endpoint_type === 'E2' || endpoint_type === 'E3'; + + if (isGen2Endpoint) { + setValue('cors_enabled', false); + } + + setValue('endpoint_type', endpoint_type, { shouldValidate: true }); + setValue('s3_endpoint', s3_endpoint); + } else { + resetSpecificFormFields(); + } + }; + + // Both of these are side effects that should only run when the region changes + React.useEffect(() => { + // Auto-select an endpoint option if there's only one + if (filteredEndpointOptions && filteredEndpointOptions.length === 1) { + updateEndpointType(filteredEndpointOptions[0]); + } else { + // When region changes, reset values + resetSpecificFormFields(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watchRegion]); + return ( { ( )} control={control} name="label" - rules={{ required: 'Label is required' }} /> ( { + field.onChange(value); + }} disabled={isRestrictedUser} error={errors.region?.message} onBlur={field.onBlur} - onChange={(value) => field.onChange(value)} required selectedRegion={field.value} /> )} control={control} name="region" - rules={{ required: 'Region is required' }} /> - {region?.id && } + {selectedRegion?.id && } + {Boolean(endpoints) && ( + <> + ( + + updateEndpointType(endpointOption) + } + textFieldProps={{ + helperText: ( + + Endpoint types impact the performance, capacity, and + rate limits for your bucket. Understand{' '} + endpoint types. + + ), + helperTextPosition: 'top', + }} + disableClearable={hasSingleEndpointType} + errorText={errors.endpoint_type?.message} + label="Object Storage Endpoint Type" + loading={isEndpointLoading} + onBlur={field.onBlur} + options={filteredEndpointOptions ?? []} + placeholder="Object Storage Endpoint Type" + value={selectedEndpointOption} + /> + )} + control={control} + name="endpoint_type" + /> + {selectedEndpointOption && ( + <> + + + Bucket Rate Limits + + + + {isGen2EndpointType + ? 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. ' + : 'This endpoint type supports up to 750 Requests Per Second (RPS). '} + Understand bucket rate limits. + + + )} + {isGen2EndpointType && ( + + )} + + )} {showGDPRCheckbox ? ( setHasSignedAgreement(e.target.checked)} + onChange={(e) => + setState((prev) => ({ + ...prev, + hasSignedAgreement: e.target.checked, + })) + } + checked={state.hasSignedAgreement} /> ) : null} { secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} /> + setState((prev) => ({ + ...prev, + isEnableObjDialogOpen: false, + })) + } handleSubmit={handleSubmit(onSubmit)} - onClose={() => setIsEnableObjDialogOpen(false)} - open={isEnableObjDialogOpen} - regionId={region?.id} + open={state.isEnableObjDialogOpen} + regionId={selectedRegion?.id} /> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index d8a628b4748..f8c25b11f11 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -66,51 +66,46 @@ export const OveragePricing = (props: Props) => {
    ) : ( - <> - - For this region, additional storage costs{' '} - - $ - {storageOveragePrice && !isErrorObjTypes - ? parseFloat(storageOveragePrice) - : UNKNOWN_PRICE}{' '} - per GB - - . - - - - Outbound transfer will cost{' '} - - $ - {transferOveragePrice && !isErrorTransferTypes - ? parseFloat(transferOveragePrice) - : UNKNOWN_PRICE}{' '} - per GB - {' '} - if it exceeds{' '} - {isDcSpecificPricingRegion ? ( - <> - the{' '} - - - ) : ( - <> - your{' '} - - - )} - . - - + + For this region, additional storage costs{' '} + + $ + {storageOveragePrice && !isErrorObjTypes + ? parseFloat(storageOveragePrice) + : UNKNOWN_PRICE}{' '} + per GB + + .
    + Outbound transfer will cost{' '} + + $ + {transferOveragePrice && !isErrorTransferTypes + ? parseFloat(transferOveragePrice) + : UNKNOWN_PRICE}{' '} + per GB + {' '} + if it exceeds{' '} + {isDcSpecificPricingRegion ? ( + <> + the{' '} + + + ) : ( + <> + your{' '} + + + )} + . +
    ); }; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 40560ba490c..bbf638fffcf 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -28,7 +28,6 @@ import { eventFactory, firewallDeviceFactory, firewallFactory, - objectStorageEndpointsFactory, imageFactory, incidentResponseFactory, invoiceFactory, @@ -63,6 +62,7 @@ import { notificationFactory, objectStorageBucketFactoryGen2, objectStorageClusterFactory, + objectStorageEndpointsFactory, objectStorageKeyFactory, objectStorageOverageTypeFactory, objectStorageTypeFactory, @@ -848,8 +848,44 @@ export const handlers = [ return HttpResponse.json(makeResourcePage(objectStorageTypes)); }), http.get('*/v4/object-storage/endpoints', ({}) => { - const endpoint = objectStorageEndpointsFactory.build(); - return HttpResponse.json(endpoint); + const endpoints = [ + objectStorageEndpointsFactory.build({ + endpoint_type: 'E0', + region: 'us-sea', + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: 'us-sea', + s3_endpoint: 'us-sea-1.linodeobjects.com', + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: 'us-sea', + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E2', + region: 'us-sea', + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E3', + region: 'us-sea', + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E3', + region: 'us-east', + s3_endpoint: null, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E3', + region: 'us-mia', + s3_endpoint: 'us-mia-1.linodeobjects.com', + }), + ]; + return HttpResponse.json(makeResourcePage(endpoints)); }), http.get('*object-storage/buckets/*/*/access', async () => { await sleep(2000); From a85ebfb3a777989787b19c59a09ab1f070c46cd5 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:42:02 -0400 Subject: [PATCH 16/43] test: M3-8148: Cypress tests for refactored Linode Create flow with add ons (#10730) * M3-8148: Cypress tests for refactored Linode Create flow with add ons * Added changeset: Add Cypress tests for refactored Linode Create flow with add ons * Fixed linting --- .../pr-10730-tests-1722367873620.md | 5 + .../create-linode-with-add-ons.spec.ts | 132 ++++++++++++++++++ .../support/ui/pages/linode-create-page.ts | 36 +++++ 3 files changed, 173 insertions(+) create mode 100644 packages/manager/.changeset/pr-10730-tests-1722367873620.md create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts diff --git a/packages/manager/.changeset/pr-10730-tests-1722367873620.md b/packages/manager/.changeset/pr-10730-tests-1722367873620.md new file mode 100644 index 00000000000..6b665e0f807 --- /dev/null +++ b/packages/manager/.changeset/pr-10730-tests-1722367873620.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress tests for refactored Linode Create flow with add ons ([#10730](https://github.com/linode/manager/pull/10730)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts new file mode 100644 index 00000000000..23d62bf666f --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -0,0 +1,132 @@ +import { linodeFactory } from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with Add-ons', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with backups using mock API data. + * - Confirms that backups is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the backups to be enabled. + */ + it('can select Backups during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['Linodes'] }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + linodeCreatePage.checkBackups(); + linodeCreatePage.checkEUAgreements(); + + // Confirm Backups assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Backups').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm property "backups_enabled" is "true" in the request payload. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const backupsEnabled = requestPayload['backups_enabled']; + expect(backupsEnabled).to.equal(true); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); + + /* + * - Confirms UI flow to create a Linode with private IPs using mock API data. + * - Confirms that Private IP is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the private IPs to be enabled. + */ + it('can select private IP during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['Linodes'] }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + linodeCreatePage.checkEUAgreements(); + linodeCreatePage.checkPrivateIPs(); + + // Confirm Private IP assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Private IP').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm property "private_ip" is "true" in the request payload. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const privateId = requestPayload['private_ip']; + expect(privateId).to.equal(true); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); +}); diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts index ddd46b2702c..a82fd76aba7 100644 --- a/packages/manager/cypress/support/ui/pages/linode-create-page.ts +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -8,6 +8,42 @@ import { ui } from 'support/ui'; * Page utilities for interacting with the Linode create page. */ export const linodeCreatePage = { + /** + * Checks the Linode's backups. + */ + checkBackups: () => { + // eslint-disable-next-line sonarjs/no-duplicate-string + cy.get('[data-testid="backups"]').should('be.visible').click(); + }, + + /** + * Checks the EU agreements. + */ + checkEUAgreements: () => { + cy.get('body').then(($body) => { + if ($body.find('div[data-testid="eu-agreement-checkbox"]').length > 0) { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.findAllByText('EU Standard Contractual Clauses', { + exact: false, + }).should('be.visible'); + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get('[data-testid="eu-agreement-checkbox"]') + .within(() => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get('[id="gdpr-checkbox"]').click(); + }) + .click(); + } + }); + }, + + /** + * Checks the Linode's private IPs. + */ + checkPrivateIPs: () => { + cy.findByText('Private IP').should('be.visible').closest('label').click(); + }, + /** * Selects the Image with the given name. * From ca1cb41f4e7ec0bc8253c0ae534bef04973584cd Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 9 Aug 2024 11:21:00 +0530 Subject: [PATCH 17/43] feat: [M3-7666] - Remove Animation from Search Results and Animate Icon Instead (#10754) * Remove animation from search results and animate icon instead * Fix SearchLanding Search render test * Added changeset: Remove animation from Search Results and Animate Icon instead. * Clean up.. * Fix no search results test in smoke-delete-linode.spec.ts --- .../pr-10754-added-1723039007976.md | 5 +++ .../core/linodes/smoke-delete-linode.spec.ts | 2 +- .../features/Search/SearchLanding.styles.ts | 41 ++++++++++++++++++- .../features/Search/SearchLanding.test.tsx | 2 +- .../src/features/Search/SearchLanding.tsx | 14 +------ .../src/features/Search/searchLanding.css | 31 -------------- 6 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 packages/manager/.changeset/pr-10754-added-1723039007976.md diff --git a/packages/manager/.changeset/pr-10754-added-1723039007976.md b/packages/manager/.changeset/pr-10754-added-1723039007976.md new file mode 100644 index 00000000000..b194a9e5d42 --- /dev/null +++ b/packages/manager/.changeset/pr-10754-added-1723039007976.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Remove animation from Search Results and Animate Icon instead. ([#10754](https://github.com/linode/manager/pull/10754)) diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 7f788003ac1..13ad0efaf4b 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -20,7 +20,7 @@ const confirmDeletion = (linodeLabel: string) => { .click() .type(`${linodeLabel}{enter}`); cy.findByText('You searched for ...').should('be.visible'); - cy.findByText('Sorry, no results for this one').should('be.visible'); + cy.findByText('Sorry, no results for this one.').should('be.visible'); }; const deleteLinodeFromActionMenu = (linodeLabel: string) => { diff --git a/packages/manager/src/features/Search/SearchLanding.styles.ts b/packages/manager/src/features/Search/SearchLanding.styles.ts index 1ff66d18187..b8e7fc08930 100644 --- a/packages/manager/src/features/Search/SearchLanding.styles.ts +++ b/packages/manager/src/features/Search/SearchLanding.styles.ts @@ -1,6 +1,7 @@ +import { keyframes } from '@emotion/react'; import { Stack } from '@mui/material'; -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import Error from 'src/assets/icons/error.svg'; import { H1Header } from 'src/components/H1Header/H1Header'; @@ -25,9 +26,47 @@ export const StyledGrid = styled(Grid, { padding: `${theme.spacing(10)} ${theme.spacing(4)}`, })); +const blink = keyframes` + 0%, 50%, 100% { + transform: scaleY(0.1); + } +`; + +const rotate = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +`; + +const shake = keyframes` + 0%, 100% { + transform: translateX(0); + } + 25%, 75% { + transform: translateX(-5px); + } + 50% { + transform: translateX(5px); + } +`; + export const StyledError = styled(Error, { label: 'StyledError', })(({ theme }) => ({ + '& path:nth-of-type(4)': { + animation: `${blink} 1s`, + transformBox: 'fill-box', + transformOrigin: 'center', + }, + '& path:nth-of-type(5)': { + animation: `${rotate} 3s`, + transformBox: 'fill-box', + transformOrigin: 'center', + }, + animation: `${shake} 0.5s`, color: theme.palette.text.primary, height: 60, marginBottom: theme.spacing(4), diff --git a/packages/manager/src/features/Search/SearchLanding.test.tsx b/packages/manager/src/features/Search/SearchLanding.test.tsx index 8789cf8c7c0..d6509f04ce9 100644 --- a/packages/manager/src/features/Search/SearchLanding.test.tsx +++ b/packages/manager/src/features/Search/SearchLanding.test.tsx @@ -41,7 +41,7 @@ describe('Component', () => { it('should render', async () => { const { findByText } = renderWithTheme(); - expect(await findByText(/search/)); + expect(await findByText(/searched/i)); }); it('should search on mount', async () => { diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 38d1835cc20..96e77530dbe 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -55,14 +55,6 @@ export interface SearchLandingProps extends SearchProps, RouteComponentProps<{}> {} -const splitWord = (word: any) => { - word = word.split(''); - for (let i = 0; i < word.length; i += 2) { - word[i] = {word[i]}; - } - return word; -}; - export const SearchLanding = (props: SearchLandingProps) => { const { entities, search, searchResultsByEntity } = props; const { data: regions } = useRegionsQuery(); @@ -285,11 +277,9 @@ export const SearchLanding = (props: SearchLandingProps) => { You searched for ... - - {query && splitWord(query)} - + {query} - Sorry, no results for this one + Sorry, no results for this one. diff --git a/packages/manager/src/features/Search/searchLanding.css b/packages/manager/src/features/Search/searchLanding.css index b85596a153c..885c0efee55 100644 --- a/packages/manager/src/features/Search/searchLanding.css +++ b/packages/manager/src/features/Search/searchLanding.css @@ -1,28 +1,3 @@ -@keyframes falling { - 0% { - transform: rotateX(0deg); - } - 12% { - transform: rotateX(240deg); - } - 24% { - transform: rotateX(150deg); - } - 36% { - transform: rotateX(200deg); - } - 48% { - transform: rotateX(175deg); - } - 60%, - 85% { - transform: rotateX(180deg); - } - 100% { - transform: rotateX(180deg); - } -} - @keyframes fadein { 0% { opacity: 0; @@ -37,12 +12,6 @@ line-height: 0.75; } -.resultq span:nth-child(1) { - display: inline-block; - animation: falling 2s linear 2s 1 normal forwards; - transform-origin: bottom center; -} - .nothing { opacity: 0; animation: fadein 0.2s linear 2.5s 1 normal forwards; From 1e2f613b4f1ca492dc2b85d36ac582257dd6d6ba Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 9 Aug 2024 15:00:39 +0530 Subject: [PATCH 18/43] fix: [M3-8409] - Community Notifications in Event Messages v2 Refactor (#10742) * Fix v2 community like notfications in event messages * Add type to ACTION_WITHOUT_USERNAMES * Added changeset: Community Like notfications in event messages v2 * Update changeset file * Small clean up... * Add unit test cases for getEventUsername util * Exclude usernames from community_mention and community_question_reply events * Update changeset file * Update community_question_reply event message --- .../pr-10742-fixed-1722601990368.md | 5 ++ .../src/features/Events/Event.helpers.ts | 4 +- .../src/features/Events/EventRowV2.tsx | 12 ++-- .../features/Events/eventMessageGenerator.ts | 2 +- .../features/Events/factories/community.tsx | 5 +- .../src/features/Events/utils.test.tsx | 56 +++++++++++++++++++ .../manager/src/features/Events/utils.tsx | 12 ++++ .../NotificationData/RenderEventV2.tsx | 4 +- 8 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-10742-fixed-1722601990368.md diff --git a/packages/manager/.changeset/pr-10742-fixed-1722601990368.md b/packages/manager/.changeset/pr-10742-fixed-1722601990368.md new file mode 100644 index 00000000000..c6d7cd5b721 --- /dev/null +++ b/packages/manager/.changeset/pr-10742-fixed-1722601990368.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Community notfications in event messages v2 refactor ([#10742](https://github.com/linode/manager/pull/10742)) diff --git a/packages/manager/src/features/Events/Event.helpers.ts b/packages/manager/src/features/Events/Event.helpers.ts index f41f41792b5..aa3a0aee266 100644 --- a/packages/manager/src/features/Events/Event.helpers.ts +++ b/packages/manager/src/features/Events/Event.helpers.ts @@ -9,7 +9,7 @@ export const maybeRemoveTrailingPeriod = (string: string) => { return string; }; -const ACTIONS_WITHOUT_USERNAMES = [ +export const ACTIONS_WITHOUT_USERNAMES: EventAction[] = [ 'entity_transfer_accept', 'entity_transfer_accept_recipient', 'entity_transfer_cancel', @@ -18,6 +18,8 @@ const ACTIONS_WITHOUT_USERNAMES = [ 'entity_transfer_stale', 'lassie_reboot', 'community_like', + 'community_mention', + 'community_question_reply', ]; export const formatEventWithUsername = ( diff --git a/packages/manager/src/features/Events/EventRowV2.tsx b/packages/manager/src/features/Events/EventRowV2.tsx index 3f885b7bccc..b949f5b34ca 100644 --- a/packages/manager/src/features/Events/EventRowV2.tsx +++ b/packages/manager/src/features/Events/EventRowV2.tsx @@ -10,7 +10,11 @@ import { TableRow } from 'src/components/TableRow'; import { getEventTimestamp } from 'src/utilities/eventUtils'; import { StyledGravatar } from './EventRow.styles'; -import { formatProgressEvent, getEventMessage } from './utils'; +import { + formatProgressEvent, + getEventMessage, + getEventUsername, +} from './utils'; import type { Event } from '@linode/api-v4/lib/account'; @@ -25,7 +29,7 @@ export const EventRowV2 = (props: EventRowProps) => { const { action, message, username } = { action: event.action, message: getEventMessage(event), - username: event.username, + username: getEventUsername(event), }; if (!message) { @@ -51,8 +55,8 @@ export const EventRowV2 = (props: EventRowProps) => { - - {username ?? 'Linode'} + + {username} diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 6004fd994e4..f26830b00d3 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -87,7 +87,7 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { community_question_reply: { notification: (e) => e.entity?.label - ? `There has been a reply to your thread "${e.entity.label}".` + ? `There has been a reply to your thread: ${e.entity.label}.` : `There has been a reply to your thread.`, }, credit_card_updated: { diff --git a/packages/manager/src/features/Events/factories/community.tsx b/packages/manager/src/features/Events/factories/community.tsx index acffa8ecb0b..250cad9dee5 100644 --- a/packages/manager/src/features/Events/factories/community.tsx +++ b/packages/manager/src/features/Events/factories/community.tsx @@ -8,10 +8,7 @@ export const community: PartialEventMap<'community'> = { community_like: { notification: (e) => e.entity?.label ? ( - <> - A post on has been{' '} - liked. - + ) : ( <> There has been a like on your community post. diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx index 89ce3f0c328..cb1d5953599 100644 --- a/packages/manager/src/features/Events/utils.test.tsx +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -5,6 +5,7 @@ import { formatEventTimeRemaining, formatProgressEvent, getEventMessage, + getEventUsername, } from './utils'; import type { Event } from '@linode/api-v4'; @@ -87,6 +88,61 @@ describe('getEventMessage', () => { }); }); +describe('getEventUsername', () => { + it('returns the username if it exists and action is not in ACTIONS_WITHOUT_USERNAMES', () => { + const mockEvent: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + status: 'finished', + username: 'test-user', + }); + + expect(getEventUsername(mockEvent)).toBe('test-user'); + }); + + it('returns "Linode" if the username exists but action is in ACTIONS_WITHOUT_USERNAMES', () => { + const mockEvent: Event = eventFactory.build({ + action: 'community_like', + entity: { + id: 234, + label: '1 user liked your answer to: this question?', + url: 'https://google.com/', + }, + status: 'notification', + username: 'test-user', + }); + + expect(getEventUsername(mockEvent)).toBe('Linode'); + }); + + it('returns "Linode" if the username does not exist', () => { + const mockEvent: Event = eventFactory.build({ + status: 'notification', + username: null, + }); + + expect(getEventUsername(mockEvent)).toBe('Linode'); + }); + + it('returns "Linode" if the username does not exist and action is in ACTIONS_WITHOUT_USERNAMES', () => { + const mockEvent: Event = eventFactory.build({ + action: 'community_like', + entity: { + id: 234, + label: '1 user liked your answer to: this question?', + url: 'https://google.com/', + }, + status: 'notification', + username: null, + }); + + expect(getEventUsername(mockEvent)).toBe('Linode'); + }); +}); + describe('formatEventTimeRemaining', () => { it('returns null if the time is null', () => { expect(formatEventTimeRemaining(null)).toBeNull(); diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx index a7d3f301796..ebd7a915cb1 100644 --- a/packages/manager/src/features/Events/utils.tsx +++ b/packages/manager/src/features/Events/utils.tsx @@ -7,6 +7,7 @@ import { getEventTimestamp } from 'src/utilities/eventUtils'; import { eventMessages } from './factory'; import type { Event } from '@linode/api-v4'; +import { ACTIONS_WITHOUT_USERNAMES } from './Event.helpers'; type EventMessageManualInput = { action: Event['action']; @@ -45,6 +46,17 @@ export function getEventMessage( return message ? message(event as Event) : null; } +/** + * The event username Getter. + * Returns the username from event or 'Linode' if username is null or excluded by action. + */ +export const getEventUsername = (event: Event) => { + if (event.username && !ACTIONS_WITHOUT_USERNAMES.includes(event.action)) { + return event.username; + } + return 'Linode'; +}; + /** * Format the time remaining for an event. * This is used for the progress events in the notification center. diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx index ca3ccf71217..8bed9077216 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx @@ -6,6 +6,7 @@ import { Typography } from 'src/components/Typography'; import { formatProgressEvent, getEventMessage, + getEventUsername, } from 'src/features/Events/utils'; import { @@ -26,6 +27,7 @@ export const RenderEventV2 = React.memo((props: RenderEventProps) => { const { classes, cx } = useRenderEventStyles(); const unseenEventClass = cx({ [classes.unseenEventV2]: !event.seen }); const message = getEventMessage(event); + const username = getEventUsername(event); /** * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). @@ -56,7 +58,7 @@ export const RenderEventV2 = React.memo((props: RenderEventProps) => { /> )} - {progressEventDisplay} | {event.username ?? 'Linode'} + {progressEventDisplay} | {username}
    From 954cba7381df2d3da8dcbf8e7dea3b204676b8c3 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 9 Aug 2024 07:19:29 -0700 Subject: [PATCH 19/43] change: [M3-8429] - Add a "Sizing a pull request" section to `CONTRIBUTING.md` (#10764) * Add section on 'Sizing a pull request' to * Added changeset: Documentation for 'Sizing a pull request' to contribution guidelines --- docs/CONTRIBUTING.md | 19 ++++++++++++++++++- .../pr-10764-added-1723149902254.md | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10764-added-1723149902254.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 946b3f703a9..cbf69499d81 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -13,7 +13,7 @@ Feel free to open an issue to report a bug or request a feature. 1. Fork this repository. 2. Clone your fork to your local machine. 3. Create a branch from `develop`, e.g. `$ git checkout develop && git pull && git checkout -b feature/my-feature`. -4. Make your changes, commit them following the standards below, and then push them to your fork. +4. Make your [small, focused](#sizing-a-pull-request) changes, commit them following the standards below, and then push them to your fork. 5. Commit message format standard: `: [JIRA-ticket-number] - ` **Commit Types:** @@ -54,6 +54,23 @@ Follow these best practices to write a good changeset: - Add changesets for `docs/` documentation changes in the `manager` package, as this is generally best-fit. - Generally, if the code change is a fix for a previous change that has been merged to `develop` but was never released to production, we don't need to include a changeset. +## Sizing a pull request + +A good PR is small. + +Examples of ‘small’: + +- Changing a docker file +- Updating a dependency ([Example 1](https://github.com/linode/manager/pull/10291), [Example 2](https://github.com/linode/manager/pull/10212)) +- Fixing 1 bug ([Example 1](https://github.com/linode/manager/pull/10583), [Example 2](https://github.com/linode/manager/pull/9726)) +- Creating 1 new component with unit test coverage ([Example](https://github.com/linode/manager/pull/9520)) +- Adding a new util with unit test coverage + +Diff size: A good PR is less than 500 changes, closer to [200](https://github.com/google/eng-practices/blob/master/review/developer/small-cls.md). + +A good PR does **exactly one thing**, and is clear about what that is in the description. +Break down *additional* things in your PR into multiple PRs (like you would do with tickets). + ## Docs To run the docs development server locally, [install Bun](https://bun.sh/) and start the server: `yarn docs`. diff --git a/packages/manager/.changeset/pr-10764-added-1723149902254.md b/packages/manager/.changeset/pr-10764-added-1723149902254.md new file mode 100644 index 00000000000..e3bda9f4aa8 --- /dev/null +++ b/packages/manager/.changeset/pr-10764-added-1723149902254.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Documentation for 'Sizing a pull request' to contribution guidelines ([#10764](https://github.com/linode/manager/pull/10764)) From 54fc4c11eb0687ac98c64d0ddf238c78fb963090 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Fri, 9 Aug 2024 20:19:51 +0530 Subject: [PATCH 20/43] refactor: [M3-6913] Replace Select with Autocomplete in: linodes (#10725) * refactor: [M3-6913] Replace Select with Autocomplete in: linodes * Added changeset: Replace Select with Autocomplete component in the Linode feature * refactor: [M3-6913] Fixed Linode Config Dialog unit test cases * refactor: [M3-6913] fixed failing e2e test case in Linode Create Page * refactor: [M3-6913] Removed linting errors * refactor: [M3-6913] Fixed Autocomplete for VPC Selection in Linode Config * test: [M3-6913] Added a test case for the utility function created inside the Linode feature * refactor: [M3-6913] Used useMemo hook to cache VPCPanel dropdown options, preventing unwanted re-renders * refactor: [M3-6913] Fixed duplicate addition of default options for the VPC dropdown list * refactor: [M3-6913] fixed the kernel select types to handle type mismatches --- .../pr-10725-tech-stories-1722343555187.md | 5 + .../core/linodes/legacy-create-linode.spec.ts | 2 + .../PaymentDrawer/PaymentMethodCard.tsx | 4 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 2 +- .../LinodesCreate/LinodeCreateContainer.tsx | 2 +- .../LinodesCreate/VLANAccordion.test.tsx | 11 +- .../Linodes/LinodesCreate/VLANAccordion.tsx | 3 +- .../Linodes/LinodesCreate/VPCPanel.tsx | 76 +++++++------ .../LinodeBackup/RestoreToLinodeDrawer.tsx | 15 ++- .../LinodeBackup/ScheduleSettings.test.tsx | 31 +++++- .../LinodeBackup/ScheduleSettings.tsx | 22 ++-- .../LinodeConfigs/LinodeConfigDialog.test.tsx | 19 +++- .../LinodeConfigs/LinodeConfigDialog.tsx | 103 ++++++++++-------- .../LinodeNetworking/IPTransfer.tsx | 78 ++++++------- .../LinodeRescue/DeviceSelection.tsx | 55 +++++----- .../LinodeSettings/InterfaceSelect.tsx | 69 +++++++++--- .../LinodeSettings/KernelSelect.test.tsx | 19 +++- .../LinodeSettings/KernelSelect.tsx | 98 ++++++++--------- .../LinodeStorage/CreateDiskDrawer.tsx | 2 +- .../Linodes/LinodesDetail/utilities.test.tsx | 25 +++++ .../Linodes/LinodesDetail/utilities.ts | 10 ++ .../Linodes/PowerActionsDialogOrDrawer.tsx | 11 +- .../manager/src/utilities/analytics/utils.ts | 10 +- 23 files changed, 421 insertions(+), 251 deletions(-) create mode 100644 packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/utilities.test.tsx diff --git a/packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md b/packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md new file mode 100644 index 00000000000..6b44068e8f1 --- /dev/null +++ b/packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace Select with Autocomplete component in the Linode feature ([#10725](https://github.com/linode/manager/pull/10725)) 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 19d8f35a550..470f624225e 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 @@ -411,6 +411,7 @@ describe('create linode', () => { id: randomNumber(), region: 'us-southeast', subnets: [mockSubnet], + label: randomLabel(), }); const mockVPCRegion = regionFactory.build({ id: region.id, @@ -497,6 +498,7 @@ describe('create linode', () => { cy.findByLabelText('Assign VPC') .should('be.visible') .focus() + .clear() .type(`${mockVPC.label}{downArrow}{enter}`); // select subnet cy.findByPlaceholderText('Select Subnet') diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx index 540956a6f39..9d0620efd45 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentMethodCard.tsx @@ -44,7 +44,9 @@ const getHeading = (paymentMethod: PaymentMethod) => { case 'paypal': return thirdPartyPaymentMap[paymentMethod.type].label; case 'google_pay': - return `${thirdPartyPaymentMap[paymentMethod.type].label} ${paymentMethod.data.card_type} ****${paymentMethod.data.last_four}`; + return `${thirdPartyPaymentMap[paymentMethod.type].label} ${ + paymentMethod.data.card_type + } ****${paymentMethod.data.last_four}`; default: return `${paymentMethod.data.card_type} ****${paymentMethod.data.last_four}`; } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 4e68b575e38..79866e62640 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -126,7 +126,7 @@ export interface LinodeCreateProps { handlePlacementGroupChange: (placementGroup: PlacementGroup | null) => void; handleShowApiAwarenessModal: () => void; handleSubmitForm: HandleSubmit; - handleSubnetChange: (subnetId: number) => void; + handleSubnetChange: (subnetId: number | undefined) => void; handleVLANChange: (updatedInterface: InterfacePayload) => void; handleVPCIPv4Change: (IPv4: string) => void; history: any; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 048be836598..6e6cebf332f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -375,7 +375,7 @@ class LinodeCreateContainer extends React.PureComponent { })); }; - handleSubnetChange = (subnetID: number) => { + handleSubnetChange = (subnetID: number | undefined) => { this.setState((prevState) => ({ errors: prevState.errors?.filter( (error) => error.field !== 'interfaces[0].subnet_id' diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx index 080998c379e..8a23365fddb 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx @@ -48,7 +48,6 @@ describe('VLANAccordion Component', () => { getByPlaceholderText, getByRole, getByTestId, - getByText, } = renderWithTheme(, { queryClient, }); @@ -61,14 +60,10 @@ describe('VLANAccordion Component', () => { name: 'Configuration Profile - link opens in a new tab', }) ).toBeVisible(); - expect(getByText('Create or select a VLAN')).toBeVisible(); - expect(container.querySelector('#vlan-label-1')).toHaveAttribute( - 'disabled' - ); + expect(getByPlaceholderText('Create or select a VLAN')).toBeVisible(); + expect(container.querySelector('#vlan-label-1')).toBeDisabled(); expect(getByPlaceholderText('192.0.2.0/24')).toBeVisible(); - expect(container.querySelector('#ipam-input-1')).toHaveAttribute( - 'disabled' - ); + expect(container.querySelector('#ipam-input-1')).toBeDisabled(); }); it('enables the input fields when a region is selected', () => { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx index 533cb0d3aa4..8785cbf8e29 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx @@ -1,4 +1,3 @@ -import { Interface } from '@linode/api-v4/lib/linodes'; import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; @@ -12,6 +11,8 @@ import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature import { InterfaceSelect } from '../LinodesDetail/LinodeSettings/InterfaceSelect'; import { VLANAvailabilityNotice } from './VLANAvailabilityNotice'; +import type { Interface } from '@linode/api-v4/lib/linodes'; + export interface VLANAccordionProps { handleVLANChange: (updatedInterface: Interface) => void; helperText?: string; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index 47c30668446..107d05cb695 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -2,9 +2,9 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; import { Checkbox } from 'src/components/Checkbox'; -import Select from 'src/components/EnhancedSelect'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; import { LinkButton } from 'src/components/LinkButton'; @@ -27,7 +27,6 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { REGION_CAVEAT_HELPER_TEXT } from './constants'; import { VPCCreateDrawer } from './VPCCreateDrawer'; -import type { Item } from 'src/components/EnhancedSelect'; import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; @@ -38,7 +37,7 @@ export interface VPCPanelProps { from: 'linodeConfig' | 'linodeCreate'; handleIPv4RangeChange: (ranges: ExtendedIP[]) => void; handleSelectVPC: (vpcId: number) => void; - handleSubnetChange: (subnetId: number) => void; + handleSubnetChange: (subnetId: number | undefined) => void; handleVPCIPv4Change: (IPv4: string) => void; publicIPv4Error?: string; region: string | undefined; @@ -107,23 +106,26 @@ export const VPCPanel = (props: VPCPanelProps) => { const vpcs = vpcsData ?? []; - const vpcDropdownOptions: Item[] = vpcs.reduce((accumulator, vpc) => { - return vpc.region === region - ? [...accumulator, { label: vpc.label, value: vpc.id }] - : accumulator; - }, []); - const fromLinodeCreate = from === 'linodeCreate'; const fromLinodeConfig = from === 'linodeConfig'; - if (fromLinodeCreate) { - vpcDropdownOptions.unshift({ - label: 'None', - value: -1, - }); + interface DropdownOption { + label: string; + value: number; } - const subnetDropdownOptions: Item[] = + const vpcDropdownOptions: DropdownOption[] = React.useMemo(() => { + return vpcs.reduce( + (accumulator, vpc) => { + return vpc.region === region + ? [...accumulator, { label: vpc.label, value: vpc.id }] + : accumulator; + }, + fromLinodeCreate ? [{ label: 'None', value: -1 }] : [] + ); + }, [vpcs, region, fromLinodeCreate]); + + const subnetDropdownOptions: DropdownOption[] = vpcs .find((vpc) => vpc.id === selectedVPCId) ?.subnets.map((subnet) => ({ @@ -139,7 +141,6 @@ export const VPCPanel = (props: VPCPanelProps) => { if (fromLinodeConfig) { return null; } - const copy = vpcDropdownOptions.length <= 1 ? 'Allow Linode to communicate in an isolated environment.' @@ -192,9 +193,12 @@ export const VPCPanel = (props: VPCPanelProps) => { )} {getMainCopyVPC()} - ) => - handleSubnetChange(selectedSubnet.value) - } + { + handleSubnetChange(selectedSubnet?.value); + }} + textFieldProps={{ + errorGroup: ERROR_GROUP_STRING, + }} value={ subnetDropdownOptions.find( (option) => option.value === selectedSubnetId - ) || null + ) ?? null } - errorGroup={ERROR_GROUP_STRING} + autoHighlight + clearIcon={null} errorText={subnetError} - isClearable={false} label="Subnet" options={subnetDropdownOptions} placeholder="Select Subnet" diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx index f5800389e6c..4abf31d5e11 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx @@ -1,12 +1,11 @@ -import { LinodeBackup } from '@linode/api-v4/lib/linodes'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Checkbox } from 'src/components/Checkbox'; import { Drawer } from 'src/components/Drawer'; -import Select from 'src/components/EnhancedSelect/Select'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { FormHelperText } from 'src/components/FormHelperText'; @@ -19,6 +18,7 @@ import { } from 'src/queries/linodes/linodes'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { LinodeBackup } from '@linode/api-v4/lib/linodes'; interface Props { backup: LinodeBackup | undefined; linodeId: number; @@ -102,17 +102,20 @@ export const RestoreToLinodeDrawer = (props: Props) => { {Boolean(errorMap.none) && ( {errorMap.none} )} - + settingsForm.setFieldValue('day', selected?.value) + } textFieldProps={{ dataAttrs: { 'data-qa-weekday-select': true, @@ -103,20 +106,19 @@ export const ScheduleSettings = (props: Props) => { value={dayOptions.find( (item) => item.value === settingsForm.values.day )} + autoHighlight + disableClearable disabled={isReadOnly} - isClearable={false} label="Day of Week" - name="Day of Week" noMarginTop - onChange={(item) => settingsForm.setFieldValue('day', item.value)} options={dayOptions} placeholder="Choose a day" /> - + option.label === value.label + } + onChange={(_, selected) => + handleInitrdChange(selected?.value) + } + value={getSelectedDeviceOption( values.initrd, categorizedInitrdOptions )} - isClearable={false} + autoHighlight + clearIcon={null} + groupBy={(option) => option.deviceType} label="initrd" noMarginTop - onChange={handleInitrdChange} options={categorizedInitrdOptions} placeholder="None" /> @@ -919,17 +928,19 @@ export const LinodeConfigDialog = (props: Props) => { name="useCustomRoot" /> {!useCustomRoot ? ( - + option.value === value.value + } + onChange={(_, selected) => + handlePrimaryInterfaceChange(selected?.value) } + autoHighlight data-testid="primary-interface-dropdown" + disableClearable disabled={isReadOnly} - isClearable={false} label="Primary Interface (Default Route)" - onChange={handlePrimaryInterfaceChange} options={getPrimaryInterfaceOptions(values.interfaces)} + value={primaryInterfaceOptions[primaryInterfaceIndex ?? 0]} /> { const [error, setError] = React.useState(undefined); const [successMessage, setSuccessMessage] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); - const [searchText, setSearchText] = React.useState(''); React.useEffect(() => { // Not using onReset here because we don't want to reset the IPs. @@ -135,12 +132,6 @@ export const IPTransfer = (props: Props) => { setSuccessMessage(''); }, [open]); - const handleInputChange = React.useRef( - debounce(500, false, (_searchText: string) => { - setSearchText(_searchText); - }) - ).current; - const { data: allLinodes, error: linodesError, @@ -148,7 +139,6 @@ export const IPTransfer = (props: Props) => { } = useAllLinodesQuery( {}, { - label: { '+contains': searchText ? searchText : undefined }, region: linode?.region, }, open // only run the query if the modal is open @@ -162,8 +152,11 @@ export const IPTransfer = (props: Props) => { const linodes = (allLinodes ?? []).filter((l) => l.id !== linodeId); - const onModeChange = (ip: string) => (e: Item) => { - const mode = e.value as Mode; + const onModeChange = (ip: string) => ( + event: React.SyntheticEvent, + selected: { label: string; value: string } + ) => { + const mode = (selected?.value as Mode) || 'none'; const firstLinode = linodes[0]; const newState = compose( @@ -207,9 +200,12 @@ export const IPTransfer = (props: Props) => { setIPs((currentState) => newState(currentState)); }; - const onSelectedLinodeChange = (ip: string) => (e: Item) => { + const onSelectedLinodeChange = (ip: string) => ( + event: React.SyntheticEvent, + selected: { label: string; value: number } + ) => { const newState = compose( - setSelectedLinodeID(ip, e.value), + setSelectedLinodeID(ip, selected.value), /** * When mode is swapping; * Update the selectedLinodesIPs (since the Linode has changed, the available IPs certainly have) @@ -221,7 +217,7 @@ export const IPTransfer = (props: Props) => { compose( /** We need to find and return the newly selected Linode's IPs. */ updateSelectedLinodesIPs(ip, () => { - const linode = linodes.find((l) => l.id === Number(e.value)); + const linode = linodes.find((l) => l.id === Number(selected.value)); if (linode) { const linodeIPv6Ranges = getLinodeIPv6Ranges( ipv6RangesData, @@ -234,7 +230,7 @@ export const IPTransfer = (props: Props) => { /** We need to find the selected Linode's IPs and return the first. */ updateSelectedIP(ip, () => { - const linode = linodes.find((l) => l.id === Number(e.value)); + const linode = linodes.find((l) => l.id === Number(selected.value)); if (linode) { return linode.ipv4[0]; } @@ -246,8 +242,11 @@ export const IPTransfer = (props: Props) => { setIPs((currentState) => newState(currentState)); }; - const onSelectedIPChange = (ip: string) => (e: Item) => { - setIPs(setSelectedIP(ip, e.value)); + const onSelectedIPChange = (ip: string) => ( + event: React.SyntheticEvent, + selected: { label: string; value: string } + ) => { + setIPs(setSelectedIP(ip, selected.value)); }; const renderRow = ( @@ -297,11 +296,16 @@ export const IPTransfer = (props: Props) => { - @@ -372,20 +375,21 @@ export const IPTransfer = (props: Props) => { return ( - + option.label === value.label + } + autoHighlight + clearIcon={null} disabled={disabled} - isClearable={false} + groupBy={(option) => option.deviceType} label={`/dev/${slot}`} noMarginTop - onChange={(e: Item) => onChange(slot, e.value)} + onChange={(_, selected) => onChange(slot, selected?.value)} options={deviceList} placeholder={'None'} value={selectedDevice} @@ -94,15 +100,14 @@ export const DeviceSelection = (props: Props) => { })} {rescue && ( - + { + const detailsOption = details?.option; + if ( + reason === 'selectOption' && + detailsOption?.label.includes(`Create "${detailsOption?.value}"`) + ) { + handleCreateOption(detailsOption.value); + } else { + handleLabelChange(selected?.value ?? ''); + } + }} + autoHighlight + disabled={readOnly} errorText={errors.labelError} - inputId={`vlan-label-${slotNumber}`} - isClearable - isDisabled={readOnly} + filterOptions={filterVLANOptions} + id={`vlan-label-${slotNumber}`} label="VLAN" - onChange={handleLabelChange} - onCreateOption={handleCreateOption} options={vlanOptions} placeholder="Create or select a VLAN" value={vlanOptions.find((thisVlan) => thisVlan.value === label) ?? null} @@ -324,7 +353,7 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { )} - { + return ( +
  • + {kernel.label} +
  • + ); + }} + textFieldProps={{ + errorGroup: 'linode-config-drawer', + }} + autoHighlight + disableClearable + disabled={readOnly} errorText={errorText} - isClearable={false} + groupBy={(option) => option.kernelType} label="Select a Kernel" - onChange={onChange} + onChange={(_, selected) => onChange(selected.value)} options={options} + placeholder="Select a Kernel" value={getSelectedKernelId(selectedKernel, options)} /> ); @@ -46,16 +58,12 @@ export const KernelSelect = React.memo((props: KernelSelectProps) => { export const getSelectedKernelId = ( kernelID: string | undefined, - options: KernelGroupOption[] + options: KernelOption[] ) => { if (!kernelID) { - return null; + return; } - const kernels = options.reduce( - (accum, thisGroup) => [...accum, ...thisGroup.options], - [] - ); - return kernels.find((thisKernel) => kernelID === thisKernel.value); + return options.find((option) => kernelID === option.value); }; export const groupKernels = (kernel: Kernel) => { @@ -80,37 +88,25 @@ export const groupKernels = (kernel: Kernel) => { return 'Current'; }; -type KernelGroup = ReturnType; - -interface KernelGroupOption { - label: KernelGroup; - options: Option[]; -} - export const kernelsToGroupedItems = (kernels: Kernel[]) => { const groupedKernels = groupBy(groupKernels, kernels); - groupedKernels.Current = sortCurrentKernels(groupedKernels.Current); + groupedKernels.Current = sortCurrentKernels(groupedKernels.Current); return Object.keys(groupedKernels) - .reduce( - (accum: KernelGroupOption[], thisGroup: KernelGroup) => { - const group = groupedKernels[thisGroup]; - if (!group || group.length === 0) { - return accum; - } - return [ - ...accum, - { - label: thisGroup, - options: groupedKernels[thisGroup].map((thisKernel) => ({ - label: thisKernel.label, - value: thisKernel.id, - })), - }, - ]; - }, - [] - ) + .reduce((accum: KernelOption[], thisGroup: KernelType) => { + const group = groupedKernels[thisGroup]; + if (!group || group.length === 0) { + return accum; + } + return [ + ...accum, + ...group.map((thisKernel) => ({ + kernelType: thisGroup, + label: thisKernel.label, + value: thisKernel.id, + })), + ]; + }, []) .sort(sortKernelGroups); }; @@ -121,11 +117,11 @@ const PRIORITY = { Deprecated: 1, }; -const sortKernelGroups = (a: KernelGroupOption, b: KernelGroupOption) => { - if (PRIORITY[a.label] > PRIORITY[b.label]) { +const sortKernelGroups = (a: KernelOption, b: KernelOption) => { + if (PRIORITY[a.kernelType] > PRIORITY[b.kernelType]) { return -1; } - if (PRIORITY[a.label] < PRIORITY[b.label]) { + if (PRIORITY[a.kernelType] < PRIORITY[b.kernelType]) { return 1; } return 0; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx index 83d27bf867c..debb1f4dd72 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx @@ -25,7 +25,7 @@ import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; import { ImageAndPassword } from '../LinodeSettings/ImageAndPassword'; -import type { Image } from '@linode/api-v4' +import type { Image } from '@linode/api-v4'; type FileSystem = 'ext3' | 'ext4' | 'initrd' | 'raw' | 'swap'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/utilities.test.tsx new file mode 100644 index 00000000000..60b9cb061d0 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/utilities.test.tsx @@ -0,0 +1,25 @@ +import { linodeDiskFactory } from 'src/factories'; + +import { getSelectedDeviceOption } from './utilities'; + +describe('Select device option', () => { + it('should return the right device using the selected value and device options', () => { + const mockDisks = [ + linodeDiskFactory.build({ filesystem: 'ext4', label: 'Debian 10 Disk' }), + linodeDiskFactory.build({ + filesystem: 'swap', + label: '512 MB Swap Image', + }), + ]; + + const deviceList = mockDisks.map((disk) => ({ + deviceType: 'Disks', + label: disk.label, + value: `disk ${disk.id}`, + })); + + expect(getSelectedDeviceOption(deviceList[0].value, deviceList)).toEqual( + deviceList[0] + ); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/utilities.ts index 17a6f97a88d..95de364bb2c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/utilities.ts @@ -9,3 +9,13 @@ export const lishLink = ( ) => { return `ssh -t ${username}@lish-${region}.linode.com ${linodeLabel}`; }; + +export const getSelectedDeviceOption = ( + selectedValue: string, + optionList: { deviceType: string; label: string; value: any }[] +) => { + if (!selectedValue) { + return null; + } + return optionList.find((option) => option.value === selectedValue) || null; +}; diff --git a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx index 9e0d7495a33..d69707fd69c 100644 --- a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx +++ b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx @@ -2,8 +2,8 @@ import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import Select from 'src/components/EnhancedSelect/Select'; import { FormHelperText } from 'src/components/FormHelperText'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; @@ -171,16 +171,17 @@ export const PowerActionsDialog = (props: Props) => { ) : null} {showConfigSelect && ( <> - - ); -}); +export const CloudPulseTimeRangeSelect = React.memo( + (props: CloudPulseTimeRangeSelectProps) => { + const { handleStatsChange, placeholder } = props; + const options = generateSelectOptions(); + const getDefaultValue = React.useCallback((): Item => { + const defaultValue = getUserPreferenceObject().timeDuration; + + return options.find((o) => o.label === defaultValue) || options[0]; + }, [options]); + const [selectedTimeRange, setSelectedTimeRange] = React.useState< + Item + >(getDefaultValue()); + + React.useEffect(() => { + const item = getDefaultValue(); + + if (handleStatsChange) { + handleStatsChange(getTimeDurationFromTimeRange(item.value)); + } + setSelectedTimeRange(item); + }, [handleStatsChange, getDefaultValue]); + + const handleChange = (item: Item) => { + updateGlobalFilterPreference({ + [TIME_DURATION]: item.value, + }); + + if (handleStatsChange) { + handleStatsChange(getTimeDurationFromTimeRange(item.value)); + } + }; + + return ( + ) => { + handleChange(value); + }} + textFieldProps={{ + hideLabel: true, + }} + autoHighlight + data-testid="cloudpulse-time-duration" + disableClearable + fullWidth + isOptionEqualToValue={(option, value) => option.value === value.value} + label="Select Time Duration" + options={options} + placeholder={placeholder ?? 'Select Time Duration'} + value={selectedTimeRange} + /> + ); + } +); /** * react-select option generator that aims to remain a pure function @@ -135,3 +145,32 @@ export const generateStartTime = (modifier: Labels, nowInSeconds: number) => { return nowInSeconds - 30 * 24 * 60 * 60; } }; + +/** + * + * @param label label for time duration to get the corresponding time duration object + * @returns time duration object for the label + */ +const getTimeDurationFromTimeRange = (label: string): TimeDuration => { + if (label === PAST_30_MINUTES) { + return { unit: 'min', value: 30 }; + } + + if (label === PAST_24_HOURS) { + return { unit: 'hr', value: 24 }; + } + + if (label === PAST_12_HOURS) { + return { unit: 'hr', value: 12 }; + } + + if (label === PAST_7_DAYS) { + return { unit: 'day', value: 7 }; + } + + if (label === PAST_30_DAYS) { + return { unit: 'day', value: 30 }; + } + + return { unit: 'min', value: 30 }; +}; From 37c6d14c50c92ae39ec3d077631dc57746b22e2a Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 9 Aug 2024 12:21:27 -0400 Subject: [PATCH 22/43] upcoming: [M3-8426] - Add Sentry Tag for Linode Create v2 (#10763) * add sentry tagging logic * Added changeset: Add Sentry Tag for Linode Create v2 --------- Co-authored-by: Banks Nussman --- .../pr-10763-upcoming-features-1723138942196.md | 5 +++++ .../src/features/Linodes/LinodeCreatev2/index.tsx | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md diff --git a/packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md b/packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md new file mode 100644 index 00000000000..cb9f61a0298 --- /dev/null +++ b/packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Sentry Tag for Linode Create v2 ([#10763](https://github.com/linode/manager/pull/10763)) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index db5792cf785..cfd83d1c7d5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -1,4 +1,5 @@ import { isEmpty } from '@linode/api-v4'; +import * as Sentry from '@sentry/react'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import React, { useEffect, useRef } from 'react'; @@ -139,6 +140,19 @@ export const LinodeCreatev2 = () => { previousSubmitCount.current = form.formState.submitCount; }, [form.formState]); + /** + * Add a Sentry tag when Linode Create v2 is mounted + * so we differentiate errors. + * + * @todo remove once Linode Create v2 is live for all users + */ + useEffect(() => { + Sentry.setTag('Linode Create Version', 'v2'); + return () => { + Sentry.setTag('Linode Create Version', undefined); + }; + }, []); + return ( From 90c277941335aec628201d9691093e95d45b9375 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:47:24 -0400 Subject: [PATCH 23/43] upcoming: [M3-7942] - Use object-storage/endpoints to get buckets (#10752) Co-authored-by: Jaalah Ramos --- packages/api-v4/src/object-storage/types.ts | 5 -- .../BucketLanding/BucketLanding.tsx | 9 +- .../BucketLanding/OMC_BucketLanding.tsx | 4 +- .../src/features/Search/SearchLanding.tsx | 3 +- packages/manager/src/mocks/serverHandlers.ts | 9 +- .../src/queries/object-storage/queries.ts | 50 +++++++---- .../src/queries/object-storage/requests.ts | 82 +++++++++++++++++-- 7 files changed, 128 insertions(+), 34 deletions(-) diff --git a/packages/api-v4/src/object-storage/types.ts b/packages/api-v4/src/object-storage/types.ts index d96094af59c..a749fbc0866 100644 --- a/packages/api-v4/src/object-storage/types.ts +++ b/packages/api-v4/src/object-storage/types.ts @@ -99,11 +99,6 @@ export interface DeleteObjectStorageBucketPayload { } export interface ObjectStorageBucket { - /* - @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' - feature flag. - */ region?: string; label: string; created: string; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index 2abf39ac90d..24658a018a6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -16,6 +16,7 @@ import { useDeleteBucketMutation, useObjectStorageBuckets, } from 'src/queries/object-storage/queries'; +import { isBucketError } from 'src/queries/object-storage/requests'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { @@ -33,9 +34,9 @@ import type { APIError, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageEndpoint, } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { BucketError } from 'src/queries/object-storage/requests'; const useStyles = makeStyles()((theme: Theme) => ({ copy: { @@ -120,8 +121,8 @@ export const BucketLanding = () => { }, [removeBucketConfirmationDialog]); const unavailableClusters = - objectStorageBucketsResponse?.errors.map( - (error: BucketError) => error.cluster + objectStorageBucketsResponse?.errors.map((error) => + isBucketError(error) ? error.cluster : error.endpoint ) || []; if (isRestrictedUser) { @@ -253,7 +254,7 @@ const RenderEmpty = () => { }; interface UnavailableClustersDisplayProps { - unavailableClusters: ObjectStorageCluster[]; + unavailableClusters: (ObjectStorageCluster | ObjectStorageEndpoint)[]; } const UnavailableClustersDisplay = React.memo( diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index 63a7c9803aa..70c924088a0 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -16,6 +16,7 @@ import { useDeleteBucketWithRegionMutation, useObjectStorageBuckets, } from 'src/queries/object-storage/queries'; +import { isBucketError } from 'src/queries/object-storage/requests'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { @@ -32,7 +33,6 @@ import { BucketTable } from './BucketTable'; import type { APIError, ObjectStorageBucket, Region } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { BucketError } from 'src/queries/object-storage/requests'; const useStyles = makeStyles()((theme: Theme) => ({ copy: { @@ -126,7 +126,7 @@ export const OMC_BucketLanding = () => { // @TODO OBJ Multicluster - region is defined as an optional field in BucketError. Once the feature is rolled out to production, we could clean this up and remove the filter. const unavailableRegions = objectStorageBucketsResponse?.errors - ?.map((error: BucketError) => error.region) + ?.map((error) => (isBucketError(error) ? error.region : error.endpoint)) .filter((region): region is Region => region !== undefined); if (isRestrictedUser) { diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 96e77530dbe..a9f6205c1fc 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -15,6 +15,7 @@ import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; +import { isBucketError } from 'src/queries/object-storage/requests'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useSpecificTypes } from 'src/queries/types'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; @@ -207,7 +208,7 @@ export const SearchLanding = (props: SearchLandingProps) => { [ objectStorageBuckets && objectStorageBuckets.errors.length > 0, `Object Storage in ${objectStorageBuckets?.errors - .map((e) => e.cluster.region) + .map((e) => (isBucketError(e) ? e.cluster.region : e.endpoint.region)) .join(', ')}`, ], ]; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index bbf638fffcf..8a1a0572734 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -94,6 +94,9 @@ import { LinodeKernelFactory } from 'src/factories/linodeKernel'; import { pickRandom } from 'src/utilities/random'; import { getStorage } from 'src/utilities/storage'; +const getRandomWholeNumber = (min: number, max: number) => + Math.floor(Math.random() * (max - min + 1) + min); + import type { AccountMaintenance, CreateObjectStorageKeyPayload, @@ -966,10 +969,12 @@ export const handlers = [ const page = Number(url.searchParams.get('page') || 1); const pageSize = Number(url.searchParams.get('page_size') || 25); + const randomBucketNumber = getRandomWholeNumber(1, 500); + const buckets = objectStorageBucketFactoryGen2.buildList(1, { cluster: `${region}-1`, - hostname: `obj-bucket-1.${region}.linodeobjects.com`, - label: `obj-bucket-1`, + hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`, + label: `obj-bucket-${randomBucketNumber}`, region, }); diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 131401a5401..a631fc25e48 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -27,6 +27,7 @@ import { queryPresets } from '../base'; import { useRegionsQuery } from '../regions/regions'; import { getAllBucketsFromClusters, + getAllBucketsFromEndpoints, getAllBucketsFromRegions, getAllObjectStorageClusters, getAllObjectStorageEndpoints, @@ -34,7 +35,7 @@ import { } from './requests'; import { prefixToQueryKey } from './utilities'; -import type { BucketsResponse } from './requests'; +import type { BucketsResponse, BucketsResponseType } from './requests'; import type { APIError, CreateObjectStorageBucketPayload, @@ -116,6 +117,7 @@ export const useObjectStorageClusters = (enabled: boolean = true) => export const useObjectStorageBuckets = (enabled = true) => { const flags = useFlags(); const { data: account } = useAccount(); + const { data: allRegions } = useRegionsQuery(); const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', @@ -123,22 +125,42 @@ export const useObjectStorageBuckets = (enabled = true) => { account?.capabilities ?? [] ); - const { data: allRegions } = useRegionsQuery(); - const { data: clusters } = useObjectStorageClusters( - enabled && !isObjMultiClusterEnabled + const isObjectStorageGen2Enabled = isFeatureEnabledV2( + 'Object Storage Endpoint Types', + Boolean(flags.objectStorageGen2?.enabled), + account?.capabilities ?? [] ); - const regions = allRegions?.filter((r) => - r.capabilities.includes('Object Storage') - ); + const endpointsQueryEnabled = enabled && isObjectStorageGen2Enabled; + const clustersQueryEnabled = enabled && !isObjMultiClusterEnabled; + + // Endpoints contain all the regions that support Object Storage. + const { data: endpoints } = useObjectStorageEndpoints(endpointsQueryEnabled); + const { data: clusters } = useObjectStorageClusters(clustersQueryEnabled); + + const regions = + isObjMultiClusterEnabled && !isObjectStorageGen2Enabled + ? allRegions?.filter((r) => r.capabilities.includes('Object Storage')) + : undefined; - return useQuery({ - enabled: isObjMultiClusterEnabled - ? regions !== undefined && enabled - : clusters !== undefined && enabled, - queryFn: isObjMultiClusterEnabled - ? () => getAllBucketsFromRegions(regions) - : () => getAllBucketsFromClusters(clusters), + const queryEnabled = isObjectStorageGen2Enabled + ? Boolean(endpoints) && enabled + : isObjMultiClusterEnabled + ? Boolean(regions) && enabled + : Boolean(clusters) && enabled; + + const queryFn = isObjectStorageGen2Enabled + ? () => getAllBucketsFromEndpoints(endpoints) + : isObjMultiClusterEnabled + ? () => getAllBucketsFromRegions(regions) + : () => getAllBucketsFromClusters(clusters); + + return useQuery< + BucketsResponseType, + APIError[] + >({ + enabled: queryEnabled, + queryFn, queryKey: objectStorageQueries.buckets.queryKey, retry: false, }); diff --git a/packages/manager/src/queries/object-storage/requests.ts b/packages/manager/src/queries/object-storage/requests.ts index 9d19b7d8d28..832bc22c7a9 100644 --- a/packages/manager/src/queries/object-storage/requests.ts +++ b/packages/manager/src/queries/object-storage/requests.ts @@ -34,23 +34,50 @@ export const getAllObjectStorageEndpoints = () => getObjectStorageEndpoints({ filter, params }) )().then((data) => data.data); +/** + * @deprecated This type will be deprecated and removed when OBJ Gen2 is in GA. + */ export interface BucketError { - /* - @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 after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ cluster: ObjectStorageCluster; error: APIError[]; region?: Region; } +/** + * @deprecated This type will be deprecated and removed when OBJ Gen2 is in GA. + */ export interface BucketsResponse { buckets: ObjectStorageBucket[]; errors: BucketError[]; } +// TODO: OBJGen2 - Remove the `Gen2` suffix when Gen2 is in GA. +export interface BucketsResponseGen2 { + buckets: ObjectStorageBucket[]; + errors: BucketErrorGen2[]; +} + +// TODO: OBJGen2 - Remove the `Gen2` suffix when Gen2 is in GA. +export interface BucketErrorGen2 { + endpoint: ObjectStorageEndpoint; + error: APIError[]; +} + +// TODO: OBJGen2 - Only needed during interim period when Gen2 is in beta. +export type BucketsResponseType = T extends true + ? BucketsResponseGen2 + : BucketsResponse; + +// TODO: OBJGen2 - Only needed during interim period when Gen2 is in beta. +export function isBucketError( + error: BucketError | BucketErrorGen2 +): error is BucketError { + return (error as BucketError).cluster !== undefined; +} + +/** + * @deprecated This function is deprecated and will be removed in the future. + */ export const getAllBucketsFromClusters = async ( clusters: ObjectStorageCluster[] | undefined ) => { @@ -86,6 +113,9 @@ export const getAllBucketsFromClusters = async ( return { buckets, errors } as BucketsResponse; }; +/** + * @deprecated This function is deprecated and will be removed in the future. + */ export const getAllBucketsFromRegions = async ( regions: Region[] | undefined ) => { @@ -120,3 +150,43 @@ export const getAllBucketsFromRegions = async ( return { buckets, errors } as BucketsResponse; }; + +/** + * We had to change the signature of things slightly since we're using the `object-storage/endpoints` + * endpoint. Note that the server response always includes information for all regions. + * @note This will be the preferred way to get all buckets once fetching by clusters is deprecated and Gen2 is in GA. + */ +export const getAllBucketsFromEndpoints = async ( + endpoints: ObjectStorageEndpoint[] | undefined +): Promise => { + if (!endpoints?.length) { + return { buckets: [], errors: [] }; + } + + const results = await Promise.all( + endpoints.map((endpoint) => + getAll((params) => + getBucketsInRegion(endpoint.region, params) + )() + .then((data) => ({ buckets: data.data, endpoint })) + .catch((error) => ({ endpoint, error })) + ) + ); + + const buckets: ObjectStorageBucket[] = []; + const errors: BucketErrorGen2[] = []; + + results.forEach((result) => { + if ('buckets' in result) { + buckets.push(...result.buckets); + } else { + errors.push({ endpoint: result.endpoint, error: result.error }); + } + }); + + if (errors.length === endpoints.length) { + throw new Error('Unable to get Object Storage buckets.'); + } + + return { buckets, errors }; +}; From d9646752b1625f67a9407a126fd0319e5f622192 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:06:26 -0400 Subject: [PATCH 24/43] refactor: [M3-8421] - Update Linode Types to use a Query Key Factory (#10760) * initial refactor * don't duplicate code * Added changeset: Query Key Factory for Linode Types --------- Co-authored-by: Banks Nussman --- .../pr-10760-tech-stories-1723236539851.md | 5 ++ .../manager/src/containers/types.container.ts | 29 ++---------- .../LinodeActionMenu/LinodeActionMenu.tsx | 18 +++----- .../LinodeActionMenu/LinodeActionMenuUtils.ts | 6 +-- .../manager/src/queries/linodes/linodes.ts | 15 ++++++ .../manager/src/queries/linodes/requests.ts | 4 ++ packages/manager/src/queries/types.ts | 46 +++++++------------ 7 files changed, 51 insertions(+), 72 deletions(-) create mode 100644 packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md diff --git a/packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md b/packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md new file mode 100644 index 00000000000..1268d934ee0 --- /dev/null +++ b/packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Linode Types ([#10760](https://github.com/linode/manager/pull/10760)) diff --git a/packages/manager/src/containers/types.container.ts b/packages/manager/src/containers/types.container.ts index 114e61c2ff0..8499b1293d5 100644 --- a/packages/manager/src/containers/types.container.ts +++ b/packages/manager/src/containers/types.container.ts @@ -1,9 +1,8 @@ -import { LinodeType } from '@linode/api-v4'; -import { APIError } from '@linode/api-v4/lib/types'; import React from 'react'; -import { useAllTypes, useSpecificTypes } from 'src/queries/types'; -import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; +import { useAllTypes } from 'src/queries/types'; + +import type { APIError, LinodeType } from '@linode/api-v4'; export interface WithTypesProps { typesData?: LinodeType[]; @@ -11,11 +10,6 @@ export interface WithTypesProps { typesLoading: boolean; } -export interface WithSpecificTypesProps { - requestedTypesData: LinodeType[]; - setRequestedTypes: (types: string[]) => void; -} - export const withTypes = ( Component: React.ComponentType, enabled = true @@ -33,20 +27,3 @@ export const withTypes = ( typesLoading, }); }; - -export const withSpecificTypes = ( - Component: React.ComponentType, - enabled = true -) => (props: Props) => { - const [requestedTypes, setRequestedTypes] = React.useState([]); - const typesQuery = useSpecificTypes(requestedTypes, enabled); - const requestedTypesData = typesQuery - .map((result) => result.data) - .filter(isNotNullOrUndefined); - - return React.createElement(Component, { - ...props, - requestedTypesData, - setRequestedTypes, - }); -}; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx index 836555c2435..f01bd6ca244 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx @@ -1,4 +1,3 @@ -import { LinodeBackups, LinodeType } from '@linode/api-v4/lib/linodes'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -6,24 +5,22 @@ import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; -import { - ActionType, - getRestrictedResourceText, -} from 'src/features/Account/utils'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { lishLaunch } from 'src/features/Lish/lishUtils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useSpecificTypes } from 'src/queries/types'; import { sendLinodeActionEvent, sendLinodeActionMenuItemEvent, sendMigrationNavigationEvent, } from 'src/utilities/analytics/customEventAnalytics'; -import { extendType } from 'src/utilities/extendType'; -import { LinodeHandlers } from '../LinodesLanding'; import { buildQueryStringForLinodeClone } from './LinodeActionMenuUtils'; +import type { LinodeHandlers } from '../LinodesLanding'; +import type { LinodeBackups, LinodeType } from '@linode/api-v4'; +import type { ActionType } from 'src/features/Account/utils'; + export interface LinodeActionMenuProps extends LinodeHandlers { inListView?: boolean; linodeBackups: LinodeBackups; @@ -53,9 +50,6 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { linodeType, } = props; - const typesQuery = useSpecificTypes(linodeType?.id ? [linodeType.id] : []); - const type = typesQuery[0]?.data; - const extendedType = type ? extendType(type) : undefined; const history = useHistory(); const regions = useRegionsQuery().data ?? []; const isBareMetalInstance = linodeType?.class === 'metal'; @@ -138,7 +132,7 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { linodeId, linodeRegion, linodeType?.id ?? null, - extendedType ? [extendedType] : null, + linodeType ? [linodeType] : undefined, regions ), }); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts index 7bec463fe95..49255879f74 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts @@ -1,12 +1,10 @@ -import { Region } from '@linode/api-v4/lib'; - -import { ExtendedType } from 'src/utilities/extendType'; +import type { LinodeType, Region } from '@linode/api-v4'; export const buildQueryStringForLinodeClone = ( linodeId: number, linodeRegion: string, linodeType: null | string, - types: ExtendedType[] | null | undefined, + types: LinodeType[] | null | undefined, regions: Region[] ): string => { const linodeRegionId = diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index e396847ab84..acfd5733490 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -14,6 +14,7 @@ import { getLinodeTransfer, getLinodeTransferByDate, getLinodes, + getType, linodeBoot, linodeReboot, linodeShutdown, @@ -43,6 +44,7 @@ import { getAllLinodeConfigs, getAllLinodeDisks, getAllLinodeKernelsRequest, + getAllLinodeTypes, getAllLinodesRequest, } from './requests'; @@ -136,6 +138,19 @@ export const linodeQueries = createQueryKeys('linodes', { }, queryKey: null, }, + types: { + contextQueries: { + all: { + queryFn: getAllLinodeTypes, + queryKey: null, + }, + type: (id: string) => ({ + queryFn: () => getType(id), + queryKey: [id], + }), + }, + queryKey: null, + }, }); export const useLinodesQuery = ( diff --git a/packages/manager/src/queries/linodes/requests.ts b/packages/manager/src/queries/linodes/requests.ts index b638e2aa690..58d447656f1 100644 --- a/packages/manager/src/queries/linodes/requests.ts +++ b/packages/manager/src/queries/linodes/requests.ts @@ -3,6 +3,7 @@ import { getLinodeDisks, getLinodeFirewalls, getLinodeKernels, + getLinodeTypes, getLinodes, } from '@linode/api-v4'; @@ -59,3 +60,6 @@ export const getAllLinodeDisks = (id: number) => getAll((params, filter) => getLinodeDisks(id, params, filter))().then( (data) => data.data ); + +export const getAllLinodeTypes = () => + getAll(getLinodeTypes)().then((results) => results.data); diff --git a/packages/manager/src/queries/types.ts b/packages/manager/src/queries/types.ts index eeef894db05..8e736bfa1af 100644 --- a/packages/manager/src/queries/types.ts +++ b/packages/manager/src/queries/types.ts @@ -1,40 +1,19 @@ -import { LinodeType, getLinodeTypes, getType } from '@linode/api-v4'; -import { APIError } from '@linode/api-v4/lib/types'; -import { - QueryClient, - UseQueryOptions, - useQueries, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; - -import { getAll } from 'src/utilities/getAll'; +import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryPresets } from './base'; +import { linodeQueries } from './linodes/linodes'; -const queryKey = 'types'; - -const getAllTypes = () => - getAll(getLinodeTypes)().then((results) => results.data); - -const getSingleType = async (type: string, queryClient: QueryClient) => { - const allTypesCache = queryClient.getQueryData( - allTypesQueryKey - ); - return ( - allTypesCache?.find((cachedType) => cachedType.id === type) ?? getType(type) - ); -}; +import type { APIError, LinodeType } from '@linode/api-v4'; +import type { UseQueryOptions } from '@tanstack/react-query'; -const allTypesQueryKey = [queryKey, 'all']; export const useAllTypes = (enabled = true) => { - return useQuery(allTypesQueryKey, getAllTypes, { + return useQuery({ + ...linodeQueries.types._ctx.all, enabled, ...queryPresets.oneTimeFetch, }); }; -const specificTypesQueryKey = (type: string) => [queryKey, 'detail', type]; /** * Some Linodes may have types that aren't returned by the /types and /types-legacy endpoints. This * hook may be useful in fetching these "shadow plans". @@ -43,12 +22,19 @@ const specificTypesQueryKey = (type: string) => [queryKey, 'detail', type]; */ export const useSpecificTypes = (types: string[], enabled = true) => { const queryClient = useQueryClient(); + return useQueries({ queries: types.map>((type) => ({ - enabled: Boolean(type) && enabled, - queryFn: () => getSingleType(type, queryClient), - queryKey: specificTypesQueryKey(type), + enabled, + ...linodeQueries.types._ctx.type(type), ...queryPresets.oneTimeFetch, + initialData() { + const allTypesFromCache = queryClient.getQueryData( + linodeQueries.types._ctx.all.queryKey + ); + + return allTypesFromCache?.find((t) => t.id === type); + }, })), }); }; From 843cacea830757599eb8bd64c0cd5a027c7e3fdc Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Tue, 13 Aug 2024 02:14:48 +0530 Subject: [PATCH 25/43] fix: [M3-7324] - LKE Cluster Create Tab Selection Reset Upon Adding Pool (#10772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 When creating an LKE cluster, if you add a node pool under the "Shared CPU", "High Memory", or "Premium CPU" tab, in some cases Cloud will erroneously reset the user's tab selection back to the "Dedicated CPU" tab. This only occurs when the node pool size has been modified using the - and + buttons (or by typing in a different value) prior to adding the node pool. If you add a node pool of the default size (3) without first modifying the value, the tab selection does not change. ## Changes 🔄 List any change relevant to the reviewer. - Remove `setSelectedType(undefined)` to prevent tab reset after adding a node pool. - Change the function name from `submitForm` to `addPool` for clarity. ## Target release date 🗓️ 8/19 ## How to test 🧪 ### Reproduction steps 1. Navigate to the LKE cluster create page at /kubernetes/create 2. Select a region 3. Click on the "Shared CPU" tab, add a node pool without modifying the size 4. Observe that upon adding the node pool, the "Shared CPU" tab remains selected 5. Click on the "Shared CPU" tab again, modify a node pool size, and then add the node pool 6. Observe that upon adding the node pool, the "Dedicated CPU" tab becomes selected ### Verification steps - Upon adding a node pool, the tab which is selected should always remain selected. --- .../manager/.changeset/pr-10772-fixed-1723476789978.md | 5 +++++ .../features/Kubernetes/CreateCluster/NodePoolPanel.tsx | 8 ++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10772-fixed-1723476789978.md diff --git a/packages/manager/.changeset/pr-10772-fixed-1723476789978.md b/packages/manager/.changeset/pr-10772-fixed-1723476789978.md new file mode 100644 index 00000000000..579004b5a1c --- /dev/null +++ b/packages/manager/.changeset/pr-10772-fixed-1723476789978.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +LKE Cluster Create tab selection reset upon adding pool ([#10772](https://github.com/linode/manager/pull/10772)) diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index af482b317ec..70c6b6b86cb 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -76,16 +76,12 @@ const Panel = (props: NodePoolPanelProps) => { const extendedTypes = types.map(extendType); - const submitForm = (selectedPlanType: string, nodeCount: number) => { - /** - * Add pool and reset form state. - */ + const addPool = (selectedPlanType: string, nodeCount: number) => { addNodePool({ count: nodeCount, id: Math.random(), type: selectedPlanType, }); - setSelectedType(undefined); }; const updatePlanCount = (planId: string, newCount: number) => { @@ -119,7 +115,7 @@ const Panel = (props: NodePoolPanelProps) => { header="Add Node Pools" isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} - onAdd={submitForm} + onAdd={addPool} onSelect={(newType: string) => setSelectedType(newType)} regionsData={regionsData} resetValues={() => null} // In this flow we don't want to clear things on tab changes From 2b8fe046b3c99d2815f22c830726ed38e16be2f4 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 12 Aug 2024 17:23:42 -0400 Subject: [PATCH 26/43] =?UTF-8?q?upcoming:=20[M3-8367]=20=E2=80=93=20Add?= =?UTF-8?q?=20"Volume=20Encryption"=20section=20to=20Volume=20Create=20pag?= =?UTF-8?q?e=20(#10750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-10750-upcoming-features-1723139035892.md | 5 ++ .../src/components/Encryption/Encryption.tsx | 25 +++++++ .../src/components/Encryption/constants.tsx | 24 +++++++ .../src/components/Encryption/utils.ts | 29 ++++++++ .../features/Volumes/VolumeCreate.test.tsx | 60 ++++++++++++++++ .../src/features/Volumes/VolumeCreate.tsx | 70 ++++++++++++++++++- 6 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md create mode 100644 packages/manager/src/features/Volumes/VolumeCreate.test.tsx diff --git a/packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md b/packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md new file mode 100644 index 00000000000..42cf3b2fd6c --- /dev/null +++ b/packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Volume Encryption section to Volume Create page ([#10750](https://github.com/linode/manager/pull/10750)) diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index f4b7e0da4d4..53d94600f70 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -1,3 +1,4 @@ +import { List, ListItem } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -13,6 +14,7 @@ export interface EncryptionProps { entityType?: string; error?: string; isEncryptEntityChecked: boolean; + notices?: string[]; onChange: (checked: boolean) => void; } @@ -28,6 +30,7 @@ export const Encryption = (props: EncryptionProps) => { entityType, error, isEncryptEntityChecked, + notices, onChange, } = props; @@ -45,6 +48,28 @@ export const Encryption = (props: EncryptionProps) => { > {descriptionCopy} + {notices && notices.length > 0 && ( + + ({ + '& > li': { + display: notices.length > 1 ? 'list-item' : 'inline', + fontSize: '0.875rem', + lineHeight: theme.spacing(2), + padding: 0, + pl: 0, + py: 0.5, + }, + listStyle: 'disc', + ml: notices.length > 1 ? theme.spacing(2) : 0, + })} + > + {notices.map((notice, i) => ( + {notice} + ))} + + + )} + Secure this volume using data at rest encryption. Data center systems take + care of encrypting and decrypting for you. Once a volume is encrypted it + cannot be undone.{' '} + Learn more. + +); + +export const BLOCK_STORAGE_CHOOSE_REGION_COPY = + 'Select a region to use Volume encryption.'; + +export const BLOCK_STORAGE_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = `Volume encryption is not available in the selected region. ${BLOCK_STORAGE_CHOOSE_REGION_COPY}`; + +// Caveats +export const BLOCK_STORAGE_ENCRYPTION_OVERHEAD_CAVEAT = + 'Please note encryption overhead may impact your volume IOPS performance negatively. This may compound when multiple encryption-enabled volumes are attached to the same Linode.'; + +export const BLOCK_STORAGE_USER_SIDE_ENCRYPTION_CAVEAT = + 'User-side encryption on top of encryption-enabled volumes is discouraged at this time, as it could severely impact your volume performance.'; diff --git a/packages/manager/src/components/Encryption/utils.ts b/packages/manager/src/components/Encryption/utils.ts index 8beaab70d68..c0347f55ada 100644 --- a/packages/manager/src/components/Encryption/utils.ts +++ b/packages/manager/src/components/Encryption/utils.ts @@ -29,3 +29,32 @@ export const useIsDiskEncryptionFeatureEnabled = (): { return { isDiskEncryptionFeatureEnabled }; }; + +/** + * Hook to determine if the Block Storage Encryption feature should be visible to the user. + * Based on the user's account capability and the feature flag. + * + * @returns { boolean } - Whether the Block Storage Encryption feature is enabled for the current user. + */ +export const useIsBlockStorageEncryptionFeatureEnabled = (): { + isBlockStorageEncryptionFeatureEnabled: boolean; +} => { + const { data: account, error } = useAccount(); + const flags = useFlags(); + + if (error || !flags) { + return { isBlockStorageEncryptionFeatureEnabled: false }; + } + + const hasAccountCapability = account?.capabilities?.includes( + 'Block Storage Encryption' + ); + + const isFeatureFlagEnabled = flags.blockStorageEncryption; + + const isBlockStorageEncryptionFeatureEnabled = Boolean( + hasAccountCapability && isFeatureFlagEnabled + ); + + return { isBlockStorageEncryptionFeatureEnabled }; +}; diff --git a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx new file mode 100644 index 00000000000..0e611ed9fb7 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { accountFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { VolumeCreate } from './VolumeCreate'; + +const accountEndpoint = '*/v4/account'; +const encryptVolumeSectionHeader = 'Encrypt Volume'; + +describe('VolumeCreate', () => { + /* @TODO BSE: Remove feature flagging/conditionality once BSE is fully rolled out */ + + it('should not have a "Volume Encryption" section visible if the user has the account capability but the feature flag is off', () => { + server.use( + http.get(accountEndpoint, () => { + return HttpResponse.json( + accountFactory.build({ capabilities: ['Block Storage Encryption'] }) + ); + }) + ); + + const { queryByText } = renderWithTheme(, { + flags: { blockStorageEncryption: false }, + }); + + expect(queryByText(encryptVolumeSectionHeader)).not.toBeInTheDocument(); + }); + + it('should not have a "Volume Encryption" section visible if the user does not have the account capability but the feature flag is on', () => { + server.use( + http.get(accountEndpoint, () => { + return HttpResponse.json(accountFactory.build({ capabilities: [] })); + }) + ); + + const { queryByText } = renderWithTheme(, { + flags: { blockStorageEncryption: true }, + }); + + expect(queryByText(encryptVolumeSectionHeader)).not.toBeInTheDocument(); + }); + + it('should have a "Volume Encryption" section visible if feature flag is on and user has the capability', async () => { + server.use( + http.get(accountEndpoint, () => { + return HttpResponse.json( + accountFactory.build({ capabilities: ['Block Storage Encryption'] }) + ); + }) + ); + + const { findByText } = renderWithTheme(, { + flags: { blockStorageEncryption: true }, + }); + + await findByText(encryptVolumeSectionHeader); + }); +}); diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index fded98105d3..8b1c5f39f2d 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -9,6 +9,15 @@ import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + BLOCK_STORAGE_CHOOSE_REGION_COPY, + BLOCK_STORAGE_ENCRYPTION_GENERAL_DESCRIPTION, + BLOCK_STORAGE_ENCRYPTION_OVERHEAD_CAVEAT, + BLOCK_STORAGE_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, + BLOCK_STORAGE_USER_SIDE_ENCRYPTION_CAVEAT, +} from 'src/components/Encryption/constants'; +import { Encryption } from 'src/components/Encryption/Encryption'; +import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; import { ErrorMessage } from 'src/components/ErrorMessage'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; @@ -33,6 +42,7 @@ import { useVolumeTypesQuery, } from 'src/queries/volumes/volumes'; import { sendCreateVolumeEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { handleFieldErrors, @@ -45,6 +55,7 @@ import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants import { ConfigSelect } from './VolumeDrawer/ConfigSelect'; import { SizeField } from './VolumeDrawer/SizeField'; +import type { VolumeEncryption } from '@linode/api-v4'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; import type { Theme } from '@mui/material/styles'; @@ -127,6 +138,10 @@ export const VolumeCreate = () => { const [hasSignedAgreement, setHasSignedAgreement] = React.useState(false); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); + const { + isBlockStorageEncryptionFeatureEnabled, + } = useIsBlockStorageEncryptionFeatureEnabled(); + const regionsWithBlockStorage = regions ?.filter((thisRegion) => @@ -168,16 +183,25 @@ export const VolumeCreate = () => { } = useFormik({ initialValues, onSubmit: (values, { resetForm, setErrors, setStatus, setSubmitting }) => { - const { config_id, label, linode_id, region, size } = values; + const { config_id, encryption, label, linode_id, region, size } = values; setSubmitting(true); /** Status holds our success and generalError messages. */ setStatus(undefined); + // If the BSE feature is not enabled or the selected region does not support BSE, set `encryption` in the payload to undefined. + // Otherwise, set it to `enabled` if the checkbox is checked, or `disabled` if it is not + const blockStorageEncryptionPayloadValue = + !isBlockStorageEncryptionFeatureEnabled || + !regionSupportsBlockStorageEncryption + ? undefined + : encryption; + createVolume({ config_id: config_id === null ? undefined : maybeCastToNumber(config_id), + encryption: blockStorageEncryptionPayloadValue, label, linode_id: linode_id === null ? undefined : maybeCastToNumber(linode_id), @@ -243,6 +267,22 @@ export const VolumeCreate = () => { } }; + const regionSupportsBlockStorageEncryption = doesRegionSupportFeature( + values.region ?? '', + regions ?? [], + 'Block Storage Encryption' + ); + + const toggleVolumeEncryptionEnabled = ( + encryption: VolumeEncryption | undefined + ) => { + if (encryption === 'enabled') { + setFieldValue('encryption', 'disabled'); + } else { + setFieldValue('encryption', 'enabled'); + } + }; + return ( <> @@ -404,6 +444,32 @@ export const VolumeCreate = () => { /> ) : null} + {isBlockStorageEncryptionFeatureEnabled && ( + + + toggleVolumeEncryptionEnabled(values.encryption) + } + descriptionCopy={BLOCK_STORAGE_ENCRYPTION_GENERAL_DESCRIPTION} + disabled={!regionSupportsBlockStorageEncryption} + entityType="Volume" + isEncryptEntityChecked={values.encryption === 'enabled'} + /> + + )} , + text: 'Example text', + }; + + const { getByRole, getByText } = renderWithTheme( + + ); + + expect(getByText('Example text')).toBeVisible(); + + const action = getByRole('button'); + expect(action).toHaveTextContent(text); + await userEvent.click(action); + expect(cb).toHaveBeenCalled(); + }); + + it('pull banner label from LD', async () => { + const props = { + text: 'Example text', + }; + + const { findByText } = renderWithTheme(, { + flags: { secureVmCopy: { bannerLabel: 'Banner Label' } } as Flags, + }); + + expect(await findByText('Banner Label')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx new file mode 100644 index 00000000000..d0b8781036f --- /dev/null +++ b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; + +import { Box } from '../Box'; +import { Stack } from '../Stack'; +import { + StyledAkamaiLogo, + StyledBanner, + StyledBannerAction, + StyledBannerLabel, + StyledWarningIcon, +} from './AkamaiBanner.styles'; + +import type { Theme } from '@mui/material/styles'; + +interface AkamaiBannerProps { + action?: JSX.Element; + link?: { + text: string; + url: string; + }; + margin?: number; + text: string; + warning?: boolean; +} + +export const AkamaiBanner = React.memo((props: AkamaiBannerProps) => { + const { action, link, margin, text, warning } = props; + const flags = useFlags(); + + const textWithLineBreaks = text.split('\n').map((text, i, lines) => + i === lines.length - 1 ? ( + text + ) : ( + <> + {text} +
    + + ) + ); + + return ( + + + + + {warning ? ( + + ) : ( + + )} + + ({ + color: 'inherit', + fontFamily: theme.font.bold, + fontSize: 11, + letterSpacing: 0.44, + })} + > + {flags.secureVmCopy?.bannerLabel} + + + + ({ padding: theme.spacing(2) })} + > + ({ + color: warning ? theme.bg.mainContentBanner : theme.color.black, + })} + variant="body2" + > + {textWithLineBreaks}{' '} + {link && ( + + {link.text} + + )} + + + + + {action} + + + + ); +}); diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index 7e5cf1616f8..3dd811c8e91 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -7,10 +7,13 @@ import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { CreateFirewallDrawer } from 'src/features/Firewalls/FirewallLanding/CreateFirewallDrawer'; +import { useFlags } from 'src/hooks/useFlags'; +import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { useFirewallsQuery } from 'src/queries/firewalls'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; +import { AkamaiBanner } from '../AkamaiBanner/AkamaiBanner'; import { Autocomplete } from '../Autocomplete/Autocomplete'; import { LinkButton } from '../LinkButton'; @@ -35,12 +38,20 @@ export const SelectFirewallPanel = (props: Props) => { } = props; const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + // @ts-expect-error TODO Secure VMs: wire up firewall generation dialog + const [isFirewallDialogOpen, setIsFirewallDialogOpen] = React.useState(false); const location = useLocation(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); const queryParams = getQueryParamsFromQueryString( location.search ); + const flags = useFlags(); + + const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); + const secureVMFirewallBanner = + (secureVMNoticesEnabled && flags.secureVmCopy) ?? false; + const handleCreateFirewallClick = () => { setIsDrawerOpen(true); if (isFromLinodeCreate) { @@ -87,6 +98,20 @@ export const SelectFirewallPanel = (props: Props) => { {helperText} + {secureVMFirewallBanner !== false && + secureVMFirewallBanner.linodeCreate && ( + setIsFirewallDialogOpen(true)}> + {secureVMFirewallBanner.generateActionText} + + ) : undefined + } + margin={2} + {...secureVMFirewallBanner.linodeCreate} + /> + )} { handleFirewallChange(selection?.value ?? -1); diff --git a/packages/manager/src/containers/withSecureVMNoticesEnabled.container.ts b/packages/manager/src/containers/withSecureVMNoticesEnabled.container.ts new file mode 100644 index 00000000000..73e8472dd55 --- /dev/null +++ b/packages/manager/src/containers/withSecureVMNoticesEnabled.container.ts @@ -0,0 +1,20 @@ +import React from 'react'; + +import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; + +export interface WithSecureVMNoticesEnabledProps { + secureVMNoticesEnabled: boolean; +} + +export const withSecureVMNoticesEnabled = ( + Component: React.ComponentType +) => { + return (props: Props) => { + const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); + + return React.createElement(Component, { + ...props, + secureVMNoticesEnabled, + }); + }; +}; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index a74f395497f..d534c64be97 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -111,7 +111,8 @@ export interface Flags { productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; - referralBannerText: ReferralBannerText; + referralBannerText: BannerContent; + secureVmCopy: SecureVMCopy; selfServeBetas: boolean; soldOutChips: boolean; supportTicketSeverity: boolean; @@ -184,7 +185,7 @@ export interface Provider { name: TPAProvider; } -interface ReferralBannerText { +interface BannerContent { link?: { text: string; url: string; @@ -192,6 +193,15 @@ interface ReferralBannerText { text: string; } +interface SecureVMCopy { + bannerLabel?: string; + firewallAuthorizationLabel?: string; + firewallAuthorizationWarning?: string; + firewallDetails?: BannerContent; + generateActionText?: string; + linodeCreate?: BannerContent; +} + export type ProductInformationBannerLocation = | 'Account' | 'Betas' diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index f0e58906668..8ea70fe26ac 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { useHistory, useParams } from 'react-router-dom'; +import { AkamaiBanner } from 'src/components/AkamaiBanner/AkamaiBanner'; import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -10,6 +11,8 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { useFlags } from 'src/hooks/useFlags'; +import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { useFirewallQuery, useMutateFirewall } from 'src/queries/firewalls'; import { useGrants, useProfile } from 'src/queries/profile/profile'; @@ -34,6 +37,11 @@ export const FirewallDetail = () => { const history = useHistory(); const { data: profile } = useProfile(); const { data: grants } = useGrants(); + const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); + const flags = useFlags(); + + const secureVMFirewallBanner = + (secureVMNoticesEnabled && flags.secureVmCopy?.firewallDetails) ?? false; const firewallId = Number(id); @@ -126,6 +134,9 @@ export const FirewallDetail = () => { docsLink="https://linode.com/docs/platform/cloud-firewall/getting-started-with-cloud-firewall/" title="Firewall Details" /> + {secureVMFirewallBanner && ( + + )} history.push(tabs[i].routeName)} diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 6665926e079..3de111edce5 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; @@ -12,8 +13,10 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { useFirewallsQuery } from 'src/queries/firewalls'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -55,6 +58,11 @@ const FirewallLanding = () => { const [isModalOpen, setIsModalOpen] = React.useState(false); const [dialogMode, setDialogMode] = React.useState('enable'); + // @ts-expect-error TODO Secure VMs: wire up firewall generation dialog + const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); + + const flags = useFlags(); + const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); const [selectedFirewallId, setSelectedFirewallId] = React.useState< number | undefined @@ -126,6 +134,16 @@ const FirewallLanding = () => { return ( setIsGenerateDialogOpen(true)} + > + {flags.secureVmCopy.generateActionText} + + ) : undefined + } breadcrumbProps={{ pathname: '/firewalls' }} docsLink="https://linode.com/docs/platform/cloud-firewall/getting-started-with-cloud-firewall/" entity="Firewall" diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx index 3e34da9d7a1..fc382cc1d20 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx @@ -11,7 +11,7 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { ApiAwarenessModal } from '../LinodesCreate/ApiAwarenessModal/ApiAwarenessModal'; import { getLinodeCreatePayload } from './utilities'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { LinodeCreateFormValues } from './utilities'; export const Actions = () => { const flags = useFlags(); @@ -24,12 +24,15 @@ export const Actions = () => { formState, getValues, trigger, - } = useFormContext(); + } = useFormContext(); const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); + const disableSubmitButton = + isLinodeCreateRestricted || 'firewallOverride' in formState.errors; + const onOpenAPIAwareness = async () => { sendApiAwarenessClickEvent('Button', 'Create Using Command Line'); if (await trigger()) { @@ -49,7 +52,7 @@ export const Actions = () => {
    - {variant === 'bucket' ? ( + {isCorsEnabled ? ( { /> ) : null} - {variant === 'bucket' ? ( + {isCorsEnabled ? ( Whether Cross-Origin Resource Sharing is enabled for all origins. For more fine-grained control of CORS, please use another{' '} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx index 96c41fcb9da..a72be5b389b 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx @@ -56,6 +56,7 @@ import ObjectTableContent from './ObjectTableContent'; import type { ObjectStorageClusterID, + ObjectStorageEndpointTypes, ObjectStorageObject, ObjectStorageObjectList, } from '@linode/api-v4'; @@ -64,8 +65,12 @@ interface MatchParams { bucketName: string; clusterId: ObjectStorageClusterID; } +interface Props { + endpointType: ObjectStorageEndpointTypes; +} -export const BucketDetail = () => { +export const BucketDetail = (props: Props) => { + const { endpointType } = props; /** * @note If `Object Storage Access Key Regions` is enabled, clusterId will actually contain * the bucket's region id @@ -473,6 +478,7 @@ export const BucketDetail = () => { bucketName={bucketName} clusterId={clusterId} displayName={selectedObject?.name} + endpointType={endpointType} lastModified={selectedObject?.last_modified} name={selectedObject?.name} onClose={closeObjectDetailsDrawer} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx index 8deaf658dcd..4ea8cb5854b 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx @@ -17,12 +17,16 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from './AccessSelect'; -import type { ACLType } from '@linode/api-v4/lib/object-storage'; +import type { + ACLType, + ObjectStorageEndpointTypes, +} from '@linode/api-v4/lib/object-storage'; export interface ObjectDetailsDrawerProps { bucketName: string; clusterId: string; displayName?: string; + endpointType?: ObjectStorageEndpointTypes; lastModified?: null | string; name?: string; onClose: () => void; @@ -38,6 +42,7 @@ export const ObjectDetailsDrawer = React.memo( bucketName, clusterId, displayName, + endpointType, lastModified, name, onClose, @@ -55,6 +60,9 @@ export const ObjectDetailsDrawer = React.memo( } } catch {} + const isAccessSelectEnabled = + open && name && endpointType !== 'E2' && endpointType !== 'E3'; + return ( ) : null} - {open && name ? ( + {isAccessSelectEnabled ? ( <> > = React.lazy(() => import('./BucketDetail').then((module) => ({ default: module.BucketDetail })) @@ -31,11 +36,27 @@ interface MatchProps { type Props = RouteComponentProps; export const BucketDetailLanding = React.memo((props: Props) => { + const { data: account } = useAccount(); + const flags = useFlags(); + + const isObjectStorageGen2Enabled = isFeatureEnabledV2( + 'Object Storage Endpoint Types', + Boolean(flags.objectStorageGen2?.enabled), + account?.capabilities ?? [] + ); + + const { data: bucketsData } = useObjectStorageBuckets( + isObjectStorageGen2Enabled + ); + const matches = (p: string) => { return Boolean(matchPath(p, { path: props.location.pathname })); }; const { bucketName, clusterId } = props.match.params; + const { endpoint_type: endpointType } = + bucketsData?.buckets.find(({ label }) => label === bucketName) ?? {}; + const tabs = [ { routeName: `${props.match.url}/objects`, @@ -85,7 +106,7 @@ export const BucketDetailLanding = React.memo((props: Props) => { }> - + diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index c9d1968d1ed..7139165f273 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -24,12 +24,16 @@ 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'; +import type { + ACLType, + ObjectStorageEndpointTypes, +} from '@linode/api-v4/lib/object-storage'; export interface BucketDetailsDrawerProps { bucketLabel?: string; bucketRegion?: Region; cluster?: string; created?: string; + endpointType?: ObjectStorageEndpointTypes; hostname?: string; objectsNumber?: number; onClose: () => void; @@ -44,6 +48,7 @@ export const BucketDetailsDrawer = React.memo( bucketRegion, cluster, created, + endpointType, hostname, objectsNumber, onClose, @@ -89,6 +94,11 @@ export const BucketDetailsDrawer = React.memo( Created: {formattedCreated} ) : null} + {Boolean(endpointType) && ( + + Endpoint Type: {endpointType} + + )} {isObjMultiClusterEnabled ? ( {bucketRegion?.label} @@ -155,6 +165,7 @@ export const BucketDetailsDrawer = React.memo( payload ); }} + endpointType={endpointType} name={bucketLabel} variant="bucket" /> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index 24658a018a6..cd8df0fdfbe 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -239,6 +239,7 @@ export const BucketLanding = () => { bucketLabel={bucketForDetails?.label} cluster={bucketForDetails?.cluster} created={bucketForDetails?.created} + endpointType={bucketForDetails?.endpoint_type} hostname={bucketForDetails?.hostname} objectsNumber={bucketForDetails?.objects} onClose={closeBucketDetailDrawer} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx index 4b53b9a89a2..781eef74551 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx @@ -1,4 +1,3 @@ -import { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -13,6 +12,8 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { BucketTableRow } from './BucketTableRow'; +import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; + interface Props { data: ObjectStorageBucket[]; handleClickDetails: (bucket: ObjectStorageBucket) => void; @@ -32,6 +33,8 @@ export const BucketTable = (props: Props) => { orderBy, } = props; + const isEndpointTypeAvailable = Boolean(data[0]?.endpoint_type); + return ( {({ @@ -66,6 +69,19 @@ export const BucketTable = (props: Props) => { Region + {isEndpointTypeAvailable && ( + + + Endpoint Type + + + )} void; onRemove: () => void; @@ -33,6 +34,7 @@ export const BucketTableRow = (props: BucketTableRowProps) => { const { cluster, created, + endpoint_type, hostname, label, objects, @@ -62,6 +64,9 @@ export const BucketTableRow = (props: BucketTableRowProps) => { const regionsLookup = regions && getRegionsByRegionId(regions); + const isLegacy = endpoint_type === 'E0'; + const typeLabel = isLegacy ? 'Legacy' : 'Standard'; + return ( @@ -92,6 +97,15 @@ export const BucketTableRow = (props: BucketTableRowProps) => { + {Boolean(endpoint_type) && ( + + + + {typeLabel} ({endpoint_type}) + + + + )} From 35c679ba60d94b71ac6d73d43ed2215a70746838 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:37:06 -0400 Subject: [PATCH 40/43] refactor: Clean up Account Settings Object Storage (#10766) * refactor * add toast * Added changeset: Clean up Account Settings Object Storage and use React Query mutation * Added changeset: Clean up Account Settings Object Storage * Delete packages/manager/.changeset/pr-10766-tech-stories-1723215064689.md * feedback @coliu-akamai --------- Co-authored-by: Banks Nussman --- .../pr-10766-tech-stories-1723171237019.md | 5 + .../enable-object-storage.spec.ts | 2 + .../Account/EnableObjectStorage.test.tsx | 50 ------- .../features/Account/EnableObjectStorage.tsx | 139 ------------------ .../src/features/Account/GlobalSettings.tsx | 14 +- .../Account/ObjectStorageSettings.test.tsx | 116 +++++++++++++++ .../Account/ObjectStorageSettings.tsx | 112 ++++++++++++++ .../src/queries/object-storage/queries.ts | 19 +++ 8 files changed, 259 insertions(+), 198 deletions(-) create mode 100644 packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md delete mode 100644 packages/manager/src/features/Account/EnableObjectStorage.test.tsx delete mode 100644 packages/manager/src/features/Account/EnableObjectStorage.tsx create mode 100644 packages/manager/src/features/Account/ObjectStorageSettings.test.tsx create mode 100644 packages/manager/src/features/Account/ObjectStorageSettings.tsx diff --git a/packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md b/packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md new file mode 100644 index 00000000000..720b231610e --- /dev/null +++ b/packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up Account Settings Object Storage and use React Query mutation ([#10766](https://github.com/linode/manager/pull/10766)) 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 869fa8aaaf1..240e88153bb 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 @@ -386,6 +386,8 @@ describe('Object Storage enrollment', () => { cy.wait('@cancelObjectStorage'); + ui.toast.assertMessage('Object Storage successfully canceled.'); + // Confirm that settings page updates to reflect that Object Storage is disabled. cy.contains(getStartedNote).should('be.visible'); }); diff --git a/packages/manager/src/features/Account/EnableObjectStorage.test.tsx b/packages/manager/src/features/Account/EnableObjectStorage.test.tsx deleted file mode 100644 index cbc5579dd34..00000000000 --- a/packages/manager/src/features/Account/EnableObjectStorage.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import EnableObjectStorage from './EnableObjectStorage'; - -describe('EnableObjectStorage Component', () => { - it('Should display button to cancel object storage, if storage is enabled', () => { - const { queryByTestId } = renderWithTheme( - - ); - - expect(queryByTestId('open-dialog-button')).toBeInTheDocument(); - }); - - it('Should not display button to cancel object storage, if storage is disabled', () => { - const { queryByTestId } = renderWithTheme( - - ); - - expect(queryByTestId('open-dialog-button')).not.toBeInTheDocument(); - }); - - it('Should open confirmation dialog on Cancel Object Storage', async () => { - const { findByTitle, getByTestId } = renderWithTheme( - - ); - const button = getByTestId('open-dialog-button'); - fireEvent.click(button); - - const dialog = await findByTitle('Cancel Object Storage'); - - expect(dialog).toBeVisible(); - }); - - it('Should close confirmation dialog on Cancel', async () => { - const { findByTitle, getByTestId } = renderWithTheme( - - ); - const dialogButton = getByTestId('open-dialog-button'); - fireEvent.click(dialogButton); - - const dialog = await findByTitle('Cancel Object Storage'); - const cancelButton = getByTestId('cancel'); - fireEvent.click(cancelButton); - - expect(dialog).not.toBeVisible(); - }); -}); diff --git a/packages/manager/src/features/Account/EnableObjectStorage.tsx b/packages/manager/src/features/Account/EnableObjectStorage.tsx deleted file mode 100644 index 88da8d4a771..00000000000 --- a/packages/manager/src/features/Account/EnableObjectStorage.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { cancelObjectStorage } from '@linode/api-v4'; -import Grid from '@mui/material/Unstable_Grid2'; -import { useQueryClient } from '@tanstack/react-query'; -import * as React from 'react'; - -import { Accordion } from 'src/components/Accordion'; -import { Button } from 'src/components/Button/Button'; -import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { Typography } from 'src/components/Typography'; -import { updateAccountSettingsData } from 'src/queries/account/settings'; -import { objectStorageQueries } from 'src/queries/object-storage/queries'; -import { useProfile } from 'src/queries/profile/profile'; - -import type { APIError, AccountSettings } from '@linode/api-v4'; - -interface Props { - object_storage: AccountSettings['object_storage']; -} - -interface ContentProps extends Props { - openConfirmationModal: () => void; -} - -export const ObjectStorageContent = (props: ContentProps) => { - const { object_storage, openConfirmationModal } = props; - - if (object_storage !== 'disabled') { - return ( - - - - Object Storage is enabled on your account. Upon cancellation, all - Object Storage Access Keys will be revoked, all buckets will be - removed, and their objects deleted. - - - - - - - ); - } - - return ( - - Content storage and delivery for unstructured data. Great for multimedia, - static sites, software delivery, archives, and data backups.
    - To get started with Object Storage, create a{' '} - Bucket or an{' '} - Access Key.{' '} - - Learn more - - . -
    - ); -}; - -export const EnableObjectStorage = (props: Props) => { - const { object_storage } = props; - const [isOpen, setOpen] = React.useState(false); - const [error, setError] = React.useState(); - const [isLoading, setLoading] = React.useState(false); - const { data: profile } = useProfile(); - const queryClient = useQueryClient(); - const username = profile?.username; - - const handleClose = () => { - setOpen(false); - setError(undefined); - }; - - const handleError = (e: APIError[]) => { - setError(e[0].reason); - setLoading(false); - }; - - const handleSubmit = () => { - setLoading(true); - setError(undefined); - cancelObjectStorage() - .then(() => { - updateAccountSettingsData({ object_storage: 'disabled' }, queryClient); - queryClient.invalidateQueries({ - queryKey: objectStorageQueries.buckets.queryKey, - }); - queryClient.invalidateQueries({ - queryKey: objectStorageQueries.accessKeys._def, - }); - handleClose(); - }) - .catch(handleError); - }; - - return ( - <> - - setOpen(true)} - /> - - - {error ? : null} - - - Warning: Canceling Object Storage will permanently - delete all buckets and their objects. Object Storage Access Keys - will be revoked. - - - - - ); -}; - -export default React.memo(EnableObjectStorage); diff --git a/packages/manager/src/features/Account/GlobalSettings.tsx b/packages/manager/src/features/Account/GlobalSettings.tsx index 7a6b43b0c7f..6cd6e48c495 100644 --- a/packages/manager/src/features/Account/GlobalSettings.tsx +++ b/packages/manager/src/features/Account/GlobalSettings.tsx @@ -1,4 +1,3 @@ -import { APIError } from '@linode/api-v4/lib/types'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -15,8 +14,10 @@ import { BackupDrawer } from '../Backups'; import AutoBackups from './AutoBackups'; import CloseAccountSetting from './CloseAccountSetting'; import { EnableManaged } from './EnableManaged'; -import EnableObjectStorage from './EnableObjectStorage'; import NetworkHelper from './NetworkHelper'; +import { ObjectStorageSettings } from './ObjectStorageSettings'; + +import type { APIError } from '@linode/api-v4'; const GlobalSettings = () => { const [isBackupsDrawerOpen, setIsBackupsDrawerOpen] = React.useState(false); @@ -66,12 +67,7 @@ const GlobalSettings = () => { return null; } - const { - backups_enabled, - managed, - network_helper, - object_storage, - } = accountSettings; + const { backups_enabled, managed, network_helper } = accountSettings; const toggleAutomaticBackups = () => { updateAccount({ backups_enabled: !backups_enabled }).catch(displayError); @@ -94,7 +90,7 @@ const GlobalSettings = () => { networkHelperEnabled={network_helper} onChange={toggleNetworkHelper} /> - + { + it('Should display button to cancel object storage, if storage is enabled', async () => { + server.use( + http.get('*/account/settings', () => { + return HttpResponse.json( + accountSettingsFactory.build({ object_storage: 'active' }) + ); + }) + ); + + const { findByText } = renderWithTheme(); + + const cancelButton = (await findByText('Cancel Object Storage')).closest( + 'button' + ); + + const copy = await findByText( + 'Object Storage is enabled on your account.', + { + exact: false, + } + ); + + expect(copy).toBeVisible(); + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + }); + + it('Should not display button to cancel object storage if storage is disabled', async () => { + server.use( + http.get('*/account/settings', () => { + return HttpResponse.json( + accountSettingsFactory.build({ object_storage: 'disabled' }) + ); + }) + ); + + const { findByText, queryByText } = renderWithTheme( + + ); + + const copy = await findByText('To get started with Object Storage', { + exact: false, + }); + + expect(copy).toBeVisible(); + + expect(queryByText('Cancel Object Storage')).not.toBeInTheDocument(); + }); + + it('Should update the UI when the cancel request is successful', async () => { + server.use( + http.get('*/v4/account/settings', () => { + return HttpResponse.json( + accountSettingsFactory.build({ object_storage: 'active' }) + ); + }), + http.get('*/v4/profile', () => { + return HttpResponse.json( + profileFactory.build({ username: 'my-username-1' }) + ); + }), + http.post('*/v4/object-storage/cancel', () => { + return HttpResponse.json({}); + }) + ); + + const { findByText, getByLabelText, getByTitle } = renderWithTheme( + + ); + + const cancelButton = (await findByText('Cancel Object Storage')).closest( + 'button' + ); + + // Click the "Cancel Object Storage" button + await userEvent.click(cancelButton!); + + // Verify the dialog opens and has the correct title + expect(getByTitle('Cancel Object Storage')).toBeVisible(); + + const confirmButton = (await findByText('Confirm Cancellation')).closest( + 'button' + ); + + // The confirm button is disabled because the user needs to type to confirm + expect(confirmButton).toBeDisabled(); + + const typeToConfirmTextField = getByLabelText('Username'); + + // Type the user's username to confirm + await userEvent.type(typeToConfirmTextField, 'my-username-1'); + + // The confirm button became enabled because we typed username + expect(confirmButton).toBeEnabled(); + + // Confirm cancelation of Object Storage + await userEvent.click(confirmButton!); + + // Verify UI updates to reflect Object Storage is not active + const copy = await findByText('To get started with Object Storage', { + exact: false, + }); + + expect(copy).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Account/ObjectStorageSettings.tsx b/packages/manager/src/features/Account/ObjectStorageSettings.tsx new file mode 100644 index 00000000000..fe30d15cf50 --- /dev/null +++ b/packages/manager/src/features/Account/ObjectStorageSettings.tsx @@ -0,0 +1,112 @@ +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; + +import { Accordion } from 'src/components/Accordion'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; +import { Typography } from 'src/components/Typography'; +import { useAccountSettings } from 'src/queries/account/settings'; +import { useCancelObjectStorageMutation } from 'src/queries/object-storage/queries'; +import { useProfile } from 'src/queries/profile/profile'; + +export const ObjectStorageSettings = () => { + const { data: profile } = useProfile(); + const { data: accountSettings, isLoading } = useAccountSettings(); + + const { + error, + isLoading: isCancelLoading, + mutateAsync: cancelObjectStorage, + reset, + } = useCancelObjectStorageMutation(); + + const username = profile?.username; + + const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState( + false + ); + + const handleCloseCancelDialog = () => { + setIsCancelDialogOpen(false); + reset(); + }; + + const handleCancelObjectStorage = () => { + cancelObjectStorage().then(() => { + handleCloseCancelDialog(); + enqueueSnackbar('Object Storage successfully canceled.', { + variant: 'success', + }); + }); + }; + + if (isLoading) { + return ; + } + + return ( + <> + + {accountSettings?.object_storage === 'active' ? ( + + + Object Storage is enabled on your account. Upon cancellation, all + Object Storage Access Keys will be revoked, all buckets will be + removed, and their objects deleted. + + + + + + ) : ( + + Content storage and delivery for unstructured data. Great for + multimedia, static sites, software delivery, archives, and data + backups.
    + To get started with Object Storage, create a{' '} + Bucket or an{' '} + Access Key.{' '} + + Learn more + + . +
    + )} +
    + + {error && } + + + Warning: Canceling Object Storage will permanently + delete all buckets and their objects. Object Storage Access Keys + will be revoked. + + + + + ); +}; diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index a631fc25e48..ddc77351830 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -1,4 +1,5 @@ import { + cancelObjectStorage, createBucket, deleteBucket, deleteBucketWithRegion, @@ -23,6 +24,7 @@ import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import { useAccount } from '../account/account'; import { accountQueries } from '../account/queries'; +import { updateAccountSettingsData } from '../account/settings'; import { queryPresets } from '../base'; import { useRegionsQuery } from '../regions/regions'; import { @@ -340,3 +342,20 @@ export const useObjectStorageTypesQuery = (enabled = true) => ...queryPresets.oneTimeFetch, enabled, }); + +export const useCancelObjectStorageMutation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[]>({ + mutationFn: cancelObjectStorage, + onSuccess() { + updateAccountSettingsData({ object_storage: 'disabled' }, queryClient); + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.buckets.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + }, + }); +}; From 834c665d550123be432a9d0ac04215ee8c2c371a Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:52:26 -0400 Subject: [PATCH 41/43] upcoming: [M3-8398] - Generate firewall dialog (#10770) * Add secure VM notices and banners * Fix test * Fix type errors * Fix flag type issues * Added changeset: Secure VM informational banners * More empty flag fixes * Styling tweaks * Disable notices if feature flag turned off * Feedback @bnussman-akamai @jaalah-akamai * Fix test * Implement generate dialog and associated utilities and tests * New warning styles * Intercept header in e2e tests * Added changeset: Add firewall generation dialog * Added changeset: Add firewall template endpoints * Remove duplicated cypress interceptor * Replace generate button component with hook * Enhancements * Fix test * AkamaiBanner vertical layout on mobile devices * Add action button to firewall landing banner * Feedback @bnussman-akamai @jaalah-akamai * Move dialog buttons to the right * Fetch single template instead of getting all * Feedback @carrillo-erik * Fix unit tests * Fix progress bar on subsequent firewall generations * Use #region notation --- .../pr-10770-added-1723479930096.md | 5 + packages/api-v4/src/firewalls/firewalls.ts | 34 ++- packages/api-v4/src/firewalls/types.ts | 5 + ...r-10770-upcoming-features-1723479438379.md | 5 + .../AkamaiBanner/AkamaiBanner.styles.ts | 6 +- .../components/AkamaiBanner/AkamaiBanner.tsx | 28 +-- .../GenerateFirewallDialog.test.tsx | 109 +++++++++ .../GenerateFirewallDialog.tsx | 223 ++++++++++++++++++ .../useCreateFirewallFromTemplate.ts | 88 +++++++ .../SelectFirewallPanel.tsx | 7 +- packages/manager/src/factories/firewalls.ts | 13 +- packages/manager/src/featureFlags.ts | 3 + .../Firewalls/FirewallDetail/index.tsx | 25 +- .../FirewallLanding/FirewallLanding.tsx | 6 +- .../manager/src/features/Firewalls/index.tsx | 6 +- .../Linodes/LinodeCreatev2/Firewall.tsx | 7 +- .../hooks/useSecureVMNoticesEnabled.test.ts | 14 +- packages/manager/src/queries/firewalls.ts | 16 +- .../replaceNewlinesWithLineBreaks.test.tsx | 32 +++ .../replaceNewlinesWithLineBreaks.tsx | 13 + 20 files changed, 611 insertions(+), 34 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10770-added-1723479930096.md create mode 100644 packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md create mode 100644 packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx create mode 100644 packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx create mode 100644 packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts create mode 100644 packages/manager/src/utilities/replaceNewlinesWithLineBreaks.test.tsx create mode 100644 packages/manager/src/utilities/replaceNewlinesWithLineBreaks.tsx diff --git a/packages/api-v4/.changeset/pr-10770-added-1723479930096.md b/packages/api-v4/.changeset/pr-10770-added-1723479930096.md new file mode 100644 index 00000000000..eb311d8862c --- /dev/null +++ b/packages/api-v4/.changeset/pr-10770-added-1723479930096.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Add firewall template endpoints ([#10770](https://github.com/linode/manager/pull/10770)) diff --git a/packages/api-v4/src/firewalls/firewalls.ts b/packages/api-v4/src/firewalls/firewalls.ts index 0bf355f1d6a..6b247ce7eac 100644 --- a/packages/api-v4/src/firewalls/firewalls.ts +++ b/packages/api-v4/src/firewalls/firewalls.ts @@ -18,6 +18,7 @@ import { FirewallDevice, FirewallDevicePayload, FirewallRules, + FirewallTemplate, UpdateFirewallPayload, } from './types'; @@ -120,7 +121,7 @@ export const deleteFirewall = (firewallID: number) => ) ); -// FIREWALL RULES +// #region Firewall Rules /** * getFirewallRules @@ -160,7 +161,7 @@ export const updateFirewallRules = (firewallID: number, data: FirewallRules) => ) ); -// DEVICES +// #region Devices /** * getFirewallDevices @@ -243,3 +244,32 @@ export const deleteFirewallDevice = (firewallID: number, deviceID: number) => )}/devices/${encodeURIComponent(deviceID)}` ) ); + +// #region Templates + +/** + * getTemplates + * + * Returns a paginated list of all firewall templates on this account. + */ +export const getTemplates = () => + Request>( + setMethod('GET'), + setURL(`${BETA_API_ROOT}/networking/firewalls/templates`) + ); + +/** + * getTemplate + * + * Get a specific firewall template by its slug. + * + */ +export const getTemplate = (templateSlug: string) => + Request( + setMethod('GET'), + setURL( + `${BETA_API_ROOT}/networking/firewalls/templates/${encodeURIComponent( + templateSlug + )}` + ) + ); diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 19f86dc1480..f5fc7d0bb90 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -55,6 +55,11 @@ export interface FirewallDevice { entity: FirewallDeviceEntity; } +export interface FirewallTemplate { + slug: string; + rules: FirewallRules; +} + export interface CreateFirewallPayload { label?: string; tags?: string[]; diff --git a/packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md b/packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md new file mode 100644 index 00000000000..9cb326ae4f9 --- /dev/null +++ b/packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add firewall generation dialog ([#10770](https://github.com/linode/manager/pull/10770)) diff --git a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.styles.ts b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.styles.ts index 5affee86bc1..80ce5d2e751 100644 --- a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.styles.ts +++ b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.styles.ts @@ -53,8 +53,12 @@ export const StyledBannerAction = styled(Box, { shouldForwardProp: omittedProps(['warning']), })<{ warning?: boolean }>(({ theme, warning }) => ({ color: warning ? theme.bg.mainContentBanner : theme.color.black, - paddingRight: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + padding: theme.spacing(2), + paddingTop: 0, + }, [theme.breakpoints.up('sm')]: { + paddingRight: theme.spacing(2), textWrap: 'nowrap', }, })); diff --git a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx index d0b8781036f..ea5d2bce76d 100644 --- a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx +++ b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx @@ -1,8 +1,10 @@ +import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; +import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWithLineBreaks'; import { Box } from '../Box'; import { Stack } from '../Stack'; @@ -30,22 +32,15 @@ interface AkamaiBannerProps { export const AkamaiBanner = React.memo((props: AkamaiBannerProps) => { const { action, link, margin, text, warning } = props; const flags = useFlags(); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); - const textWithLineBreaks = text.split('\n').map((text, i, lines) => - i === lines.length - 1 ? ( - text - ) : ( - <> - {text} -
    - - ) - ); + const textWithLineBreaks = replaceNewlinesWithLineBreaks(text); return ( @@ -82,11 +77,14 @@ export const AkamaiBanner = React.memo((props: AkamaiBannerProps) => { })} variant="body2" > - {textWithLineBreaks}{' '} + {textWithLineBreaks} {link && ( - - {link.text} - + <> + {' '} + + {link.text} + + )}
    diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx new file mode 100644 index 00000000000..608446ae196 --- /dev/null +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx @@ -0,0 +1,109 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { firewallFactory, firewallTemplateFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { GenerateFirewallDialog } from './GenerateFirewallDialog'; + +describe('GenerateFirewallButton', () => { + it('Can successfully generate a firewall', async () => { + const firewalls = firewallFactory.buildList(2); + const template = firewallTemplateFactory.build(); + const createFirewallCallback = vi.fn(); + const onClose = vi.fn(); + const onFirewallGenerated = vi.fn(); + + server.use( + http.get('*/v4beta/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage(firewalls)); + }), + http.get('*/v4beta/networking/firewalls/templates/*', () => { + return HttpResponse.json(template); + }), + http.post('*/v4beta/networking/firewalls', async ({ request }) => { + const body = await request.json(); + const payload = body as any; + const newFirewall = firewallFactory.build({ + label: payload.label ?? 'mock-firewall', + }); + createFirewallCallback(newFirewall); + return HttpResponse.json(newFirewall); + }) + ); + + const { findByText, getByText } = renderWithTheme( + + ); + + getByText('Generate an Akamai Compliant Firewall'); + + const clickPromise = userEvent.click(getByText('Generate Firewall Now')); + + await findByText('Generating Firewall'); + + await clickPromise; + + await findByText('Complete!'); + + expect(onFirewallGenerated).toHaveBeenCalledWith( + expect.objectContaining({ + label: `${template.slug}-1`, + rules: template.rules, + }) + ); + + expect(createFirewallCallback).toHaveBeenCalledWith( + expect.objectContaining({ + label: `${template.slug}-1`, + rules: template.rules, + }) + ); + }); + + it('Handles errors gracefully', async () => { + const firewalls = firewallFactory.buildList(2); + const template = firewallTemplateFactory.build(); + const onClose = vi.fn(); + const onFirewallGenerated = vi.fn(); + + server.use( + http.get('*/v4beta/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage(firewalls)); + }), + http.get('*/v4beta/networking/firewalls/templates/*', () => { + return HttpResponse.json(template); + }), + http.post('*/v4beta/networking/firewalls', async () => { + return HttpResponse.json( + { error: [{ reason: 'An error occurred.' }] }, + { status: 500 } + ); + }) + ); + + const { findByText, getByText } = renderWithTheme( + + ); + + getByText('Generate an Akamai Compliant Firewall'); + + const clickPromise = userEvent.click(getByText('Generate Firewall Now')); + + await findByText('Generating Firewall'); + + await clickPromise; + + await findByText('An error occurred'); + }); +}); diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx new file mode 100644 index 00000000000..add07267945 --- /dev/null +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx @@ -0,0 +1,223 @@ +import React from 'react'; + +import { useFlags } from 'src/hooks/useFlags'; +import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWithLineBreaks'; + +import { Button } from '../Button/Button'; +import { Dialog } from '../Dialog/Dialog'; +import { LinearProgress } from '../LinearProgress'; +import { Link } from '../Link'; +import { Stack } from '../Stack'; +import { Typography } from '../Typography'; +import { useCreateFirewallFromTemplate } from './useCreateFirewallFromTemplate'; + +import type { Firewall } from '@linode/api-v4'; + +const TEMPLATE_SLUG = 'akamai-non-prod'; + +interface GenerateFirewallDialogProps { + onClose: () => void; + onFirewallGenerated?: (firewall: Firewall) => void; + open: boolean; +} + +export type DialogState = + | ErrorDialogState + | ProgressDialogState + | PromptDialogState + | SuccessDialogState; + +interface BaseDialogState { + step: 'error' | 'progress' | 'prompt' | 'success'; +} + +interface PromptDialogState extends BaseDialogState { + step: 'prompt'; +} + +interface ProgressDialogState extends BaseDialogState { + progress: number; + step: 'progress'; +} + +interface SuccessDialogState extends BaseDialogState { + firewall: Firewall; + step: 'success'; +} + +interface ErrorDialogState extends BaseDialogState { + error: string; + step: 'error'; +} + +export const GenerateFirewallDialog = (props: GenerateFirewallDialogProps) => { + const { onClose, onFirewallGenerated, open } = props; + + const [dialogState, setDialogState] = React.useState({ + step: 'prompt', + }); + + const dialogProps = { + onClose, + onFirewallGenerated, + setDialogState, + }; + + return ( + setDialogState({ step: 'prompt' })} + open={open} + title="Generate an Akamai Compliant Firewall" + > + {dialogState.step === 'prompt' && ( + + )} + {dialogState.step === 'progress' && ( + + )} + {dialogState.step === 'success' && ( + + )} + {dialogState.step === 'error' && ( + + )} + + ); +}; + +interface GenerateFirewallDialogContentProps { + onClose: () => void; + onFirewallGenerated?: (firewall: Firewall) => void; + setDialogState: (state: DialogState) => void; + state: State; +} + +const PromptDialogContent = ( + props: GenerateFirewallDialogContentProps +) => { + const { onClose, onFirewallGenerated, setDialogState } = props; + const flags = useFlags(); + const dialogCopy = flags.secureVmCopy?.generatePrompt; + const { createFirewallFromTemplate } = useCreateFirewallFromTemplate({ + onFirewallGenerated, + setDialogState, + templateSlug: TEMPLATE_SLUG, + }); + + return ( + + {dialogCopy && ( + + {replaceNewlinesWithLineBreaks(dialogCopy.text)} + {dialogCopy.link && ( + <> + {' '} + + {dialogCopy.link.text} + + + )} + + )} + + + + + + ); +}; + +const ProgressDialogContent = ( + props: GenerateFirewallDialogContentProps +) => ( + + Generating Firewall + + +); + +const SuccessDialogContent = ( + props: GenerateFirewallDialogContentProps +) => { + const { + onClose, + state: { firewall }, + } = props; + + const flags = useFlags(); + const dialogCopy = flags.secureVmCopy?.generateSuccess; + const docsLink = flags.secureVmCopy?.generateDocsLink; + const docsLinkText = 'applied to your Linodes.'; + + return ( + + Complete! + + The {firewall.label}{' '} + firewall is ready and can now be{' '} + {docsLink ? ( + + {docsLinkText} + + ) : ( + docsLinkText + )} + + {dialogCopy && ( + + {replaceNewlinesWithLineBreaks(dialogCopy.text)} + {dialogCopy.link && ( + <> + {' '} + + {dialogCopy.link.text} + + + )} + + )} + + + + + ); +}; + +const ErrorDialogContent = ( + props: GenerateFirewallDialogContentProps +) => { + const { + onClose, + onFirewallGenerated, + setDialogState, + state: { error }, + } = props; + + const { createFirewallFromTemplate } = useCreateFirewallFromTemplate({ + onFirewallGenerated, + setDialogState, + templateSlug: TEMPLATE_SLUG, + }); + + return ( + + An error occurred + {error} + + + + + + ); +}; diff --git a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts new file mode 100644 index 00000000000..814ff4bac84 --- /dev/null +++ b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts @@ -0,0 +1,88 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { firewallQueries } from 'src/queries/firewalls'; +import { useCreateFirewall } from 'src/queries/firewalls'; + +import type { DialogState } from './GenerateFirewallDialog'; +import type { CreateFirewallPayload, Firewall } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; + +export const useCreateFirewallFromTemplate = (options: { + onFirewallGenerated?: (firewall: Firewall) => void; + setDialogState: (state: DialogState) => void; + templateSlug: string; +}) => { + const { onFirewallGenerated, setDialogState, templateSlug } = options; + const queryClient = useQueryClient(); + const { mutateAsync: createFirewall } = useCreateFirewall(); + + return { + createFirewallFromTemplate: () => + createFirewallFromTemplate({ + createFirewall, + queryClient, + templateSlug, + updateProgress: (progress: number) => + setDialogState({ progress, step: 'progress' }), + }) + .then((firewall) => { + onFirewallGenerated?.(firewall); + setDialogState({ + firewall, + step: 'success', + }); + }) + .catch((error) => + setDialogState({ + error: error?.[0]?.reason ?? error, + step: 'error', + }) + ), + }; +}; + +const createFirewallFromTemplate = async (options: { + createFirewall: (firewall: CreateFirewallPayload) => Promise; + queryClient: QueryClient; + templateSlug: string; + updateProgress: (progress: number | undefined) => void; +}): Promise => { + const { createFirewall, queryClient, templateSlug, updateProgress } = options; + updateProgress(0); + await new Promise((resolve) => setTimeout(resolve, 0)); // return control to the DOM to update the progress + + // Get firewalls and firewall template in parallel + const [{ rules, slug }, firewalls] = await Promise.all([ + queryClient.ensureQueryData(firewallQueries.template(templateSlug)), + queryClient.fetchQuery(firewallQueries.firewalls._ctx.all), // must fetch fresh data if generating more than one firewall + ]); + updateProgress(80); // this gives the appearance of linear progress + + // Determine new firewall name + const label = getUniqueFirewallLabel(slug, firewalls); + + // Create new firewall + return await createFirewall({ label, rules }); +}; + +const getUniqueFirewallLabel = ( + templateSlug: string, + firewalls: Firewall[] +) => { + let iterator = 1; + const firewallLabelExists = (firewall: Firewall) => + firewall.label === firewallLabelFromSlug(templateSlug, iterator); + while (firewalls.some(firewallLabelExists)) { + iterator++; + } + + return firewallLabelFromSlug(templateSlug, iterator); +}; + +const firewallLabelFromSlug = (slug: string, iterator: number) => { + const MAX_LABEL_LENGTH = 32; + const iteratorSuffix = `-${iterator}`; + return ( + slug.substring(0, MAX_LABEL_LENGTH - iteratorSuffix.length) + iteratorSuffix + ); +}; diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index b9b47f49277..2111beb0af2 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -15,6 +15,7 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { AkamaiBanner } from '../AkamaiBanner/AkamaiBanner'; import { Autocomplete } from '../Autocomplete/Autocomplete'; +import { GenerateFirewallDialog } from '../GenerateFirewallDialog/GenerateFirewallDialog'; import { LinkButton } from '../LinkButton'; import type { Firewall, FirewallDeviceEntityType } from '@linode/api-v4'; @@ -39,7 +40,6 @@ export const SelectFirewallPanel = (props: Props) => { } = props; const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); - // @ts-expect-error TODO Secure VMs: wire up firewall generation dialog const [isFirewallDialogOpen, setIsFirewallDialogOpen] = React.useState(false); const location = useLocation(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); @@ -155,6 +155,11 @@ export const SelectFirewallPanel = (props: Props) => { onFirewallCreated={handleFirewallCreated} open={isDrawerOpen} /> + setIsFirewallDialogOpen(false)} + onFirewallGenerated={handleFirewallCreated} + open={isFirewallDialogOpen} + /> ); diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index 5c72caa6d61..85591dcbc18 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -1,11 +1,13 @@ -import { +import Factory from 'src/factories/factoryProxy'; + +import type { Firewall, FirewallDevice, FirewallDeviceEntityType, FirewallRuleType, FirewallRules, + FirewallTemplate, } from '@linode/api-v4/lib/firewalls/types'; -import Factory from 'src/factories/factoryProxy'; export const firewallRuleFactory = Factory.Sync.makeFactory({ action: 'DROP', @@ -55,3 +57,10 @@ export const firewallDeviceFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => i), updated: '2020-01-01', }); + +export const firewallTemplateFactory = Factory.Sync.makeFactory( + { + rules: firewallRulesFactory.build(), + slug: Factory.each((i) => `template-${i}`), + } +); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index d534c64be97..1e8c9db05dd 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -199,6 +199,9 @@ interface SecureVMCopy { firewallAuthorizationWarning?: string; firewallDetails?: BannerContent; generateActionText?: string; + generateDocsLink: string; + generatePrompt?: BannerContent; + generateSuccess?: BannerContent; linodeCreate?: BannerContent; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 8ea70fe26ac..59b7cf32d21 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -5,7 +5,9 @@ import { AkamaiBanner } from 'src/components/AkamaiBanner/AkamaiBanner'; import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/GenerateFirewallDialog'; import { LandingHeader } from 'src/components/LandingHeader'; +import { LinkButton } from 'src/components/LinkButton'; import { NotFound } from 'src/components/NotFound'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; @@ -39,9 +41,10 @@ export const FirewallDetail = () => { const { data: grants } = useGrants(); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); const flags = useFlags(); + const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); const secureVMFirewallBanner = - (secureVMNoticesEnabled && flags.secureVmCopy?.firewallDetails) ?? false; + (secureVMNoticesEnabled && flags.secureVmCopy) ?? false; const firewallId = Number(id); @@ -134,8 +137,18 @@ export const FirewallDetail = () => { docsLink="https://linode.com/docs/platform/cloud-firewall/getting-started-with-cloud-firewall/" title="Firewall Details" /> - {secureVMFirewallBanner && ( - + {secureVMFirewallBanner && secureVMFirewallBanner.firewallDetails && ( + setIsGenerateDialogOpen(true)}> + {secureVMFirewallBanner.generateActionText} + + ) : undefined + } + margin={3} + {...secureVMFirewallBanner.firewallDetails} + /> )} { + setIsGenerateDialogOpen(false)} + open={isGenerateDialogOpen} + /> ); }; - -export default FirewallDetail; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 3de111edce5..2001bb6adf9 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -4,6 +4,7 @@ import { useHistory, useLocation } from 'react-router-dom'; import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/GenerateFirewallDialog'; import { Hidden } from 'src/components/Hidden'; import { LandingHeader } from 'src/components/LandingHeader'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -58,7 +59,6 @@ const FirewallLanding = () => { const [isModalOpen, setIsModalOpen] = React.useState(false); const [dialogMode, setDialogMode] = React.useState('enable'); - // @ts-expect-error TODO Secure VMs: wire up firewall generation dialog const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); const flags = useFlags(); @@ -203,6 +203,10 @@ const FirewallLanding = () => { selectedFirewall={selectedFirewall} /> )} + setIsGenerateDialogOpen(false)} + open={isGenerateDialogOpen} + /> ); }; diff --git a/packages/manager/src/features/Firewalls/index.tsx b/packages/manager/src/features/Firewalls/index.tsx index 201d407e9d5..7cb6200610b 100644 --- a/packages/manager/src/features/Firewalls/index.tsx +++ b/packages/manager/src/features/Firewalls/index.tsx @@ -9,7 +9,11 @@ const FirewallLanding = React.lazy( () => import('./FirewallLanding/FirewallLanding') ); -const FirewallDetail = React.lazy(() => import('./FirewallDetail')); +const FirewallDetail = React.lazy(() => + import('./FirewallDetail').then((module) => ({ + default: module.FirewallDetail, + })) +); const Firewall = () => { const { path } = useRouteMatch(); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx index a172f541a58..648dfaf9312 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Firewall.tsx @@ -4,6 +4,7 @@ import { useController, useFormContext } from 'react-hook-form'; import { AkamaiBanner } from 'src/components/AkamaiBanner/AkamaiBanner'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; +import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/GenerateFirewallDialog'; import { Link } from 'src/components/Link'; import { LinkButton } from 'src/components/LinkButton'; import { Paper } from 'src/components/Paper'; @@ -29,7 +30,6 @@ export const Firewall = () => { const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); - // @ts-expect-error TODO Secure VMs: wire up firewall generation dialog const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); const flags = useFlags(); @@ -104,6 +104,11 @@ export const Firewall = () => { onFirewallCreated={(firewall) => field.onChange(firewall.id)} open={isDrawerOpen} /> + setIsGenerateDialogOpen(false)} + onFirewallGenerated={(firewall) => onChange(firewall.id)} + open={isGenerateDialogOpen} + /> ); }; diff --git a/packages/manager/src/hooks/useSecureVMNoticesEnabled.test.ts b/packages/manager/src/hooks/useSecureVMNoticesEnabled.test.ts index 225a6f6f7f3..a9bf92244ba 100644 --- a/packages/manager/src/hooks/useSecureVMNoticesEnabled.test.ts +++ b/packages/manager/src/hooks/useSecureVMNoticesEnabled.test.ts @@ -5,6 +5,8 @@ import { wrapWithTheme } from 'src/utilities/testHelpers'; import { useSecureVMNoticesEnabled } from './useSecureVMNoticesEnabled'; +import type { Flags } from 'src/featureFlags'; + describe('useSecureVMNoticesEnabled', () => { it('returns true when the header is included', async () => { server.use( @@ -18,7 +20,9 @@ describe('useSecureVMNoticesEnabled', () => { const { result } = renderHook(() => useSecureVMNoticesEnabled(), { wrapper: (ui) => wrapWithTheme(ui, { - flags: { secureVmCopy: { bannerLabel: 'Test' } }, + flags: { + secureVmCopy: { bannerLabel: 'Test' } as Flags['secureVmCopy'], + }, }), }); @@ -39,7 +43,9 @@ describe('useSecureVMNoticesEnabled', () => { const { result } = renderHook(() => useSecureVMNoticesEnabled(), { wrapper: (ui) => wrapWithTheme(ui, { - flags: { secureVmCopy: { bannerLabel: 'Test' } }, + flags: { + secureVmCopy: { bannerLabel: 'Test' } as Flags['secureVmCopy'], + }, }), }); @@ -60,7 +66,7 @@ describe('useSecureVMNoticesEnabled', () => { const { result } = renderHook(() => useSecureVMNoticesEnabled(), { wrapper: (ui) => wrapWithTheme(ui, { - flags: { secureVmCopy: {} }, + flags: { secureVmCopy: {} as Flags['secureVmCopy'] }, }), }); @@ -73,7 +79,7 @@ describe('useSecureVMNoticesEnabled', () => { const { result } = renderHook(() => useSecureVMNoticesEnabled(), { wrapper: (ui) => wrapWithTheme(ui, { - flags: { secureVmCopy: {} }, + flags: { secureVmCopy: {} as Flags['secureVmCopy'] }, }), }); diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index 66e91d26f56..6ad96e8ba59 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -6,6 +6,8 @@ import { getFirewall, getFirewallDevices, getFirewalls, + getTemplate, + getTemplates, updateFirewall, updateFirewallRules, } from '@linode/api-v4/lib/firewalls'; @@ -14,6 +16,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; +import { linodeQueries } from './linodes/linodes'; import { nodebalancerQueries } from './nodebalancers'; import { profileQueries } from './profile/profile'; @@ -25,11 +28,11 @@ import type { FirewallDevice, FirewallDevicePayload, FirewallRules, + FirewallTemplate, Params, ResourcePage, } from '@linode/api-v4'; import type { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { linodeQueries } from './linodes/linodes'; const getAllFirewallDevices = ( id: number, @@ -44,6 +47,9 @@ const getAllFirewallDevices = ( ) )().then((data) => data.data); +const getAllFirewallTemplates = () => + getAll(getTemplates)().then((data) => data.data); + const getAllFirewallsRequest = () => getAll((passedParams, passedFilter) => getFirewalls(passedParams, passedFilter) @@ -73,6 +79,14 @@ export const firewallQueries = createQueryKeys('firewalls', { }, queryKey: null, }, + template: (slug: string) => ({ + queryFn: () => getTemplate(slug), + queryKey: [slug], + }), + templates: { + queryFn: getAllFirewallTemplates, + queryKey: null, + }, }); export const useAllFirewallDevicesQuery = (id: number) => diff --git a/packages/manager/src/utilities/replaceNewlinesWithLineBreaks.test.tsx b/packages/manager/src/utilities/replaceNewlinesWithLineBreaks.test.tsx new file mode 100644 index 00000000000..b9abdf062f4 --- /dev/null +++ b/packages/manager/src/utilities/replaceNewlinesWithLineBreaks.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { replaceNewlinesWithLineBreaks } from './replaceNewlinesWithLineBreaks'; + +describe('replaceNewlinesWithLineBreaks', () => { + it('Replaces newlines with line breaks', () => { + const noBreaks = 'test string with no line breaks'; + expect(replaceNewlinesWithLineBreaks(noBreaks)).toEqual([noBreaks]); + + const oneBreak = 'test string\nwith one break'; + expect(replaceNewlinesWithLineBreaks(oneBreak)).toEqual([ + + test string +
    +
    , + 'with one break', + ]); + + const twoBreaks = 'test string\nwith two\nbreaks'; + expect(replaceNewlinesWithLineBreaks(twoBreaks)).toEqual([ + + test string +
    +
    , + + with two +
    +
    , + 'breaks', + ]); + }); +}); diff --git a/packages/manager/src/utilities/replaceNewlinesWithLineBreaks.tsx b/packages/manager/src/utilities/replaceNewlinesWithLineBreaks.tsx new file mode 100644 index 00000000000..6d71c664d01 --- /dev/null +++ b/packages/manager/src/utilities/replaceNewlinesWithLineBreaks.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export const replaceNewlinesWithLineBreaks = (text: string) => + text.split('\n').map((text, i, lines) => + i === lines.length - 1 ? ( + text + ) : ( + + {text} +
    +
    + ) + ); From 6b9207c27308619ebd1280dd0afc14a5331dccaf Mon Sep 17 00:00:00 2001 From: Hana Xu Date: Fri, 16 Aug 2024 11:56:40 -0400 Subject: [PATCH 42/43] Cloud version 1.126.0 and API v4 version 0.124.0 --- ...r-10710-upcoming-features-1721901283228.md | 5 -- .../pr-10744-changed-1722619037605.md | 5 -- ...r-10747-upcoming-features-1723026166451.md | 5 -- ...r-10768-upcoming-features-1723463731514.md | 5 -- .../pr-10770-added-1723479930096.md | 5 -- packages/api-v4/CHANGELOG.md | 16 +++++ packages/api-v4/package.json | 2 +- .../pr-10697-tests-1721417045353.md | 5 -- ...r-10710-upcoming-features-1721901189629.md | 5 -- .../pr-10715-tech-stories-1722374365063.md | 5 -- ...r-10718-upcoming-features-1721988074945.md | 5 -- .../pr-10722-tech-stories-1723478781442.md | 5 -- .../pr-10725-tech-stories-1722343555187.md | 5 -- .../pr-10726-tech-stories-1722355169168.md | 5 -- .../pr-10730-tests-1722367873620.md | 5 -- ...r-10736-upcoming-features-1722454894455.md | 5 -- ...r-10739-upcoming-features-1722528251084.md | 5 -- .../pr-10740-changed-1723488918061.md | 5 -- .../pr-10742-fixed-1722601990368.md | 5 -- ...r-10744-upcoming-features-1722618984741.md | 5 -- .../pr-10746-added-1722932770115.md | 5 -- ...r-10747-upcoming-features-1723026263446.md | 5 -- .../pr-10749-tech-stories-1722885758140.md | 5 -- ...r-10750-upcoming-features-1723139035892.md | 5 -- ...r-10751-upcoming-features-1722963124052.md | 5 -- .../pr-10754-added-1723039007976.md | 5 -- .../pr-10755-tech-stories-1722965778724.md | 5 -- .../pr-10756-added-1723036827057.md | 5 -- .../pr-10757-tests-1722971426093.md | 5 -- .../pr-10758-added-1722975143465.md | 5 -- .../pr-10759-tests-1723040352780.md | 5 -- .../pr-10760-tech-stories-1723236539851.md | 5 -- ...r-10763-upcoming-features-1723138942196.md | 5 -- .../pr-10764-added-1723149902254.md | 5 -- .../pr-10766-tech-stories-1723171237019.md | 5 -- .../pr-10767-tech-stories-1723554692779.md | 5 -- ...r-10768-upcoming-features-1723464477834.md | 5 -- ...r-10770-upcoming-features-1723479438379.md | 5 -- ...r-10771-upcoming-features-1723731588900.md | 5 -- .../pr-10772-fixed-1723476789978.md | 5 -- ...r-10775-upcoming-features-1723502865486.md | 5 -- .../pr-10777-fixed-1723516402764.md | 5 -- .../pr-10778-changed-1723563275697.md | 5 -- .../pr-10779-fixed-1723593549058.md | 5 -- .../pr-10782-fixed-1723574317048.md | 5 -- packages/manager/CHANGELOG.md | 61 +++++++++++++++++++ packages/manager/package.json | 2 +- 47 files changed, 79 insertions(+), 217 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md delete mode 100644 packages/api-v4/.changeset/pr-10744-changed-1722619037605.md delete mode 100644 packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md delete mode 100644 packages/api-v4/.changeset/pr-10768-upcoming-features-1723463731514.md delete mode 100644 packages/api-v4/.changeset/pr-10770-added-1723479930096.md delete mode 100644 packages/manager/.changeset/pr-10697-tests-1721417045353.md delete mode 100644 packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md delete mode 100644 packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md delete mode 100644 packages/manager/.changeset/pr-10718-upcoming-features-1721988074945.md delete mode 100644 packages/manager/.changeset/pr-10722-tech-stories-1723478781442.md delete mode 100644 packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md delete mode 100644 packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md delete mode 100644 packages/manager/.changeset/pr-10730-tests-1722367873620.md delete mode 100644 packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md delete mode 100644 packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md delete mode 100644 packages/manager/.changeset/pr-10740-changed-1723488918061.md delete mode 100644 packages/manager/.changeset/pr-10742-fixed-1722601990368.md delete mode 100644 packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md delete mode 100644 packages/manager/.changeset/pr-10746-added-1722932770115.md delete mode 100644 packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md delete mode 100644 packages/manager/.changeset/pr-10749-tech-stories-1722885758140.md delete mode 100644 packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md delete mode 100644 packages/manager/.changeset/pr-10751-upcoming-features-1722963124052.md delete mode 100644 packages/manager/.changeset/pr-10754-added-1723039007976.md delete mode 100644 packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md delete mode 100644 packages/manager/.changeset/pr-10756-added-1723036827057.md delete mode 100644 packages/manager/.changeset/pr-10757-tests-1722971426093.md delete mode 100644 packages/manager/.changeset/pr-10758-added-1722975143465.md delete mode 100644 packages/manager/.changeset/pr-10759-tests-1723040352780.md delete mode 100644 packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md delete mode 100644 packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md delete mode 100644 packages/manager/.changeset/pr-10764-added-1723149902254.md delete mode 100644 packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md delete mode 100644 packages/manager/.changeset/pr-10767-tech-stories-1723554692779.md delete mode 100644 packages/manager/.changeset/pr-10768-upcoming-features-1723464477834.md delete mode 100644 packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md delete mode 100644 packages/manager/.changeset/pr-10771-upcoming-features-1723731588900.md delete mode 100644 packages/manager/.changeset/pr-10772-fixed-1723476789978.md delete mode 100644 packages/manager/.changeset/pr-10775-upcoming-features-1723502865486.md delete mode 100644 packages/manager/.changeset/pr-10777-fixed-1723516402764.md delete mode 100644 packages/manager/.changeset/pr-10778-changed-1723563275697.md delete mode 100644 packages/manager/.changeset/pr-10779-fixed-1723593549058.md delete mode 100644 packages/manager/.changeset/pr-10782-fixed-1723574317048.md diff --git a/packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md b/packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md deleted file mode 100644 index 6e0380dd0a6..00000000000 --- a/packages/api-v4/.changeset/pr-10710-upcoming-features-1721901283228.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -CloudPulseMetricsRequest, CloudPulseMetricsResponse, CloudPulseMetricsResponseData, CloudPulseMetricsList, CloudPulseMetricValues and getCloudPulseMetricsAPI is added ([#10710](https://github.com/linode/manager/pull/10710)) diff --git a/packages/api-v4/.changeset/pr-10744-changed-1722619037605.md b/packages/api-v4/.changeset/pr-10744-changed-1722619037605.md deleted file mode 100644 index 8e08a0e3fff..00000000000 --- a/packages/api-v4/.changeset/pr-10744-changed-1722619037605.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -Moved `getObjectStorageEndpoints` from `/objects.ts` to `/buckets.ts` ([#10744](https://github.com/linode/manager/pull/10744)) diff --git a/packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md b/packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md deleted file mode 100644 index d426d6c4963..00000000000 --- a/packages/api-v4/.changeset/pr-10747-upcoming-features-1723026166451.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Change JweTokenPayLoad's `resource_id` to `resource_ids` ([#10747](https://github.com/linode/manager/pull/10747)) diff --git a/packages/api-v4/.changeset/pr-10768-upcoming-features-1723463731514.md b/packages/api-v4/.changeset/pr-10768-upcoming-features-1723463731514.md deleted file mode 100644 index ce6471d3903..00000000000 --- a/packages/api-v4/.changeset/pr-10768-upcoming-features-1723463731514.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add 'Akamai Cloud Pulse' in AccountCapability type interface ([#10768](https://github.com/linode/manager/pull/10768)) diff --git a/packages/api-v4/.changeset/pr-10770-added-1723479930096.md b/packages/api-v4/.changeset/pr-10770-added-1723479930096.md deleted file mode 100644 index eb311d8862c..00000000000 --- a/packages/api-v4/.changeset/pr-10770-added-1723479930096.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -Add firewall template endpoints ([#10770](https://github.com/linode/manager/pull/10770)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 6af57ce2702..341a9141a6f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,19 @@ +## [2024-08-19] - v0.124.0 + +### Added: + +- Firewall template endpoints ([#10770](https://github.com/linode/manager/pull/10770)) + +### Changed: + +- Move `getObjectStorageEndpoints` from `/objects.ts` to `/buckets.ts` ([#10744](https://github.com/linode/manager/pull/10744)) + +### Upcoming Features: + +- Add several CloudPulseMetrics types ([#10710](https://github.com/linode/manager/pull/10710)) +- Change JWETokenPayLoad `resource_id` to `resource_ids` ([#10747](https://github.com/linode/manager/pull/10747)) +- Add 'Akamai Cloud Pulse' in AccountCapability type interface ([#10768](https://github.com/linode/manager/pull/10768)) + ## [2024-08-05] - v0.123.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 67e970d0b7a..a0ea63982d6 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.123.0", + "version": "0.124.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-10697-tests-1721417045353.md b/packages/manager/.changeset/pr-10697-tests-1721417045353.md deleted file mode 100644 index f0192adb2df..00000000000 --- a/packages/manager/.changeset/pr-10697-tests-1721417045353.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Cypress integration test for closing support tickets ([#10697](https://github.com/linode/manager/pull/10697)) diff --git a/packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md b/packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md deleted file mode 100644 index 8073707a98f..00000000000 --- a/packages/manager/.changeset/pr-10710-upcoming-features-1721901189629.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulseLineGraph is added with dummy data ([#10710](https://github.com/linode/manager/pull/10710)) diff --git a/packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md b/packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md deleted file mode 100644 index fe28df2fc77..00000000000 --- a/packages/manager/.changeset/pr-10715-tech-stories-1722374365063.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Replace Select with Autocomplete in: stackscripts and Images ([#10715](https://github.com/linode/manager/pull/10715)) diff --git a/packages/manager/.changeset/pr-10718-upcoming-features-1721988074945.md b/packages/manager/.changeset/pr-10718-upcoming-features-1721988074945.md deleted file mode 100644 index 8c1b59c5594..00000000000 --- a/packages/manager/.changeset/pr-10718-upcoming-features-1721988074945.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -The CloudPulseDashboardFilterBuilder component builds the required mandatory global filters from a JSON config based on service types like linode, dbaas etc., ([#10718](https://github.com/linode/manager/pull/10718)) diff --git a/packages/manager/.changeset/pr-10722-tech-stories-1723478781442.md b/packages/manager/.changeset/pr-10722-tech-stories-1723478781442.md deleted file mode 100644 index ff7edb75483..00000000000 --- a/packages/manager/.changeset/pr-10722-tech-stories-1723478781442.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Fix and enable Linode Create flow v1 form events ([#10722](https://github.com/linode/manager/pull/10722)) diff --git a/packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md b/packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md deleted file mode 100644 index 6b44068e8f1..00000000000 --- a/packages/manager/.changeset/pr-10725-tech-stories-1722343555187.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Replace Select with Autocomplete component in the Linode feature ([#10725](https://github.com/linode/manager/pull/10725)) diff --git a/packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md b/packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md deleted file mode 100644 index a6078d51f1b..00000000000 --- a/packages/manager/.changeset/pr-10726-tech-stories-1722355169168.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Query Key Factory for Object Storage ([#10726](https://github.com/linode/manager/pull/10726)) diff --git a/packages/manager/.changeset/pr-10730-tests-1722367873620.md b/packages/manager/.changeset/pr-10730-tests-1722367873620.md deleted file mode 100644 index 6b665e0f807..00000000000 --- a/packages/manager/.changeset/pr-10730-tests-1722367873620.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Cypress tests for refactored Linode Create flow with add ons ([#10730](https://github.com/linode/manager/pull/10730)) diff --git a/packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md b/packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md deleted file mode 100644 index d64ecc262e4..00000000000 --- a/packages/manager/.changeset/pr-10736-upcoming-features-1722454894455.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Added Object Storage Gen2 `/endpoints` query ([#10736](https://github.com/linode/manager/pull/10736)) diff --git a/packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md b/packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md deleted file mode 100644 index 2dd918042bb..00000000000 --- a/packages/manager/.changeset/pr-10739-upcoming-features-1722528251084.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Added data visualization tokens to theme files ([#10739](https://github.com/linode/manager/pull/10739)) diff --git a/packages/manager/.changeset/pr-10740-changed-1723488918061.md b/packages/manager/.changeset/pr-10740-changed-1723488918061.md deleted file mode 100644 index 066eb1aa05b..00000000000 --- a/packages/manager/.changeset/pr-10740-changed-1723488918061.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Update Region label globally ([#10740](https://github.com/linode/manager/pull/10740)) diff --git a/packages/manager/.changeset/pr-10742-fixed-1722601990368.md b/packages/manager/.changeset/pr-10742-fixed-1722601990368.md deleted file mode 100644 index c6d7cd5b721..00000000000 --- a/packages/manager/.changeset/pr-10742-fixed-1722601990368.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Community notfications in event messages v2 refactor ([#10742](https://github.com/linode/manager/pull/10742)) diff --git a/packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md b/packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md deleted file mode 100644 index 08d8d2faef7..00000000000 --- a/packages/manager/.changeset/pr-10744-upcoming-features-1722618984741.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Object Storage Gen2 Create Bucket Additions ([#10744](https://github.com/linode/manager/pull/10744)) diff --git a/packages/manager/.changeset/pr-10746-added-1722932770115.md b/packages/manager/.changeset/pr-10746-added-1722932770115.md deleted file mode 100644 index 30a9e6ba7ff..00000000000 --- a/packages/manager/.changeset/pr-10746-added-1722932770115.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Support for VPCs in 'Open Support Ticket' Flow ([#10746](https://github.com/linode/manager/pull/10746)) diff --git a/packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md b/packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md deleted file mode 100644 index a6b8e5da77d..00000000000 --- a/packages/manager/.changeset/pr-10747-upcoming-features-1723026263446.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add conversion for data roll up and modify positioning of "No data to display" message ([#10747](https://github.com/linode/manager/pull/10747)) diff --git a/packages/manager/.changeset/pr-10749-tech-stories-1722885758140.md b/packages/manager/.changeset/pr-10749-tech-stories-1722885758140.md deleted file mode 100644 index 4ccc6f63e1a..00000000000 --- a/packages/manager/.changeset/pr-10749-tech-stories-1722885758140.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Allow URL path to open OBJ Create Access Key drawer ([#10749](https://github.com/linode/manager/pull/10749)) diff --git a/packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md b/packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md deleted file mode 100644 index 42cf3b2fd6c..00000000000 --- a/packages/manager/.changeset/pr-10750-upcoming-features-1723139035892.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Volume Encryption section to Volume Create page ([#10750](https://github.com/linode/manager/pull/10750)) diff --git a/packages/manager/.changeset/pr-10751-upcoming-features-1722963124052.md b/packages/manager/.changeset/pr-10751-upcoming-features-1722963124052.md deleted file mode 100644 index 875bfdc877f..00000000000 --- a/packages/manager/.changeset/pr-10751-upcoming-features-1722963124052.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Secure VM informational banners ([#10751](https://github.com/linode/manager/pull/10751)) diff --git a/packages/manager/.changeset/pr-10754-added-1723039007976.md b/packages/manager/.changeset/pr-10754-added-1723039007976.md deleted file mode 100644 index b194a9e5d42..00000000000 --- a/packages/manager/.changeset/pr-10754-added-1723039007976.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Remove animation from Search Results and Animate Icon instead. ([#10754](https://github.com/linode/manager/pull/10754)) diff --git a/packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md b/packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md deleted file mode 100644 index 49e4db22435..00000000000 --- a/packages/manager/.changeset/pr-10755-tech-stories-1722965778724.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Remove `suppressImplicitAnyIndexErrors` and `ignoreDeprecations` Typescript Options ([#10755](https://github.com/linode/manager/pull/10755)) diff --git a/packages/manager/.changeset/pr-10756-added-1723036827057.md b/packages/manager/.changeset/pr-10756-added-1723036827057.md deleted file mode 100644 index 20f6778760d..00000000000 --- a/packages/manager/.changeset/pr-10756-added-1723036827057.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Empty Kubernetes landing page with the 'Create Cluster' button disabled for restricted users ([#10756](https://github.com/linode/manager/pull/10756)) diff --git a/packages/manager/.changeset/pr-10757-tests-1722971426093.md b/packages/manager/.changeset/pr-10757-tests-1722971426093.md deleted file mode 100644 index 4973c90d8e4..00000000000 --- a/packages/manager/.changeset/pr-10757-tests-1722971426093.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Update StackScript Deploy test ([#10757](https://github.com/linode/manager/pull/10757)) diff --git a/packages/manager/.changeset/pr-10758-added-1722975143465.md b/packages/manager/.changeset/pr-10758-added-1722975143465.md deleted file mode 100644 index d63f063fd50..00000000000 --- a/packages/manager/.changeset/pr-10758-added-1722975143465.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Documentation for changeset best practices ([#10758](https://github.com/linode/manager/pull/10758)) diff --git a/packages/manager/.changeset/pr-10759-tests-1723040352780.md b/packages/manager/.changeset/pr-10759-tests-1723040352780.md deleted file mode 100644 index c8d1ca58a7a..00000000000 --- a/packages/manager/.changeset/pr-10759-tests-1723040352780.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix `EditImageDrawer.test.tsx` unit test flake ([#10759](https://github.com/linode/manager/pull/10759)) diff --git a/packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md b/packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md deleted file mode 100644 index 1268d934ee0..00000000000 --- a/packages/manager/.changeset/pr-10760-tech-stories-1723236539851.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Query Key Factory for Linode Types ([#10760](https://github.com/linode/manager/pull/10760)) diff --git a/packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md b/packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md deleted file mode 100644 index cb9f61a0298..00000000000 --- a/packages/manager/.changeset/pr-10763-upcoming-features-1723138942196.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Sentry Tag for Linode Create v2 ([#10763](https://github.com/linode/manager/pull/10763)) diff --git a/packages/manager/.changeset/pr-10764-added-1723149902254.md b/packages/manager/.changeset/pr-10764-added-1723149902254.md deleted file mode 100644 index e3bda9f4aa8..00000000000 --- a/packages/manager/.changeset/pr-10764-added-1723149902254.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Documentation for 'Sizing a pull request' to contribution guidelines ([#10764](https://github.com/linode/manager/pull/10764)) diff --git a/packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md b/packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md deleted file mode 100644 index 720b231610e..00000000000 --- a/packages/manager/.changeset/pr-10766-tech-stories-1723171237019.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Clean up Account Settings Object Storage and use React Query mutation ([#10766](https://github.com/linode/manager/pull/10766)) diff --git a/packages/manager/.changeset/pr-10767-tech-stories-1723554692779.md b/packages/manager/.changeset/pr-10767-tech-stories-1723554692779.md deleted file mode 100644 index f6498c8c64a..00000000000 --- a/packages/manager/.changeset/pr-10767-tech-stories-1723554692779.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Prepare for React Query v5 ([#10767](https://github.com/linode/manager/pull/10767)) diff --git a/packages/manager/.changeset/pr-10768-upcoming-features-1723464477834.md b/packages/manager/.changeset/pr-10768-upcoming-features-1723464477834.md deleted file mode 100644 index c6c5fe49baf..00000000000 --- a/packages/manager/.changeset/pr-10768-upcoming-features-1723464477834.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Use useIsACLPEnabled function to check aclp enable ([#10768](https://github.com/linode/manager/pull/10768)) diff --git a/packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md b/packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md deleted file mode 100644 index 9cb326ae4f9..00000000000 --- a/packages/manager/.changeset/pr-10770-upcoming-features-1723479438379.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add firewall generation dialog ([#10770](https://github.com/linode/manager/pull/10770)) diff --git a/packages/manager/.changeset/pr-10771-upcoming-features-1723731588900.md b/packages/manager/.changeset/pr-10771-upcoming-features-1723731588900.md deleted file mode 100644 index 4852e4ed0c6..00000000000 --- a/packages/manager/.changeset/pr-10771-upcoming-features-1723731588900.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Endpoint Type Column & Disable CORS for Gen2 Buckets ([#10771](https://github.com/linode/manager/pull/10771)) diff --git a/packages/manager/.changeset/pr-10772-fixed-1723476789978.md b/packages/manager/.changeset/pr-10772-fixed-1723476789978.md deleted file mode 100644 index 579004b5a1c..00000000000 --- a/packages/manager/.changeset/pr-10772-fixed-1723476789978.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -LKE Cluster Create tab selection reset upon adding pool ([#10772](https://github.com/linode/manager/pull/10772)) diff --git a/packages/manager/.changeset/pr-10775-upcoming-features-1723502865486.md b/packages/manager/.changeset/pr-10775-upcoming-features-1723502865486.md deleted file mode 100644 index de0fb24aa8b..00000000000 --- a/packages/manager/.changeset/pr-10775-upcoming-features-1723502865486.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add "Encryption" column to Volumes landing table ([#10775](https://github.com/linode/manager/pull/10775)) diff --git a/packages/manager/.changeset/pr-10777-fixed-1723516402764.md b/packages/manager/.changeset/pr-10777-fixed-1723516402764.md deleted file mode 100644 index bd6335c5adf..00000000000 --- a/packages/manager/.changeset/pr-10777-fixed-1723516402764.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Incorrect spelling of Ukraine's capital ([#10777](https://github.com/linode/manager/pull/10777)) diff --git a/packages/manager/.changeset/pr-10778-changed-1723563275697.md b/packages/manager/.changeset/pr-10778-changed-1723563275697.md deleted file mode 100644 index adabcb67dbb..00000000000 --- a/packages/manager/.changeset/pr-10778-changed-1723563275697.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Update Passbolt CE naming in Marketplace ([#10778](https://github.com/linode/manager/pull/10778)) diff --git a/packages/manager/.changeset/pr-10779-fixed-1723593549058.md b/packages/manager/.changeset/pr-10779-fixed-1723593549058.md deleted file mode 100644 index f58feb2c353..00000000000 --- a/packages/manager/.changeset/pr-10779-fixed-1723593549058.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Enabled login history for unrestricted child accounts ([#10779](https://github.com/linode/manager/pull/10779)) diff --git a/packages/manager/.changeset/pr-10782-fixed-1723574317048.md b/packages/manager/.changeset/pr-10782-fixed-1723574317048.md deleted file mode 100644 index 33e46625625..00000000000 --- a/packages/manager/.changeset/pr-10782-fixed-1723574317048.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Guide link for Secure Your Server app on Marketplace ([#10782](https://github.com/linode/manager/pull/10782)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index ae3b9815e4c..1ffe480881c 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-08-19] - v1.126.0 + +### Added: + +- Support for VPCs in 'Open Support Ticket' Flow ([#10746](https://github.com/linode/manager/pull/10746)) +- Documentation for changeset best practices ([#10758](https://github.com/linode/manager/pull/10758)) +- Documentation for 'Sizing a pull request' to contribution guidelines ([#10764](https://github.com/linode/manager/pull/10764)) + +### Changed: + +- Update Region label globally ([#10740](https://github.com/linode/manager/pull/10740)) +- Update Passbolt CE naming in Marketplace ([#10778](https://github.com/linode/manager/pull/10778)) +- Empty-state Kubernetes landing page with the 'Create Cluster' button disabled for restricted users ([#10756](https://github.com/linode/manager/pull/10756)) +- Allow URL path to open OBJ Create Access Key drawer ([#10749](https://github.com/linode/manager/pull/10749)) + +### Fixed: + +- Community notifications in event messages v2 refactor ([#10742](https://github.com/linode/manager/pull/10742)) +- LKE Cluster Create tab selection reset upon adding pool ([#10772](https://github.com/linode/manager/pull/10772)) +- Incorrect spelling of Ukraine's capital ([#10777](https://github.com/linode/manager/pull/10777)) +- Restricted access to login history for unrestricted child accounts ([#10779](https://github.com/linode/manager/pull/10779)) +- Guide link for Secure Your Server app on Marketplace (#10782) + +### Removed: +- Animation from search results and animate icon instead ([#10754](https://github.com/linode/manager/pull/10754)) + +### Tech Stories: + +- Replace Select with Autocomplete: + - Stackscripts and Images ([#10715](https://github.com/linode/manager/pull/10715)) + - Linodes ([#10725](https://github.com/linode/manager/pull/10725)) +- Fix and enable Linode Create flow v1 form events ([#10722](https://github.com/linode/manager/pull/10722)) +- Use Query Key Factory for Object Storage ([#10726](https://github.com/linode/manager/pull/10726)) +- Remove `suppressImplicitAnyIndexErrors` and `ignoreDeprecations` Typescript options ([#10755](https://github.com/linode/manager/pull/10755)) +- Use Query Key Factory for Linode Types ([#10760](https://github.com/linode/manager/pull/10760)) +- Clean up Account Settings Object Storage and use React Query mutation ([#10766](https://github.com/linode/manager/pull/10766)) +- Prepare for React Query v5 ([#10767](https://github.com/linode/manager/pull/10767)) + +### Tests: + +- Add Cypress integration test for closing support tickets ([#10697](https://github.com/linode/manager/pull/10697)) +- Add Cypress tests for refactored Linode Create flow with add-ons ([#10730](https://github.com/linode/manager/pull/10730)) +- Update StackScript deploy test ([#10757](https://github.com/linode/manager/pull/10757)) +- Fix `EditImageDrawer.test.tsx` unit test flake ([#10759](https://github.com/linode/manager/pull/10759)) + +### Upcoming Features: + +- Add mock data to CloudPulseLineGraph ([#10710](https://github.com/linode/manager/pull/10710)) +- Add CloudPulseDashboardFilterBuilder component to build filters per service type ([#10718](https://github.com/linode/manager/pull/10718)) +- Add Object Storage Gen2 `/endpoints` query ([#10736](https://github.com/linode/manager/pull/10736)) +- Add data visualization tokens to theme files ([#10739](https://github.com/linode/manager/pull/10739)) +- Add Object Storage Gen2 factories, mocks, and `BucketRateLimitTable` component ([#10744](https://github.com/linode/manager/pull/10744)) +- Add CloudPulse conversion for data roll up and modify positioning of "No data to display" message ([#10747](https://github.com/linode/manager/pull/10747)) +- Add Volume Encryption section to Volume Create page ([#10750](https://github.com/linode/manager/pull/10750)) +- Add Secure VM informational banners ([#10751](https://github.com/linode/manager/pull/10751)) +- Add Sentry Tag for Linode Create v2 ([#10763](https://github.com/linode/manager/pull/10763)) +- Determine if ACLP should be enabled based on account capabilities ([#10768](https://github.com/linode/manager/pull/10768)) +- Add Firewall generation dialog ([#10770](https://github.com/linode/manager/pull/10770)) +- Add Endpoint Type column & disable CORS for Gen2 buckets ([#10771](https://github.com/linode/manager/pull/10771)) +- Add "Encryption" column to Volumes landing table ([#10775](https://github.com/linode/manager/pull/10775)) + ## [2024-08-05] - v1.125.0 ### Added: diff --git a/packages/manager/package.json b/packages/manager/package.json index 5598b70c01d..61283f897a3 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.125.0", + "version": "1.126.0", "private": true, "type": "module", "bugs": { From 340743e65324bc3067705ebd47ddec8a4cd1a69c Mon Sep 17 00:00:00 2001 From: Hana Xu Date: Mon, 19 Aug 2024 11:50:08 -0400 Subject: [PATCH 43/43] fix wrong pull request number for object storage --- packages/api-v4/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 341a9141a6f..5310b2b6755 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -6,7 +6,7 @@ ### Changed: -- Move `getObjectStorageEndpoints` from `/objects.ts` to `/buckets.ts` ([#10744](https://github.com/linode/manager/pull/10744)) +- Move `getObjectStorageEndpoints` from `/objects.ts` to `/buckets.ts` ([#10736](https://github.com/linode/manager/pull/10736)) ### Upcoming Features: